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
6 changes: 3 additions & 3 deletions TRACKER.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
## Status

- **Current phase:** 2 — Instance graph & cross-file data flow
- **Next step:** 2.1Instance tree construction
- **Done:** 0.1–0.4, 1.1–1.6
- **Next step:** 2.2Prop-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
Expand Down Expand Up @@ -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).
Expand Down
11 changes: 11 additions & 0 deletions eval/fixtures/a5-design-system/app/MembersPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Avatar, ProfileCard } from "./ui";

export function MembersPage() {
return (
<section>
<h1>Members directory</h1>
<ProfileCard name="Ada" />
<Avatar />
</section>
);
}
11 changes: 11 additions & 0 deletions eval/fixtures/a5-design-system/app/SettingsPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Button, DataGrid } from "@acme/ui";

export function SettingsPage() {
return (
<main>
<h1>Workspace settings</h1>
<DataGrid title="Team grid" />
<Button label="Save changes" />
</main>
);
}
12 changes: 12 additions & 0 deletions eval/fixtures/a5-design-system/app/ui/ProfileCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export function ProfileCardInner({ name }: { name: string }) {
return (
<div>
<h3>Profile details</h3>
<span>{name}</span>
</div>
);
}

export default function AvatarBadge() {
return <img alt="Member avatar" />;
}
2 changes: 2 additions & 0 deletions eval/fixtures/a5-design-system/app/ui/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { ProfileCardInner as ProfileCard } from "./ProfileCard";
export { default as Avatar } from "./ProfileCard";
21 changes: 21 additions & 0 deletions eval/fixtures/a5-design-system/golden.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"failureMode": "A5",
"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).",
"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" }
]
}
}
1 change: 1 addition & 0 deletions eval/history.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -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}
58 changes: 58 additions & 0 deletions packages/parser-react/src/instances.test.ts
Original file line number Diff line number Diff line change
@@ -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 <Trans> 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();
});
});
147 changes: 135 additions & 12 deletions packages/parser-react/src/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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: <Card><Button/></Card> → 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]/;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -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: <Card><Button/></Card> → 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();
}
}
}

/**
Expand All @@ -802,6 +853,8 @@ function materializeInstances(
nodes: Map<string, LineageNode>,
addEdge: (edge: LineageEdge) => void,
hocAliases: ReadonlyMap<string, string>,
designSystemPackages: string[],
root: string,
): void {
const definitionsByName = new Map<string, string>();
for (const node of nodes.values()) {
Expand All @@ -810,19 +863,73 @@ function materializeInstances(

// <Panel/> 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<string | null> = [];
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;
Expand All @@ -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;
}
}
}

Expand Down
Loading