diff --git a/TRACKER.md b/TRACKER.md
index 4845520..4411ae0 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -5,8 +5,8 @@
## Status
- **Current phase:** 2 — Instance graph & cross-file data flow
-- **Next step:** 2.1 — Instance tree construction
-- **Done:** 0.1–0.4, 1.1–1.6
+- **Next step:** 2.2 — Prop-flow: data attribution per instance
+- **Done:** 0.1–0.4, 1.1–1.6, 2.1
- **Gates passed:** Gate 0 (CI + red-path, PRs #5/#6) · Gate 1 (precision 1.000 ≥ 0.90, recall 0.895 ≥ 0.80 across C2/C3/C5/A2/A7/A8/D4, zero forbidden hits)
## What CodeRadar is
@@ -140,7 +140,7 @@ follow-one-reference; the cross-file instance/prop-flow machinery is Phase 2.
The heart of the project. C1 and B1 live here.
-### [ ] 2.1 Instance tree construction
+### [x] 2.1 Instance tree construction
**Failure modes:** C1 (graph half), A5
**Build:** cross-file pass in `parser-react`:
- Resolve every JSX tag to its component definition through imports (including `export { X as Y }`, barrel files, default exports).
diff --git a/eval/fixtures/a5-design-system/app/MembersPage.tsx b/eval/fixtures/a5-design-system/app/MembersPage.tsx
new file mode 100644
index 0000000..989352c
--- /dev/null
+++ b/eval/fixtures/a5-design-system/app/MembersPage.tsx
@@ -0,0 +1,11 @@
+import { Avatar, ProfileCard } from "./ui";
+
+export function MembersPage() {
+ return (
+
+ Members directory
+
+
+
+ );
+}
diff --git a/eval/fixtures/a5-design-system/app/SettingsPage.tsx b/eval/fixtures/a5-design-system/app/SettingsPage.tsx
new file mode 100644
index 0000000..0edcd52
--- /dev/null
+++ b/eval/fixtures/a5-design-system/app/SettingsPage.tsx
@@ -0,0 +1,11 @@
+import { Button, DataGrid } from "@acme/ui";
+
+export function SettingsPage() {
+ return (
+
+ Workspace settings
+
+
+
+ );
+}
diff --git a/eval/fixtures/a5-design-system/app/ui/ProfileCard.tsx b/eval/fixtures/a5-design-system/app/ui/ProfileCard.tsx
new file mode 100644
index 0000000..aebc623
--- /dev/null
+++ b/eval/fixtures/a5-design-system/app/ui/ProfileCard.tsx
@@ -0,0 +1,12 @@
+export function ProfileCardInner({ name }: { name: string }) {
+ return (
+
+
Profile details
+ {name}
+
+ );
+}
+
+export default function AvatarBadge() {
+ return
;
+}
diff --git a/eval/fixtures/a5-design-system/app/ui/index.ts b/eval/fixtures/a5-design-system/app/ui/index.ts
new file mode 100644
index 0000000..70d85c6
--- /dev/null
+++ b/eval/fixtures/a5-design-system/app/ui/index.ts
@@ -0,0 +1,2 @@
+export { ProfileCardInner as ProfileCard } from "./ProfileCard";
+export { default as Avatar } from "./ProfileCard";
diff --git a/eval/fixtures/a5-design-system/golden.json b/eval/fixtures/a5-design-system/golden.json
new file mode 100644
index 0000000..3362be7
--- /dev/null
+++ b/eval/fixtures/a5-design-system/golden.json
@@ -0,0 +1,21 @@
+{
+ "failureMode": "A5",
+ "note": "Design-system components: comes from @acme/ui — the definition isn't ours, but the USAGE SITE is what a screenshot match must return. Also exercises barrel re-exports with rename (ProfileCardInner as ProfileCard) and default-export aliasing (default as Avatar).",
+ "scan": {
+ "designSystemPackages": ["@acme/ui"]
+ },
+ "expect": {
+ "components": [
+ { "name": "SettingsPage", "instances": 0 },
+ { "name": "MembersPage", "instances": 0 },
+ { "name": "ProfileCardInner", "instances": 1 },
+ { "name": "AvatarBadge", "instances": 1 }
+ ],
+ "queries": [
+ { "terms": ["Save changes"], "status": "ok", "top": "SettingsPage" },
+ { "terms": ["Team grid"], "status": "ok", "top": "SettingsPage" },
+ { "terms": ["Profile details"], "status": "ok", "top": "ProfileCardInner" },
+ { "terms": ["Members directory"], "status": "ok", "top": "MembersPage" }
+ ]
+ }
+}
diff --git a/eval/history.jsonl b/eval/history.jsonl
index c5b77c8..a052df0 100644
--- a/eval/history.jsonl
+++ b/eval/history.jsonl
@@ -5,3 +5,4 @@
{"generatedAt":"2026-07-13T10:02:55.141Z","commitSha":"67411c5662523fd633383013f6a0591ac3faea3c","pass":67,"fail":0,"xfail":2,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":0.889,"matchAccuracy":1}
{"generatedAt":"2026-07-13T10:09:02.527Z","commitSha":"4b4fd9e72204c47fa8ad523ff9b68fee41fc3a26","pass":77,"fail":0,"xfail":2,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":0.889,"matchAccuracy":1}
{"generatedAt":"2026-07-13T11:04:05.130Z","commitSha":"d13b90dd99c993889f57218997984e3e2e601cfd","pass":91,"fail":0,"xfail":2,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":0.895,"matchAccuracy":1}
+{"generatedAt":"2026-07-13T11:10:56.348Z","commitSha":"b628c816231c4896f030ebc68a6621d0963d183c","pass":99,"fail":0,"xfail":2,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":0.895,"matchAccuracy":1}
diff --git a/packages/parser-react/src/instances.test.ts b/packages/parser-react/src/instances.test.ts
new file mode 100644
index 0000000..1ec3e25
--- /dev/null
+++ b/packages/parser-react/src/instances.test.ts
@@ -0,0 +1,58 @@
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+import type { InstanceNode } from "@coderadar/core";
+import { describe, expect, it } from "vitest";
+
+import { scanReact } from "./scan.js";
+
+const fixtures = path.resolve(
+ path.dirname(fileURLToPath(import.meta.url)),
+ "../../../eval/fixtures",
+);
+
+const graph = scanReact({
+ root: path.join(fixtures, "a5-design-system/app"),
+ designSystemPackages: ["@acme/ui"],
+});
+
+function instancesNamed(name: string): InstanceNode[] {
+ return graph.nodes.flatMap((n) => (n.kind === "instance" && n.name === name ? [n] : []));
+}
+
+describe("instance tree construction (a5 fixture)", () => {
+ it("creates flagged instances for design-system components", () => {
+ const button = instancesNamed("Button")[0];
+ expect(button?.definitionId).toBe("external:@acme/ui#Button");
+ expect(button?.flags).toContain("external-definition");
+ expect(button?.staticProps).toEqual({ label: "Save changes" });
+ });
+
+ it("resolves barrel re-exports with rename to the original definition", () => {
+ const card = instancesNamed("ProfileCard")[0];
+ expect(card?.definitionId).toBe("component:ui/ProfileCard.tsx#ProfileCardInner");
+ });
+
+ it("resolves default-export aliases through the barrel", () => {
+ const avatar = instancesNamed("Avatar")[0];
+ expect(avatar?.definitionId).toBe("component:ui/ProfileCard.tsx#AvatarBadge");
+ });
+
+ it("does not create instances for unconfigured external modules", () => {
+ // react-i18next's in other fixtures never materializes; here,
+ // assert the only external instances are the two @acme/ui ones.
+ const externals = graph.nodes.filter(
+ (n) => n.kind === "instance" && n.flags?.includes("external-definition"),
+ );
+ expect(externals.map((n) => n.name).sort()).toEqual(["Button", "DataGrid"]);
+ });
+});
+
+describe("same-body nesting (parentInstanceId)", () => {
+ it("links nested project-component call sites", () => {
+ const nested = scanReact({ root: path.join(fixtures, "..", "..", "examples/demo-app/src") });
+ // demo-app has no nesting — assert null baseline holds.
+ const card = nested.nodes.find((n) => n.kind === "instance" && n.name === "UserCard");
+ expect(card?.kind === "instance" ? card.parentInstanceId : "missing").toBeNull();
+ });
+});
diff --git a/packages/parser-react/src/scan.ts b/packages/parser-react/src/scan.ts
index e05228a..18f2fb7 100644
--- a/packages/parser-react/src/scan.ts
+++ b/packages/parser-react/src/scan.ts
@@ -51,6 +51,13 @@ export interface ScanOptions {
* in any language match.
*/
i18n?: I18nOptions;
+ /**
+ * Package names whose components should still produce instances even
+ * though their definitions live outside the repo (e.g. ["@acme/ui"]).
+ * Such instances are flagged "external-definition" — the usage site is
+ * ours even when the definition isn't (failure mode A5).
+ */
+ designSystemPackages?: string[];
}
type FunctionLike = FunctionDeclaration | ArrowFunction | FunctionExpression;
@@ -72,6 +79,14 @@ interface PendingInstance {
/** Node id of the enclosing component/hook declaration. */
ownerId: string;
file: string;
+ /** The JSX element itself — used for import resolution and nesting. */
+ element: JsxOpeningElement | JsxSelfClosingElement;
+ /**
+ * Index (into the global pending list) of the nearest enclosing component
+ * call site in the same body: → Button's parent is
+ * the Card site. Null at the body root (the renders edge covers the owner).
+ */
+ parentIndex: number | null;
}
const COMPONENT_NAME = /^[A-Z]/;
@@ -153,7 +168,14 @@ export function scanReact(options: ScanOptions): LineageGraph {
collectHocAliases(sourceFile, hocAliases);
}
- materializeInstances(pendingInstances, nodes, addEdge, hocAliases);
+ materializeInstances(
+ pendingInstances,
+ nodes,
+ addEdge,
+ hocAliases,
+ options.designSystemPackages ?? [],
+ root,
+ );
return {
version: 2,
@@ -772,6 +794,7 @@ function collectInstanceSites(
file: string,
pendingInstances: PendingInstance[],
): void {
+ const bodyStart = pendingInstances.length;
const record = (el: JsxOpeningElement | JsxSelfClosingElement): void => {
const head = el.getTagNameNode().getText().split(".")[0];
if (head === undefined || !COMPONENT_NAME.test(head)) return;
@@ -783,10 +806,38 @@ function collectInstanceSites(
staticProps[attr.getNameNode().getText()] = init.getLiteralValue();
}
}
- pendingInstances.push({ tagName: head, loc: locOf(el, file), staticProps, ownerId, file });
+ pendingInstances.push({
+ tagName: head,
+ loc: locOf(el, file),
+ staticProps,
+ ownerId,
+ file,
+ element: el,
+ parentIndex: null,
+ });
};
for (const el of body.getDescendantsOfKind(SyntaxKind.JsxOpeningElement)) record(el);
for (const el of body.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement)) record(el);
+
+ // Same-body nesting: → Button's parent site is Card.
+ const bodySites = pendingInstances.slice(bodyStart);
+ for (const site of bodySites) {
+ let ancestor: Node | undefined = site.element.getParent();
+ while (ancestor !== undefined && ancestor !== body && site.parentIndex === null) {
+ for (let i = 0; i < bodySites.length; i += 1) {
+ const candidate = bodySites[i];
+ if (candidate === undefined || candidate === site) continue;
+ const candidateElement: Node | undefined = Node.isJsxOpeningElement(candidate.element)
+ ? candidate.element.getParent() // the enclosing JsxElement
+ : candidate.element;
+ if (candidateElement !== undefined && candidateElement === ancestor) {
+ site.parentIndex = bodyStart + i;
+ break;
+ }
+ }
+ ancestor = ancestor.getParent();
+ }
+ }
}
/**
@@ -802,6 +853,8 @@ function materializeInstances(
nodes: Map,
addEdge: (edge: LineageEdge) => void,
hocAliases: ReadonlyMap,
+ designSystemPackages: string[],
+ root: string,
): void {
const definitionsByName = new Map();
for (const node of nodes.values()) {
@@ -810,19 +863,73 @@ function materializeInstances(
// where Panel = connect(...)(PanelInner): resolve through the alias
// chain (bounded — alias graphs are tiny and could in principle cycle).
- const resolveAlias = (tagName: string): string => {
- let name = tagName;
+ const resolveAlias = (name: string): string => {
+ let current = name;
for (let hop = 0; hop < 3; hop += 1) {
- const target = hocAliases.get(name);
+ const target = hocAliases.get(current);
if (target === undefined) break;
- name = target;
+ current = target;
+ }
+ return current;
+ };
+
+ /**
+ * Resolve a tag to its definition. Priority: the file's imports (barrels,
+ * renames, and default exports resolve via getExportedDeclarations), then
+ * same-file/global name lookup, then configured design-system packages.
+ */
+ const resolveTag = (
+ pending: PendingInstance,
+ ): { definitionId: string; external: boolean } | null => {
+ const sourceFile = pending.element.getSourceFile();
+ const importDecl = sourceFile.getImportDeclarations().find((decl) => {
+ if (decl.getDefaultImport()?.getText() === pending.tagName) return true;
+ return decl.getNamedImports().some((named) => (named.getAliasNode()?.getText() ?? named.getName()) === pending.tagName);
+ });
+
+ if (importDecl !== undefined) {
+ const target = importDecl.getModuleSpecifierSourceFile();
+ const specifier = importDecl.getModuleSpecifierValue();
+ const inNodeModules = target?.getFilePath().includes("node_modules") ?? false;
+ if (target !== undefined && !inNodeModules) {
+ const named = importDecl
+ .getNamedImports()
+ .find((n) => (n.getAliasNode()?.getText() ?? n.getName()) === pending.tagName);
+ const importedName = named !== undefined ? named.getName() : "default";
+ for (const declaration of target.getExportedDeclarations().get(importedName) ?? []) {
+ const declName = Node.hasName(declaration) ? declaration.getName() : undefined;
+ if (declName === undefined) continue;
+ const declFile = toPosix(path.relative(root, declaration.getSourceFile().getFilePath()));
+ const resolvedName = resolveAlias(declName);
+ const candidate = nodeId("component", declFile, resolvedName);
+ if (nodes.has(candidate)) return { definitionId: candidate, external: false };
+ }
+ }
+ const isDesignSystem = designSystemPackages.some(
+ (pkg) => specifier === pkg || specifier.startsWith(`${pkg}/`),
+ );
+ if (isDesignSystem || inNodeModules) {
+ return { definitionId: `external:${specifier}#${pending.tagName}`, external: true };
+ }
+ return null; // imported from an unknown, unconfigured module — not ours
}
- return name;
+
+ // Same file first, then unique-name fallback across the project.
+ const resolvedName = resolveAlias(pending.tagName);
+ const sameFile = nodeId("component", pending.file, resolvedName);
+ if (nodes.has(sameFile)) return { definitionId: sameFile, external: false };
+ const global = definitionsByName.get(resolvedName);
+ return global !== undefined ? { definitionId: global, external: false } : null;
};
- for (const pending of pendingInstances) {
- const definitionId = definitionsByName.get(resolveAlias(pending.tagName));
- if (definitionId === undefined || definitionId === pending.ownerId) continue;
+ const idByIndex: Array = [];
+ const created: InstanceNode[] = [];
+ for (const [index, pending] of pendingInstances.entries()) {
+ const resolved = resolveTag(pending);
+ if (resolved === null || resolved.definitionId === pending.ownerId) {
+ idByIndex[index] = null;
+ continue;
+ }
let id = instanceId(pending.file, pending.loc.line, pending.tagName);
let suffix = 1;
@@ -835,13 +942,29 @@ function materializeInstances(
kind: "instance",
name: pending.tagName,
loc: pending.loc,
- definitionId,
+ definitionId: resolved.definitionId,
parentInstanceId: null,
staticProps: pending.staticProps,
+ ...(resolved.external ? { flags: ["external-definition"] } : {}),
};
nodes.set(id, instance);
+ idByIndex[index] = id;
+ created.push(instance);
addEdge({ from: pending.ownerId, to: id, kind: "renders" });
- addEdge({ from: id, to: definitionId, kind: "instance-of" });
+ if (!resolved.external) {
+ addEdge({ from: id, to: resolved.definitionId, kind: "instance-of" });
+ }
+ }
+
+ // Second pass: same-body nesting resolved now that every id exists.
+ for (const [index, pending] of pendingInstances.entries()) {
+ const id = idByIndex[index];
+ if (id === null || id === undefined || pending.parentIndex === null) continue;
+ const parentId = idByIndex[pending.parentIndex];
+ const instance = created.find((n) => n.id === id);
+ if (instance !== undefined && parentId !== null && parentId !== undefined) {
+ instance.parentInstanceId = parentId;
+ }
}
}