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