@terpjs/eslint-boundaries 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@terpjs/eslint-boundaries",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "type": "module",
5
5
  "description": "Terp frontend boundary rules (as data) + the ESLint adapter: no cross-module imports, no package internals, design-token-only styling (no style/className/module stylesheets), token-styled components for raw HTML tags, router-only in-app links, generated-client-only, and browser XSS/navigation sink bans. Strict-only (no modes); governed opt-outs via terp-allow markers + the escape-hatch budget ratchet (terp-boundaries-budget).",
6
6
  "main": "./src/index.js",
@@ -25,7 +25,7 @@
25
25
  "typescript-eslint": "^8.20.0"
26
26
  },
27
27
  "devDependencies": {
28
- "@terpjs/spec": "0.27.0",
28
+ "@terpjs/spec": "0.29.1",
29
29
  "eslint": "^10.8.0",
30
30
  "vitest": "^4.1.9"
31
31
  },
package/src/budget.js CHANGED
@@ -2,7 +2,8 @@
2
2
  /**
3
3
  * The escape-hatch budget ratchet — the frontend analog of the backend's governed
4
4
  * `# arch-allow-*` budget (design §8): `terp-allow-*` marker counts in the app-authored
5
- * surface (`src/modules/**`) must match the checked-in `escape-hatch-budget.json` **exactly**.
5
+ * surface (`src/**`, excluding the generated `src/api/**`) must match the checked-in
6
+ * `escape-hatch-budget.json` **exactly**.
6
7
  * A marker that rose needs a justified budget bump in the same change; one that dropped must
7
8
  * be lowered to lock in the win; an unbudgeted marker must be added with a justified count.
8
9
  * This keeps every boundary opt-out visible, greppable, and governed.
@@ -42,10 +43,10 @@ function parsedReviewDate(value) {
42
43
  return roundTrips ? date : null;
43
44
  }
44
45
 
45
- /** Every `src/modules/**` TypeScript file under *root*, recursively. */
46
- function moduleFiles(root) {
47
- const modulesRoot = path.join(root, "src", "modules");
48
- if (!fs.existsSync(modulesRoot)) {
46
+ /** Every app-authored `src/**` TypeScript file under *root*, recursively. */
47
+ function sourceFiles(root) {
48
+ const sourceRoot = path.join(root, "src");
49
+ if (!fs.existsSync(sourceRoot)) {
49
50
  return [];
50
51
  }
51
52
  const files = [];
@@ -53,20 +54,21 @@ function moduleFiles(root) {
53
54
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
54
55
  const full = path.join(dir, entry.name);
55
56
  if (entry.isDirectory()) {
57
+ if (full === path.join(sourceRoot, "api")) continue;
56
58
  walk(full);
57
59
  } else if (MODULE_FILE_RE.test(entry.name)) {
58
60
  files.push(full);
59
61
  }
60
62
  }
61
63
  };
62
- walk(modulesRoot);
64
+ walk(sourceRoot);
63
65
  return files;
64
66
  }
65
67
 
66
68
  /** Actual `terp-allow-<rule>` marker counts across the app-authored surface. */
67
69
  export function countMarkers(root) {
68
70
  const counts = {};
69
- for (const file of moduleFiles(root)) {
71
+ for (const file of sourceFiles(root)) {
70
72
  for (const marker of parseAllowMarkers(fs.readFileSync(file, "utf-8"))) {
71
73
  const name = `${BOUNDARY_SPEC.allowMarkerPrefix}${marker.rule}`;
72
74
  counts[name] = (counts[name] ?? 0) + 1;
@@ -143,7 +145,7 @@ export function checkBudget(root, budgetPath, today = new Date()) {
143
145
  // Date-only comparison (like the backend checker): a review-by dated today
144
146
  // is due, not yet passed.
145
147
  const deadline = Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate());
146
- for (const file of moduleFiles(root)) {
148
+ for (const file of sourceFiles(root)) {
147
149
  const relative = path.relative(root, file).split(path.sep).join("/");
148
150
  for (const marker of parseAllowMarkers(fs.readFileSync(file, "utf-8"))) {
149
151
  if (marker.reason === null) {
@@ -55,7 +55,20 @@ describe("countMarkers", () => {
55
55
  expect(countMarkers(root)).toEqual({ "terp-allow-no-unsafe-target-blank": 1 });
56
56
  });
57
57
 
58
- it("is empty for an app with no modules directory", () => {
58
+ it("governs localization markers across app source but ignores the generated client", () => {
59
+ const root = appRoot({});
60
+ const main = path.join(root, "src", "main.tsx");
61
+ const generated = path.join(root, "src", "api", "schema.ts");
62
+ fs.mkdirSync(path.dirname(generated), { recursive: true });
63
+ fs.writeFileSync(
64
+ main,
65
+ "// terp-allow-no-untranslated-ui: product name is intentionally fixed\nexport const x = 1;\n",
66
+ );
67
+ fs.writeFileSync(generated, MARKED);
68
+ expect(countMarkers(root)).toEqual({ "terp-allow-no-untranslated-ui": 1 });
69
+ });
70
+
71
+ it("is empty for an app with no source directory", () => {
59
72
  const root = path.join(scratchRoot, `case-${rootCounter++}`);
60
73
  fs.rmSync(root, { recursive: true, force: true });
61
74
  fs.mkdirSync(root, { recursive: true });
@@ -55,12 +55,17 @@ export async function lintCaseFindings(caseDir) {
55
55
  rules: { "terp/layout-contract": ["error", { contract }] },
56
56
  },
57
57
  ];
58
- const eslint = new ESLint({ overrideConfigFile: true, overrideConfig });
58
+ // Treat the corpus case as the app root. That keeps the real on-disk path
59
+ // (so declaration-backed rules can walk up to i18n.json/layout-contract.json)
60
+ // while still making the config's module globs relative to the case rather
61
+ // than to the framework checkout.
62
+ const eslint = new ESLint({ cwd: caseDir, overrideConfigFile: true, overrideConfig });
59
63
  const findings = [];
60
64
  for (const file of caseFiles(caseDir)) {
61
65
  if (!/\.tsx?$/.test(file)) continue; // config carriers (layout-contract.json) are not linted
62
- // Resolve under the cwd so the config's `files` globs match the module path.
63
- const filePath = path.resolve(path.relative(caseDir, file));
66
+ // Use the physical case path so declaration-backed rules can find checked-in
67
+ // carriers such as i18n.json by walking upward from the real file.
68
+ const filePath = file;
64
69
  const [result] = await eslint.lintText(fs.readFileSync(file, "utf8"), { filePath });
65
70
  findings.push(
66
71
  ...result.messages.map((message) => ({
@@ -29,14 +29,22 @@ const SPEC_ROOT = path.dirname(
29
29
  const CATALOG = path.join(SPEC_ROOT, "catalog", "frontend");
30
30
  const CORPUS = path.join(SPEC_ROOT, "corpus", "frontend");
31
31
 
32
- const entries = fs
32
+ const catalogEntries = fs
33
33
  .readdirSync(CATALOG)
34
34
  .filter((name) => name.endsWith(".json"))
35
- .map((name) => JSON.parse(fs.readFileSync(path.join(CATALOG, name), "utf8")))
35
+ .map((name) => JSON.parse(fs.readFileSync(path.join(CATALOG, name), "utf8")));
36
+ const catalogIds = new Set(catalogEntries.map((entry) => entry.id));
37
+ const entries = catalogEntries
36
38
  .filter((entry) => entry.corpus);
37
39
 
40
+ async function findingsForConsumedSpec(caseDir) {
41
+ return (await lintCaseFindings(caseDir)).filter(
42
+ (finding) => finding.rule === null || catalogIds.has(finding.rule),
43
+ );
44
+ }
45
+
38
46
  async function lintCase(caseDir) {
39
- return (await lintCaseFindings(caseDir)).map((finding) => finding.rule);
47
+ return (await findingsForConsumedSpec(caseDir)).map((finding) => finding.rule);
40
48
  }
41
49
 
42
50
  describe("frontend corpus (spec/corpus/frontend)", () => {
@@ -72,7 +80,7 @@ describe("findings round-trip (spec/findings.schema.json)", () => {
72
80
  const ruleDir = path.join(CORPUS, entry.id.split("/")[1]);
73
81
  for (const caseName of fs.readdirSync(ruleDir).sort()) {
74
82
  if (!caseName.startsWith("violation-")) continue;
75
- findings.push(...(await lintCaseFindings(path.join(ruleDir, caseName))));
83
+ findings.push(...(await findingsForConsumedSpec(path.join(ruleDir, caseName))));
76
84
  }
77
85
  }
78
86
  expect(findings.length).toBeGreaterThan(0);
package/src/findings.js CHANGED
@@ -43,6 +43,7 @@
43
43
  * seam (ADR 0083) existing consumers parse strictly. */
44
44
 
45
45
  import fs from "node:fs";
46
+ import { createHash } from "node:crypto";
46
47
  import path from "node:path";
47
48
  import process from "node:process";
48
49
  import { pathToFileURL } from "node:url";
@@ -171,9 +172,30 @@ async function main() {
171
172
  args.splice(formatIndex, 2);
172
173
  }
173
174
  const budgetPath = args[0] ?? path.join(cwd, "escape-hatch-budget.json");
174
- // The app's own config and ignore set, with the same cache the plain CLI used
175
- // (`--cache --cache-location node_modules/.cache/eslint/`).
176
- const eslint = new ESLint({ cache: true, cacheLocation: "node_modules/.cache/eslint/" });
175
+ // The app's own config and ignore set. Two rules also read checked-in declarations
176
+ // outside the linted source file, so include those bytes in the cache namespace.
177
+ // Otherwise fixing i18n.json can replay a stale failure, and breaking it after a
178
+ // cached pass can stay falsely green until a source file happens to change.
179
+ const externalInputs = ["i18n.json", "layout-contract.json"].map((name) => {
180
+ const file = path.join(cwd, name);
181
+ return fs.existsSync(file) ? fs.readFileSync(file) : Buffer.from("<missing>");
182
+ });
183
+ const declarationHash = createHash("sha256")
184
+ .update(externalInputs[0])
185
+ .update("\0")
186
+ .update(externalInputs[1])
187
+ .digest("hex")
188
+ .slice(0, 16);
189
+ const eslint = new ESLint({
190
+ cache: true,
191
+ cacheLocation: path.join(
192
+ "node_modules",
193
+ ".cache",
194
+ "eslint",
195
+ `declarations-${declarationHash}`,
196
+ ".eslintcache",
197
+ ),
198
+ });
177
199
  const results = await eslint.lintFiles(["."]);
178
200
  // The ratchet runs regardless of the lint verdict — both halves always report.
179
201
  const budgetProblems = checkBudget(cwd, budgetPath);
@@ -50,15 +50,22 @@ async function lintModule(text) {
50
50
  }
51
51
 
52
52
  describe("catalogRuleIds (the evaluated-rule inventory)", () => {
53
- it("matches the Terp Standard frontend catalog exactly, both directions", () => {
54
- // The inventory can't lie: every catalog entry is evaluated, and no evaluated id
55
- // outlives its catalog entry (the same parity discipline as test_spec_catalog).
53
+ it("covers the pinned catalog and matches a substituted candidate exactly", () => {
54
+ // Framework main must land a reference implementation before the corresponding
55
+ // Standard candidate can certify. The last published (equal-version) pin may
56
+ // therefore be a subset during that staging window. Candidate CI substitutes a
57
+ // newer spec, where exactness is restored before release.
56
58
  const catalogued = fs
57
59
  .readdirSync(path.join(SPEC_ROOT, "catalog", "frontend"))
58
60
  .filter((name) => name.endsWith(".json"))
59
61
  .map((name) => `frontend/${name.replace(/\.json$/, "")}`)
60
62
  .sort();
61
- expect(catalogRuleIds()).toEqual(catalogued);
63
+ const consumedVersion = fs.readFileSync(path.join(SPEC_ROOT, "VERSION"), "utf8").trim();
64
+ if (consumedVersion === SPEC_VERSION) {
65
+ expect(catalogRuleIds()).toEqual(expect.arrayContaining(catalogued));
66
+ } else {
67
+ expect(catalogRuleIds()).toEqual(catalogued);
68
+ }
62
69
  });
63
70
  });
64
71
 
@@ -209,6 +216,39 @@ describe("terp-boundaries-lint (the bin)", () => {
209
216
  );
210
217
  });
211
218
 
219
+ it("invalidates cached source results when the locale declaration changes", () => {
220
+ const root = appRoot({
221
+ "package.json": '{ "type": "module" }',
222
+ "eslint.config.js": config,
223
+ "escape-hatch-budget.json": "{}",
224
+ "i18n.json": JSON.stringify({
225
+ sourceLocale: "en",
226
+ locales: { en: {}, nl: { messages: {} } },
227
+ }),
228
+ "src/main.tsx":
229
+ 'export const title = { id: "app.title", message: "Example" };\n',
230
+ });
231
+
232
+ const missing = runBin(root);
233
+ expect(missing.status).toBe(1);
234
+ expect(JSON.parse(missing.stdout).findings).toEqual(
235
+ expect.arrayContaining([
236
+ expect.objectContaining({ rule: "frontend/locale-catalogs-complete" }),
237
+ ]),
238
+ );
239
+
240
+ fs.writeFileSync(
241
+ path.join(root, "i18n.json"),
242
+ JSON.stringify({
243
+ sourceLocale: "en",
244
+ locales: { en: {}, nl: { messages: { "app.title": "Voorbeeld" } } },
245
+ }),
246
+ );
247
+ const complete = runBin(root);
248
+ expect(complete.status).toBe(0);
249
+ expect(JSON.parse(complete.stdout).findings).toEqual([]);
250
+ });
251
+
212
252
  it("reports budget drift even when the boundary lint fails (both halves always run)", () => {
213
253
  // The regression the merged bin exists for: with `eslint . && terp-boundaries-budget`
214
254
  // a boundary violation short-circuited the ratchet, hiding budget drift from the run.
@@ -0,0 +1,307 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { ESLint } from "eslint";
5
+ import { afterEach, describe, expect, it } from "vitest";
6
+
7
+ import terpBoundaries from "./index.js";
8
+
9
+ const roots = [];
10
+
11
+ afterEach(() => {
12
+ for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true });
13
+ });
14
+
15
+ async function lintAt(root, source, relative = "src/modules/widgets/Widget.tsx") {
16
+ const filePath = path.join(root, relative);
17
+ const eslint = new ESLint({ cwd: root, overrideConfigFile: true, overrideConfig: terpBoundaries });
18
+ const [result] = await eslint.lintText(source, { filePath });
19
+ return result.messages;
20
+ }
21
+
22
+ async function lintWithCatalog(catalog, source, relative) {
23
+ const root = fs.mkdtempSync(path.join(process.cwd(), ".terp-i18n-test-"));
24
+ roots.push(root);
25
+ fs.writeFileSync(path.join(root, "i18n.json"), JSON.stringify(catalog));
26
+ return lintAt(root, source, relative);
27
+ }
28
+
29
+ async function lintWithoutCatalog(source, relative) {
30
+ const root = fs.mkdtempSync(path.join(process.cwd(), ".terp-i18n-test-"));
31
+ roots.push(root);
32
+ return lintAt(root, source, relative);
33
+ }
34
+
35
+ function publicUiTextPropertyNames() {
36
+ const rootsToScan = [
37
+ path.resolve(import.meta.dirname, "../../react-core/src"),
38
+ path.resolve(import.meta.dirname, "../../contract/src"),
39
+ ];
40
+ const names = new Set();
41
+ const walk = (dir) => {
42
+ for (const item of fs.readdirSync(dir, { withFileTypes: true })) {
43
+ const full = path.join(dir, item.name);
44
+ if (item.isDirectory()) {
45
+ walk(full);
46
+ } else if (/\.(?:ts|tsx)$/.test(item.name) && !/\.test\.tsx?$/.test(item.name)) {
47
+ const sourceText = fs.readFileSync(full, "utf8");
48
+ for (const match of sourceText.matchAll(/^\s*(\w+)\??:\s*UiText\b/gm)) {
49
+ names.add(match[1]);
50
+ }
51
+ }
52
+ }
53
+ };
54
+ rootsToScan.forEach(walk);
55
+ return [...names].sort();
56
+ }
57
+
58
+ const source = 'export const title = { id: "widgets.title", message: "Widgets" };';
59
+
60
+ describe("locale catalog completeness", () => {
61
+ it("fails closed when authored descriptors have no i18n declaration", async () => {
62
+ const messages = await lintWithoutCatalog(source);
63
+ expect(messages.map((message) => message.ruleId)).toContain("terp/locale-catalogs-complete");
64
+ expect(messages.find((message) => message.ruleId === "terp/locale-catalogs-complete")?.message)
65
+ .toContain("has no frontend/i18n.json declaration");
66
+ });
67
+
68
+ it("does not let a nested i18n.json shadow the authoritative app catalog", async () => {
69
+ const root = fs.mkdtempSync(path.join(process.cwd(), ".terp-i18n-test-"));
70
+ roots.push(root);
71
+ const nested = path.join(root, "src", "modules", "widgets");
72
+ fs.mkdirSync(nested, { recursive: true });
73
+ fs.writeFileSync(
74
+ path.join(nested, "i18n.json"),
75
+ JSON.stringify({
76
+ sourceLocale: "en",
77
+ locales: { en: {}, nl: { messages: { "widgets.title": "Onderdelen" } } },
78
+ }),
79
+ );
80
+ const messages = await lintAt(root, source);
81
+ expect(messages.find((message) => message.ruleId === "terp/locale-catalogs-complete")?.message)
82
+ .toContain("has no frontend/i18n.json declaration");
83
+ });
84
+
85
+ it("reports every target locale missing an authored id", async () => {
86
+ const messages = await lintWithCatalog(
87
+ { sourceLocale: "en", locales: { en: {}, nl: { messages: {} }, de: {} } },
88
+ source,
89
+ );
90
+ expect(messages.map((message) => message.ruleId)).toContain("terp/locale-catalogs-complete");
91
+ expect(messages[0].message).toContain("nl: missing");
92
+ expect(messages[0].message).toContain("de: missing");
93
+ });
94
+
95
+ it("refuses copied source text unless the id is explicitly allowlisted", async () => {
96
+ const messages = await lintWithCatalog(
97
+ { sourceLocale: "en", locales: { en: {}, nl: { messages: { "widgets.title": "Widgets" } } } },
98
+ source,
99
+ );
100
+ expect(messages[0].message).toContain("copied source");
101
+ });
102
+
103
+ it("accepts complete translations and documented identical proper nouns", async () => {
104
+ const messages = await lintWithCatalog(
105
+ {
106
+ sourceLocale: "en",
107
+ locales: {
108
+ en: {},
109
+ nl: { messages: { "widgets.title": "Widgets" }, allowIdentical: ["widgets.title"] },
110
+ },
111
+ },
112
+ source,
113
+ );
114
+ expect(messages).toEqual([]);
115
+ });
116
+
117
+ it("refuses malformed declarations, descriptors, and stale allowlist entries", async () => {
118
+ const malformed = await lintWithCatalog(
119
+ { sourceLocale: "en", locales: { en: {}, nl: { messages: { stale: "" } } } },
120
+ source,
121
+ );
122
+ expect(malformed[0].message).toContain("empty or invalid message entry");
123
+
124
+ const blank = await lintWithCatalog(
125
+ { sourceLocale: "en", locales: { en: {}, nl: {} } },
126
+ 'export const title = { id: "", message: "Widgets" };',
127
+ );
128
+ expect(blank.map((message) => message.ruleId)).toContain("terp/locale-catalogs-complete");
129
+ expect(blank.find((message) => message.ruleId === "terp/locale-catalogs-complete")?.message)
130
+ .toContain("non-empty static id and message");
131
+
132
+ const dynamicDescriptor = await lintWithCatalog(
133
+ { sourceLocale: "en", locales: { en: {}, nl: {} } },
134
+ 'export const title = { id: getId(), message: "Widgets" };',
135
+ );
136
+ expect(dynamicDescriptor
137
+ .find((message) => message.ruleId === "terp/locale-catalogs-complete")?.message)
138
+ .toContain("static non-empty id and message");
139
+
140
+ const dynamicTrans = await lintWithCatalog(
141
+ { sourceLocale: "en", locales: { en: {}, nl: {} } },
142
+ 'export const W = ({ id }) => <Trans id={id} message="Widgets" />;',
143
+ );
144
+ expect(dynamicTrans.find((message) => message.ruleId === "terp/locale-catalogs-complete")?.message)
145
+ .toContain("requires static non-empty id and message");
146
+
147
+ const staleAllowlist = await lintWithCatalog(
148
+ {
149
+ sourceLocale: "en",
150
+ locales: {
151
+ en: {},
152
+ nl: { messages: { "widgets.title": "Onderdelen" }, allowIdentical: ["widgets.title"] },
153
+ },
154
+ },
155
+ source,
156
+ );
157
+ expect(staleAllowlist[0].message).toContain("stale allowIdentical entry");
158
+ });
159
+
160
+ it("does not mistake dynamic business records for UiText descriptors", async () => {
161
+ const messages = await lintWithCatalog(
162
+ { sourceLocale: "en", locales: { en: {}, nl: {} } },
163
+ "export const apiRecord = (id, message) => ({ id, message });",
164
+ );
165
+ expect(messages).toEqual([]);
166
+ });
167
+
168
+ it("honours the catalog-derived governed marker for an intentional exception", async () => {
169
+ const messages = await lintWithoutCatalog(
170
+ [
171
+ "// terp-allow-locale-catalogs-complete: documented temporary catalog migration",
172
+ 'export const title = { id: "widgets.title", message: "Widgets" };',
173
+ ].join("\n"),
174
+ );
175
+ expect(messages).toEqual([]);
176
+ });
177
+ });
178
+
179
+ describe("untranslated UI coverage", () => {
180
+ const declaration = { sourceLocale: "en", locales: { en: {}, nl: {} } };
181
+
182
+ it("applies across src, not only module directories", async () => {
183
+ const messages = await lintWithCatalog(
184
+ declaration,
185
+ 'export const bootstrap = { label: "Single sign-on" };',
186
+ "src/main.tsx",
187
+ );
188
+ expect(messages.map((message) => message.ruleId)).toContain("terp/no-untranslated-ui");
189
+ });
190
+
191
+ it.each([
192
+ ['<ModuleNav ariaLabel="Modules" />', "ariaLabel"],
193
+ ['<Tooltip content="More information" />', "content"],
194
+ ['<Combobox loadingText="Loading options" />', "loadingText"],
195
+ ['<Markdown source="Read the documentation" />', "source"],
196
+ ['<DataView searchPlaceholder="Search widgets" />', "searchPlaceholder"],
197
+ ['<div aria-description="Extra context" />', "aria-description"],
198
+ ])("refuses literal UI copy in %s", async (jsx) => {
199
+ const messages = await lintWithCatalog(declaration, `export const W = () => ${jsx};`);
200
+ expect(messages.map((message) => message.ruleId)).toContain("terp/no-untranslated-ui");
201
+ });
202
+
203
+ it("covers UiText-bearing data properties beyond the original short list", async () => {
204
+ const messages = await lintWithCatalog(
205
+ declaration,
206
+ 'export const text = { actions: "Actions", "aria-label": "Open widget", cardView: "Card view", clearFilters: "Clear filters", clearSelection: "Clear selection", openRow: "Open details", pageOf: "Page of" };',
207
+ );
208
+ expect(messages.filter((message) => message.ruleId === "terp/no-untranslated-ui"))
209
+ .toHaveLength(7);
210
+ });
211
+
212
+ it("tracks every public UiText property so new component copy cannot bypass the rule", async () => {
213
+ const names = publicUiTextPropertyNames();
214
+ expect(names.length).toBeGreaterThan(20);
215
+ const sourceText = [
216
+ "export const copy = {",
217
+ ...names.map((name) => ` ${name}: "Translate ${name}",`),
218
+ "};",
219
+ ].join("\n");
220
+ const messages = await lintWithCatalog(declaration, sourceText);
221
+ const findingLines = new Set(
222
+ messages
223
+ .filter((message) => message.ruleId === "terp/no-untranslated-ui")
224
+ .map((message) => message.line),
225
+ );
226
+ for (const [index, name] of names.entries()) {
227
+ expect(findingLines.has(index + 2), `public UiText property "${name}" escaped`).toBe(true);
228
+ }
229
+ });
230
+
231
+ it.each([
232
+ '<>{"Save changes"}</>',
233
+ '<Text>{ready ? "Ready" : "Waiting"}</Text>',
234
+ '<Page title={ready && "Ready"} />',
235
+ '<Text>{["First item", "Second item"]}</Text>',
236
+ ])("refuses authored copy in nontrivial rendered expressions: %s", async (jsx) => {
237
+ const messages = await lintWithCatalog(
238
+ declaration,
239
+ `export const W = ({ ready }) => ${jsx};`,
240
+ );
241
+ expect(messages.map((message) => message.ruleId)).toContain("terp/no-untranslated-ui");
242
+ });
243
+
244
+ it("refuses conditional object-property copy and unwraps TypeScript const assertions", async () => {
245
+ const conditional = await lintWithCatalog(
246
+ declaration,
247
+ 'export const action = { label: ready ? "Save changes" : "Cancel changes" };',
248
+ );
249
+ expect(conditional.map((message) => message.ruleId)).toContain("terp/no-untranslated-ui");
250
+
251
+ const descriptor = await lintWithCatalog(
252
+ {
253
+ sourceLocale: "en",
254
+ locales: { en: {}, nl: { messages: { "widgets.title": "Onderdelen" } } },
255
+ },
256
+ 'export const title = { id: "widgets.title" as const, message: "Widgets" as const };',
257
+ );
258
+ expect(descriptor).toEqual([]);
259
+ });
260
+
261
+ it("keeps authored code samples exempt, including expression forms", async () => {
262
+ const messages = await lintWithCatalog(
263
+ declaration,
264
+ 'export const W = ({ ready }) => <Code>{ready ? "npm run build" : "npm run lint"}</Code>;',
265
+ );
266
+ expect(messages).toEqual([]);
267
+ });
268
+
269
+ it("refuses bare copy sent through the standard toast feedback channel", async () => {
270
+ const messages = await lintWithCatalog(
271
+ declaration,
272
+ [
273
+ 'import { useToast as useFeedback } from "@terpjs/react-core";',
274
+ "export function SaveButton({ ready }) {",
275
+ " const feedback = useFeedback();",
276
+ " const { warning: warn } = useFeedback();",
277
+ ' feedback.success(ready ? "Saved" : "Still saving");',
278
+ ' warn("Could not save");',
279
+ " return null;",
280
+ "}",
281
+ ].join("\n"),
282
+ );
283
+ expect(
284
+ messages.filter((message) => message.ruleId === "terp/no-untranslated-ui"),
285
+ ).toHaveLength(2);
286
+ });
287
+
288
+ it("accepts toast copy resolved from a catalog descriptor", async () => {
289
+ const messages = await lintWithCatalog(
290
+ {
291
+ sourceLocale: "en",
292
+ locales: { en: {}, nl: { messages: { "save.done": "Opgeslagen" } } },
293
+ },
294
+ [
295
+ 'import { useToast, useUiText } from "@terpjs/react-core";',
296
+ "export function SaveButton({ error }) {",
297
+ " const toast = useToast();",
298
+ " const text = useUiText();",
299
+ ' toast.success(text({ id: "save.done", message: "Saved" }));',
300
+ " toast.error(error.message);",
301
+ " return null;",
302
+ "}",
303
+ ].join("\n"),
304
+ );
305
+ expect(messages).toEqual([]);
306
+ });
307
+ });