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.4i18n adapter
- **Done:** 0.1–0.4, 1.1–1.3
- **Next step:** 1.5Rendered-text hardening
- **Done:** 0.1–0.4, 1.1–1.4
- **Gates passed:** Gate 0 (CI green + red-path verified on PRs #5/#6)

## What CodeRadar is
Expand Down Expand Up @@ -110,7 +110,7 @@ follow-one-reference; the cross-file instance/prop-flow machinery is Phase 2.
**Build:** when `useQuery`/`useMutation`/`useSWR`'s fn argument is a reference, resolve to its declaration (same file or import) and extract the endpoint from its body via 1.1/1.2. Data source records both the query key and the resolved endpoint; mutations get `method` from the inner call.
**Accept:** fixture `c5-queryfn-indirection` green (queryFn in a separate `api/users.ts`).

### [ ] 1.4 i18n adapter
### [x] 1.4 i18n adapter
**Failure modes:** A2
**Build:**
- Scan option `i18n: { localeGlobs: string[], defaultLocale: string }`; parse JSON/YAML locale files into a key → string-per-locale table (nested keys flattened, `{{var}}` placeholders preserved).
Expand Down
11 changes: 11 additions & 0 deletions eval/fixtures/a2-i18n-keys/app/BillingHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Trans } from "react-i18next";

export function BillingHeader() {
return (
<header>
<h1>
<Trans i18nKey="billing.title" />
</h1>
</header>
);
}
12 changes: 12 additions & 0 deletions eval/fixtures/a2-i18n-keys/app/TeamHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { useTranslation } from "react-i18next";

