Skip to content

Commit ce7a3bc

Browse files
Merge pull request #13 from officialCodeWork/build/phase-2/step-2.1-instance-tree
feat(parser-react): instance tree — import-resolved definitions, nesting, design-system instances
2 parents b628c81 + d2b2da4 commit ce7a3bc

9 files changed

Lines changed: 254 additions & 15 deletions

File tree

TRACKER.md

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

77
- **Current phase:** 2 — Instance graph & cross-file data flow
8-
- **Next step:** 2.1Instance tree construction
9-
- **Done:** 0.1–0.4, 1.1–1.6
8+
- **Next step:** 2.2Prop-flow: data attribution per instance
9+
- **Done:** 0.1–0.4, 1.1–1.6, 2.1
1010
- **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)
1111

1212
## What CodeRadar is
@@ -140,7 +140,7 @@ follow-one-reference; the cross-file instance/prop-flow machinery is Phase 2.
140140

141141
The heart of the project. C1 and B1 live here.
142142

143-
### [ ] 2.1 Instance tree construction
143+
### [x] 2.1 Instance tree construction
144144
**Failure modes:** C1 (graph half), A5
145145
**Build:** cross-file pass in `parser-react`:
146146
- Resolve every JSX tag to its component definition through imports (including `export { X as Y }`, barrel files, default exports).
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { Avatar, ProfileCard } from "./ui";
2+
3+
export function MembersPage() {
4+
return (
5+
<section>
6+
<h1>Members directory</h1>
7+
<ProfileCard name="Ada" />
8+
<Avatar />
9+
</section>
10+
);
11+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { Button, DataGrid } from "@acme/ui";
2+
3+
export function SettingsPage() {
4+
return (
5+
<main>
6+
<h1>Workspace settings</h1>
7+
<DataGrid title="Team grid" />
8+
<Button label="Save changes" />
9+
</main>
10+
);
11+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export function ProfileCardInner({ name }: { name: string }) {
2+
return (
3+
<div>
4+
<h3>Profile details</h3>
5+
<span>{name}</span>
6+
</div>
7+
);
8+
}
9+
10+
export default function AvatarBadge() {
11+
return <img alt="Member avatar" />;
12+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { ProfileCardInner as ProfileCard } from "./ProfileCard";
2+
export { default as Avatar } from "./ProfileCard";
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"failureMode": "A5",
3+
"note": "Design-system components: <Button label='Save changes'/> 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).",
4+
"scan": {
5+
"designSystemPackages": ["@acme/ui"]
6+
},
7+
"expect": {
8+
"components": [
9+
{ "name": "SettingsPage", "instances": 0 },
10+
{ "name": "MembersPage", "instances": 0 },
11+
{ "name": "ProfileCardInner", "instances": 1 },
12+
{ "name": "AvatarBadge", "instances": 1 }
13+
],
14+
"queries": [
15+
{ "terms": ["Save changes"], "status": "ok", "top": "SettingsPage" },
16+
{ "terms": ["Team grid"], "status": "ok", "top": "SettingsPage" },
17+
{ "terms": ["Profile details"], "status": "ok", "top": "ProfileCardInner" },
18+
{ "terms": ["Members directory"], "status": "ok", "top": "MembersPage" }
19+
]
20+
}
21+
}

eval/history.jsonl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@
55
{"generatedAt":"2026-07-13T10:02:55.141Z","commitSha":"67411c5662523fd633383013f6a0591ac3faea3c","pass":67,"fail":0,"xfail":2,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":0.889,"matchAccuracy":1}
66
{"generatedAt":"2026-07-13T10:09:02.527Z","commitSha":"4b4fd9e72204c47fa8ad523ff9b68fee41fc3a26","pass":77,"fail":0,"xfail":2,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":0.889,"matchAccuracy":1}
77
{"generatedAt":"2026-07-13T11:04:05.130Z","commitSha":"d13b90dd99c993889f57218997984e3e2e601cfd","pass":91,"fail":0,"xfail":2,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":0.895,"matchAccuracy":1}
8+
{"generatedAt":"2026-07-13T11:10:56.348Z","commitSha":"b628c816231c4896f030ebc68a6621d0963d183c","pass":99,"fail":0,"xfail":2,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":0.895,"matchAccuracy":1}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import path from "node:path";
2+
import { fileURLToPath } from "node:url";
3+
4+
import type { InstanceNode } from "@coderadar/core";
5+
import { describe, expect, it } from "vitest";
6+
7+
import { scanReact } from "./scan.js";
8+
9+
const fixtures = path.resolve(
10+
path.dirname(fileURLToPath(import.meta.url)),
11+
"../../../eval/fixtures",
12+
);
13+
14+
const graph = scanReact({
15+
root: path.join(fixtures, "a5-design-system/app"),
16+
designSystemPackages: ["@acme/ui"],
17+
});
18+
19+
function instancesNamed(name: string): InstanceNode[] {
20+
return graph.nodes.flatMap((n) => (n.kind === "instance" && n.name === name ? [n] : []));
21+
}
22+
23+
describe("instance tree construction (a5 fixture)", () => {
24+
it("creates flagged instances for design-system components", () => {
25+
const button = instancesNamed("Button")[0];
26+
expect(button?.definitionId).toBe("external:@acme/ui#Button");
27+
expect(button?.flags).toContain("external-definition");
28+
expect(button?.staticProps).toEqual({ label: "Save changes" });
29+
});
30+
31+
it("resolves barrel re-exports with rename to the original definition", () => {
32+
const card = instancesNamed("ProfileCard")[0];
33+
expect(card?.definitionId).toBe("component:ui/ProfileCard.tsx#ProfileCardInner");
34+
});
35+
36+
it("resolves default-export aliases through the barrel", () => {
37+
const avatar = instancesNamed("Avatar")[0];
38+
expect(avatar?.definitionId).toBe("component:ui/ProfileCard.tsx#AvatarBadge");
39+
});
40+
41+
it("does not create instances for unconfigured external modules", () => {
42+
// react-i18next's <Trans> in other fixtures never materializes; here,
43+
// assert the only external instances are the two @acme/ui ones.
44+
const externals = graph.nodes.filter(
45+
(n) => n.kind === "instance" && n.flags?.includes("external-definition"),
46+
);
47+
expect(externals.map((n) => n.name).sort()).toEqual(["Button", "DataGrid"]);
48+
});
49+
});
50+
51+
describe("same-body nesting (parentInstanceId)", () => {
52+
it("links nested project-component call sites", () => {
53+
const nested = scanReact({ root: path.join(fixtures, "..", "..", "examples/demo-app/src") });
54+
// demo-app has no nesting — assert null baseline holds.
55+
const card = nested.nodes.find((n) => n.kind === "instance" && n.name === "UserCard");
56+
expect(card?.kind === "instance" ? card.parentInstanceId : "missing").toBeNull();
57+
});
58+
});

packages/parser-react/src/scan.ts

Lines changed: 135 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,13 @@ export interface ScanOptions {
5151
* in any language match.
5252
*/
5353
i18n?: I18nOptions;
54+
/**
55+
* Package names whose components should still produce instances even
56+
* though their definitions live outside the repo (e.g. ["@acme/ui"]).
57+
* Such instances are flagged "external-definition" — the usage site is
58+
* ours even when the definition isn't (failure mode A5).
59+
*/
60+
designSystemPackages?: string[];
5461
}
5562

5663
type FunctionLike = FunctionDeclaration | ArrowFunction | FunctionExpression;
@@ -72,6 +79,14 @@ interface PendingInstance {
7279
/** Node id of the enclosing component/hook declaration. */
7380
ownerId: string;
7481
file: string;
82+
/** The JSX element itself — used for import resolution and nesting. */
83+
element: JsxOpeningElement | JsxSelfClosingElement;
84+
/**
85+
* Index (into the global pending list) of the nearest enclosing component
86+
* call site in the same body: <Card><Button/></Card> → Button's parent is
87+
* the Card site. Null at the body root (the renders edge covers the owner).
88+
*/
89+
parentIndex: number | null;
7590
}
7691

7792
const COMPONENT_NAME = /^[A-Z]/;
@@ -153,7 +168,14 @@ export function scanReact(options: ScanOptions): LineageGraph {
153168
collectHocAliases(sourceFile, hocAliases);
154169
}
155170

156-
materializeInstances(pendingInstances, nodes, addEdge, hocAliases);
171+
materializeInstances(
172+
pendingInstances,
173+
nodes,
174+
addEdge,
175+
hocAliases,
176+
options.designSystemPackages ?? [],
177+
root,
178+
);
157179

158180
return {
159181
version: 2,
@@ -772,6 +794,7 @@ function collectInstanceSites(
772794
file: string,
773795
pendingInstances: PendingInstance[],
774796
): void {
797+
const bodyStart = pendingInstances.length;
775798
const record = (el: JsxOpeningElement | JsxSelfClosingElement): void => {
776799
const head = el.getTagNameNode().getText().split(".")[0];
777800
if (head === undefined || !COMPONENT_NAME.test(head)) return;
@@ -783,10 +806,38 @@ function collectInstanceSites(
783806
staticProps[attr.getNameNode().getText()] = init.getLiteralValue();
784807
}
785808
}
786-
pendingInstances.push({ tagName: head, loc: locOf(el, file), staticProps, ownerId, file });
809+
pendingInstances.push({
810+
tagName: head,
811+
loc: locOf(el, file),
812+
staticProps,
813+
ownerId,
814+
file,
815+
element: el,
816+
parentIndex: null,
817+
});
787818
};
788819
for (const el of body.getDescendantsOfKind(SyntaxKind.JsxOpeningElement)) record(el);
789820
for (const el of body.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement)) record(el);
821+
822+
// Same-body nesting: <Card><Button/></Card> → Button's parent site is Card.
823+
const bodySites = pendingInstances.slice(bodyStart);
824+
for (const site of bodySites) {
825+
let ancestor: Node | undefined = site.element.getParent();
826+
while (ancestor !== undefined && ancestor !== body && site.parentIndex === null) {
827+
for (let i = 0; i < bodySites.length; i += 1) {
828+
const candidate = bodySites[i];
829+
if (candidate === undefined || candidate === site) continue;
830+
const candidateElement: Node | undefined = Node.isJsxOpeningElement(candidate.element)
831+
? candidate.element.getParent() // the enclosing JsxElement
832+
: candidate.element;
833+
if (candidateElement !== undefined && candidateElement === ancestor) {
834+
site.parentIndex = bodyStart + i;
835+
break;
836+
}
837+
}
838+
ancestor = ancestor.getParent();
839+
}
840+
}
790841
}
791842

792843
/**
@@ -802,6 +853,8 @@ function materializeInstances(
802853
nodes: Map<string, LineageNode>,
803854
addEdge: (edge: LineageEdge) => void,
804855
hocAliases: ReadonlyMap<string, string>,
856+
designSystemPackages: string[],
857+
root: string,
805858
): void {
806859
const definitionsByName = new Map<string, string>();
807860
for (const node of nodes.values()) {
@@ -810,19 +863,73 @@ function materializeInstances(
810863

811864
// <Panel/> where Panel = connect(...)(PanelInner): resolve through the alias
812865
// chain (bounded — alias graphs are tiny and could in principle cycle).
813-
const resolveAlias = (tagName: string): string => {
814-
let name = tagName;
866+
const resolveAlias = (name: string): string => {
867+
let current = name;
815868
for (let hop = 0; hop < 3; hop += 1) {
816-
const target = hocAliases.get(name);
869+
const target = hocAliases.get(current);
817870
if (target === undefined) break;
818-
name = target;
871+
current = target;
872+
}
873+
return current;
874+
};
875+
876+
/**
877+
* Resolve a tag to its definition. Priority: the file's imports (barrels,
878+
* renames, and default exports resolve via getExportedDeclarations), then
879+
* same-file/global name lookup, then configured design-system packages.
880+
*/
881+
const resolveTag = (
882+
pending: PendingInstance,
883+
): { definitionId: string; external: boolean } | null => {
884+
const sourceFile = pending.element.getSourceFile();
885+
const importDecl = sourceFile.getImportDeclarations().find((decl) => {
886+
if (decl.getDefaultImport()?.getText() === pending.tagName) return true;
887+
return decl.getNamedImports().some((named) => (named.getAliasNode()?.getText() ?? named.getName()) === pending.tagName);
888+
});
889+
890+
if (importDecl !== undefined) {
891+
const target = importDecl.getModuleSpecifierSourceFile();
892+
const specifier = importDecl.getModuleSpecifierValue();
893+
const inNodeModules = target?.getFilePath().includes("node_modules") ?? false;
894+
if (target !== undefined && !inNodeModules) {
895+
const named = importDecl
896+
.getNamedImports()
897+
.find((n) => (n.getAliasNode()?.getText() ?? n.getName()) === pending.tagName);
898+
const importedName = named !== undefined ? named.getName() : "default";
899+
for (const declaration of target.getExportedDeclarations().get(importedName) ?? []) {
900+
const declName = Node.hasName(declaration) ? declaration.getName() : undefined;
901+
if (declName === undefined) continue;
902+
const declFile = toPosix(path.relative(root, declaration.getSourceFile().getFilePath()));
903+
const resolvedName = resolveAlias(declName);
904+
const candidate = nodeId("component", declFile, resolvedName);
905+
if (nodes.has(candidate)) return { definitionId: candidate, external: false };
906+
}
907+
}
908+
const isDesignSystem = designSystemPackages.some(
909+
(pkg) => specifier === pkg || specifier.startsWith(`${pkg}/`),
910+
);
911+
if (isDesignSystem || inNodeModules) {
912+
return { definitionId: `external:${specifier}#${pending.tagName}`, external: true };
913+
}
914+
return null; // imported from an unknown, unconfigured module — not ours
819915
}
820-
return name;
916+
917+
// Same file first, then unique-name fallback across the project.
918+
const resolvedName = resolveAlias(pending.tagName);
919+
const sameFile = nodeId("component", pending.file, resolvedName);
920+
if (nodes.has(sameFile)) return { definitionId: sameFile, external: false };
921+
const global = definitionsByName.get(resolvedName);
922+
return global !== undefined ? { definitionId: global, external: false } : null;
821923
};
822924

823-
for (const pending of pendingInstances) {
824-
const definitionId = definitionsByName.get(resolveAlias(pending.tagName));
825-
if (definitionId === undefined || definitionId === pending.ownerId) continue;
925+
const idByIndex: Array<string | null> = [];
926+
const created: InstanceNode[] = [];
927+
for (const [index, pending] of pendingInstances.entries()) {
928+
const resolved = resolveTag(pending);
929+
if (resolved === null || resolved.definitionId === pending.ownerId) {
930+
idByIndex[index] = null;
931+
continue;
932+
}
826933

827934
let id = instanceId(pending.file, pending.loc.line, pending.tagName);
828935
let suffix = 1;
@@ -835,13 +942,29 @@ function materializeInstances(
835942
kind: "instance",
836943
name: pending.tagName,
837944
loc: pending.loc,
838-
definitionId,
945+
definitionId: resolved.definitionId,
839946
parentInstanceId: null,
840947
staticProps: pending.staticProps,
948+
...(resolved.external ? { flags: ["external-definition"] } : {}),
841949
};
842950
nodes.set(id, instance);
951+
idByIndex[index] = id;
952+
created.push(instance);
843953
addEdge({ from: pending.ownerId, to: id, kind: "renders" });
844-
addEdge({ from: id, to: definitionId, kind: "instance-of" });
954+
if (!resolved.external) {
955+
addEdge({ from: id, to: resolved.definitionId, kind: "instance-of" });
956+
}
957+
}
958+
959+
// Second pass: same-body nesting resolved now that every id exists.
960+
for (const [index, pending] of pendingInstances.entries()) {
961+
const id = idByIndex[index];
962+
if (id === null || id === undefined || pending.parentIndex === null) continue;
963+
const parentId = idByIndex[pending.parentIndex];
964+
const instance = created.find((n) => n.id === id);
965+
if (instance !== undefined && parentId !== null && parentId !== undefined) {
966+
instance.parentInstanceId = parentId;
967+
}
845968
}
846969
}
847970

0 commit comments

Comments
 (0)