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": [