diff --git a/TRACKER.md b/TRACKER.md
index 3902af5..70c4d96 100644
--- a/TRACKER.md
+++ b/TRACKER.md
@@ -5,8 +5,8 @@
## Status
- **Current phase:** 1 — Robust extraction
-- **Next step:** 1.4 — i18n adapter
-- **Done:** 0.1–0.4, 1.1–1.3
+- **Next step:** 1.5 — Rendered-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
@@ -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).
diff --git a/eval/fixtures/a2-i18n-keys/app/BillingHeader.tsx b/eval/fixtures/a2-i18n-keys/app/BillingHeader.tsx
new file mode 100644
index 0000000..f1b8a39
--- /dev/null
+++ b/eval/fixtures/a2-i18n-keys/app/BillingHeader.tsx
@@ -0,0 +1,11 @@
+import { Trans } from "react-i18next";
+
+export function BillingHeader() {
+ return (
+
+ );
+}
diff --git a/eval/fixtures/a2-i18n-keys/app/TeamHeader.tsx b/eval/fixtures/a2-i18n-keys/app/TeamHeader.tsx
new file mode 100644
index 0000000..9926a09
--- /dev/null
+++ b/eval/fixtures/a2-i18n-keys/app/TeamHeader.tsx
@@ -0,0 +1,12 @@
+import { useTranslation } from "react-i18next";
+
+export function TeamHeader() {
+ const { t } = useTranslation();
+
+ return (
+
+ {t("team.title")}
+
+
+ );
+}
diff --git a/eval/fixtures/a2-i18n-keys/app/locales/en.json b/eval/fixtures/a2-i18n-keys/app/locales/en.json
new file mode 100644
index 0000000..0364b78
--- /dev/null
+++ b/eval/fixtures/a2-i18n-keys/app/locales/en.json
@@ -0,0 +1,9 @@
+{
+ "team": {
+ "title": "Team Members",
+ "invite": "Invite teammate"
+ },
+ "billing": {
+ "title": "Billing overview"
+ }
+}
diff --git a/eval/fixtures/a2-i18n-keys/app/locales/fr.json b/eval/fixtures/a2-i18n-keys/app/locales/fr.json
new file mode 100644
index 0000000..e1ca417
--- /dev/null
+++ b/eval/fixtures/a2-i18n-keys/app/locales/fr.json
@@ -0,0 +1,9 @@
+{
+ "team": {
+ "title": "Membres de l'équipe",
+ "invite": "Inviter un coéquipier"
+ },
+ "billing": {
+ "title": "Aperçu de la facturation"
+ }
+}
diff --git a/eval/fixtures/a2-i18n-keys/golden.json b/eval/fixtures/a2-i18n-keys/golden.json
new file mode 100644
index 0000000..a72fd85
--- /dev/null
+++ b/eval/fixtures/a2-i18n-keys/golden.json
@@ -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 .",
+ "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" }
+ ]
+ }
+}
diff --git a/eval/history.jsonl b/eval/history.jsonl
index baf2ea4..1df98ce 100644
--- a/eval/history.jsonl
+++ b/eval/history.jsonl
@@ -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}
diff --git a/eval/src/golden.ts b/eval/src/golden.ts
index 6dd00b1..f4cfec6 100644
--- a/eval/src/golden.ts
+++ b/eval/src/golden.ts
@@ -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[];
diff --git a/eval/src/run.ts b/eval/src/run.ts
index fae645c..641412d 100644
--- a/eval/src/run.ts
+++ b/eval/src/run.ts
@@ -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));
}
diff --git a/packages/core/src/query.test.ts b/packages/core/src/query.test.ts
index 96cb49c..bce0e9c 100644
--- a/packages/core/src/query.test.ts
+++ b/packages/core/src/query.test.ts
@@ -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: [],
};
}
diff --git a/packages/core/src/query.ts b/packages/core/src/query.ts
index 849b06c..7a1d034 100644
--- a/packages/core/src/query.ts
+++ b/packages/core/src/query.ts
@@ -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,
});
}
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index 8d6b6ec..313bdeb 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -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";
@@ -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[];
}
diff --git a/packages/parser-react/package.json b/packages/parser-react/package.json
index dfe11b5..5fc9ca0 100644
--- a/packages/parser-react/package.json
+++ b/packages/parser-react/package.json
@@ -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",
diff --git a/packages/parser-react/src/i18n.test.ts b/packages/parser-react/src/i18n.test.ts
new file mode 100644
index 0000000..858cdf3
--- /dev/null
+++ b/packages/parser-react/src/i18n.test.ts
@@ -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 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);
+ });
+});
diff --git a/packages/parser-react/src/i18n.ts b/packages/parser-react/src/i18n.ts
new file mode 100644
index 0000000..2116b3d
--- /dev/null
+++ b/packages/parser-react/src/i18n.ts
@@ -0,0 +1,146 @@
+/**
+ * i18n adapter (TRACKER step 1.4, failure mode A2).
+ *
+ * Source contains `t("team.title")`; the screenshot shows "Team Members" —
+ * or "Membres de l'équipe" when the reporter runs the app in French. The
+ * literal lives in locale files, not JSX. This module loads those files into
+ * a key → text-per-locale table so every t()/ call site expands to one
+ * RenderedText entry per locale.
+ */
+
+import fs from "node:fs";
+import path from "node:path";
+
+import type { RenderedText } from "@coderadar/core";
+import { Node, SyntaxKind } from "ts-morph";
+import YAML from "yaml";
+
+export interface I18nOptions {
+ /** Globs relative to the scan root, e.g. ["locales/*.json", "i18n/**\/*.yaml"]. */
+ localeGlobs: string[];
+ /** Locale reported first / used when a file's locale can't be inferred. */
+ defaultLocale: string;
+}
+
+/** key → (locale → text) */
+export type LocaleTable = ReadonlyMap>;
+
+const LOCALE_CODE = /^[a-z]{2,3}(?:[-_][A-Za-z]{2,4})?$/;
+
+export function loadLocaleTable(root: string, options: I18nOptions): LocaleTable {
+ const table = new Map>();
+ for (const pattern of options.localeGlobs) {
+ for (const file of globFiles(root, pattern)) {
+ const locale = inferLocale(path.relative(root, file), options.defaultLocale);
+ const content = fs.readFileSync(file, "utf-8");
+ const parsed: unknown = file.endsWith(".json") ? JSON.parse(content) : YAML.parse(content);
+ if (typeof parsed !== "object" || parsed === null) continue;
+ for (const [key, text] of flatten(parsed as Record, "")) {
+ let perLocale = table.get(key);
+ if (perLocale === undefined) {
+ perLocale = new Map();
+ table.set(key, perLocale);
+ }
+ perLocale.set(locale, text);
+ }
+ }
+ }
+ return table;
+}
+
+/** Expand every i18n key used in `body` into per-locale RenderedText entries. */
+export function i18nRenderedText(body: Node, table: LocaleTable): RenderedText[] {
+ const entries: RenderedText[] = [];
+ for (const key of collectI18nKeys(body)) {
+ const perLocale = lookup(table, key);
+ if (perLocale === undefined) continue;
+ for (const [locale, text] of perLocale) {
+ entries.push({ text, source: "i18n", key, locale });
+ }
+ }
+ return entries;
+}
+
+/** t("key") / i18n.t("key") calls and attributes. */
+function collectI18nKeys(body: Node): Set {
+ const keys = new Set();
+ for (const call of body.getDescendantsOfKind(SyntaxKind.CallExpression)) {
+ const callee = call.getExpression().getText();
+ if (callee !== "t" && !callee.endsWith(".t")) continue;
+ const arg = call.getArguments()[0];
+ if (arg !== undefined && Node.isStringLiteral(arg)) keys.add(arg.getLiteralValue());
+ }
+ for (const attr of body.getDescendantsOfKind(SyntaxKind.JsxAttribute)) {
+ if (attr.getNameNode().getText() !== "i18nKey") continue;
+ const init = attr.getInitializer();
+ if (init !== undefined && Node.isStringLiteral(init)) keys.add(init.getLiteralValue());
+ }
+ return keys;
+}
+
+/** Try the key as written, then without an i18next "namespace:" prefix. */
+function lookup(table: LocaleTable, key: string): ReadonlyMap | undefined {
+ const direct = table.get(key);
+ if (direct !== undefined) return direct;
+ const colon = key.indexOf(":");
+ return colon >= 0 ? table.get(key.slice(colon + 1)) : undefined;
+}
+
+function flatten(value: Record, prefix: string): Array<[string, string]> {
+ const out: Array<[string, string]> = [];
+ for (const [key, child] of Object.entries(value)) {
+ const full = prefix.length > 0 ? `${prefix}.${key}` : key;
+ if (typeof child === "string") {
+ out.push([full, child]);
+ } else if (typeof child === "object" && child !== null) {
+ out.push(...flatten(child as Record, full));
+ }
+ }
+ return out;
+}
+
+/** "locales/fr.json" → "fr"; "i18n/de/common.yaml" → "de"; fallback otherwise. */
+function inferLocale(relativeFile: string, fallback: string): string {
+ const base = path.basename(relativeFile, path.extname(relativeFile));
+ if (LOCALE_CODE.test(base)) return base;
+ const dir = path.basename(path.dirname(relativeFile));
+ if (LOCALE_CODE.test(dir)) return dir;
+ return fallback;
+}
+
+/** Minimal glob: `**` crosses directories, `*` stays within one segment. */
+function globFiles(root: string, pattern: string): string[] {
+ const regex = new RegExp(
+ "^" +
+ pattern
+ .split(/(\*\*\/|\*\*|\*)/)
+ .map((part) => {
+ if (part === "**/" ) return "(?:.*/)?";
+ if (part === "**") return ".*";
+ if (part === "*") return "[^/]*";
+ return part.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
+ })
+ .join("") +
+ "$",
+ );
+ const results: string[] = [];
+ const walk = (dir: string): void => {
+ let entries: fs.Dirent[];
+ try {
+ entries = fs.readdirSync(dir, { withFileTypes: true });
+ } catch {
+ return;
+ }
+ for (const entry of entries) {
+ if (entry.name === "node_modules" || entry.name.startsWith(".")) continue;
+ const full = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ walk(full);
+ } else if (regex.test(path.relative(root, full).split(path.sep).join("/"))) {
+ results.push(full);
+ }
+ }
+ };
+ walk(root);
+ return results.sort();
+}
diff --git a/packages/parser-react/src/scan.ts b/packages/parser-react/src/scan.ts
index d085719..ef08ee6 100644
--- a/packages/parser-react/src/scan.ts
+++ b/packages/parser-react/src/scan.ts
@@ -8,6 +8,7 @@ import {
type LineageGraph,
type LineageNode,
nodeId,
+ type RenderedText,
type SourceLocation,
} from "@coderadar/core";
import {
@@ -25,6 +26,7 @@ import {
} from "ts-morph";
import { fetchMethod, resolveEndpoint, type ResolvedEndpoint } from "./endpoint.js";
+import { i18nRenderedText, type I18nOptions, loadLocaleTable, type LocaleTable } from "./i18n.js";
import { detectWrappers, type WrapperRegistry } from "./wrappers.js";
export interface ScanOptions {
@@ -43,6 +45,12 @@ export interface ScanOptions {
* for clients the heuristic can't see. Heuristic detection runs regardless.
*/
apiWrappers?: string[];
+ /**
+ * Locale-file configuration. When set, t("key") / call
+ * sites expand into renderedText entries for every locale, so screenshots
+ * in any language match.
+ */
+ i18n?: I18nOptions;
}
type FunctionLike = FunctionDeclaration | ArrowFunction | FunctionExpression;
@@ -97,6 +105,7 @@ export function scanReact(options: ScanOptions): LineageGraph {
}
const wrappers = detectWrappers(project, options.apiWrappers ?? []);
+ const localeTable = options.i18n !== undefined ? loadLocaleTable(root, options.i18n) : null;
const nodes = new Map();
const edges: LineageEdge[] = [];
const pendingInstances: PendingInstance[] = [];
@@ -124,7 +133,10 @@ export function scanReact(options: ScanOptions): LineageGraph {
loc: decl.loc,
exportName: decl.exportName,
props: extractProps(decl.fn),
- renderedText: extractRenderedText(decl.fn),
+ renderedText: [
+ ...extractRenderedText(decl.fn),
+ ...(localeTable !== null ? i18nRenderedText(decl.fn, localeTable) : []),
+ ],
rendersComponents: extractRenderedComponents(decl.fn),
});
collectInstanceSites(decl, id, file, pendingInstances);
@@ -229,21 +241,21 @@ function extractProps(fn: FunctionLike): string[] {
return [first.getName()];
}
-function extractRenderedText(fn: FunctionLike): string[] {
- const texts = new Set();
+function extractRenderedText(fn: FunctionLike): RenderedText[] {
+ const entries = new Map();
for (const jsxText of fn.getDescendantsOfKind(SyntaxKind.JsxText)) {
const text = jsxText.getText().replace(/\s+/g, " ").trim();
- if (text.length > 0) texts.add(text);
+ if (text.length > 0) entries.set(`jsx:${text}`, { text, source: "jsx" });
}
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) texts.add(text);
+ if (text.length > 0) entries.set(`attribute:${text}`, { text, source: "attribute" });
}
}
- return [...texts];
+ return [...entries.values()];
}
function extractRenderedComponents(fn: FunctionLike): string[] {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index cce41e9..c9ec249 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -56,7 +56,7 @@ importers:
version: 5.9.3
vitest:
specifier: ^3.2.7
- version: 3.2.7(@types/node@22.20.1)
+ version: 3.2.7(@types/node@22.20.1)(yaml@2.9.0)
packages/parser-react:
dependencies:
@@ -66,6 +66,9 @@ importers:
ts-morph:
specifier: ^24.0.0
version: 24.0.0
+ yaml:
+ specifier: ^2.9.0
+ version: 2.9.0
devDependencies:
'@types/node':
specifier: ^22.20.1
@@ -75,7 +78,7 @@ importers:
version: 5.9.3
vitest:
specifier: ^3.2.7
- version: 3.2.7(@types/node@22.20.1)
+ version: 3.2.7(@types/node@22.20.1)(yaml@2.9.0)
packages:
@@ -721,6 +724,11 @@ packages:
engines: {node: '>=8'}
hasBin: true
+ yaml@2.9.0:
+ resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==}
+ engines: {node: '>= 14.6'}
+ hasBin: true
+
snapshots:
'@esbuild/aix-ppc64@0.28.1':
@@ -907,13 +915,13 @@ snapshots:
chai: 5.3.3
tinyrainbow: 2.0.0
- '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@22.20.1))':
+ '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@22.20.1)(yaml@2.9.0))':
dependencies:
'@vitest/spy': 3.2.7
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
- vite: 7.3.6(@types/node@22.20.1)
+ vite: 7.3.6(@types/node@22.20.1)(yaml@2.9.0)
'@vitest/pretty-format@3.2.7':
dependencies:
@@ -1160,13 +1168,13 @@ snapshots:
undici-types@6.21.0: {}
- vite-node@3.2.4(@types/node@22.20.1):
+ vite-node@3.2.4(@types/node@22.20.1)(yaml@2.9.0):
dependencies:
cac: 6.7.14
debug: 4.4.3
es-module-lexer: 1.7.0
pathe: 2.0.3
- vite: 7.3.6(@types/node@22.20.1)
+ vite: 7.3.6(@types/node@22.20.1)(yaml@2.9.0)
transitivePeerDependencies:
- '@types/node'
- jiti
@@ -1181,7 +1189,7 @@ snapshots:
- tsx
- yaml
- vite@7.3.6(@types/node@22.20.1):
+ vite@7.3.6(@types/node@22.20.1)(yaml@2.9.0):
dependencies:
esbuild: 0.28.1
fdir: 6.5.0(picomatch@4.0.5)
@@ -1192,12 +1200,13 @@ snapshots:
optionalDependencies:
'@types/node': 22.20.1
fsevents: 2.3.3
+ yaml: 2.9.0
- vitest@3.2.7(@types/node@22.20.1):
+ vitest@3.2.7(@types/node@22.20.1)(yaml@2.9.0):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.7
- '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@22.20.1))
+ '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@22.20.1)(yaml@2.9.0))
'@vitest/pretty-format': 3.2.7
'@vitest/runner': 3.2.7
'@vitest/snapshot': 3.2.7
@@ -1215,8 +1224,8 @@ snapshots:
tinyglobby: 0.2.17
tinypool: 1.1.1
tinyrainbow: 2.0.0
- vite: 7.3.6(@types/node@22.20.1)
- vite-node: 3.2.4(@types/node@22.20.1)
+ vite: 7.3.6(@types/node@22.20.1)(yaml@2.9.0)
+ vite-node: 3.2.4(@types/node@22.20.1)(yaml@2.9.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 22.20.1
@@ -1238,3 +1247,5 @@ snapshots:
dependencies:
siginfo: 2.0.0
stackback: 0.0.2
+
+ yaml@2.9.0: {}
diff --git a/schemas/lineage-graph.schema.json b/schemas/lineage-graph.schema.json
index 69f5480..5be6601 100644
--- a/schemas/lineage-graph.schema.json
+++ b/schemas/lineage-graph.schema.json
@@ -131,9 +131,9 @@
"renderedText": {
"type": "array",
"items": {
- "type": "string"
+ "$ref": "#/definitions/RenderedText"
},
- "description": "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."
+ "description": "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."
},
"rendersComponents": {
"type": "array",
@@ -180,6 +180,41 @@
"additionalProperties": false,
"description": "Where a node lives in the codebase."
},
+ "RenderedText": {
+ "type": "object",
+ "properties": {
+ "text": {
+ "type": "string"
+ },
+ "source": {
+ "type": "string",
+ "enum": [
+ "jsx",
+ "attribute",
+ "i18n"
+ ],
+ "description": "Where the text comes from: JSX children, a string attribute (placeholder/label/title/alt/aria-label), or an i18n key resolved against locale files."
+ },
+ "key": {
+ "type": "string",
+ "description": "i18n entries only: the translation key, e.g. \"team.title\"."
+ },
+ "locale": {
+ "type": "string",
+ "description": "i18n entries only: which locale this text belongs to."
+ },
+ "branch": {
+ "type": "string",
+ "description": "Condition source text when the text renders only in a branch (step 1.5)."
+ }
+ },
+ "required": [
+ "text",
+ "source"
+ ],
+ "additionalProperties": false,
+ "description": "One piece of text a component can render, with its provenance."
+ },
"InstanceNode": {
"type": "object",
"properties": {