From 9a9879e4e0b17b69907acb79fbff68f610502859 Mon Sep 17 00:00:00 2001 From: Deep Kumar Singh Kushwah Date: Wed, 15 Jul 2026 01:25:44 +0530 Subject: [PATCH] feat(events): form-library & non-JSX event adapters (B7/B8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend event extraction beyond JSX on* props (TRACKER step 3.4): - react-hook-form:
unwraps to the real submit handler (the wrapped argument), event sourced "form". - Formik: onSubmit on a / tag treated as a submit handler. - addEventListener("keydown", onKey) inside an effect → event sourced "effect". - Hotkey hooks (useHotkeys/useKeyboardShortcut/…) → event sourced "hotkey", keyed by the shortcut string. - Unresolvable event type → event flagged "unscanned-events", never dropped. All routes share one registerEvent helper that queues the handler for the existing effect-mining pass, so form/effect/hotkey handlers get the same navigate/fetch/dispatch/setState effect edges as JSX handlers. Schema: EventNode gains optional `source` ("jsx"|"form"|"effect"|"hotkey"); JSON schema regenerated. Fixtures b8-react-hook-form (POST /api/signup via handleSubmit) and b7-effect-listeners (keydown + ctrl+s → /api/save) green. 87 parser + 29 core tests pass; eval green (precision/recall 1.000). Co-Authored-By: Claude Opus 4.8 --- TRACKER.md | 6 +- .../b7-effect-listeners/app/SaveHotkey.tsx | 8 ++ .../b7-effect-listeners/app/ShortcutBar.tsx | 14 ++ eval/fixtures/b7-effect-listeners/golden.json | 15 +++ .../b8-react-hook-form/app/SignupForm.tsx | 21 +++ eval/fixtures/b8-react-hook-form/golden.json | 11 ++ packages/core/src/types.ts | 9 +- packages/parser-react/src/effects.test.ts | 36 ++++++ packages/parser-react/src/scan.ts | 122 +++++++++++++----- schemas/lineage-graph.schema.json | 12 +- 10 files changed, 220 insertions(+), 34 deletions(-) create mode 100644 eval/fixtures/b7-effect-listeners/app/SaveHotkey.tsx create mode 100644 eval/fixtures/b7-effect-listeners/app/ShortcutBar.tsx create mode 100644 eval/fixtures/b7-effect-listeners/golden.json create mode 100644 eval/fixtures/b8-react-hook-form/app/SignupForm.tsx create mode 100644 eval/fixtures/b8-react-hook-form/golden.json diff --git a/TRACKER.md b/TRACKER.md index 62eb6f0..8965e4d 100644 --- a/TRACKER.md +++ b/TRACKER.md @@ -5,8 +5,8 @@ ## Status - **Current phase:** 3 — Journey graph -- **Next step:** 3.4 — Form libraries & non-JSX events -- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5, 3.1–3.3 +- **Next step:** 3.5 — Flag / 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 @@ -204,7 +204,7 @@ The heart of the project. C1 and B1 live here. - Returns `QueryResult`. **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. diff --git a/eval/fixtures/b7-effect-listeners/app/SaveHotkey.tsx b/eval/fixtures/b7-effect-listeners/app/SaveHotkey.tsx new file mode 100644 index 0000000..d7a736f --- /dev/null +++ b/eval/fixtures/b7-effect-listeners/app/SaveHotkey.tsx @@ -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 Ctrl+S to save; +} diff --git a/eval/fixtures/b7-effect-listeners/app/ShortcutBar.tsx b/eval/fixtures/b7-effect-listeners/app/ShortcutBar.tsx new file mode 100644 index 0000000..a434c0a --- /dev/null +++ b/eval/fixtures/b7-effect-listeners/app/ShortcutBar.tsx @@ -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
Press S to save
; +} diff --git a/eval/fixtures/b7-effect-listeners/golden.json b/eval/fixtures/b7-effect-listeners/golden.json new file mode 100644 index 0000000..d0b0454 --- /dev/null +++ b/eval/fixtures/b7-effect-listeners/golden.json @@ -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" }] + } +} diff --git a/eval/fixtures/b8-react-hook-form/app/SignupForm.tsx b/eval/fixtures/b8-react-hook-form/app/SignupForm.tsx new file mode 100644 index 0000000..999c07f --- /dev/null +++ b/eval/fixtures/b8-react-hook-form/app/SignupForm.tsx @@ -0,0 +1,21 @@ +import { useForm } from "react-hook-form"; + +interface FormValues { + email: string; +} + +export function SignupForm() { + const { register, handleSubmit } = useForm(); + + // 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 ( + +

Create your account

+ + + + ); +} diff --git a/eval/fixtures/b8-react-hook-form/golden.json b/eval/fixtures/b8-react-hook-form/golden.json new file mode 100644 index 0000000..8dee21f --- /dev/null +++ b/eval/fixtures/b8-react-hook-form/golden.json @@ -0,0 +1,11 @@ +{ + "failureMode": "B8", + "note": "react-hook-form:
— 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" }] + } +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 800c13b..f0b7ae9 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -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. */ diff --git a/packages/parser-react/src/effects.test.ts b/packages/parser-react/src/effects.test.ts index 48ba09b..f976ac7 100644 --- a/packages/parser-react/src/effects.test.ts +++ b/packages/parser-react/src/effects.test.ts @@ -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") })); diff --git a/packages/parser-react/src/scan.ts b/packages/parser-react/src/scan.ts index 4f55dde..0940e37 100644 --- a/packages/parser-react/src/scan.ts +++ b/packages/parser-react/src/scan.ts @@ -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 { @@ -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") { @@ -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: — 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. */ diff --git a/schemas/lineage-graph.schema.json b/schemas/lineage-graph.schema.json index 0efd934..1167c26 100644 --- a/schemas/lineage-graph.schema.json +++ b/schemas/lineage-graph.schema.json @@ -480,7 +480,7 @@ }, "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": [ @@ -488,6 +488,16 @@ "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": [