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
18 changes: 15 additions & 3 deletions TRACKER.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
## Status

- **Current phase:** 6F — Field hardening, feedback round 1 (runs before 6.1–6.5)
- **Next step:** 6F.5 object-config React Router recognition
- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5, 3.1–3.6, 4.1–4.6, 5.1–5.7, 6F.1–6F.4
- **Next step:** 6F.6 test-coverage detection hardening (needs the field's actual failing variant) · 6F.7 scoring polish · 6F.8 visualizer
- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5, 3.1–3.6, 4.1–4.6, 5.1–5.7, 6F.1–6F.5
- **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) · Gate 3 (B3 action effects · B4 routers · B6 cyclic journeys terminate · B7/B8 form & non-JSX events · G5 flag/role conditions — precision & recall 1.000) · Gate 4 (A4 rarity · A10 fuzzy/OCR · A1 structural · A6 subtree · E3 vision annotations · E2 aliases · G4 corrections — high-conf correct 1.000, ambiguity honesty 1.000, poison rate 0.000) · Gate 5 (F1 context bundle · F2 blast radius · F3 test coverage · F4 response schema · F5 git history · MCP server over stdio — scorecard 265/0/0, all honesty metrics 1.000; **M5 reached** — ticket in → budgeted context bundle out, over MCP)

## What CodeRadar is
Expand Down Expand Up @@ -454,7 +454,7 @@ a consumer-less source. Server-cache `useSelector` reads remain future work (not
the accept criteria). 4 new parser tests (125 total); eval 285 pass / 0 fail / 5 xfail (all
6F.5) / 0 unexpected-pass, gate OK, metrics 1.000.

### [ ] 6F.5 Object-config React Router recognition
### [x] 6F.5 Object-config React Router recognition
**Failure modes:** B4, B3
**Build:** step 3.1 covers `createBrowserRouter` on fixtures, but the field app produced 0
route nodes — diagnose and close the gap: route-object arrays defined in separate
Expand All @@ -464,6 +464,18 @@ survive the lazy wrapper (depends on 6F.3).
**Accept:** field-patterns fixture: all golden routes emit `RouteNode`s; `journeys("/route")`
returns a real multi-step path (field regression: was `declined: not-found`); enable the 6F.2
checks.
**Done:** three changes in routes.ts. (1) New `routeArrayElements`: a router config resolves
whether it's an array literal, an imported identifier (via go-to-definition), or
spread-composed from separately-declared arrays — applied to both the `createBrowserRouter`
argument and every `children` property, `as`/`satisfies` unwrapped, hop-bounded. (2)
`resolveLazyVariable` now unwraps wrapper helpers around the lazy call —
`Loadable(lazy(() => import()))` resolves to the imported page's default export, so
`routes-to` lands on the real component. (3) No change needed for `navigates-to`/journeys:
they lit up as soon as route nodes existed. Field-patterns golden: all 5 remaining xfails
removed — 3 routes, the onClick→/users effect, and the "/"→"/users" terminal journey all pass
unmarked, so **the fixture is fully green with zero marks and Gate 6F's extractor criteria
are met**. 4 new parser tests (129 total); eval 290 pass / 0 fail / 0 xfail / 0
unexpected-pass, gate OK, metrics 1.000.

