diff --git a/TRACKER.md b/TRACKER.md
index 26b81ea..2da687a 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -5,8 +5,8 @@
## Status
- **Current phase:** 4 — Matching engine
-- **Next step:** 4.2 — Structural matching
-- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5, 3.1–3.5, 4.1
+- **Next step:** 4.3 — Most-specific-subtree resolution
+- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5, 3.1–3.5, 4.1–4.2
- **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)
## What CodeRadar is
@@ -226,7 +226,7 @@ The heart of the project. C1 and B1 live here.
- Combination bonus: co-occurrence of multiple terms in one instance subtree outweighs scattered singles.
**Accept:** `a4-generic-text` green ("Save" alone → `ambiguous`; "Save" + "invoice details" → correct top-1); noisy-term fixture `a10-ocr-noise` (misspelled terms) top-3 correct.
-### [ ] 4.2 Structural matching
+### [x] 4.2 Structural matching
**Failure modes:** A1, A3, A12
**Build:** structural signature per instance subtree (child element kinds/counts: table with N columns, form with M inputs, card grid). Query side accepts a structure descriptor (from vision output: "a table with columns Name, Email, Actions") and scores against signatures. Text and structure scores combine into one ranking.
**Accept:** fixture `a1-no-static-text` (dashboard, zero literals) top-3 correct via structure alone.
diff --git a/eval/fixtures/a1-no-static-text/app/ImageGallery.tsx b/eval/fixtures/a1-no-static-text/app/ImageGallery.tsx
new file mode 100644
index 0000000..f5d522a
--- /dev/null
+++ b/eval/fixtures/a1-no-static-text/app/ImageGallery.tsx
@@ -0,0 +1,14 @@
+interface Photo {
+ id: string;
+ src: string;
+}
+
+export function ImageGallery({ photos }: { photos: Photo[] }) {
+ return (
+
+ {photos.map((p) => (
+

+ ))}
+
+ );
+}
diff --git a/eval/fixtures/a1-no-static-text/app/LoginForm.tsx b/eval/fixtures/a1-no-static-text/app/LoginForm.tsx
new file mode 100644
index 0000000..0af2494
--- /dev/null
+++ b/eval/fixtures/a1-no-static-text/app/LoginForm.tsx
@@ -0,0 +1,9 @@
+export function LoginForm({ onSubmit }: { onSubmit: () => void }) {
+ return (
+
+ );
+}
diff --git a/eval/fixtures/a1-no-static-text/app/SettingsList.tsx b/eval/fixtures/a1-no-static-text/app/SettingsList.tsx
new file mode 100644
index 0000000..10afcfe
--- /dev/null
+++ b/eval/fixtures/a1-no-static-text/app/SettingsList.tsx
@@ -0,0 +1,14 @@
+interface Option {
+ id: string;
+ name: string;
+}
+
+export function SettingsList({ options }: { options: Option[] }) {
+ return (
+
+ {options.map((o) => (
+ - {o.name}
+ ))}
+
+ );
+}
diff --git a/eval/fixtures/a1-no-static-text/app/StatsDashboard.tsx b/eval/fixtures/a1-no-static-text/app/StatsDashboard.tsx
new file mode 100644
index 0000000..4d1381b
--- /dev/null
+++ b/eval/fixtures/a1-no-static-text/app/StatsDashboard.tsx
@@ -0,0 +1,42 @@
+interface Stat {
+ id: string;
+ label: string;
+ value: number;
+}
+interface Row {
+ id: string;
+}
+
+// A dashboard with zero static text — a card grid over a 4-column table. It can
+// only be matched by structure (Phase 4.2).
+export function StatsDashboard({ stats, rows }: { stats: Stat[]; rows: Row[] }) {
+ return (
+
+
+ {stats.map((s) => (
+
+ {s.label}
+ {s.value}
+
+ ))}
+
+
+
+
+ |
+ |
+ |
+ |
+
+
+
+ {rows.map((r) => (
+
+ | {r.id} |
+
+ ))}
+
+
+
+ );
+}
diff --git a/eval/fixtures/a1-no-static-text/golden.json b/eval/fixtures/a1-no-static-text/golden.json
new file mode 100644
index 0000000..a610ff3
--- /dev/null
+++ b/eval/fixtures/a1-no-static-text/golden.json
@@ -0,0 +1,17 @@
+{
+ "failureMode": "A1",
+ "note": "No static text: a dashboard, form, gallery and list carry zero literals, so text matching finds nothing. Structural signatures (table with N columns, form with M inputs, card/image grid) let a vision-style structure descriptor still rank the right component top-1.",
+ "expect": {
+ "components": [
+ { "name": "StatsDashboard", "instances": 0 },
+ { "name": "LoginForm", "instances": 0 },
+ { "name": "ImageGallery", "instances": 0 },
+ { "name": "SettingsList", "instances": 0 }
+ ],
+ "queries": [
+ { "terms": [], "structure": { "table": true, "columns": 4, "cards": 2 }, "status": "ok", "top": "StatsDashboard", "topK": 3 },
+ { "terms": [], "structure": { "form": true, "inputs": 2, "buttons": 1 }, "status": "ok", "top": "LoginForm", "topK": 3 },
+ { "terms": [], "structure": { "table": true }, "status": "ok", "top": "StatsDashboard" }
+ ]
+ }
+}
diff --git a/eval/src/checks.ts b/eval/src/checks.ts
index 0c5cf7f..a003d81 100644
--- a/eval/src/checks.ts
+++ b/eval/src/checks.ts
@@ -3,7 +3,7 @@
import {
journeys,
type LineageGraph,
- matchComponentsByText,
+ matchComponents,
traceLineage,
} from "@coderadar/core";
@@ -217,8 +217,8 @@ export function runChecks(fixture: string, golden: Golden, graph: LineageGraph):
}
for (const query of golden.expect.queries ?? []) {
- const id = `query:${query.terms.join("+")}`;
- const result = matchComponentsByText(graph, query.terms);
+ const id = `query:${query.terms.join("+") || JSON.stringify(query.structure)}`;
+ const result = matchComponents(graph, { terms: query.terms, structure: query.structure });
let passed = result.status === query.status;
let detail: string | undefined;
if (!passed) {
diff --git a/eval/src/golden.ts b/eval/src/golden.ts
index dc87240..cefd07d 100644
--- a/eval/src/golden.ts
+++ b/eval/src/golden.ts
@@ -35,6 +35,8 @@ export interface GoldenForbidden {
export interface GoldenQuery {
terms: string[];
+ /** Optional structural descriptor (Phase 4.2), e.g. { table: true, columns: 4 }. */
+ structure?: import("@coderadar/core").StructureDescriptor;
status: "ok" | "ambiguous" | "declined";
/** Required top-1 component name when status is "ok". */
top?: string;
diff --git a/packages/core/src/query.ts b/packages/core/src/query.ts
index ace2690..87f2718 100644
--- a/packages/core/src/query.ts
+++ b/packages/core/src/query.ts
@@ -29,6 +29,8 @@ import type {
RouteNode,
SourceLocation,
StateNode,
+ StructuralSignature,
+ StructureDescriptor,
} from "./types.js";
export interface ComponentMatch {
@@ -54,12 +56,36 @@ export interface ComponentMatch {
* Returns `ambiguous` when the leaders tie on rarity-weighted score (a lone
* generic term is honestly ambiguous), `declined("no-signal")` on no match.
*/
+/** A screenshot/ticket query: visible text, a structure descriptor, or both. */
+export interface MatchQuery {
+ terms?: string[];
+ structure?: StructureDescriptor;
+}
+
+/** Weight of a full structural match relative to a rare matched term. */
+const STRUCTURE_WEIGHT = 3;
+
+/** Back-compat text-only entry point. */
export function matchComponentsByText(
graph: LineageGraph,
terms: string[],
): QueryResult {
- const queryTerms = terms.map((t) => normalizeText(t)).filter((t) => t.length > 1);
- if (queryTerms.length === 0) return declined("no-signal");
+ return matchComponents(graph, { terms });
+}
+
+/**
+ * Match components by rendered text (rarity-weighted, fuzzy — step 4.1) and/or
+ * structural shape (step 4.2). Text and structure scores combine into one
+ * ranking, so a dashboard with no static text still matches on "a table with
+ * four columns and a card grid".
+ */
+export function matchComponents(
+ graph: LineageGraph,
+ query: MatchQuery,
+): QueryResult {
+ const queryTerms = (query.terms ?? []).map((t) => normalizeText(t)).filter((t) => t.length > 1);
+ const descriptor = query.structure;
+ if (queryTerms.length === 0 && descriptor === undefined) return declined("no-signal");
const instancesByDefinition = groupInstances(graph);
const components = graph.nodes.filter((n): n is ComponentNode => n.kind === "component");
@@ -129,16 +155,27 @@ export function matchComponentsByText(
loc: component.loc,
});
}
- if (matched.length === 0) continue;
- const combination = 1 + 0.5 * (matched.length - 1);
+ const textScore = matched.length > 0 ? weight * (1 + 0.5 * (matched.length - 1)) : 0;
+
+ const structureFit = descriptor !== undefined ? structureScore(component.structure, descriptor) : 0;
+ if (structureFit > 0) {
+ evidence.push({
+ kind: "structure",
+ detail: `structural shape matched the descriptor (fit ${structureFit.toFixed(2)})`,
+ loc: component.loc,
+ });
+ }
+
+ if (matched.length === 0 && structureFit === 0) continue;
+ const coverage = queryTerms.length > 0 ? matched.length / queryTerms.length : structureFit;
scored.push({
match: {
component,
instances: instancesByDefinition.get(component.id) ?? [],
matchedText: matched,
},
- score: weight * combination,
- coverage: matched.length / queryTerms.length,
+ score: textScore + structureFit * STRUCTURE_WEIGHT,
+ coverage: Math.max(coverage, structureFit),
evidence,
});
}
@@ -536,6 +573,25 @@ export function journeys(
return ok([{ value: paths, confidence: confidenceFromScore(0.9), evidence }]);
}
+/**
+ * Fraction (0–1) of a structure descriptor's specified expectations that a
+ * component's signature satisfies. Counts and columns match with tolerance so
+ * OCR/vision miscounts still land.
+ */
+function structureScore(sig: StructuralSignature, desc: StructureDescriptor): number {
+ const checks: boolean[] = [];
+ if (desc.table !== undefined) checks.push(desc.table === sig.table > 0);
+ if (desc.form !== undefined) checks.push(desc.form === sig.form > 0);
+ if (desc.list !== undefined) checks.push(desc.list === (sig.list > 0 || sig.repeated > 0));
+ if (desc.columns !== undefined) checks.push(Math.abs(sig.columns - desc.columns) <= 1);
+ if (desc.inputs !== undefined) checks.push(sig.input >= desc.inputs - 1);
+ if (desc.buttons !== undefined) checks.push(sig.button >= desc.buttons - 1);
+ if (desc.images !== undefined) checks.push(sig.image >= desc.images - 1);
+ if (desc.cards !== undefined) checks.push(sig.repeated >= Math.max(1, desc.cards - 1));
+ if (checks.length === 0) return 0;
+ return checks.filter(Boolean).length / checks.length;
+}
+
/** True when `phrase` tokens appear as a contiguous, in-order, fuzzy run in `haystack`. */
function containsPhrase(haystack: string[], phrase: string[]): boolean {
if (phrase.length === 0 || phrase.length > haystack.length) return false;
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index f0b7ae9..a6ab9d1 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -67,6 +67,28 @@ export interface RenderedText {
template?: boolean;
}
+/**
+ * Counts of the structural elements a component renders — the signal for
+ * matching a screenshot with little or no static text (TRACKER step 4.2,
+ * failure modes A1/A3/A12). Raw DOM tags plus common design-system aliases
+ * (``, ``, …) are folded into these buckets.
+ */
+export interface StructuralSignature {
+ table: number;
+ /** Column headers — the max `` (or design-system column) count in a table. */
+ columns: number;
+ form: number;
+ input: number;
+ button: number;
+ link: number;
+ image: number;
+ heading: number;
+ /** ``/``/`- ` groupings. */
+ list: number;
+ /** `.map(...)` JSX renders — repeated cards/rows/grid items. */
+ repeated: number;
+}
+
/** A React component definition — the code, not a usage. */
export interface ComponentNode extends BaseNode {
kind: "component";
@@ -82,6 +104,25 @@ export interface ComponentNode extends BaseNode {
renderedText: RenderedText[];
/** Names of components this component renders in its JSX (deduplicated). */
rendersComponents: string[];
+ /** Structural fingerprint of the rendered subtree (Phase 4.2). */
+ structure: StructuralSignature;
+}
+
+/**
+ * A structural query, e.g. from vision output "a table with columns Name,
+ * Email, Actions". Only the specified fields are scored against a signature.
+ */
+export interface StructureDescriptor {
+ table?: boolean;
+ /** Expected column count (matched within ±1). */
+ columns?: number;
+ form?: boolean;
+ inputs?: number;
+ buttons?: number;
+ images?: number;
+ list?: boolean;
+ /** Expected count of repeated items (cards/rows), matched with tolerance. */
+ cards?: number;
}
/**
diff --git a/packages/parser-react/src/scan.ts b/packages/parser-react/src/scan.ts
index d4f624d..2908ddb 100644
--- a/packages/parser-react/src/scan.ts
+++ b/packages/parser-react/src/scan.ts
@@ -11,6 +11,7 @@ import {
nodeId,
type RenderedText,
type SourceLocation,
+ type StructuralSignature,
} from "@coderadar/core";
import {
type ArrowFunction,
@@ -198,6 +199,7 @@ export function scanReact(options: ScanOptions): LineageGraph {
...(localeTable !== null ? i18nRenderedText(decl.fn, localeTable) : []),
],
rendersComponents: extractRenderedComponents(decl.fn),
+ structure: extractStructure(decl.fn),
...(usesPortal ? { flags: ["portal"] } : {}),
});
collectInstanceSites(decl.fn, id, file, pendingInstances);
@@ -492,6 +494,67 @@ function classifyTest(test: Node, flagCallees: ReadonlySet): EdgeConditi
return undefined;
}
+/**
+ * Structural fingerprint of a component's rendered JSX (TRACKER step 4.2):
+ * counts of tables, columns, forms, inputs, etc., folding raw DOM tags and
+ * common design-system component names into the same buckets so a screenshot
+ * with little text still matches on shape (failure modes A1/A3/A12).
+ */
+function emptyStructure(): StructuralSignature {
+ return {
+ table: 0,
+ columns: 0,
+ form: 0,
+ input: 0,
+ button: 0,
+ link: 0,
+ image: 0,
+ heading: 0,
+ list: 0,
+ repeated: 0,
+ };
+}
+
+function extractStructure(fn: Node): StructuralSignature {
+ const sig = emptyStructure();
+ let thCount = 0;
+ const tags = [
+ ...fn.getDescendantsOfKind(SyntaxKind.JsxOpeningElement),
+ ...fn.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement),
+ ];
+ for (const el of tags) {
+ const raw = el.getTagNameNode().getText();
+ const tag = raw.split(".").pop() ?? raw;
+ const lower = tag.toLowerCase();
+ if (lower === "table" || /^(data)?(table|grid|datagrid)$/.test(lower)) sig.table += 1;
+ else if (lower === "form" || tag === "Formik") sig.form += 1;
+ else if (
+ lower === "input" ||
+ lower === "select" ||
+ lower === "textarea" ||
+ /^(textfield|input|select|checkbox|radio|switch|datepicker)$/.test(lower)
+ )
+ sig.input += 1;
+ else if (lower === "button" || /^(iconbutton|button)$/.test(lower)) sig.button += 1;
+ else if (lower === "a" || tag === "Link" || tag === "NavLink") sig.link += 1;
+ else if (lower === "img" || tag === "Image" || tag === "Avatar") sig.image += 1;
+ else if (/^h[1-6]$/.test(lower) || tag === "Heading" || tag === "Title") sig.heading += 1;
+ else if (lower === "ul" || lower === "ol" || tag === "List") sig.list += 1;
+ if (lower === "th") thCount += 1;
+ }
+ sig.columns = thCount;
+ // Repeated items: `.map(cb)` where the callback returns JSX (rows, cards, grid).
+ for (const call of fn.getDescendantsOfKind(SyntaxKind.CallExpression)) {
+ const callee = call.getExpression();
+ if (!Node.isPropertyAccessExpression(callee) || callee.getName() !== "map") continue;
+ const cb = call.getArguments()[0];
+ if (cb !== undefined && (Node.isArrowFunction(cb) || Node.isFunctionExpression(cb)) && returnsJsx(cb)) {
+ sig.repeated += 1;
+ }
+ }
+ return sig;
+}
+
function extractRenderedComponents(fn: Node): string[] {
const names = new Set();
const record = (tagName: string): void => {
@@ -1089,6 +1152,7 @@ function scanClassComponents(
props: [],
renderedText: [],
rendersComponents: [],
+ structure: emptyStructure(),
flags: ["incomplete"],
});
continue;
@@ -1106,6 +1170,7 @@ function scanClassComponents(
...(localeTable !== null ? i18nRenderedText(render, localeTable) : []),
],
rendersComponents: extractRenderedComponents(render),
+ structure: extractStructure(render),
});
collectInstanceSites(render, id, file, pendingInstances);
// Whole class body: lifecycle fetches (componentDidMount etc.) + render events.
diff --git a/packages/parser-react/src/structure.test.ts b/packages/parser-react/src/structure.test.ts
new file mode 100644
index 0000000..f3d8129
--- /dev/null
+++ b/packages/parser-react/src/structure.test.ts
@@ -0,0 +1,49 @@
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+import { matchComponents } from "@coderadar/core";
+import { describe, expect, it } from "vitest";
+
+import { resolveHookEdges, scanReact } from "./scan.js";
+
+const a1 = resolveHookEdges(
+ scanReact({
+ root: path.resolve(
+ path.dirname(fileURLToPath(import.meta.url)),
+ "../../../eval/fixtures/a1-no-static-text/app",
+ ),
+ }),
+);
+
+const structureOf = (name: string) =>
+ a1.nodes.find((n) => n.kind === "component" && n.name === name)?.kind === "component"
+ ? (a1.nodes.find((n) => n.kind === "component" && n.name === name) as { structure: unknown }).structure
+ : undefined;
+
+describe("structural signatures (a1 fixture, TRACKER 4.2)", () => {
+ it("counts a table's columns and repeated items", () => {
+ expect(structureOf("StatsDashboard")).toMatchObject({ table: 1, columns: 4, repeated: 2 });
+ });
+
+ it("counts form inputs and buttons, folding raw DOM tags", () => {
+ expect(structureOf("LoginForm")).toMatchObject({ form: 1, input: 2, button: 1 });
+ });
+
+ it("these components carry no static text at all", () => {
+ for (const name of ["StatsDashboard", "LoginForm", "ImageGallery", "SettingsList"]) {
+ const c = a1.nodes.find((n) => n.kind === "component" && n.name === name);
+ expect(c?.kind === "component" ? c.renderedText.length : -1).toBe(0);
+ }
+ });
+
+ it("matches a text-free dashboard by structure descriptor alone", () => {
+ const result = matchComponents(a1, { structure: { table: true, columns: 4, cards: 2 } });
+ expect(result.status).toBe("ok");
+ expect(result.candidates[0]?.value.component.name).toBe("StatsDashboard");
+ });
+
+ it("matches a form by its shape, not its (absent) text", () => {
+ const result = matchComponents(a1, { structure: { form: true, inputs: 2, buttons: 1 } });
+ expect(result.candidates[0]?.value.component.name).toBe("LoginForm");
+ });
+});
diff --git a/schemas/lineage-graph.schema.json b/schemas/lineage-graph.schema.json
index 1167c26..5ca662b 100644
--- a/schemas/lineage-graph.schema.json
+++ b/schemas/lineage-graph.schema.json
@@ -144,6 +144,10 @@
"type": "string"
},
"description": "Names of components this component renders in its JSX (deduplicated)."
+ },
+ "structure": {
+ "$ref": "#/definitions/StructuralSignature",
+ "description": "Structural fingerprint of the rendered subtree (Phase 4.2)."
}
},
"required": [
@@ -154,7 +158,8 @@
"name",
"props",
"renderedText",
- "rendersComponents"
+ "rendersComponents",
+ "structure"
],
"additionalProperties": false,
"description": "A React component definition — the code, not a usage."
@@ -223,6 +228,58 @@
"additionalProperties": false,
"description": "One piece of text a component can render, with its provenance."
},
+ "StructuralSignature": {
+ "type": "object",
+ "properties": {
+ "table": {
+ "type": "number"
+ },
+ "columns": {
+ "type": "number",
+ "description": "Column headers — the max `
` (or design-system column) count in a table."
+ },
+ "form": {
+ "type": "number"
+ },
+ "input": {
+ "type": "number"
+ },
+ "button": {
+ "type": "number"
+ },
+ "link": {
+ "type": "number"
+ },
+ "image": {
+ "type": "number"
+ },
+ "heading": {
+ "type": "number"
+ },
+ "list": {
+ "type": "number",
+ "description": "``/``/`- ` groupings."
+ },
+ "repeated": {
+ "type": "number",
+ "description": "`.map(...)` JSX renders — repeated cards/rows/grid items."
+ }
+ },
+ "required": [
+ "table",
+ "columns",
+ "form",
+ "input",
+ "button",
+ "link",
+ "image",
+ "heading",
+ "list",
+ "repeated"
+ ],
+ "additionalProperties": false,
+ "description": "Counts of the structural elements a component renders — the signal for matching a screenshot with little or no static text (TRACKER step 4.2, failure modes A1/A3/A12). Raw DOM tags plus common design-system aliases (`
`, ``, …) are folded into these buckets."
+ },
"InstanceNode": {
"type": "object",
"properties": {
| |