Skip to content

Commit 525ccfc

Browse files
DeepCodeWorkclaude
andcommitted
feat(parser): resolve tsconfig-path aliases and Loadable/lazy wrappers to definitions (6F.3)
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 <noreply@anthropic.com>
1 parent 2f6973a commit 525ccfc

5 files changed

Lines changed: 114 additions & 17 deletions

File tree

TRACKER.md

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
## Status
66

77
- **Current phase:** 6F — Field hardening, feedback round 1 (runs before 6.1–6.5)
8-
- **Next step:** 6F.3 instance→definition resolution hardening
9-
- **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
8+
- **Next step:** 6F.4 RTK Query extractor
9+
- **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
1010
- **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)
1111

1212
## What CodeRadar is
@@ -405,7 +405,7 @@ checks): relative two-hop barrel rename (InvoicesPage's Grid), covered-by throug
405405
wrapper — so 6F.6 must find the actual field-breaking coverage variant and extend the fixture.
406406
eval 280 pass / 0 fail / 9 xfail / 0 unexpected-pass, gate OK, all metrics 1.000.
407407

408-
### [ ] 6F.3 Instance→definition resolution hardening
408+
### [x] 6F.3 Instance→definition resolution hardening
409409
**Failure modes:** A5, C1, F2, D4
410410
**Build:** the field run linked only 563/1,982 instances (28%) — barrel resolution passes unit
411411
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
415415
**Accept:** field-patterns fixture instance→definition resolution ≥ 95%; `blastRadius` on a
416416
shared component returns its true dependents (field regression: was 0); render graph connects
417417
route roots to leaf instances; enable the 6F.2 checks.
418+
**Done:** two scanner changes. (1) `scanReact` now honors the scanned app's own
419+
`tsconfig.json` (passed as `tsConfigFilePath`, file discovery still ours) so `baseUrl`/`paths`
420+
make alias imports resolvable — `@ui` → two-hop rename barrel → definition now links, where
421+
before the usage produced no instance node at all. (2) New `lazyDefinition` resolution in
422+
`materializeInstances`: a tag naming a module-scope
423+
`const X = Loadable(lazy(() => import("./Page")))` (or bare `React.lazy`) unwraps to the
424+
dynamic import and resolves the target module's default export — exercised by a new
425+
`PreviewPane` fixture component whose variable name differs from the definition, so only the
426+
unwrap can link it. Fixture golden: 6F.3 marks removed (DataGrid instances = 2, blast radius
427+
DataGrid → UsersPage + InvoicesPage), InvoicesPage now has 1 instance (PreviewPane's lazy
428+
usage). 100% of the fixture's component usages resolve. 2 new parser unit tests (121 total);
429+
eval 283 pass / 0 fail / 7 xfail (all owned by 6F.4–6F.5) / 0 unexpected-pass, gate OK,
430+
metrics 1.000.
418431

419432
### [ ] 6F.4 RTK Query extractor
420433
**Failure modes:** B2, C5, C6
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { lazy } from "react";
2+
3+
import { Loadable } from "../loadable";
4+
5+
// The variable name differs from the definition name on purpose: only
6+
// unwrapping the Loadable(lazy(() => import())) chain can link this usage.
7+
const Invoices = Loadable(lazy(() => import("./InvoicesPage")));
8+
9+
export function PreviewPane() {
10+
return (
11+
<aside>
12+
<h2>Preview</h2>
13+
<Invoices />
14+
</aside>
15+
);
16+
}

eval/fixtures/field-patterns/golden.json

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,13 @@
11
{
22
"failureMode": "D2",
3-
"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.",
3+
"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.",
44
"expect": {
55
"components": [
66
{ "name": "HomePage", "instances": 0 },
77
{ "name": "UsersPage", "instances": 0 },
8-
{ "name": "InvoicesPage", "instances": 0 },
9-
{
10-
"name": "DataGrid",
11-
"instances": 2,
12-
"expectedFail": "enabled by 6F.3 — the @ui tsconfig-path alias import leaves UsersPage's <Grid/> unresolved (no instance node at all today)"
13-
},
8+
{ "name": "InvoicesPage", "instances": 1 },
9+
{ "name": "PreviewPane", "instances": 0 },
10+
{ "name": "DataGrid", "instances": 2 },
1411
{ "name": "StatusBadge", "instances": 1 }
1512
],
1613
"queries": [
@@ -71,12 +68,7 @@
7168
"blast": [
7269
{
7370
"node": "DataGrid",
74-
"expect": [{ "node": "UsersPage" }],
75-
"expectedFail": "enabled by 6F.3 — the shared-component blast radius misses UsersPage while its Grid instance is unresolved (field regression: 0 dependents)"
76-
},
77-
{
78-
"node": "DataGrid",
79-
"expect": [{ "node": "InvoicesPage" }]
71+
"expect": [{ "node": "UsersPage" }, { "node": "InvoicesPage" }]
8072
}
8173
],
8274
"coverage": [

packages/parser-react/src/instances.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,27 @@ describe("same-body nesting (parentInstanceId)", () => {
5656
expect(card?.kind === "instance" ? card.parentInstanceId : "missing").toBeNull();
5757
});
5858
});
59+
60+
describe("field-pattern instance resolution (TRACKER 6F.3)", () => {
61+
const fieldGraph = scanReact({ root: path.join(fixtures, "field-patterns/app") });
62+
const fieldInstances = (name: string): InstanceNode[] =>
63+
fieldGraph.nodes.flatMap((n) => (n.kind === "instance" && n.name === name ? [n] : []));
64+
65+
it("resolves a tsconfig-path alias import through a two-hop rename barrel", () => {
66+
// UsersPage does `import { Grid } from "@ui"` — alias → components/index.ts
67+
// (rename) → ui/index.ts → DataGrid. The field run dropped this usage.
68+
const grids = fieldInstances("Grid");
69+
expect(grids).toHaveLength(2);
70+
for (const grid of grids) {
71+
expect(grid.definitionId).toBe("component:components/ui/DataGrid.tsx#DataGrid");
72+
}
73+
expect(grids.some((i) => i.loc.file === "pages/UsersPage.tsx")).toBe(true);
74+
});
75+
76+
it("unwraps Loadable(lazy(() => import())) variables to the page definition", () => {
77+
// <Invoices/> where Invoices = Loadable(lazy(() => import("./InvoicesPage"))):
78+
// the variable name differs from the definition, so only the unwrap links it.
79+
const preview = fieldInstances("Invoices")[0];
80+
expect(preview?.definitionId).toBe("component:pages/InvoicesPage.tsx#InvoicesPage");
81+
});
82+
});

packages/parser-react/src/scan.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import fs from "node:fs";
12
import path from "node:path";
23

34
import {
@@ -152,7 +153,13 @@ export function scanReact(options: ScanOptions): LineageGraph {
152153
const baseUrls = options.baseUrls ?? [];
153154
const flagCallees = new Set<string>([...DEFAULT_FLAG_CALLEES, ...(options.featureFlags ?? [])]);
154155

156+
// Honor the scanned app's own tsconfig (6F.3, failure mode A5/C1): its
157+
// baseUrl/paths make alias imports ("@ui") resolvable, which is the
158+
// difference between linking an instance to its definition and dropping the
159+
// usage entirely. File discovery stays ours (skipAddingFilesFromTsConfig).
160+
const tsConfigFilePath = path.join(root, "tsconfig.json");
155161
const project = new Project({
162+
...(fs.existsSync(tsConfigFilePath) ? { tsConfigFilePath } : {}),
156163
compilerOptions: { allowJs: true, jsx: 4 /* ReactJSX */ },
157164
skipAddingFilesFromTsConfig: true,
158165
});
@@ -1435,14 +1442,59 @@ function materializeInstances(
14351442
return null; // imported from an unknown, unconfigured module — not ours
14361443
}
14371444

1438-
// Same file first, then unique-name fallback across the project.
1445+
// Same file first, then a lazy-wrapper variable, then unique-name fallback.
14391446
const resolvedName = resolveAlias(pending.tagName);
14401447
const sameFile = nodeId("component", pending.file, resolvedName);
14411448
if (nodes.has(sameFile)) return { definitionId: sameFile, external: false };
1449+
const lazy = lazyDefinition(sourceFile, pending.tagName);
1450+
if (lazy !== null) return lazy;
14421451
const global = definitionsByName.get(resolvedName);
14431452
return global !== undefined ? { definitionId: global, external: false } : null;
14441453
};
14451454

1455+
/**
1456+
* <Users/> where Users = Loadable(lazy(() => import("./pages/UsersPage")))
1457+
* or React.lazy(() => import(...)): unwrap the wrapper chain to the dynamic
1458+
* import and resolve the target module's default export (6F.3 — the field
1459+
* app wrapped every page this way).
1460+
*/
1461+
function lazyDefinition(
1462+
sourceFile: SourceFile,
1463+
tagName: string,
1464+
): { definitionId: string; external: boolean } | null {
1465+
const variable = sourceFile.getVariableDeclaration(tagName);
1466+
const initializer = variable?.getInitializer();
1467+
if (initializer === undefined || !Node.isCallExpression(initializer)) return null;
1468+
const importCall = [initializer, ...initializer.getDescendantsOfKind(SyntaxKind.CallExpression)]
1469+
.find((call) => call.getExpression().getKind() === SyntaxKind.ImportKeyword);
1470+
const specifier = importCall?.getArguments()[0];
1471+
if (specifier === undefined || !Node.isStringLiteral(specifier)) return null;
1472+
// The compiler binds a resolvable specifier to its module symbol; fall
1473+
// back to relative resolution against the importing file for odd setups.
1474+
const target =
1475+
specifier.getSymbol()?.getDeclarations().find(Node.isSourceFile) ??
1476+
resolveRelativeModule(sourceFile, specifier.getLiteralText());
1477+
if (target === undefined) return null;
1478+
for (const declaration of target.getExportedDeclarations().get("default") ?? []) {
1479+
const declName = Node.hasName(declaration) ? declaration.getName() : undefined;
1480+
if (declName === undefined) continue;
1481+
const declFile = toPosix(path.relative(root, declaration.getSourceFile().getFilePath()));
1482+
const candidate = nodeId("component", declFile, resolveAlias(declName));
1483+
if (nodes.has(candidate)) return { definitionId: candidate, external: false };
1484+
}
1485+
return null;
1486+
}
1487+
1488+
function resolveRelativeModule(from: SourceFile, spec: string): SourceFile | undefined {
1489+
if (!spec.startsWith(".")) return undefined;
1490+
const base = path.join(path.dirname(from.getFilePath()), spec);
1491+
for (const suffix of [".tsx", ".ts", ".jsx", ".js", "/index.tsx", "/index.ts"]) {
1492+
const found = from.getProject().getSourceFile(`${base}${suffix}`);
1493+
if (found !== undefined) return found;
1494+
}
1495+
return undefined;
1496+
}
1497+
14461498
const idByIndex: Array<string | null> = [];
14471499
const created: InstanceNode[] = [];
14481500
for (const [index, pending] of pendingInstances.entries()) {

0 commit comments

Comments
 (0)