### [ ] 6F.6 Test-coverage detection hardening
**Failure modes:** F3
Expand Down
40 changes: 6 additions & 34 deletions eval/fixtures/field-patterns/golden.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"failureMode": "D2",
"note": "Field-pattern regression fixture (6F.2): mirrors the shapes the 2026-07-15 ih-frontend validation run used where every gate scored 1.000 but the field run failed — tsconfig-path-alias barrel imports (@ui), Loadable(lazy(() => import())) page elements, route arrays composed in a separate file and passed to createBrowserRouter as an identifier, an RTK Query store (createApi/injectEndpoints/builder.query|mutation), and tests that render through a custom providers wrapper. Checks assert TARGET behavior; the ones the current scanner cannot satisfy are marked expectedFail with the step that must enable them (6F.5 object-config routes; the 6F.3 alias/lazy and 6F.4 RTK Query marks were removed when those steps landed). xfail semantics remove stale marks automatically: once a step lands, its checks flip to unexpected-pass and gate until the marks are deleted. Verified working already in this shape: relative multi-hop barrel with rename (InvoicesPage's Grid), and covered-by detection through the custom render wrapper.",
"note": "Field-pattern regression fixture (6F.2): mirrors the shapes the 2026-07-15 ih-frontend validation run used where every gate scored 1.000 but the field run failed — tsconfig-path-alias barrel imports (@ui), Loadable(lazy(() => import())) page elements, route arrays composed in a separate file and passed to createBrowserRouter as an identifier, an RTK Query store (createApi/injectEndpoints/builder.query|mutation), and tests that render through a custom providers wrapper. Checks assert TARGET behavior; while a capability was missing its checks carried expectedFail naming the enabling step, and the unexpected-pass gate forced the marks off as each step landed (6F.3 aliases/lazy, 6F.4 RTK Query, 6F.5 object-config routes — all landed, no marks remain). Verified working already in this shape: relative multi-hop barrel with rename (InvoicesPage's Grid), and covered-by detection through the custom render wrapper.",
"expect": {
"components": [
{ "name": "HomePage", "instances": 0 },
Expand All @@ -15,43 +15,15 @@
{ "terms": ["Invoice history"], "status": "ok", "top": "InvoicesPage" }
],
"routes": [
{
"path": "/",
"component": "HomePage",
"layout": null,
"guards": [],
"expectedFail": "enabled by 6F.5 — createBrowserRouter(routes) with an imported identifier produces 0 route nodes today"
},
{
"path": "/users",
"component": "UsersPage",
"layout": null,
"guards": [],
"expectedFail": "enabled by 6F.5 — routes-to must survive the Loadable(lazy(() => import())) wrapper"
},
{
"path": "/invoices",
"component": "InvoicesPage",
"layout": null,
"guards": [],
"expectedFail": "enabled by 6F.5 — route comes from a spread of a separately-declared array"
}
{ "path": "/", "component": "HomePage", "layout": null, "guards": [] },
{ "path": "/users", "component": "UsersPage", "layout": null, "guards": [] },
{ "path": "/invoices", "component": "InvoicesPage", "layout": null, "guards": [] }
],
"effects": [
{
"component": "HomePage",
"event": "onClick",
"effect": "navigates-to",
"to": "/users",
"expectedFail": "enabled by 6F.5 — navigates-to needs the /users route node to exist"
}
{ "component": "HomePage", "event": "onClick", "effect": "navigates-to", "to": "/users" }
],
"journeys": [
{
"start": "/",
"expect": [{ "pages": ["/", "/users"], "end": "terminal" }],
"expectedFail": "enabled by 6F.5 — journeys from a path need route nodes (field regression: declined not-found)"
}
{ "start": "/", "expect": [{ "pages": ["/", "/users"], "end": "terminal" }] }
],
"attributions": [
{ "component": "UsersPage", "endpoints": ["/api/users"] },
Expand Down
28 changes: 28 additions & 0 deletions packages/parser-react/src/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,3 +122,31 @@ describe("Next.js adapter (b4 fixture)", () => {
expect(routes(reactRouterGraph).every((r) => r.router === "react-router")).toBe(true);
});
});

describe("object-config router composition (TRACKER 6F.5)", () => {
const fieldGraph = scanReact({
root: path.join(fixturesDir, "field-patterns/app"),
});
const route = (p: string) =>
fieldGraph.nodes.find((n) => n.kind === "route" && n.path === p);
const routesTo = (p: string) =>
fieldGraph.edges.find((e) => e.kind === "routes-to" && e.from === route(p)?.id)?.to;

it("resolves createBrowserRouter(identifier) with a config from another file", () => {
expect(route("/")).toBeDefined();
expect(routesTo("/")).toBe("component:pages/HomePage.tsx#HomePage");
});

it("routes-to survives the Loadable(lazy(() => import())) wrapper", () => {
expect(routesTo("/users")).toBe("component:pages/UsersPage.tsx#UsersPage");
});

it("expands spread-composed route arrays", () => {
expect(routesTo("/invoices")).toBe("component:pages/InvoicesPage.tsx#InvoicesPage");
});

it("links programmatic navigation to the recognized route", () => {
const nav = fieldGraph.edges.find((e) => e.kind === "navigates-to");
expect(nav?.to).toBe(route("/users")?.id);
});
});
55 changes: 42 additions & 13 deletions packages/parser-react/src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,15 +192,49 @@ class RouteAdapter {
return found;
}

