From 005a8c91c39181e3a6347165f3d2a9248d4a02cc Mon Sep 17 00:00:00 2001 From: Deep Kumar Singh Kushwah Date: Mon, 13 Jul 2026 15:39:02 +0530 Subject: [PATCH] =?UTF-8?q?feat:=20rendered-text=20hardening=20=E2=80=94?= =?UTF-8?q?=20template=20wildcards,=20branch=20tagging,=20shared=20normali?= =?UTF-8?q?zation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 1.5 (TRACKER). Failure modes A7 (extraction half), A8: - core/text.ts: normalizeText (lowercase, unicode-safe punctuation strip, whitespace collapse, naive plural folding) + textMatches (bidirectional containment with * wildcards) — ONE normalization used by extraction and matching, and later by the Phase 4 matcher. - Template JSX children extract as wildcard entries: {`${count} items in cart`} → '* items in cart' (template: true), so the screenshot text '3 items in cart' matches. Resolvable template parts fold via 1.1. - Branch tagging: text guarded by ternaries, && gates, or early-return ifs carries branch: ; ternary else-branches negate. Evidence surfaces it: 'renders only when isAdmin'. Ternary string branches and {"literal"} JSX expressions are now extracted at all (found by the new a8 test failing). - Fixtures a7-transformed-text (CSS uppercase / template / plural queries) and a8-conditional-text (error/empty/admin states). - Scorecard: 77 pass / 0 fail / 2 xfail. 60 unit tests. Co-Authored-By: Claude Fable 5 --- TRACKER.md | 6 +- .../a7-transformed-text/app/CartSummary.tsx | 9 +++ eval/fixtures/a7-transformed-text/golden.json | 13 ++++ .../a8-conditional-text/app/OrdersPanel.tsx | 25 +++++++ eval/fixtures/a8-conditional-text/golden.json | 13 ++++ eval/history.jsonl | 1 + packages/core/src/index.ts | 1 + packages/core/src/query.ts | 16 ++-- packages/core/src/text.test.ts | 36 +++++++++ packages/core/src/text.ts | 44 +++++++++++ packages/core/src/types.ts | 7 +- packages/parser-react/src/scan.ts | 74 ++++++++++++++++++- .../parser-react/src/text-hardening.test.ts | 68 +++++++++++++++++ schemas/lineage-graph.schema.json | 6 +- 14 files changed, 305 insertions(+), 14 deletions(-) create mode 100644 eval/fixtures/a7-transformed-text/app/CartSummary.tsx create mode 100644 eval/fixtures/a7-transformed-text/golden.json create mode 100644 eval/fixtures/a8-conditional-text/app/OrdersPanel.tsx create mode 100644 eval/fixtures/a8-conditional-text/golden.json create mode 100644 packages/core/src/text.test.ts create mode 100644 packages/core/src/text.ts create mode 100644 packages/parser-react/src/text-hardening.test.ts diff --git a/TRACKER.md b/TRACKER.md index 70c4d96..6cddfc1 100644 --- a/TRACKER.md +++ b/TRACKER.md @@ -5,8 +5,8 @@ ## Status - **Current phase:** 1 — Robust extraction -- **Next step:** 1.5 — Rendered-text hardening -- **Done:** 0.1–0.4, 1.1–1.4 +- **Next step:** 1.6 — Legacy patterns & graceful degradation +- **Done:** 0.1–0.4, 1.1–1.5 - **Gates passed:** Gate 0 (CI green + red-path verified on PRs #5/#6) ## What CodeRadar is @@ -118,7 +118,7 @@ follow-one-reference; the cross-file instance/prop-flow machinery is Phase 2. - `renderedText` becomes structured: `{ text: string, source: "jsx" | "attribute" | "i18n", branch?: string, locale?: string }[]` (schema addition — update JSON Schema + goldens). **Accept:** fixture `a2-i18n-keys` green: searching "Team Members" *and* "Équipe" both find the component. -### [ ] 1.5 Rendered-text hardening +### [x] 1.5 Rendered-text hardening **Failure modes:** A7 (extraction half), A8 **Build:** - Template text: `` `${count} items` `` → `"* items"` with a `template: true` flag. diff --git a/eval/fixtures/a7-transformed-text/app/CartSummary.tsx b/eval/fixtures/a7-transformed-text/app/CartSummary.tsx new file mode 100644 index 0000000..6ba0cfb --- /dev/null +++ b/eval/fixtures/a7-transformed-text/app/CartSummary.tsx @@ -0,0 +1,9 @@ +export function CartSummary({ count, total }: { count: number; total: string }) { + return ( + + ); +} diff --git a/eval/fixtures/a7-transformed-text/golden.json b/eval/fixtures/a7-transformed-text/golden.json new file mode 100644 index 0000000..65f0e06 --- /dev/null +++ b/eval/fixtures/a7-transformed-text/golden.json @@ -0,0 +1,13 @@ +{ + "failureMode": "A7", + "note": "Runtime text transforms: screenshot shows 'SHOPPING CART' (CSS uppercase), '3 items in cart' (template literal), 'Total: $41.97'. Source has none of those exact strings. Normalization + template wildcards must bridge the gap.", + "expect": { + "components": [{ "name": "CartSummary", "instances": 0 }], + "queries": [ + { "terms": ["SHOPPING CART"], "status": "ok", "top": "CartSummary" }, + { "terms": ["3 items in cart"], "status": "ok", "top": "CartSummary" }, + { "terms": ["Total: $41.97"], "status": "ok", "top": "CartSummary" }, + { "terms": ["1 item in cart"], "status": "ok", "top": "CartSummary" } + ] + } +} diff --git a/eval/fixtures/a8-conditional-text/app/OrdersPanel.tsx b/eval/fixtures/a8-conditional-text/app/OrdersPanel.tsx new file mode 100644 index 0000000..06b05aa --- /dev/null +++ b/eval/fixtures/a8-conditional-text/app/OrdersPanel.tsx @@ -0,0 +1,25 @@ +export function OrdersPanel({ + orders, + error, + isAdmin, +}: { + orders: string[]; + error: boolean; + isAdmin: boolean; +}) { + if (error) return

