From 525ccfc3fe421d206cff2702deff711c78f08ca1 Mon Sep 17 00:00:00 2001 From: Deep Kumar Singh Kushwah Date: Wed, 15 Jul 2026 21:07:24 +0530 Subject: [PATCH] feat(parser): resolve tsconfig-path aliases and Loadable/lazy wrappers to definitions (6F.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The field run linked only 28% of JSX instances to definitions. Two scanner changes close the diagnosed causes: 1. scanReact honors the scanned app's own tsconfig.json (tsConfigFilePath; file discovery unchanged), so baseUrl/paths make alias imports like "@ui" resolvable through multi-hop rename barrels. Before, such usages produced no instance node at all. 2. New lazyDefinition resolution: a JSX 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 name, so only the unwrap can link it. Fixture golden: both 6F.3 expectedFail marks removed — DataGrid instances = 2 and blast radius DataGrid → {UsersPage, InvoicesPage} now pass unmarked; 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. Co-Authored-By: Claude Fable 5 --- TRACKER.md | 19 +++++-- .../field-patterns/app/pages/PreviewPane.tsx | 16 ++++++ eval/fixtures/field-patterns/golden.json | 18 ++----- packages/parser-react/src/instances.test.ts | 24 +++++++++ packages/parser-react/src/scan.ts | 54 ++++++++++++++++++- 5 files changed, 114 insertions(+), 17 deletions(-) create mode 100644 eval/fixtures/field-patterns/app/pages/PreviewPane.tsx 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()) {