/** `const Settings = lazy(() => import("./Settings"))` → the imported page. */
/**
* `const Settings = lazy(() => import("./Settings"))` → the imported page.
* Wrapper helpers around the lazy call — `Loadable(lazy(() => import()))`,
* the field app's universal pattern (6F.5) — unwrap to the same import.
*/
private resolveLazyVariable(definition: Node): string | null {
if (!Node.isVariableDeclaration(definition)) return null;
const init = definition.getInitializer();
if (init === undefined || !Node.isCallExpression(init)) return null;
if (!/^(React\.)?lazy$/.test(init.getExpression().getText())) return null;
const isLazy = (call: Node): boolean =>
Node.isCallExpression(call) && /^(React\.)?lazy$/.test(call.getExpression().getText());
const hasLazy = [init, ...init.getDescendantsOfKind(SyntaxKind.CallExpression)].some(isLazy);
if (!hasLazy) return null;
return this.resolveDynamicImport(init);
}

/**
* The route objects of a router config: an array literal (spread elements
* expanded), or an identifier resolved to its (possibly imported) array
* declaration (6F.5). Hop-bounded — config indirection chains are shallow.
*/
private routeArrayElements(node: Node | undefined, hop: number): Node[] {
if (node === undefined || hop > 4) return [];
if (Node.isAsExpression(node) || Node.isSatisfiesExpression(node)) {
return this.routeArrayElements(node.getExpression(), hop);
}
if (Node.isArrayLiteralExpression(node)) {
return node.getElements().flatMap((element) =>
Node.isSpreadElement(element)
? this.routeArrayElements(element.getExpression(), hop + 1)
: [element],
);
}
if (Node.isIdentifier(node)) {
for (const definition of node.getDefinitionNodes()) {
if (!Node.isVariableDeclaration(definition)) continue;
const elements = this.routeArrayElements(definition.getInitializer(), hop + 1);
if (elements.length > 0) return elements;
}
}
return [];
}

/**
* Resolve the first `import("...")` inside `node` to the target module's
* Component (react-router lazy convention) or default export component.
Expand Down Expand Up @@ -247,14 +281,13 @@ class RouteAdapter {

reactRouter(): void {
for (const sourceFile of this.project.getSourceFiles()) {
// Object form: createBrowserRouter([{ path, element, children }, ...])
// Object form: createBrowserRouter([{ path, element, children }, ...]).
// The config may also be an imported identifier or spread-composed from
// separately-declared arrays (6F.5 — the field app's shape).
for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
if (!ROUTER_FACTORIES.has(call.getExpression().getText())) continue;
const routes = call.getArguments()[0];
if (routes !== undefined && Node.isArrayLiteralExpression(routes)) {
for (const route of routes.getElements()) {
this.objectRoute(route, { basePath: "/", layout: null, guards: [] });
}
for (const route of this.routeArrayElements(call.getArguments()[0], 0)) {
this.objectRoute(route, { basePath: "/", layout: null, guards: [] });
}
}
// JSX form: <Routes><Route …/></Routes> / createRoutesFromElements(<Route/>)
Expand All @@ -273,11 +306,7 @@ class RouteAdapter {
const segment =
pathInit !== undefined && Node.isStringLiteral(pathInit) ? pathInit.getLiteralValue() : null;
const isIndex = objectProperty(route, "index")?.getText() === "true";
const childrenInit = objectProperty(route, "children");
const children =
childrenInit !== undefined && Node.isArrayLiteralExpression(childrenInit)
? childrenInit.getElements()
: [];
const children = this.routeArrayElements(objectProperty(route, "children"), 0);

const routePath = segment !== null ? this.joinPaths(context.basePath, segment) : context.basePath;

Expand Down
Loading