export function TeamHeader() {
const { t } = useTranslation();

return (
<header>
<h1>{t("team.title")}</h1>
<button>{t("team.invite")}</button>
</header>
);
}
9 changes: 9 additions & 0 deletions eval/fixtures/a2-i18n-keys/app/locales/en.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"team": {
"title": "Team Members",
"invite": "Invite teammate"
},
"billing": {
"title": "Billing overview"
}
}
9 changes: 9 additions & 0 deletions eval/fixtures/a2-i18n-keys/app/locales/fr.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"team": {
"title": "Membres de l'équipe",
"invite": "Inviter un coéquipier"
},
"billing": {
"title": "Aperçu de la facturation"
}
}
20 changes: 20 additions & 0 deletions eval/fixtures/a2-i18n-keys/golden.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"failureMode": "A2",
"note": "Text behind i18n keys: source has t('team.title'), the screenshot shows 'Team Members' — or 'Membres de l'équipe' when the reporter's app runs in French. Both must find the same component. Covers t() calls and <Trans i18nKey>.",
"scan": {
"i18n": { "localeGlobs": ["locales/*.json"], "defaultLocale": "en" }
},
"expect": {
"components": [
{ "name": "TeamHeader", "instances": 0 },
{ "name": "BillingHeader", "instances": 0 }
],
"queries": [
{ "terms": ["Team Members"], "status": "ok", "top": "TeamHeader" },
{ "terms": ["Membres de l'équipe"], "status": "ok", "top": "TeamHeader" },
{ "terms": ["Invite teammate"], "status": "ok", "top": "TeamHeader" },
{ "terms": ["Billing overview"], "status": "ok", "top": "BillingHeader" },
{ "terms": ["Aperçu de la facturation"], "status": "ok", "top": "BillingHeader" }
]
}
}
1 change: 1 addition & 0 deletions eval/history.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
{"generatedAt":"2026-07-13T09:45:35.873Z","commitSha":"d59ef32a575e12295e2fcc309d44dd9538a458e1","pass":38,"fail":0,"xfail":2,"unexpectedPass":0,"lineagePrecision":1,"lineageRecall":0.833,"matchAccuracy":1}
{"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}
6 changes: 6 additions & 0 deletions eval/src/golden.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ export interface Golden {
note?: string;
/** App directory relative to the fixture dir. Default "./app". */
app?: string;
/** Extra scan options this fixture needs (passed through to scanReact). */
scan?: {
baseUrls?: string[];
apiWrappers?: string[];
i18n?: { localeGlobs: string[]; defaultLocale: string };
};
expect: {
components?: GoldenComponent[];
attributions?: GoldenAttribution[];
Expand Down
2 changes: 1 addition & 1 deletion eval/src/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ function main(): void {
}
const golden = JSON.parse(fs.readFileSync(goldenPath, "utf-8")) as Golden;
const appDir = path.resolve(fixtureDir, golden.app ?? "./app");
const graph = resolveHookEdges(scanReact({ root: appDir }));
const graph = resolveHookEdges(scanReact({ root: appDir, ...(golden.scan ?? {}) }));
results.push(runChecks(name, golden, graph));
}

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ function component(file: string, name: string, renderedText: string[]): Componen
loc: loc(file),
exportName: name,
props: [],
renderedText,
renderedText: renderedText.map((text) => ({ text, source: "jsx" as const })),
rendersComponents: [],
};
}
Expand Down
12 changes: 8 additions & 4 deletions packages/core/src/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,16 +52,20 @@ export function matchComponentsByText(

for (const node of graph.nodes) {
if (node.kind !== "component") continue;
const haystack = node.renderedText.map((t) => t.toLowerCase());
const matchedText: string[] = [];
const evidence: Evidence[] = [];
for (const needle of needles) {
const hit = haystack.find((h) => h.includes(needle) || needle.includes(h));
const hit = node.renderedText.find((entry) => {
const haystack = entry.text.toLowerCase();
return haystack.includes(needle) || needle.includes(haystack);
});
if (hit !== undefined) {
matchedText.push(hit);
matchedText.push(hit.text.toLowerCase());
const provenance =
hit.source === "i18n" ? ` (i18n key ${hit.key ?? "?"}, locale ${hit.locale ?? "?"})` : "";
evidence.push({
kind: "text-match",
detail: `"${needle}" matched rendered text "${hit}"`,
detail: `"${needle}" matched rendered text "${hit.text}"${provenance}`,
loc: node.loc,
});
}
Expand Down
25 changes: 21 additions & 4 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,23 @@ export interface BaseNode {
flags?: string[];
}

/** One piece of text a component can render, with its provenance. */
export interface RenderedText {
text: string;
/**
* Where the text comes from: JSX children, a string attribute
* (placeholder/label/title/alt/aria-label), or an i18n key resolved
* against locale files.
*/
source: "jsx" | "attribute" | "i18n";
/** i18n entries only: the translation key, e.g. "team.title". */
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). */
branch?: string;
}

/** A React component definition — the code, not a usage. */
export interface ComponentNode extends BaseNode {
kind: "component";
Expand All @@ -50,11 +67,11 @@ export interface ComponentNode extends BaseNode {
/** Prop names destructured or accessed from the props object. */
props: string[];
/**
* Static text visible in the rendered output (JSX text, string literals in
* attributes like placeholder/label/title/alt/aria-label). This is the
* primary signal for matching a screenshot to a component.
* Static text visible in the rendered output — the primary signal for
* matching a screenshot to a component. i18n keys are expanded to one
* entry per locale, so a French screenshot matches the same component.
*/
renderedText: string[];
renderedText: RenderedText[];
/** Names of components this component renders in its JSX (deduplicated). */
rendersComponents: string[];
}
Expand Down
3 changes: 2 additions & 1 deletion packages/parser-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
},
"dependencies": {
"@coderadar/core": "workspace:*",
"ts-morph": "^24.0.0"
"ts-morph": "^24.0.0",
"yaml": "^2.9.0"
},
"devDependencies": {
"@types/node": "^22.20.1",
Expand Down
63 changes: 63 additions & 0 deletions packages/parser-react/src/i18n.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
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 fixture = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../../../eval/fixtures/a2-i18n-keys/app",
);

const graph = scanReact({
root: fixture,
i18n: { localeGlobs: ["locales/*.json"], defaultLocale: "en" },
});

const teamHeader = graph.nodes.find((n) => n.kind === "component" && n.name === "TeamHeader");
const billingHeader = graph.nodes.find(
(n) => n.kind === "component" && n.name === "BillingHeader",
);

describe("i18n adapter (a2 fixture)", () => {
it("expands t() keys into one entry per locale with provenance", () => {
if (teamHeader?.kind !== "component") throw new Error("TeamHeader not found");
const titles = teamHeader.renderedText.filter((e) => e.key === "team.title");
expect(titles).toHaveLength(2);
expect(titles.map((e) => `${e.locale}:${e.text}`).sort()).toEqual([
"en:Team Members",
"fr:Membres de l'équipe",
]);
expect(titles.every((e) => e.source === "i18n")).toBe(true);
});

it("resolves <Trans i18nKey> attributes", () => {
if (billingHeader?.kind !== "component") throw new Error("BillingHeader not found");
const texts = billingHeader.renderedText.map((e) => e.text);
expect(texts).toContain("Billing overview");
expect(texts).toContain("Aperçu de la facturation");
});

it("matches screenshot text in any locale to the same component", () => {
for (const term of ["Team Members", "Membres de l'équipe"]) {
const result = matchComponentsByText(graph, [term]);
expect(result.status).toBe("ok");
expect(result.candidates[0]?.value.component.name).toBe("TeamHeader");
}
});

it("records the i18n key in the match evidence", () => {
const result = matchComponentsByText(graph, ["Membres de l'équipe"]);
expect(result.candidates[0]?.evidence[0]?.detail).toContain("team.title");
expect(result.candidates[0]?.evidence[0]?.detail).toContain("locale fr");
});

it("skips i18n expansion entirely when unconfigured", () => {
const bare = scanReact({ root: fixture });
const header = bare.nodes.find((n) => n.kind === "component" && n.name === "TeamHeader");
if (header?.kind !== "component") throw new Error("TeamHeader not found");
expect(header.renderedText.some((e) => e.source === "i18n")).toBe(false);
});
});
Loading
Loading