Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions TRACKER.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
## Status

- **Current phase:** 3 — Journey graph
- **Next step:** 3.4Form libraries & non-JSX events
- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5, 3.1–3.3
- **Next step:** 3.5Flag / role conditions (closes Gate 3)
- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5, 3.1–3.4
- **Gates passed:** Gate 0 (CI + red-path, #5/#6) · Gate 1 (precision 1.000, recall 0.895, zero poison) · Gate 2 (C1 instance attribution 1.000 · B1 4-level handler chains · C6 store writers↔readers · A9 portals — scorecard 137/0/0, precision & recall 1.000)

## What CodeRadar is
Expand Down Expand Up @@ -204,7 +204,7 @@ The heart of the project. C1 and B1 live here.
- Returns `QueryResult<JourneyPath[]>`.
**Accept:** fixture `b6-cyclic-journeys` (list ↔ detail loop): 3-level golden paths exact, terminates < 1 s; depth-n request on a cyclic graph never hangs.

### [ ] 3.4 Form libraries & non-JSX events
### [x] 3.4 Form libraries & non-JSX events
**Failure modes:** B7, B8
**Build:** react-hook-form / Formik adapters (`handleSubmit(onSubmit)` → real handler); `addEventListener` in `useEffect` → EventNode (`source: "effect"`); adapter list for hotkey libs. Unknown patterns → file-level `flags: ["unscanned-events"]`.
**Accept:** fixtures `b8-react-hook-form`, `b7-effect-listeners` green.
Expand Down
8 changes: 8 additions & 0 deletions eval/fixtures/b7-effect-listeners/app/SaveHotkey.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { useHotkeys } from "react-hotkeys-hook";

export function SaveHotkey() {
// Hotkey-library registration → an event sourced "hotkey" keyed "ctrl+s".
useHotkeys("ctrl+s", () => fetch("/api/save", { method: "POST" }));

return <span>Ctrl+S to save</span>;
}
14 changes: 14 additions & 0 deletions eval/fixtures/b7-effect-listeners/app/ShortcutBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { useEffect } from "react";

export function ShortcutBar() {
useEffect(() => {
// addEventListener inside an effect → an event sourced "effect".
const onKey = (e: KeyboardEvent) => {
if (e.key === "s") fetch("/api/save", { method: "POST" });
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);

return <div>Press S to save</div>;
}
15 changes: 15 additions & 0 deletions eval/fixtures/b7-effect-listeners/golden.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"failureMode": "B7",
"note": "Non-JSX events. addEventListener('keydown', onKey) inside a useEffect becomes an event sourced 'effect'; useHotkeys('ctrl+s', fn) becomes an event sourced 'hotkey'. Both handlers are mined for effects (POST /api/save), so the save action is discoverable even though there's no on* JSX prop.",
"expect": {
"components": [
{ "name": "ShortcutBar", "instances": 0 },
{ "name": "SaveHotkey", "instances": 0 }
],
"effects": [
{ "component": "ShortcutBar", "event": "keydown", "effect": "triggers", "to": "/api/save" },
{ "component": "SaveHotkey", "event": "ctrl+s", "effect": "triggers", "to": "/api/save" }
],
"queries": [{ "terms": ["Press S to save"], "status": "ok", "top": "ShortcutBar" }]
}
}
21 changes: 21 additions & 0 deletions eval/fixtures/b8-react-hook-form/app/SignupForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { useForm } from "react-hook-form";

interface FormValues {
email: string;
}

export function SignupForm() {
const { register, handleSubmit } = useForm<FormValues>();

// The real submit handler, wrapped by handleSubmit() in the JSX below.
const onValid = (data: FormValues) =>
fetch("/api/signup", { method: "POST", body: JSON.stringify(data) });

return (
<form onSubmit={handleSubmit(onValid)}>
<h1>Create your account</h1>
<input {...register("email")} placeholder="Email" />
<button type="submit">Sign up</button>
</form>
);
}
11 changes: 11 additions & 0 deletions eval/fixtures/b8-react-hook-form/golden.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"failureMode": "B8",
"note": "react-hook-form: <form onSubmit={handleSubmit(onValid)}> — the real submit handler is the wrapped argument, not the handleSubmit() call. The onSubmit event is sourced 'form' and its handler onValid is mined for effects (POST /api/signup).",
"expect": {
"components": [{ "name": "SignupForm", "instances": 0 }],
"effects": [
{ "component": "SignupForm", "event": "onSubmit", "effect": "triggers", "to": "/api/signup" }
],
"queries": [{ "terms": ["Create your account"], "status": "ok", "top": "SignupForm" }]
}
}
9 changes: 8 additions & 1 deletion packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,10 +166,17 @@ export interface StateNode extends BaseNode {
/** A user or system event a component responds to. */
export interface EventNode extends BaseNode {
kind: "event";
/** e.g. "onClick", "onSubmit", "onChange" */
/** e.g. "onClick", "onSubmit", "onChange", or a DOM/hotkey key ("keydown", "ctrl+s"). */
event: string;
/** Name of the handler function, if resolvable. */
handler: string | null;
/**
* Where the binding comes from (TRACKER step 3.4): a JSX on* prop (the
* default when absent), a form-library submit handler (react-hook-form's
* `handleSubmit`, Formik), an `addEventListener` inside an effect, or a
* hotkey-library registration (`useHotkeys`).
*/
source?: "jsx" | "form" | "effect" | "hotkey";
}

/** Which routing system declared a route. */
Expand Down
36 changes: 36 additions & 0 deletions packages/parser-react/src/effects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,42 @@ describe("action effects (b3 fixture, TRACKER 3.2)", () => {
});
});

