diff --git a/TRACKER.md b/TRACKER.md
index 2da687a..749edfe 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -5,8 +5,8 @@
## Status
- **Current phase:** 4 — Matching engine
-- **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
+- **Next step:** 4.4 — Screenshot adapter (@coderadar/vision)
+- **Done:** 0.1–0.4, 1.1–1.6, 2.1–2.5, 3.1–3.5, 4.1–4.3
- **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
@@ -231,7 +231,7 @@ The heart of the project. C1 and B1 live here.
**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.
-### [ ] 4.3 Most-specific-subtree resolution
+### [x] 4.3 Most-specific-subtree resolution
**Failure modes:** A6
**Build:** when matches nest (Page > Section > Card all match), return the deepest instance covering the matched term/structure set; ancestors listed as `context`, not competing candidates.
**Accept:** fixture `a6-composed-page` green: full-page term set → page node; card-specific terms → card instance with page as context.
diff --git a/eval/fixtures/a6-composed-page/app/DashboardPage.tsx b/eval/fixtures/a6-composed-page/app/DashboardPage.tsx
new file mode 100644
index 0000000..2a73494
--- /dev/null
+++ b/eval/fixtures/a6-composed-page/app/DashboardPage.tsx
@@ -0,0 +1,12 @@
+import { RevenueSection } from "./RevenueSection";
+import { SettingsSection } from "./SettingsSection";
+
+export function DashboardPage() {
+ return (
+
+ Analytics Dashboard
+
+
+
+ );
+}
diff --git a/eval/fixtures/a6-composed-page/app/RevenueCard.tsx b/eval/fixtures/a6-composed-page/app/RevenueCard.tsx
new file mode 100644
index 0000000..38a6acb
--- /dev/null
+++ b/eval/fixtures/a6-composed-page/app/RevenueCard.tsx
@@ -0,0 +1,8 @@
+export function RevenueCard() {
+ return (
+
+
Total revenue
+
vs last month
+
+ );
+}
diff --git a/eval/fixtures/a6-composed-page/app/RevenueSection.tsx b/eval/fixtures/a6-composed-page/app/RevenueSection.tsx
new file mode 100644
index 0000000..806b6a0
--- /dev/null
+++ b/eval/fixtures/a6-composed-page/app/RevenueSection.tsx
@@ -0,0 +1,10 @@
+import { RevenueCard } from "./RevenueCard";
+
+export function RevenueSection() {
+ return (
+
+ );
+}
diff --git a/eval/fixtures/a6-composed-page/app/SettingsSection.tsx b/eval/fixtures/a6-composed-page/app/SettingsSection.tsx
new file mode 100644
index 0000000..c9ef24d
--- /dev/null
+++ b/eval/fixtures/a6-composed-page/app/SettingsSection.tsx
@@ -0,0 +1,7 @@
+export function SettingsSection() {
+ return (
+
+ );
+}
diff --git a/eval/fixtures/a6-composed-page/golden.json b/eval/fixtures/a6-composed-page/golden.json
new file mode 100644
index 0000000..0aece58
--- /dev/null
+++ b/eval/fixtures/a6-composed-page/golden.json
@@ -0,0 +1,25 @@
+{
+ "failureMode": "A6",
+ "note": "Composed page: DashboardPage renders RevenueSection (→ RevenueCard) and SettingsSection, so several nested components 'cover' a query via their subtree. A full-page term set resolves to the page (smallest subtree covering all terms); a card-specific term set resolves to the deepest node (RevenueCard) with its ancestors listed as context, not competing candidates.",
+ "expect": {
+ "components": [
+ { "name": "DashboardPage", "instances": 0 },
+ { "name": "RevenueSection", "instances": 1 },
+ { "name": "RevenueCard", "instances": 1 },
+ { "name": "SettingsSection", "instances": 1 }
+ ],
+ "queries": [
+ {
+ "terms": ["Analytics Dashboard", "Total revenue", "Workspace settings"],
+ "status": "ok",
+ "top": "DashboardPage"
+ },
+ {
+ "terms": ["Total revenue", "vs last month"],
+ "status": "ok",
+ "top": "RevenueCard",
+ "context": ["RevenueSection", "DashboardPage"]
+ }
+ ]
+ }
+}
diff --git a/eval/src/checks.ts b/eval/src/checks.ts
index a003d81..ca77cfc 100644
--- a/eval/src/checks.ts
+++ b/eval/src/checks.ts
@@ -232,6 +232,11 @@ export function runChecks(fixture: string, golden: Golden, graph: LineageGraph):
k > 1
? `expected ${query.top} in top ${k}, got [${topNames.join(", ")}]`
: `expected top ${query.top}, got ${topNames[0] ?? "none"}`;
+ } else if (query.context !== undefined) {
+ const ctx = (result.candidates[0]?.value.context ?? []).map((c) => c.name);
+ const missing = query.context.filter((c) => !ctx.includes(c));
+ passed = missing.length === 0;
+ if (!passed) detail = `expected context [${query.context.join(", ")}], got [${ctx.join(", ")}]`;
}
}
finalize("queries", id, passed, query.expectedFail, detail);
diff --git a/eval/src/golden.ts b/eval/src/golden.ts
index cefd07d..808fc5b 100644
--- a/eval/src/golden.ts
+++ b/eval/src/golden.ts
@@ -40,6 +40,8 @@ export interface GoldenQuery {
status: "ok" | "ambiguous" | "declined";
/** Required top-1 component name when status is "ok". */
top?: string;
+ /** Ancestor names the top match must list as `context` (step 4.3). */
+ context?: string[];
/**
* When set, `top` need only appear within the first `topK` candidates rather
* than at rank 1 — the honest bar for OCR-noisy input (failure mode A10).
diff --git a/packages/core/src/query.ts b/packages/core/src/query.ts
index 87f2718..076606e 100644
--- a/packages/core/src/query.ts
+++ b/packages/core/src/query.ts
@@ -38,6 +38,13 @@ export interface ComponentMatch {
/** Call sites of this component, when known. */
instances: InstanceNode[];
matchedText: string[];
+ /**
+ * Ancestor components that also cover the matched terms but are less specific
+ * than `component` (TRACKER step 4.3). Present when the match resolved to the
+ * deepest node in a Page > Section > Card nesting — the ancestors are context,
+ * not competing candidates.
+ */
+ context?: ComponentNode[];
}
/**
@@ -126,36 +133,81 @@ export function matchComponents(
const termWeight = (term: string): number =>
tokenize(term).reduce((sum, t) => sum + idf(t), 0);
- const scored: Array<{
+ // Render tree (definition level): A renders B when A has a `renders` edge to
+ // an instance whose `instance-of` points at B. Used to collapse nested
+ // matches to the most specific one (step 4.3).
+ const byId = new Map(components.map((c) => [c.id, c]));
+ const instanceDef = new Map();
+ for (const e of graph.edges) if (e.kind === "instance-of") instanceDef.set(e.from, e.to);
+ const childDefs = new Map>();
+ for (const e of graph.edges) {
+ if (e.kind !== "renders") continue;
+ const childDef = instanceDef.get(e.to);
+ if (childDef === undefined || !byId.has(e.from) || !byId.has(childDef)) continue;
+ (childDefs.get(e.from) ?? childDefs.set(e.from, new Set()).get(e.from)!).add(childDef);
+ }
+ const descendantsMemo = new Map>();
+ const descendants = (id: string): Set => {
+ const cached = descendantsMemo.get(id);
+ if (cached) return cached;
+ const out = new Set();
+ descendantsMemo.set(id, out); // set first to break cycles
+ for (const child of childDefs.get(id) ?? []) {
+ if (out.has(child)) continue;
+ out.add(child);
+ for (const d of descendants(child)) out.add(d);
+ }
+ return out;
+ };
+
+ interface Scored {
match: ComponentMatch;
score: number;
coverage: number;
+ covered: Set;
evidence: Evidence[];
- }> = [];
+ }
+ const scored: Scored[] = [];
for (const component of components) {
+ const subtree = [component.id, ...descendants(component.id)];
const matched: string[] = [];
+ const covered = new Set();
const evidence: Evidence[] = [];
let weight = 0;
for (const term of queryTerms) {
- const hit = matchingEntry(term, component);
+ let hit: RenderedText | null = null;
+ let where = component;
+ for (const id of subtree) {
+ const node = byId.get(id);
+ if (node === undefined) continue;
+ const found = matchingEntry(term, node);
+ if (found !== null) {
+ hit = found;
+ where = node;
+ break;
+ }
+ }
if (hit === null) continue;
const w = termWeight(term);
- matched.push(term);
+ covered.add(term);
+ if (where.id === component.id) matched.push(term);
weight += w;
const provenance =
hit.source === "i18n"
? ` (i18n key ${hit.key ?? "?"}, locale ${hit.locale ?? "?"})`
: hit.branch !== undefined
? ` (renders only when ${hit.branch})`
- : "";
+ : where.id !== component.id
+ ? ` (in descendant ${where.name})`
+ : "";
evidence.push({
kind: "text-match",
detail: `"${term}" matched rendered text "${hit.text}"${provenance} — rarity weight ${w.toFixed(2)}`,
- loc: component.loc,
+ loc: where.loc,
});
}
- const textScore = matched.length > 0 ? weight * (1 + 0.5 * (matched.length - 1)) : 0;
+ const textScore = covered.size > 0 ? weight * (1 + 0.5 * (covered.size - 1)) : 0;
const structureFit = descriptor !== undefined ? structureScore(component.structure, descriptor) : 0;
if (structureFit > 0) {
@@ -166,34 +218,61 @@ export function matchComponents(
});
}
- if (matched.length === 0 && structureFit === 0) continue;
- const coverage = queryTerms.length > 0 ? matched.length / queryTerms.length : structureFit;
+ if (covered.size === 0 && structureFit === 0) continue;
+ const coverage = queryTerms.length > 0 ? covered.size / queryTerms.length : structureFit;
scored.push({
match: {
component,
instances: instancesByDefinition.get(component.id) ?? [],
- matchedText: matched,
+ matchedText: matched.length > 0 ? matched : [...covered],
},
score: textScore + structureFit * STRUCTURE_WEIGHT,
coverage: Math.max(coverage, structureFit),
+ covered,
evidence,
});
}
if (scored.length === 0) return declined("no-signal");
+ const sameCovered = (a: Set, b: Set): boolean =>
+ a.size === b.size && [...a].every((t) => b.has(t));
+
+ // Most-specific-subtree collapse: when an ancestor and a descendant cover the
+ // same term set (equal score), keep the deepest and fold ancestors into its
+ // `context`, so a Card that a Page renders wins with the Page as context.
+ const subtreeSize = (s: Scored): number => descendants(s.match.component.id).size;
scored.sort(
- (a, b) => b.score - a.score || a.match.component.name.localeCompare(b.match.component.name),
+ (a, b) =>
+ b.score - a.score ||
+ subtreeSize(a) - subtreeSize(b) ||
+ a.match.component.name.localeCompare(b.match.component.name),
);
- const candidates: Candidate[] = scored.map((s) => ({
+ const collapsed = new Set();
+ for (const s of scored) {
+ if (collapsed.has(s.match.component.id)) continue;
+ const context: ComponentNode[] = [];
+ for (const other of scored) {
+ if (other === s || collapsed.has(other.match.component.id)) continue;
+ const otherOwnsS = descendants(other.match.component.id).has(s.match.component.id);
+ if (otherOwnsS && sameCovered(other.covered, s.covered)) {
+ context.push(other.match.component);
+ collapsed.add(other.match.component.id);
+ }
+ }
+ if (context.length > 0) s.match.context = context;
+ }
+ const winners = scored.filter((s) => !collapsed.has(s.match.component.id));
+
+ const candidates: Candidate[] = winners.map((s) => ({
value: s.match,
confidence: confidenceFromScore(s.coverage),
evidence: s.evidence,
}));
- const top = scored[0];
+ const top = winners[0];
const tied =
- top === undefined ? [] : scored.filter((s) => Math.abs(s.score - top.score) < 1e-9);
+ top === undefined ? [] : winners.filter((s) => Math.abs(s.score - top.score) < 1e-9);
if (tied.length > 1) {
const names = tied.map((s) => s.match.component.name).join(", ");
return ambiguous(
diff --git a/packages/parser-react/src/structure.test.ts b/packages/parser-react/src/structure.test.ts
index f3d8129..d7f5b77 100644
--- a/packages/parser-react/src/structure.test.ts
+++ b/packages/parser-react/src/structure.test.ts
@@ -47,3 +47,35 @@ describe("structural signatures (a1 fixture, TRACKER 4.2)", () => {
expect(result.candidates[0]?.value.component.name).toBe("LoginForm");
});
});
+
+const a6 = resolveHookEdges(
+ scanReact({
+ root: path.resolve(
+ path.dirname(fileURLToPath(import.meta.url)),
+ "../../../eval/fixtures/a6-composed-page/app",
+ ),
+ }),
+);
+
+describe("most-specific-subtree resolution (a6 fixture, TRACKER 4.3)", () => {
+ it("a full-page term set resolves to the page (smallest subtree covering all)", () => {
+ const result = matchComponents(a6, {
+ terms: ["Analytics Dashboard", "Total revenue", "Workspace settings"],
+ });
+ expect(result.candidates[0]?.value.component.name).toBe("DashboardPage");
+ });
+
+ it("card-specific terms resolve to the deepest node with ancestors as context", () => {
+ const result = matchComponents(a6, { terms: ["Total revenue", "vs last month"] });
+ const top = result.candidates[0]?.value;
+ expect(top?.component.name).toBe("RevenueCard");
+ expect((top?.context ?? []).map((c) => c.name)).toEqual(["RevenueSection", "DashboardPage"]);
+ });
+
+ it("collapses ancestors instead of returning them as competing candidates", () => {
+ const result = matchComponents(a6, { terms: ["Total revenue", "vs last month"] });
+ const names = result.candidates.map((c) => c.value.component.name);
+ expect(names).not.toContain("DashboardPage");
+ expect(names).not.toContain("RevenueSection");
+ });
+});