diff --git a/TRACKER.md b/TRACKER.md
index 2f519e6..feab603 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.3 instance→definition resolution hardening
-- **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.2
+- **Next step:** 6F.4 RTK Query extractor
+- **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.3
- **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
@@ -405,7 +405,7 @@ checks): relative two-hop barrel rename (InvoicesPage's Grid), covered-by throug
wrapper — so 6F.6 must find the actual field-breaking coverage variant and extend the fixture.
eval 280 pass / 0 fail / 9 xfail / 0 unexpected-pass, gate OK, all metrics 1.000.
-### [ ] 6F.3 Instance→definition resolution hardening
+### [x] 6F.3 Instance→definition resolution hardening
**Failure modes:** A5, C1, F2, D4
**Build:** the field run linked only 563/1,982 instances (28%) — barrel resolution passes unit
tests but real chains break it. Extend import resolution: tsconfig `paths` aliases, multi-hop
@@ -415,6 +415,19 @@ re-export chains, `export * from`, default-export renames, and
**Accept:** field-patterns fixture instance→definition resolution ≥ 95%; `blastRadius` on a
shared component returns its true dependents (field regression: was 0); render graph connects
route roots to leaf instances; enable the 6F.2 checks.
+**Done:** two scanner changes. (1) `scanReact` now honors the scanned app's own
+`tsconfig.json` (passed as `tsConfigFilePath`, file discovery still ours) so `baseUrl`/`paths`
+make alias imports resolvable — `@ui` → two-hop rename barrel → definition now links, where
+before the usage produced no instance node at all. (2) New `lazyDefinition` resolution in
+`materializeInstances`: a tag naming a module-scope
+`const X = Loadable(lazy(() => import("./Page")))` (or bare `React.lazy`) unwraps to the
+dynamic import and resolves the target module's default export — exercised by a new
+`PreviewPane` fixture component whose variable name differs from the definition, so only the
+unwrap can link it. Fixture golden: 6F.3 marks removed (DataGrid instances = 2, blast radius
+DataGrid → UsersPage + InvoicesPage), InvoicesPage now has 1 instance (PreviewPane's lazy
+usage). 100% of the fixture's component usages resolve. 2 new parser unit tests (121 total);
+eval 283 pass / 0 fail / 7 xfail (all owned by 6F.4–6F.5) / 0 unexpected-pass, gate OK,
+metrics 1.000.
### [ ] 6F.4 RTK Query extractor
**Failure modes:** B2, C5, C6
diff --git a/eval/fixtures/field-patterns/app/pages/PreviewPane.tsx b/eval/fixtures/field-patterns/app/pages/PreviewPane.tsx
new file mode 100644
index 0000000..56306c7
--- /dev/null
+++ b/eval/fixtures/field-patterns/app/pages/PreviewPane.tsx
@@ -0,0 +1,16 @@
+import { lazy } from "react";
+
+import { Loadable } from "../loadable";
+
+// The variable name differs from the definition name on purpose: only
+// unwrapping the Loadable(lazy(() => import())) chain can link this usage.
+const Invoices = Loadable(lazy(() => import("./InvoicesPage")));
+
+export function PreviewPane() {
+ return (
+
+ );
+}
diff --git a/eval/fixtures/field-patterns/golden.json b/eval/fixtures/field-patterns/golden.json
index ad6fbec..10a1935 100644
--- a/eval/fixtures/field-patterns/golden.json
+++ b/eval/fixtures/field-patterns/golden.json
@@ -1,16 +1,13 @@
{
"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.3 aliases, 6F.4 RTK Query, 6F.5 object-config routes). 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; the ones the current scanner cannot satisfy are marked expectedFail with the step that must enable them (6F.4 RTK Query, 6F.5 object-config routes; the 6F.3 alias/lazy marks were removed when that step 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.",
"expect": {
"components": [
{ "name": "HomePage", "instances": 0 },
{ "name": "UsersPage", "instances": 0 },
- { "name": "InvoicesPage", "instances": 0 },
- {
- "name": "DataGrid",
- "instances": 2,
- "expectedFail": "enabled by 6F.3 — the @ui tsconfig-path alias import leaves UsersPage's unresolved (no instance node at all today)"
- },
+ { "name": "InvoicesPage", "instances": 1 },
+ { "name": "PreviewPane", "instances": 0 },
+ { "name": "DataGrid", "instances": 2 },
{ "name": "StatusBadge", "instances": 1 }
],
"queries": [
@@ -71,12 +68,7 @@
"blast": [
{
"node": "DataGrid",
- "expect": [{ "node": "UsersPage" }],
- "expectedFail": "enabled by 6F.3 — the shared-component blast radius misses UsersPage while its Grid instance is unresolved (field regression: 0 dependents)"
- },
- {
- "node": "DataGrid",
- "expect": [{ "node": "InvoicesPage" }]
+ "expect": [{ "node": "UsersPage" }, { "node": "InvoicesPage" }]
}
],
"coverage": [
diff --git a/packages/parser-react/src/instances.test.ts b/packages/parser-react/src/instances.test.ts
index 1ec3e25..36f8f9c 100644
--- a/packages/parser-react/src/instances.test.ts
+++ b/packages/parser-react/src/instances.test.ts
@@ -56,3 +56,27 @@ describe("same-body nesting (parentInstanceId)", () => {
expect(card?.kind === "instance" ? card.parentInstanceId : "missing").toBeNull();
});
});
+
+describe("field-pattern instance resolution (TRACKER 6F.3)", () => {
+ const fieldGraph = scanReact({ root: path.join(fixtures, "field-patterns/app") });
+ const fieldInstances = (name: string): InstanceNode[] =>
+ fieldGraph.nodes.flatMap((n) => (n.kind === "instance" && n.name === name ? [n] : []));
+
+ it("resolves a tsconfig-path alias import through a two-hop rename barrel", () => {
+ // UsersPage does `import { Grid } from "@ui"` — alias → components/index.ts
+ // (rename) → ui/index.ts → DataGrid. The field run dropped this usage.
+ const grids = fieldInstances("Grid");
+ expect(grids).toHaveLength(2);
+ for (const grid of grids) {
+ expect(grid.definitionId).toBe("component:components/ui/DataGrid.tsx#DataGrid");
+ }
+ expect(grids.some((i) => i.loc.file === "pages/UsersPage.tsx")).toBe(true);
+ });
+
+ it("unwraps Loadable(lazy(() => import())) variables to the page definition", () => {
+ // where Invoices = Loadable(lazy(() => import("./InvoicesPage"))):
+ // the variable name differs from the definition, so only the unwrap links it.
+ const preview = fieldInstances("Invoices")[0];
+ expect(preview?.definitionId).toBe("component:pages/InvoicesPage.tsx#InvoicesPage");
+ });
+});
diff --git a/packages/parser-react/src/scan.ts b/packages/parser-react/src/scan.ts
index db7ba2a..bb60e6e 100644
--- a/packages/parser-react/src/scan.ts
+++ b/packages/parser-react/src/scan.ts
@@ -1,3 +1,4 @@
+import fs from "node:fs";
import path from "node:path";
import {
@@ -152,7 +153,13 @@ export function scanReact(options: ScanOptions): LineageGraph {
const baseUrls = options.baseUrls ?? [];
const flagCallees = new Set([...DEFAULT_FLAG_CALLEES, ...(options.featureFlags ?? [])]);
+ // Honor the scanned app's own tsconfig (6F.3, failure mode A5/C1): its
+ // baseUrl/paths make alias imports ("@ui") resolvable, which is the
+ // difference between linking an instance to its definition and dropping the
+ // usage entirely. File discovery stays ours (skipAddingFilesFromTsConfig).
+ const tsConfigFilePath = path.join(root, "tsconfig.json");
const project = new Project({
+ ...(fs.existsSync(tsConfigFilePath) ? { tsConfigFilePath } : {}),
compilerOptions: { allowJs: true, jsx: 4 /* ReactJSX */ },
skipAddingFilesFromTsConfig: true,
});
@@ -1435,14 +1442,59 @@ function materializeInstances(
return null; // imported from an unknown, unconfigured module — not ours
}
- // Same file first, then unique-name fallback across the project.
+ // Same file first, then a lazy-wrapper variable, then unique-name fallback.
const resolvedName = resolveAlias(pending.tagName);
const sameFile = nodeId("component", pending.file, resolvedName);
if (nodes.has(sameFile)) return { definitionId: sameFile, external: false };
+ const lazy = lazyDefinition(sourceFile, pending.tagName);
+ if (lazy !== null) return lazy;
const global = definitionsByName.get(resolvedName);
return global !== undefined ? { definitionId: global, external: false } : null;
};
+ /**
+ * where Users = Loadable(lazy(() => import("./pages/UsersPage")))
+ * or React.lazy(() => import(...)): unwrap the wrapper chain to the dynamic
+ * import and resolve the target module's default export (6F.3 — the field
+ * app wrapped every page this way).
+ */
+ function lazyDefinition(
+ sourceFile: SourceFile,
+ tagName: string,
+ ): { definitionId: string; external: boolean } | null {
+ const variable = sourceFile.getVariableDeclaration(tagName);
+ const initializer = variable?.getInitializer();
+ if (initializer === undefined || !Node.isCallExpression(initializer)) return null;
+ const importCall = [initializer, ...initializer.getDescendantsOfKind(SyntaxKind.CallExpression)]
+ .find((call) => call.getExpression().getKind() === SyntaxKind.ImportKeyword);
+ const specifier = importCall?.getArguments()[0];
+ if (specifier === undefined || !Node.isStringLiteral(specifier)) return null;
+ // The compiler binds a resolvable specifier to its module symbol; fall
+ // back to relative resolution against the importing file for odd setups.
+ const target =
+ specifier.getSymbol()?.getDeclarations().find(Node.isSourceFile) ??
+ resolveRelativeModule(sourceFile, specifier.getLiteralText());
+ if (target === undefined) return null;
+ for (const declaration of target.getExportedDeclarations().get("default") ?? []) {
+ const declName = Node.hasName(declaration) ? declaration.getName() : undefined;
+ if (declName === undefined) continue;
+ const declFile = toPosix(path.relative(root, declaration.getSourceFile().getFilePath()));
+ const candidate = nodeId("component", declFile, resolveAlias(declName));
+ if (nodes.has(candidate)) return { definitionId: candidate, external: false };
+ }
+ return null;
+ }
+
+ function resolveRelativeModule(from: SourceFile, spec: string): SourceFile | undefined {
+ if (!spec.startsWith(".")) return undefined;
+ const base = path.join(path.dirname(from.getFilePath()), spec);
+ for (const suffix of [".tsx", ".ts", ".jsx", ".js", "/index.tsx", "/index.ts"]) {
+ const found = from.getProject().getSourceFile(`${base}${suffix}`);
+ if (found !== undefined) return found;
+ }
+ return undefined;
+ }
+
const idByIndex: Array = [];
const created: InstanceNode[] = [];
for (const [index, pending] of pendingInstances.entries()) {