Could not load orders

; + if (orders.length === 0) return

No orders yet

; + + return ( +
+

Recent orders

+
    + {orders.map((o) => ( +
  • {o}
  • + ))} +
+ {isAdmin && } + {orders.length > 10 ? "Large order book" : "Small order book"} +
+ ); +} diff --git a/eval/fixtures/a8-conditional-text/golden.json b/eval/fixtures/a8-conditional-text/golden.json new file mode 100644 index 0000000..d59c921 --- /dev/null +++ b/eval/fixtures/a8-conditional-text/golden.json @@ -0,0 +1,13 @@ +{ + "failureMode": "A8", + "note": "Conditional branches: error/empty/admin states render text the happy path never shows. A screenshot of the error state must still find the component, and the branch condition rides along as evidence.", + "expect": { + "components": [{ "name": "OrdersPanel", "instances": 0 }], + "queries": [ + { "terms": ["Could not load orders"], "status": "ok", "top": "OrdersPanel" }, + { "terms": ["No orders yet"], "status": "ok", "top": "OrdersPanel" }, + { "terms": ["Export orders"], "status": "ok", "top": "OrdersPanel" }, + { "terms": ["Recent orders"], "status": "ok", "top": "OrdersPanel" } + ] + } +} diff --git a/eval/history.jsonl b/eval/history.jsonl index 1df98ce..b7967de 100644 --- a/eval/history.jsonl +++ b/eval/history.jsonl @@ -3,3 +3,4 @@ {"generatedAt":"2026-07-13T09:52:22.725Z","commitSha":"13241fc441898e72fdb85f2ef7d0678988fde929","pass":47,"fail":0,"xfail":2,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":0.857,"matchAccuracy":1} {"generatedAt":"2026-07-13T09:56:46.891Z","commitSha":"44e439834950a42645eb659bb8c387cb5469d03e","pass":60,"fail":0,"xfail":2,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":0.889,"matchAccuracy":1} {"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} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 55f6be9..0a7310b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,3 +2,4 @@ export * from "./types.js"; export * from "./result.js"; export * from "./query.js"; export * from "./storage.js"; +export * from "./text.js"; diff --git a/packages/core/src/query.ts b/packages/core/src/query.ts index 7a1d034..9210eb1 100644 --- a/packages/core/src/query.ts +++ b/packages/core/src/query.ts @@ -14,6 +14,7 @@ import { ok, type QueryResult, } from "./result.js"; +import { normalizeText, textMatches } from "./text.js"; import type { ComponentNode, DataSourceNode, @@ -44,7 +45,7 @@ export function matchComponentsByText( graph: LineageGraph, terms: string[], ): QueryResult { - const needles = terms.map((t) => t.trim().toLowerCase()).filter((t) => t.length > 1); + const needles = terms.map((t) => normalizeText(t)).filter((t) => t.length > 1); if (needles.length === 0) return declined("no-signal"); const instancesByDefinition = groupInstances(graph); @@ -55,14 +56,17 @@ export function matchComponentsByText( const matchedText: string[] = []; const evidence: Evidence[] = []; for (const needle of needles) { - const hit = node.renderedText.find((entry) => { - const haystack = entry.text.toLowerCase(); - return haystack.includes(needle) || needle.includes(haystack); - }); + const hit = node.renderedText.find((entry) => + textMatches(normalizeText(entry.text), needle), + ); if (hit !== undefined) { matchedText.push(hit.text.toLowerCase()); const provenance = - hit.source === "i18n" ? ` (i18n key ${hit.key ?? "?"}, locale ${hit.locale ?? "?"})` : ""; + hit.source === "i18n" + ? ` (i18n key ${hit.key ?? "?"}, locale ${hit.locale ?? "?"})` + : hit.branch !== undefined + ? ` (renders only when ${hit.branch})` + : ""; evidence.push({ kind: "text-match", detail: `"${needle}" matched rendered text "${hit.text}"${provenance}`, diff --git a/packages/core/src/text.test.ts b/packages/core/src/text.test.ts new file mode 100644 index 0000000..6eff294 --- /dev/null +++ b/packages/core/src/text.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; + +import { normalizeText, textMatches } from "./text.js"; + +describe("normalizeText", () => { + it("lowercases, strips punctuation, collapses whitespace", () => { + expect(normalizeText(" SHOPPING CART! ")).toBe("shopping cart"); + expect(normalizeText("Total: $41.97")).toBe("total 41 97"); + }); + + it("keeps accents and * wildcards", () => { + expect(normalizeText("Membres de l'équipe")).toBe("membre de l équipe"); + expect(normalizeText("* items in cart")).toBe("* item in cart"); + }); + + it("folds naive plurals", () => { + expect(normalizeText("categories")).toBe("category"); + expect(normalizeText("items")).toBe("item"); + expect(normalizeText("class")).toBe("class"); + expect(normalizeText("gas")).toBe("gas"); + }); +}); + +describe("textMatches", () => { + it("matches containment in either direction", () => { + expect(textMatches("recent order", "recent")).toBe(true); + expect(textMatches("save", "save change")).toBe(true); + expect(textMatches("billing", "invoice")).toBe(false); + }); + + it("treats * as a wildcard", () => { + expect(textMatches("* item in cart", "3 item in cart")).toBe(true); + expect(textMatches("total *", "total 41 97")).toBe(true); + expect(textMatches("* item in cart", "empty cart")).toBe(false); + }); +}); diff --git a/packages/core/src/text.ts b/packages/core/src/text.ts new file mode 100644 index 0000000..03ab32f --- /dev/null +++ b/packages/core/src/text.ts @@ -0,0 +1,44 @@ +/** + * Text normalization shared by extraction (parser) and matching (query layer, + * Phase 4 matcher). Screenshot text never equals source text exactly — CSS + * uppercasing, punctuation, pluralization, OCR whitespace — so both sides of + * every comparison go through the same normalization (failure mode A7). + */ + +/** + * Lowercase, strip punctuation (unicode-aware — accents survive), collapse + * whitespace, and fold naive plurals ("items" → "item", "categories" → + * "category"). `*` survives because template entries use it as a wildcard. + */ +export function normalizeText(input: string): string { + return input + .toLowerCase() + .replace(/[^\p{L}\p{N}*\s]/gu, " ") + .replace(/\s+/g, " ") + .trim() + .split(" ") + .map(foldPlural) + .join(" "); +} + +/** + * Does `needle` (normalized) match `haystack` (normalized), where `haystack` + * may contain `*` wildcards from template text ("* item in cart" matches + * "3 items in cart")? + */ +export function textMatches(haystack: string, needle: string): boolean { + if (haystack.includes("*")) { + const pattern = haystack + .split("*") + .map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&")) + .join(".*"); + return new RegExp(pattern).test(needle); + } + return haystack.includes(needle) || needle.includes(haystack); +} + +function foldPlural(token: string): string { + if (token.length > 4 && token.endsWith("ies")) return `${token.slice(0, -3)}y`; + if (token.length > 3 && token.endsWith("s") && !token.endsWith("ss")) return token.slice(0, -1); + return token; +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 313bdeb..060189b 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -55,8 +55,13 @@ export interface RenderedText { key?: string; /** i18n entries only: which locale this text belongs to. */ locale?: string; - /** Condition source text when the text renders only in a branch (step 1.5). */ + /** Condition source text when the text renders only in a branch. */ branch?: string; + /** + * True when the text came from a template literal with runtime parts — + * unknown segments appear as `*` ("* items in cart") and match as wildcards. + */ + template?: boolean; } /** A React component definition — the code, not a usage. */ diff --git a/packages/parser-react/src/scan.ts b/packages/parser-react/src/scan.ts index ef08ee6..1cd969b 100644 --- a/packages/parser-react/src/scan.ts +++ b/packages/parser-react/src/scan.ts @@ -25,7 +25,7 @@ import { type VariableDeclaration, } from "ts-morph"; -import { fetchMethod, resolveEndpoint, type ResolvedEndpoint } from "./endpoint.js"; +import { fetchMethod, resolveEndpoint, type ResolvedEndpoint, resolveStringValue } from "./endpoint.js"; import { i18nRenderedText, type I18nOptions, loadLocaleTable, type LocaleTable } from "./i18n.js"; import { detectWrappers, type WrapperRegistry } from "./wrappers.js"; @@ -243,21 +243,89 @@ function extractProps(fn: FunctionLike): string[] { function extractRenderedText(fn: FunctionLike): RenderedText[] { const entries = new Map(); + const add = (entry: RenderedText): void => { + entries.set(`${entry.source}:${entry.text}:${entry.branch ?? ""}`, entry); + }; + for (const jsxText of fn.getDescendantsOfKind(SyntaxKind.JsxText)) { const text = jsxText.getText().replace(/\s+/g, " ").trim(); - if (text.length > 0) entries.set(`jsx:${text}`, { text, source: "jsx" }); + if (text.length === 0) continue; + add({ text, source: "jsx", ...branchTag(jsxText, fn) }); } + for (const attr of fn.getDescendantsOfKind(SyntaxKind.JsxAttribute)) { if (!TEXT_ATTRIBUTES.has(attr.getNameNode().getText())) continue; const init = attr.getInitializer(); if (init !== undefined && Node.isStringLiteral(init)) { const text = init.getLiteralValue().trim(); - if (text.length > 0) entries.set(`attribute:${text}`, { text, source: "attribute" }); + if (text.length > 0) add({ text, source: "attribute", ...branchTag(attr, fn) }); + } + } + + // Template literals rendered as JSX children: {`${count} items in cart`} → + // "* items in cart" (unknown segments become * wildcards). + for (const expr of fn.getDescendantsOfKind(SyntaxKind.JsxExpression)) { + const inner = expr.getExpression(); + if (inner === undefined) continue; + if (Node.isStringLiteral(inner)) { + const text = inner.getLiteralValue().replace(/\s+/g, " ").trim(); + if (text.length > 0) add({ text, source: "jsx", ...branchTag(expr, fn) }); + } else if (Node.isConditionalExpression(inner)) { + // {cond ? "Large order book" : "Small order book"} + for (const branchNode of [inner.getWhenTrue(), inner.getWhenFalse()]) { + if (Node.isStringLiteral(branchNode)) { + const text = branchNode.getLiteralValue().replace(/\s+/g, " ").trim(); + if (text.length > 0) add({ text, source: "jsx", ...branchTag(branchNode, fn) }); + } + } + } else if (Node.isNoSubstitutionTemplateLiteral(inner)) { + const text = inner.getLiteralValue().replace(/\s+/g, " ").trim(); + if (text.length > 0) add({ text, source: "jsx", ...branchTag(expr, fn) }); + } else if (Node.isTemplateExpression(inner)) { + let text = inner.getHead().getLiteralText(); + for (const span of inner.getTemplateSpans()) { + text += `${resolveStringValue(span.getExpression(), 0) ?? "*"}${span.getLiteral().getLiteralText()}`; + } + text = text.replace(/\s+/g, " ").trim(); + if (text.replace(/[*\s]/g, "").length > 0) { + add({ text, source: "jsx", template: true, ...branchTag(expr, fn) }); + } } } + return [...entries.values()]; } +/** + * The nearest condition guarding a rendered-text node: a ternary branch, a + * `cond && ` gate, or an enclosing if-statement (early-return pattern). + * Ternary else-branches are negated. Empty object when unconditional. + */ +function branchTag(node: Node, boundary: FunctionLike): { branch?: string } { + let current: Node | undefined = node; + while (current !== undefined && current !== boundary) { + const parent: Node | undefined = current.getParent(); + if (parent === undefined) break; + if (Node.isConditionalExpression(parent)) { + const condition = parent.getCondition().getText(); + if (parent.getWhenTrue() === current) return { branch: condition }; + if (parent.getWhenFalse() === current) return { branch: `!(${condition})` }; + } + if ( + Node.isBinaryExpression(parent) && + parent.getOperatorToken().getKind() === SyntaxKind.AmpersandAmpersandToken && + parent.getRight() === current + ) { + return { branch: parent.getLeft().getText() }; + } + if (Node.isIfStatement(parent) && parent.getThenStatement() === current) { + return { branch: parent.getExpression().getText() }; + } + current = parent; + } + return {}; +} + function extractRenderedComponents(fn: FunctionLike): string[] { const names = new Set(); const record = (tagName: string): void => { diff --git a/packages/parser-react/src/text-hardening.test.ts b/packages/parser-react/src/text-hardening.test.ts new file mode 100644 index 0000000..5fa93af --- /dev/null +++ b/packages/parser-react/src/text-hardening.test.ts @@ -0,0 +1,68 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { matchComponentsByText } 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", +); + +function componentText(fixture: string, name: string) { + const graph = scanReact({ root: path.join(fixtures, fixture, "app") }); + const node = graph.nodes.find((n) => n.kind === "component" && n.name === name); + if (node?.kind !== "component") throw new Error(`${name} not found`); + return { graph, renderedText: node.renderedText }; +} + +describe("template text (a7 fixture)", () => { + const { graph, renderedText } = componentText("a7-transformed-text", "CartSummary"); + + it("extracts template literals with * wildcards and a template flag", () => { + const items = renderedText.find((e) => e.text === "* items in cart"); + expect(items?.template).toBe(true); + expect(renderedText.some((e) => e.text === "Total: *")).toBe(true); + }); + + it("matches concrete screenshot text against the wildcard", () => { + const result = matchComponentsByText(graph, ["3 items in cart"]); + expect(result.status).toBe("ok"); + expect(result.candidates[0]?.value.component.name).toBe("CartSummary"); + }); + + it("matches case-transformed and singular/plural variants", () => { + for (const term of ["SHOPPING CART", "1 item in cart"]) { + expect(matchComponentsByText(graph, [term]).status).toBe("ok"); + } + }); +}); + +describe("branch tagging (a8 fixture)", () => { + const { graph, renderedText } = componentText("a8-conditional-text", "OrdersPanel"); + + it("tags early-return branches with their condition", () => { + expect(renderedText.find((e) => e.text === "Could not load orders")?.branch).toBe("error"); + expect(renderedText.find((e) => e.text === "No orders yet")?.branch).toBe( + "orders.length === 0", + ); + }); + + it("tags && gates and ternary branches (negated on the else side)", () => { + expect(renderedText.find((e) => e.text === "Export orders")?.branch).toBe("isAdmin"); + expect(renderedText.find((e) => e.text === "Large order book")?.branch).toBe( + "orders.length > 10", + ); + expect(renderedText.find((e) => e.text === "Small order book")?.branch).toBe( + "!(orders.length > 10)", + ); + }); + + it("leaves unconditional text untagged and surfaces the branch in evidence", () => { + expect(renderedText.find((e) => e.text === "Recent orders")?.branch).toBeUndefined(); + const result = matchComponentsByText(graph, ["Export orders"]); + expect(result.candidates[0]?.evidence[0]?.detail).toContain("renders only when isAdmin"); + }); +}); diff --git a/schemas/lineage-graph.schema.json b/schemas/lineage-graph.schema.json index 5be6601..4eec9e8 100644 --- a/schemas/lineage-graph.schema.json +++ b/schemas/lineage-graph.schema.json @@ -205,7 +205,11 @@ }, "branch": { "type": "string", - "description": "Condition source text when the text renders only in a branch (step 1.5)." + "description": "Condition source text when the text renders only in a branch." + }, + "template": { + "type": "boolean", + "description": "True when the text came from a template literal with runtime parts — unknown segments appear as `*` (\"* items in cart\") and match as wildcards." } }, "required": [