From 0e784043cb60da3b6cb7829ccc1b4f4403e9eb49 Mon Sep 17 00:00:00 2001 From: Deep Kumar Singh Kushwah Date: Wed, 15 Jul 2026 02:00:31 +0530 Subject: [PATCH] feat(match): structural signatures + structure-aware matching (A1/A3/A12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match components by shape when text is scarce (TRACKER step 4.2): - Parser emits a StructuralSignature per component — counts of tables, columns (th), forms, inputs, buttons, links, images, headings, lists, and repeated (.map) items — folding raw DOM tags and common design-system names (, , , …) into the same buckets. - New matchComponents(graph, { terms?, structure? }) combines the rarity- weighted text score (4.1) with a structure-descriptor fit into one ranking; matchComponentsByText now wraps it. A vision-style descriptor ("a table with four columns and a card grid") ranks the right component even with zero text. Schema: ComponentNode.structure (StructuralSignature) + StructureDescriptor; JSON schema regenerated. Eval GoldenQuery gains an optional `structure`. Fixture a1-no-static-text (dashboard/form/gallery/list, no literals) matched by structure alone. 36 core + 97 parser tests pass; eval green. Co-Authored-By: Claude Opus 4.8 --- TRACKER.md | 6 +- .../a1-no-static-text/app/ImageGallery.tsx | 14 ++++ .../a1-no-static-text/app/LoginForm.tsx | 9 +++ .../a1-no-static-text/app/SettingsList.tsx | 14 ++++ .../a1-no-static-text/app/StatsDashboard.tsx | 42 ++++++++++++ eval/fixtures/a1-no-static-text/golden.json | 17 +++++ eval/src/checks.ts | 6 +- eval/src/golden.ts | 2 + packages/core/src/query.ts | 68 +++++++++++++++++-- packages/core/src/types.ts | 41 +++++++++++ packages/parser-react/src/scan.ts | 65 ++++++++++++++++++ packages/parser-react/src/structure.test.ts | 49 +++++++++++++ schemas/lineage-graph.schema.json | 59 +++++++++++++++- 13 files changed, 379 insertions(+), 13 deletions(-) create mode 100644 eval/fixtures/a1-no-static-text/app/ImageGallery.tsx create mode 100644 eval/fixtures/a1-no-static-text/app/LoginForm.tsx create mode 100644 eval/fixtures/a1-no-static-text/app/SettingsList.tsx create mode 100644 eval/fixtures/a1-no-static-text/app/StatsDashboard.tsx create mode 100644 eval/fixtures/a1-no-static-text/golden.json create mode 100644 packages/parser-react/src/structure.test.ts 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 ( +
+ + +
+ + + + + + + + + + {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; + /** `
    `/`
      `/`
    1. ` 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": "`
    `/`
      `/`
    1. ` 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": {