describe("form & non-JSX events (TRACKER 3.4, B7/B8)", () => {
const b8 = resolveHookEdges(scanReact({ root: path.join(fixtures, "b8-react-hook-form/app") }));
const b7 = resolveHookEdges(scanReact({ root: path.join(fixtures, "b7-effect-listeners/app") }));

const eventNamed = (graph: LineageGraph, event: string): LineageNode | undefined =>
graph.nodes.find((n) => n.kind === "event" && n.event === event);

it("react-hook-form: handleSubmit(onValid) unwraps to the real submit handler", () => {
const submit = eventNamed(b8, "onSubmit");
expect(submit?.kind === "event" ? submit.source : undefined).toBe("form");
expect(submit?.kind === "event" ? submit.handler : undefined).toBe("onValid");
const triggers = b8.edges.filter((e) => e.kind === "triggers" && e.from === submit?.id);
const ds = b8.nodes.find((n) => n.id === triggers[0]?.to);
expect(ds?.kind === "data-source" ? ds.endpoint : undefined).toBe("/api/signup");
});

it("addEventListener in an effect becomes an event sourced 'effect'", () => {
const key = eventNamed(b7, "keydown");
expect(key?.kind === "event" ? key.source : undefined).toBe("effect");
const triggers = b7.edges.some(
(e) => e.kind === "triggers" && e.from === key?.id && b7.nodes.find((n) => n.id === e.to)?.kind === "data-source",
);
expect(triggers).toBe(true);
});

it("a hotkey registration becomes an event keyed by its shortcut", () => {
const hotkey = eventNamed(b7, "ctrl+s");
expect(hotkey?.kind === "event" ? hotkey.source : undefined).toBe("hotkey");
const target = b7.edges
.filter((e) => e.kind === "triggers" && e.from === hotkey?.id)
.map((e) => b7.nodes.find((n) => n.id === e.to))
.find((n) => n?.kind === "data-source");
expect(target?.kind === "data-source" ? target.endpoint : undefined).toBe("/api/save");
});
});

