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;