Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions TRACKER.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
16 changes: 16 additions & 0 deletions eval/fixtures/field-patterns/app/pages/PreviewPane.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<aside>
<h2>Preview</h2>
<Invoices />
</aside>
);
}
18 changes: 5 additions & 13 deletions eval/fixtures/field-patterns/golden.json
Original file line number Diff line number Diff line change
@@ -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 <Grid/> unresolved (no instance node at all today)"
},
{ "name": "InvoicesPage", "instances": 1 },
{ "name": "PreviewPane", "instances": 0 },
{ "name": "DataGrid", "instances": 2 },
{ "name": "StatusBadge", "instances": 1 }
],
"queries": [
Expand Down Expand Up @@ -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": [
Expand Down
24 changes: 24 additions & 0 deletions packages/parser-react/src/instances.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
// <Invoices/> 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");
});
});
54 changes: 53 additions & 1 deletion packages/parser-react/src/scan.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import fs from "node:fs";
import path from "node:path";

import {
Expand Down Expand Up @@ -152,7 +153,13 @@ export function scanReact(options: ScanOptions): LineageGraph {
const baseUrls = options.baseUrls ?? [];
const flagCallees = new Set<string>([...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,
});
Expand Down Expand Up @@ -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;
};

/**
* <Users/> 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<string | null> = [];
const created: InstanceNode[] = [];
for (const [index, pending] of pendingInstances.entries()) {
Expand Down
Loading