diff --git a/TRACKER.md b/TRACKER.md index 1e45e60..bacc931 100644 --- a/TRACKER.md +++ b/TRACKER.md @@ -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 @@ -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 @@ -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 diff --git a/eval/fixtures/field-patterns/golden.json b/eval/fixtures/field-patterns/golden.json index fc41777..d9139e9 100644 --- a/eval/fixtures/field-patterns/golden.json +++ b/eval/fixtures/field-patterns/golden.json @@ -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 }, @@ -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"] }, diff --git a/packages/parser-react/src/routes.test.ts b/packages/parser-react/src/routes.test.ts index 6262ab5..4c5133e 100644 --- a/packages/parser-react/src/routes.test.ts +++ b/packages/parser-react/src/routes.test.ts @@ -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); + }); +}); diff --git a/packages/parser-react/src/routes.ts b/packages/parser-react/src/routes.ts index aa5a321..350d2d4 100644 --- a/packages/parser-react/src/routes.ts +++ b/packages/parser-react/src/routes.ts @@ -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. @@ -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: / createRoutesFromElements() @@ -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;