Skip to content

Commit aa848ab

Browse files
Merge pull request #23 from officialCodeWork/build/phase-3/step-3.4-form-events
feat(events): form-library & non-JSX event adapters (B7/B8)
2 parents 29a344c + 9a9879e commit aa848ab

10 files changed

Lines changed: 220 additions & 34 deletions

File tree

TRACKER.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
## Status
66

77
- **Current phase:** 3 — Journey graph
8-
- **Next step:** 3.4Form libraries & non-JSX events
9-
- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5, 3.1–3.3
8+
- **Next step:** 3.5Flag / role conditions (closes Gate 3)
9+
- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5, 3.1–3.4
1010
- **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)
1111

1212
## What CodeRadar is
@@ -204,7 +204,7 @@ The heart of the project. C1 and B1 live here.
204204
- Returns `QueryResult<JourneyPath[]>`.
205205
**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.
206206

207-
### [ ] 3.4 Form libraries & non-JSX events
207+
### [x] 3.4 Form libraries & non-JSX events
208208
**Failure modes:** B7, B8
209209
**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"]`.
210210
**Accept:** fixtures `b8-react-hook-form`, `b7-effect-listeners` green.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { useHotkeys } from "react-hotkeys-hook";
2+
3+
export function SaveHotkey() {
4+
// Hotkey-library registration → an event sourced "hotkey" keyed "ctrl+s".
5+
useHotkeys("ctrl+s", () => fetch("/api/save", { method: "POST" }));
6+
7+
return <span>Ctrl+S to save</span>;
8+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { useEffect } from "react";
2+
3+
export function ShortcutBar() {
4+
useEffect(() => {
5+
// addEventListener inside an effect → an event sourced "effect".
6+
const onKey = (e: KeyboardEvent) => {
7+
if (e.key === "s") fetch("/api/save", { method: "POST" });
8+
};
9+
window.addEventListener("keydown", onKey);
10+
return () => window.removeEventListener("keydown", onKey);
11+
}, []);
12+
13+
return <div>Press S to save</div>;
14+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"failureMode": "B7",
3+
"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.",
4+
"expect": {
5+
"components": [
6+
{ "name": "ShortcutBar", "instances": 0 },
7+
{ "name": "SaveHotkey", "instances": 0 }
8+
],
9+
"effects": [
10+
{ "component": "ShortcutBar", "event": "keydown", "effect": "triggers", "to": "/api/save" },
11+
{ "component": "SaveHotkey", "event": "ctrl+s", "effect": "triggers", "to": "/api/save" }
12+
],
13+
"queries": [{ "terms": ["Press S to save"], "status": "ok", "top": "ShortcutBar" }]
14+
}
15+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { useForm } from "react-hook-form";
2+
3+
interface FormValues {
4+
email: string;
5+
}
6+
7+
export function SignupForm() {
8+
const { register, handleSubmit } = useForm<FormValues>();
9+
10+
// The real submit handler, wrapped by handleSubmit() in the JSX below.
11+
const onValid = (data: FormValues) =>
12+
fetch("/api/signup", { method: "POST", body: JSON.stringify(data) });
13+
14+
return (
15+
<form onSubmit={handleSubmit(onValid)}>
16+
<h1>Create your account</h1>
17+
<input {...register("email")} placeholder="Email" />
18+
<button type="submit">Sign up</button>
19+
</form>
20+
);
21+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"failureMode": "B8",
3+
"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).",
4+
"expect": {
5+
"components": [{ "name": "SignupForm", "instances": 0 }],
6+
"effects": [
7+
{ "component": "SignupForm", "event": "onSubmit", "effect": "triggers", "to": "/api/signup" }
8+
],
9+
"queries": [{ "terms": ["Create your account"], "status": "ok", "top": "SignupForm" }]
10+
}
11+
}

packages/core/src/types.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,10 +166,17 @@ export interface StateNode extends BaseNode {
166166
/** A user or system event a component responds to. */
167167
export interface EventNode extends BaseNode {
168168
kind: "event";
169-
/** e.g. "onClick", "onSubmit", "onChange" */
169+
/** e.g. "onClick", "onSubmit", "onChange", or a DOM/hotkey key ("keydown", "ctrl+s"). */
170170
event: string;
171171
/** Name of the handler function, if resolvable. */
172172
handler: string | null;
173+
/**
174+
* Where the binding comes from (TRACKER step 3.4): a JSX on* prop (the
175+
* default when absent), a form-library submit handler (react-hook-form's
176+
* `handleSubmit`, Formik), an `addEventListener` inside an effect, or a
177+
* hotkey-library registration (`useHotkeys`).
178+
*/
179+
source?: "jsx" | "form" | "effect" | "hotkey";
173180
}
174181

175182
/** Which routing system declared a route. */

packages/parser-react/src/effects.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,42 @@ describe("action effects (b3 fixture, TRACKER 3.2)", () => {
8080
});
8181
});
8282

83+
describe("form & non-JSX events (TRACKER 3.4, B7/B8)", () => {
84+
const b8 = resolveHookEdges(scanReact({ root: path.join(fixtures, "b8-react-hook-form/app") }));
85+
const b7 = resolveHookEdges(scanReact({ root: path.join(fixtures, "b7-effect-listeners/app") }));
86+
87+
const eventNamed = (graph: LineageGraph, event: string): LineageNode | undefined =>
88+
graph.nodes.find((n) => n.kind === "event" && n.event === event);
89+
90+
it("react-hook-form: handleSubmit(onValid) unwraps to the real submit handler", () => {
91+
const submit = eventNamed(b8, "onSubmit");
92+
expect(submit?.kind === "event" ? submit.source : undefined).toBe("form");
93+
expect(submit?.kind === "event" ? submit.handler : undefined).toBe("onValid");
94+
const triggers = b8.edges.filter((e) => e.kind === "triggers" && e.from === submit?.id);
95+
const ds = b8.nodes.find((n) => n.id === triggers[0]?.to);
96+
expect(ds?.kind === "data-source" ? ds.endpoint : undefined).toBe("/api/signup");
97+
});
98+
99+
it("addEventListener in an effect becomes an event sourced 'effect'", () => {
100+
const key = eventNamed(b7, "keydown");
101+
expect(key?.kind === "event" ? key.source : undefined).toBe("effect");
102+
const triggers = b7.edges.some(
103+
(e) => e.kind === "triggers" && e.from === key?.id && b7.nodes.find((n) => n.id === e.to)?.kind === "data-source",
104+
);
105+
expect(triggers).toBe(true);
106+
});
107+
108+
it("a hotkey registration becomes an event keyed by its shortcut", () => {
109+
const hotkey = eventNamed(b7, "ctrl+s");
110+
expect(hotkey?.kind === "event" ? hotkey.source : undefined).toBe("hotkey");
111+
const target = b7.edges
112+
.filter((e) => e.kind === "triggers" && e.from === hotkey?.id)
113+
.map((e) => b7.nodes.find((n) => n.id === e.to))
114+
.find((n) => n?.kind === "data-source");
115+
expect(target?.kind === "data-source" ? target.endpoint : undefined).toBe("/api/save");
116+
});
117+
});
118+
83119
describe("prop-drilled handlers still ground effects (b1 fixture, no regression)", () => {
84120
const b1 = resolveHookEdges(scanReact({ root: path.join(fixtures, "b1-prop-drilled-handler/app") }));
85121

packages/parser-react/src/scan.ts

Lines changed: 93 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,16 @@ const TEXT_ATTRIBUTES = new Set([
102102
]);
103103
const HTTP_METHODS = new Set(["get", "post", "put", "patch", "delete", "head", "options"]);
104104
const TOAST_CALLEES = /^(toast(\.\w+)?|enqueueSnackbar|message\.(success|error|info|warning))$/;
105+
/** Hotkey-library hooks whose (keys, handler) registration becomes an event (3.4). */
106+
const HOTKEY_HOOKS = new Set([
107+
"useHotkeys",
108+
"useHotkey",
109+
"useKeyboardShortcut",
110+
"useKey",
111+
"useKeyPress",
112+
]);
113+
/** Form components whose `onSubmit` prop is a real submit handler (Formik, 3.4). */
114+
const FORM_TAGS = new Set(["Formik", "Form"]);
105115

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

441+
// Create an EventNode + handles edge and queue its handler for effect mining.
442+
// Shared by JSX on* props and the non-JSX bindings (forms, listeners, hotkeys).
443+
const registerEvent = (
444+
eventName: string,
445+
handlerNode: Node | undefined,
446+
source: "jsx" | "form" | "effect" | "hotkey",
447+
at: Node,
448+
flags?: string[],
449+
): void => {
450+
let handler: string | null = null;
451+
if (
452+
handlerNode !== undefined &&
453+
(Node.isIdentifier(handlerNode) || Node.isPropertyAccessExpression(handlerNode))
454+
) {
455+
handler = handlerNode.getText();
456+
}
457+
const suffix = `${handler !== null ? `:${handler}` : ""}${source !== "jsx" ? `@${source}` : ""}`;
458+
const evId = nodeId("event", file, `${declName}.${eventName}${suffix}`);
459+
if (!nodes.has(evId)) {
460+
nodes.set(evId, {
461+
id: evId,
462+
kind: "event",
463+
name: eventName,
464+
loc: locOf(at, file),
465+
event: eventName,
466+
handler,
467+
...(source !== "jsx" ? { source } : {}),
468+
...(flags !== undefined && flags.length > 0 ? { flags } : {}),
469+
});
470+
}
471+
if (handlerNode !== undefined) {
472+
const list = handlerExprs.get(evId);
473+
if (list) list.push(handlerNode);
474+
else handlerExprs.set(evId, [handlerNode]);
475+
}
476+
addEdge({ from: ownerId, to: evId, kind: "handles" });
477+
};
478+
431479
for (const call of body.getDescendantsOfKind(SyntaxKind.CallExpression)) {
432480
const callee = call.getExpression().getText();
433481

482+
// Non-JSX event bindings (TRACKER 3.4): addEventListener (usually in an
483+
// effect) and hotkey-library registrations become events; an unresolvable
484+
// event type is flagged "unscanned-events" rather than dropped.
485+
if (callee === "addEventListener" || callee.endsWith(".addEventListener")) {
486+
const args = call.getArguments();
487+
const type = resolveStringValue(args[0], 0);
488+
registerEvent(type ?? "unknown", args[1], "effect", call, type === null ? ["unscanned-events"] : undefined);
489+
continue;
490+
}
491+
if (HOTKEY_HOOKS.has(callee)) {
492+
const args = call.getArguments();
493+
const keys = resolveStringValue(args[0], 0);
494+
registerEvent(keys ?? "unknown", args[1], "hotkey", call, keys === null ? ["unscanned-events"] : undefined);
495+
continue;
496+
}
497+
434498
// Store readers/dispatchers first — useSelector would otherwise fall
435499
// through to the generic per-component state handling.
436500
if (callee === "useSelector") {
@@ -502,39 +566,39 @@ function extractBodyFacts(
502566
const attrName = attr.getNameNode().getText();
503567
if (!/^on[A-Z]/.test(attrName)) continue;
504568
const init = attr.getInitializer();
505-
let handler: string | null = null;
506-
let handlerExpr: Node | undefined;
507-
if (init !== undefined && Node.isJsxExpression(init)) {
508-
const expr = init.getExpression();
509-
handlerExpr = expr;
510-
// Plain references (handleDelete) and method references (this.refresh).
511-
if (
512-
expr !== undefined &&
513-
(Node.isIdentifier(expr) || Node.isPropertyAccessExpression(expr))
514-
) {
515-
handler = expr.getText();
516-
}
569+
if (init === undefined || !Node.isJsxExpression(init)) {
570+
registerEvent(attrName, undefined, "jsx", attr);
571+
continue;
517572
}
518-
const evId = nodeId("event", file, `${declName}.${attrName}${handler !== null ? `:${handler}` : ""}`);
519-
if (!nodes.has(evId)) {
520-
nodes.set(evId, {
521-
id: evId,
522-
kind: "event",
523-
name: attrName,
524-
loc: locOf(attr, file),
525-
event: attrName,
526-
handler,
527-
});
573+
let expr: Node | undefined = init.getExpression();
574+
let source: "jsx" | "form" = "jsx";
575+
// react-hook-form: onSubmit={handleSubmit(onValid)} — the real handler is
576+
// the wrapped argument, not the handleSubmit() call itself.
577+
if (expr !== undefined && Node.isCallExpression(expr)) {
578+
const callee = expr.getExpression();
579+
const calleeName = Node.isPropertyAccessExpression(callee) ? callee.getName() : callee.getText();
580+
if (calleeName === "handleSubmit") {
581+
expr = expr.getArguments()[0] ?? expr;
582+
source = "form";
583+
}
528584
}
529-
// The handler expression is mined for effects in resolveHandlerChains (3.2);
530-
// stored by node id so the same evId collects every inline arrow it carries.
531-
if (handlerExpr !== undefined) {
532-
const list = handlerExprs.get(evId);
533-
if (list) list.push(handlerExpr);
534-
else handlerExprs.set(evId, [handlerExpr]);
585+
// Formik: <Formik onSubmit={onSubmit}> — the onSubmit prop is a submit handler.
586+
if (source === "jsx" && attrName === "onSubmit" && FORM_TAGS.has(jsxTagName(attr))) {
587+
source = "form";
535588
}
536-
addEdge({ from: ownerId, to: evId, kind: "handles" });
589+
registerEvent(attrName, expr, source, attr);
590+
}
591+
}
592+
593+
/** The tag name of the JSX element an attribute belongs to (e.g. "Formik"). */
594+
function jsxTagName(attr: Node): string {
595+
const element = attr.getFirstAncestor(
596+
(a) => Node.isJsxOpeningElement(a) || Node.isJsxSelfClosingElement(a),
597+
);
598+
if (element !== undefined && (Node.isJsxOpeningElement(element) || Node.isJsxSelfClosingElement(element))) {
599+
return element.getTagNameNode().getText();
537600
}
601+
return "";
538602
}
539603

540604
/** `useSelector((s) => s.users.list)` → the "users" slice's StateNode id. */

schemas/lineage-graph.schema.json

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -480,14 +480,24 @@
480480
},
481481
"event": {
482482
"type": "string",
483-
"description": "e.g. \"onClick\", \"onSubmit\", \"onChange\""
483+
"description": "e.g. \"onClick\", \"onSubmit\", \"onChange\", or a DOM/hotkey key (\"keydown\", \"ctrl+s\")."
484484
},
485485
"handler": {
486486
"type": [
487487
"string",
488488
"null"
489489
],
490490
"description": "Name of the handler function, if resolvable."
491+
},
492+
"source": {
493+
"type": "string",
494+
"enum": [
495+
"jsx",
496+
"form",
497+
"effect",
498+
"hotkey"
499+
],
500+
"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`)."
491501
}
492502
},
493503
"required": [

0 commit comments

Comments
 (0)