Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions TRACKER.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
## Status

- **Current phase:** 1 — Robust extraction
- **Next step:** 1.5Rendered-text hardening
- **Done:** 0.1–0.4, 1.1–1.4
- **Next step:** 1.6Legacy 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
Expand Down Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions eval/fixtures/a7-transformed-text/app/CartSummary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export function CartSummary({ count, total }: { count: number; total: string }) {
return (
<aside>
<h2 style={{ textTransform: "uppercase" }}>Shopping cart</h2>
<span>{`${count} items in cart`}</span>
<strong>{`Total: ${total}`}</strong>
</aside>
);
}
13 changes: 13 additions & 0 deletions eval/fixtures/a7-transformed-text/golden.json
Original file line number Diff line number Diff line change
@@ -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" }
]
}
}
25 changes: 25 additions & 0 deletions eval/fixtures/a8-conditional-text/app/OrdersPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export function OrdersPanel({
orders,
error,
isAdmin,
}: {
orders: string[];
error: boolean;
isAdmin: boolean;
}) {
if (error) return <p>Could not load orders</p>;
if (orders.length === 0) return <p>No orders yet</p>;

return (
<section>
<h2>Recent orders</h2>
<ul>
{orders.map((o) => (
<li key={o}>{o}</li>
))}
</ul>
{isAdmin && <button>Export orders</button>}
<span>{orders.length > 10 ? "Large order book" : "Small order book"}</span>
</section>
);
}
13 changes: 13 additions & 0 deletions eval/fixtures/a8-conditional-text/golden.json
Original file line number Diff line number Diff line change
@@ -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" }
]
}
}
1 change: 1 addition & 0 deletions eval/history.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -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}
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from "./types.js";
export * from "./result.js";
export * from "./query.js";
export * from "./storage.js";
export * from "./text.js";
16 changes: 10 additions & 6 deletions packages/core/src/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
ok,
type QueryResult,
} from "./result.js";
import { normalizeText, textMatches } from "./text.js";
import type {
ComponentNode,
DataSourceNode,
Expand Down Expand Up @@ -44,7 +45,7 @@ export function matchComponentsByText(
graph: LineageGraph,
terms: string[],
): QueryResult<ComponentMatch> {
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);
Expand All @@ -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}`,
Expand Down
36 changes: 36 additions & 0 deletions packages/core/src/text.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
44 changes: 44 additions & 0 deletions packages/core/src/text.ts
Original file line number Diff line number Diff line change
@@ -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;
}
7 changes: 6 additions & 1 deletion packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
74 changes: 71 additions & 3 deletions packages/parser-react/src/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -243,21 +243,89 @@ function extractProps(fn: FunctionLike): string[] {

function extractRenderedText(fn: FunctionLike): RenderedText[] {
const entries = new Map<string, RenderedText>();
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 && <jsx>` 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<string>();
const record = (tagName: string): void => {
Expand Down
Loading
Loading