describe("prop-drilled handlers still ground effects (b1 fixture, no regression)", () => {
const b1 = resolveHookEdges(scanReact({ root: path.join(fixtures, "b1-prop-drilled-handler/app") }));

Expand Down
122 changes: 93 additions & 29 deletions packages/parser-react/src/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,16 @@ const TEXT_ATTRIBUTES = new Set([
]);
const HTTP_METHODS = new Set(["get", "post", "put", "patch", "delete", "head", "options"]);
const TOAST_CALLEES = /^(toast(\.\w+)?|enqueueSnackbar|message\.(success|error|info|warning))$/;
/** Hotkey-library hooks whose (keys, handler) registration becomes an event (3.4). */
const HOTKEY_HOOKS = new Set([
"useHotkeys",
"useHotkey",
"useKeyboardShortcut",
"useKey",
"useKeyPress",
]);
/** Form components whose `onSubmit` prop is a real submit handler (Formik, 3.4). */
const FORM_TAGS = new Set(["Formik", "Form"]);

/** Scan a directory of React source and produce a lineage graph. */
export function scanReact(options: ScanOptions): LineageGraph {
Expand Down Expand Up @@ -428,9 +438,63 @@ function extractBodyFacts(
// sites get the real, substituted endpoint instead.
const declIsWrapper = wrappers.has(declName);

// Create an EventNode + handles edge and queue its handler for effect mining.
// Shared by JSX on* props and the non-JSX bindings (forms, listeners, hotkeys).
const registerEvent = (
eventName: string,
handlerNode: Node | undefined,
source: "jsx" | "form" | "effect" | "hotkey",
at: Node,
flags?: string[],
): void => {
let handler: string | null = null;
if (
handlerNode !== undefined &&
(Node.isIdentifier(handlerNode) || Node.isPropertyAccessExpression(handlerNode))
) {
handler = handlerNode.getText();
}
const suffix = `${handler !== null ? `:${handler}` : ""}${source !== "jsx" ? `@${source}` : ""}`;
const evId = nodeId("event", file, `${declName}.${eventName}${suffix}`);
if (!nodes.has(evId)) {
nodes.set(evId, {
id: evId,
kind: "event",
name: eventName,
loc: locOf(at, file),
event: eventName,
handler,
...(source !== "jsx" ? { source } : {}),
...(flags !== undefined && flags.length > 0 ? { flags } : {}),
});
}
if (handlerNode !== undefined) {
const list = handlerExprs.get(evId);
if (list) list.push(handlerNode);
else handlerExprs.set(evId, [handlerNode]);
}
addEdge({ from: ownerId, to: evId, kind: "handles" });
};

for (const call of body.getDescendantsOfKind(SyntaxKind.CallExpression)) {
const callee = call.getExpression().getText();

// Non-JSX event bindings (TRACKER 3.4): addEventListener (usually in an
// effect) and hotkey-library registrations become events; an unresolvable
// event type is flagged "unscanned-events" rather than dropped.
if (callee === "addEventListener" || callee.endsWith(".addEventListener")) {
const args = call.getArguments();
const type = resolveStringValue(args[0], 0);
registerEvent(type ?? "unknown", args[1], "effect", call, type === null ? ["unscanned-events"] : undefined);
continue;
}
if (HOTKEY_HOOKS.has(callee)) {
const args = call.getArguments();
const keys = resolveStringValue(args[0], 0);
registerEvent(keys ?? "unknown", args[1], "hotkey", call, keys === null ? ["unscanned-events"] : undefined);
continue;
}

// Store readers/dispatchers first — useSelector would otherwise fall
// through to the generic per-component state handling.
if (callee === "useSelector") {
Expand Down Expand Up @@ -502,39 +566,39 @@ function extractBodyFacts(
const attrName = attr.getNameNode().getText();
if (!/^on[A-Z]/.test(attrName)) continue;
const init = attr.getInitializer();
let handler: string | null = null;
let handlerExpr: Node | undefined;
if (init !== undefined && Node.isJsxExpression(init)) {
const expr = init.getExpression();
handlerExpr = expr;
// Plain references (handleDelete) and method references (this.refresh).
if (
expr !== undefined &&
(Node.isIdentifier(expr) || Node.isPropertyAccessExpression(expr))
) {
handler = expr.getText();
}
if (init === undefined || !Node.isJsxExpression(init)) {
registerEvent(attrName, undefined, "jsx", attr);
continue;
}
const evId = nodeId("event", file, `${declName}.${attrName}${handler !== null ? `:${handler}` : ""}`);
if (!nodes.has(evId)) {
nodes.set(evId, {
id: evId,
kind: "event",
name: attrName,
loc: locOf(attr, file),
event: attrName,
handler,
});
let expr: Node | undefined = init.getExpression();
let source: "jsx" | "form" = "jsx";
// react-hook-form: onSubmit={handleSubmit(onValid)} — the real handler is
// the wrapped argument, not the handleSubmit() call itself.
if (expr !== undefined && Node.isCallExpression(expr)) {
const callee = expr.getExpression();
const calleeName = Node.isPropertyAccessExpression(callee) ? callee.getName() : callee.getText();
if (calleeName === "handleSubmit") {
expr = expr.getArguments()[0] ?? expr;
source = "form";
}
}
// The handler expression is mined for effects in resolveHandlerChains (3.2);
// stored by node id so the same evId collects every inline arrow it carries.
if (handlerExpr !== undefined) {
const list = handlerExprs.get(evId);
if (list) list.push(handlerExpr);
else handlerExprs.set(evId, [handlerExpr]);
// Formik: <Formik onSubmit={onSubmit}> — the onSubmit prop is a submit handler.
if (source === "jsx" && attrName === "onSubmit" && FORM_TAGS.has(jsxTagName(attr))) {
source = "form";
}
addEdge({ from: ownerId, to: evId, kind: "handles" });
registerEvent(attrName, expr, source, attr);
}
}

/** The tag name of the JSX element an attribute belongs to (e.g. "Formik"). */
function jsxTagName(attr: Node): string {
const element = attr.getFirstAncestor(
(a) => Node.isJsxOpeningElement(a) || Node.isJsxSelfClosingElement(a),
);
if (element !== undefined && (Node.isJsxOpeningElement(element) || Node.isJsxSelfClosingElement(element))) {
return element.getTagNameNode().getText();
}
return "";
}

/** `useSelector((s) => s.users.list)` → the "users" slice's StateNode id. */
Expand Down
12 changes: 11 additions & 1 deletion schemas/lineage-graph.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -480,14 +480,24 @@
},
"event": {
"type": "string",
"description": "e.g. \"onClick\", \"onSubmit\", \"onChange\""
"description": "e.g. \"onClick\", \"onSubmit\", \"onChange\", or a DOM/hotkey key (\"keydown\", \"ctrl+s\")."
},
"handler": {
"type": [
"string",
"null"
],
"description": "Name of the handler function, if resolvable."
},
"source": {
"type": "string",
"enum": [
"jsx",
"form",
"effect",
"hotkey"
],
"description": "Where the binding comes from (TRACKER step 3.4): a JSX on* prop (the default when absent), a form-library submit handler (react-hook-form's `handleSubmit`, Formik), an `addEventListener` inside an effect, or a hotkey-library registration (`useHotkeys`)."
}
},
"required": [
Expand Down
Loading