@terpjs/eslint-boundaries 0.10.0 → 0.12.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/src/index.js CHANGED
@@ -95,6 +95,18 @@ function getJsxAttribute(openingElement, name) {
95
95
  );
96
96
  }
97
97
 
98
+ function unwrapExpression(node) {
99
+ let current = node;
100
+ while (
101
+ current &&
102
+ ["ChainExpression", "TSAsExpression", "TSNonNullExpression", "TSSatisfiesExpression",
103
+ "TypeCastExpression"].includes(current.type)
104
+ ) {
105
+ current = current.expression;
106
+ }
107
+ return current;
108
+ }
109
+
98
110
  function staticStringFromJsxValue(value) {
99
111
  if (!value) {
100
112
  return null;
@@ -103,13 +115,7 @@ function staticStringFromJsxValue(value) {
103
115
  return value.value;
104
116
  }
105
117
  if (value.type === "JSXExpressionContainer") {
106
- const expression = value.expression;
107
- if (expression.type === "Literal" && typeof expression.value === "string") {
108
- return expression.value;
109
- }
110
- if (expression.type === "TemplateLiteral" && expression.expressions.length === 0) {
111
- return expression.quasis.map((quasi) => quasi.value.cooked ?? quasi.value.raw).join("");
112
- }
118
+ return staticString(value.expression);
113
119
  }
114
120
  return null;
115
121
  }
@@ -289,6 +295,478 @@ const noEval = {
289
295
  },
290
296
  };
291
297
 
298
+ const UI_TEXT_ATTRIBUTES = new Set([
299
+ "actions",
300
+ "aria-description",
301
+ "aria-label",
302
+ "aria-placeholder",
303
+ "alt",
304
+ "ariaLabel",
305
+ "broadenedLabel",
306
+ "cancelLabel",
307
+ "cardView",
308
+ "caption",
309
+ "clearFilters",
310
+ "clearSearch",
311
+ "clearSelection",
312
+ "collapseRow",
313
+ "columns",
314
+ "confirmLabel",
315
+ "content",
316
+ "createPlaceholder",
317
+ "description",
318
+ "empty",
319
+ "emptyMessage",
320
+ "errorTitle",
321
+ "expandRow",
322
+ "firstPage",
323
+ "header",
324
+ "hint",
325
+ "label",
326
+ "lastPage",
327
+ "loading",
328
+ "loadingText",
329
+ "moreActions",
330
+ "moveDown",
331
+ "moveUp",
332
+ "nextPage",
333
+ "noOptionsText",
334
+ "openRow",
335
+ "pageOf",
336
+ "pageSize",
337
+ "placeholder",
338
+ "previousPage",
339
+ "refreshing",
340
+ "removeLabel",
341
+ "resizeColumn",
342
+ "resultsRange",
343
+ "searchPlaceholder",
344
+ "selectAllPage",
345
+ "selectAllResults",
346
+ "selected",
347
+ "selectRow",
348
+ "source",
349
+ "stat",
350
+ "subtitle",
351
+ "tableView",
352
+ "title",
353
+ "tooltip",
354
+ "triggerLabel",
355
+ "viewOptions",
356
+ ]);
357
+ const UI_TEXT_PROPERTIES = new Set([
358
+ "actions",
359
+ "alt",
360
+ "aria-description",
361
+ "aria-label",
362
+ "aria-placeholder",
363
+ "ariaLabel",
364
+ "broadenedLabel",
365
+ "cancelLabel",
366
+ "cardView",
367
+ "caption",
368
+ "clearFilters",
369
+ "clearSearch",
370
+ "clearSelection",
371
+ "collapseRow",
372
+ "columns",
373
+ "confirmLabel",
374
+ "content",
375
+ "createPlaceholder",
376
+ "description",
377
+ "empty",
378
+ "emptyMessage",
379
+ "errorTitle",
380
+ "expandRow",
381
+ "firstPage",
382
+ "header",
383
+ "hint",
384
+ "label",
385
+ "lastPage",
386
+ "loading",
387
+ "loadingText",
388
+ "moreActions",
389
+ "moveDown",
390
+ "moveUp",
391
+ "nextPage",
392
+ "noOptionsText",
393
+ "openRow",
394
+ "pageOf",
395
+ "pageSize",
396
+ "placeholder",
397
+ "previousPage",
398
+ "refreshing",
399
+ "removeLabel",
400
+ "resizeColumn",
401
+ "resultsRange",
402
+ "searchPlaceholder",
403
+ "selectAllPage",
404
+ "selectAllResults",
405
+ "selected",
406
+ "selectRow",
407
+ "source",
408
+ "stat",
409
+ "subtitle",
410
+ "tableView",
411
+ "title",
412
+ "tooltip",
413
+ "triggerLabel",
414
+ "viewOptions",
415
+ ]);
416
+
417
+ function staticString(node) {
418
+ const value = unwrapExpression(node);
419
+ if (value?.type === "Literal" && typeof value.value === "string") return value.value;
420
+ if (value?.type === "TemplateLiteral" && value.expressions.length === 0) {
421
+ return value.quasis.map((part) => part.value.cooked ?? part.value.raw).join("");
422
+ }
423
+ return null;
424
+ }
425
+
426
+ function containsAuthoredCopy(value) {
427
+ return typeof value === "string" && /\p{L}/u.test(value);
428
+ }
429
+
430
+ function isInsideCode(node) {
431
+ let parent = node.parent;
432
+ while (parent) {
433
+ if (parent.type === "JSXElement" && jsxName(parent.openingElement.name) === "Code") return true;
434
+ parent = parent.parent;
435
+ }
436
+ return false;
437
+ }
438
+
439
+ function propertyName(node) {
440
+ if (!node || node.computed) return null;
441
+ if (node.key.type === "Identifier") return node.key.name;
442
+ return typeof node.key.value === "string" ? node.key.value : null;
443
+ }
444
+
445
+ function isStaticDescriptor(node) {
446
+ const value = unwrapExpression(node);
447
+ if (value?.type !== "ObjectExpression") return false;
448
+ const named = (name) => value.properties.find(
449
+ (entry) => entry.type === "Property" && propertyName(entry) === name,
450
+ );
451
+ return staticString(named("id")?.value) !== null && staticString(named("message")?.value) !== null;
452
+ }
453
+
454
+ /** Authored string fragments in expressions that render or feed a known UiText property. */
455
+ function containsStaticAuthoredCopy(node) {
456
+ const value = unwrapExpression(node);
457
+ if (!value) return false;
458
+ const direct = staticString(value);
459
+ if (containsAuthoredCopy(direct)) return true;
460
+ if (value.type === "TemplateLiteral") {
461
+ return value.quasis.some((part) =>
462
+ containsAuthoredCopy(part.value.cooked ?? part.value.raw),
463
+ );
464
+ }
465
+ if (value.type === "ConditionalExpression") {
466
+ return containsStaticAuthoredCopy(value.consequent) || containsStaticAuthoredCopy(value.alternate);
467
+ }
468
+ if (value.type === "LogicalExpression" || value.type === "BinaryExpression") {
469
+ return containsStaticAuthoredCopy(value.left) || containsStaticAuthoredCopy(value.right);
470
+ }
471
+ if (value.type === "ArrayExpression") {
472
+ return value.elements.some((entry) => entry !== null && containsStaticAuthoredCopy(entry));
473
+ }
474
+ if (value.type === "SequenceExpression") {
475
+ return value.expressions.some((entry) => containsStaticAuthoredCopy(entry));
476
+ }
477
+ if (value.type === "ObjectExpression") {
478
+ if (isStaticDescriptor(value)) return false;
479
+ return value.properties.some(
480
+ (entry) => entry.type === "Property" && containsStaticAuthoredCopy(entry.value),
481
+ );
482
+ }
483
+ return false;
484
+ }
485
+
486
+ const untranslatedMessage =
487
+ "Static user-facing text must use a UiText descriptor or <Trans id=... message=... /> " +
488
+ "and must be present in frontend/i18n.json.";
489
+
490
+ /** Build-time extraction half of the app translation contract. */
491
+ const noUntranslatedUi = {
492
+ meta: {
493
+ type: "problem",
494
+ docs: { description: "Require static app-authored UI copy to use the translation seam." },
495
+ schema: [],
496
+ },
497
+ create(context) {
498
+ const report = (node) => context.report({ node, message: untranslatedMessage });
499
+ const toastHookNames = new Set();
500
+ const reactCoreNamespaces = new Set();
501
+ const toastBindings = new Set();
502
+ const toastMethodBindings = new Set();
503
+ const toastMethods = new Set(["success", "error", "warning"]);
504
+ const isToastHookCall = (node) => {
505
+ if (node?.type !== "CallExpression") return false;
506
+ if (node.callee.type === "Identifier") {
507
+ return toastHookNames.has(node.callee.name);
508
+ }
509
+ return (
510
+ node.callee.type === "MemberExpression" &&
511
+ memberName(node.callee) === "useToast" &&
512
+ node.callee.object.type === "Identifier" &&
513
+ reactCoreNamespaces.has(node.callee.object.name)
514
+ );
515
+ };
516
+ return {
517
+ ImportDeclaration(node) {
518
+ if (node.source.value !== "@terpjs/react-core") return;
519
+ for (const specifier of node.specifiers) {
520
+ if (
521
+ specifier.type === "ImportSpecifier" &&
522
+ (specifier.imported.name ?? specifier.imported.value) === "useToast"
523
+ ) {
524
+ toastHookNames.add(specifier.local.name);
525
+ }
526
+ if (specifier.type === "ImportNamespaceSpecifier") {
527
+ reactCoreNamespaces.add(specifier.local.name);
528
+ }
529
+ }
530
+ },
531
+ VariableDeclarator(node) {
532
+ if (!isToastHookCall(node.init)) return;
533
+ if (node.id.type === "Identifier") {
534
+ toastBindings.add(node.id.name);
535
+ return;
536
+ }
537
+ if (node.id.type === "ObjectPattern") {
538
+ for (const entry of node.id.properties) {
539
+ if (
540
+ entry.type === "Property" &&
541
+ toastMethods.has(propertyName(entry)) &&
542
+ entry.value.type === "Identifier"
543
+ ) {
544
+ toastMethodBindings.add(entry.value.name);
545
+ }
546
+ }
547
+ }
548
+ },
549
+ CallExpression(node) {
550
+ let feedback = false;
551
+ if (
552
+ node.callee.type === "MemberExpression" &&
553
+ node.callee.object.type === "Identifier" &&
554
+ toastBindings.has(node.callee.object.name) &&
555
+ toastMethods.has(memberName(node.callee))
556
+ ) {
557
+ feedback = true;
558
+ } else if (
559
+ node.callee.type === "Identifier" &&
560
+ toastMethodBindings.has(node.callee.name)
561
+ ) {
562
+ feedback = true;
563
+ }
564
+ if (feedback && containsStaticAuthoredCopy(node.arguments[0])) {
565
+ report(node.arguments[0]);
566
+ }
567
+ },
568
+ JSXText(node) {
569
+ if (!isInsideCode(node) && containsAuthoredCopy(node.value)) report(node);
570
+ },
571
+ JSXExpressionContainer(node) {
572
+ if (!["JSXElement", "JSXFragment"].includes(node.parent?.type) || isInsideCode(node)) return;
573
+ if (containsStaticAuthoredCopy(node.expression)) report(node);
574
+ },
575
+ JSXAttribute(node) {
576
+ const name = jsxName(node.name);
577
+ if (!UI_TEXT_ATTRIBUTES.has(name)) return;
578
+ const expression = node.value?.type === "JSXExpressionContainer"
579
+ ? node.value.expression
580
+ : node.value;
581
+ if (containsStaticAuthoredCopy(expression)) report(node);
582
+ },
583
+ Property(node) {
584
+ if (!UI_TEXT_PROPERTIES.has(propertyName(node))) return;
585
+ if (containsStaticAuthoredCopy(node.value)) report(node);
586
+ },
587
+ };
588
+ },
589
+ };
590
+
591
+ /** Read the one authoritative locale declaration at the ESLint app root. */
592
+ export function activeI18nDeclaration(appRoot) {
593
+ const file = path.join(appRoot, "i18n.json");
594
+ if (!fs.existsSync(file)) return null;
595
+ try {
596
+ return { file, declaration: JSON.parse(fs.readFileSync(file, "utf8")), error: null };
597
+ } catch (error) {
598
+ return { file, declaration: null, error: String(error) };
599
+ }
600
+ }
601
+
602
+ function isRecord(value) {
603
+ return typeof value === "object" && value !== null && !Array.isArray(value);
604
+ }
605
+
606
+ /** Return the first fail-closed declaration problem, or null when its shape is sound. */
607
+ function i18nDeclarationProblem(active) {
608
+ if (active.error !== null) return `${active.file} is not valid JSON; ${active.error}`;
609
+ const declaration = active.declaration;
610
+ if (!isRecord(declaration)) return `${active.file} must contain a JSON object.`;
611
+ const source = declaration.sourceLocale;
612
+ if (typeof source !== "string" || source.trim() === "") {
613
+ return `${active.file} must declare a non-empty sourceLocale.`;
614
+ }
615
+ if (!isRecord(declaration.locales) || !isRecord(declaration.locales[source])) {
616
+ return `${active.file} must contain a locales map that includes sourceLocale "${source}".`;
617
+ }
618
+ for (const [code, catalog] of Object.entries(declaration.locales)) {
619
+ if (code.trim() === "" || !isRecord(catalog)) {
620
+ return `${active.file} locale entries must be non-empty codes mapped to objects.`;
621
+ }
622
+ if (catalog.messages !== undefined && !isRecord(catalog.messages)) {
623
+ return `${active.file} locale "${code}" messages must be an object.`;
624
+ }
625
+ for (const [id, translated] of Object.entries(catalog.messages ?? {})) {
626
+ if (id.trim() === "" || typeof translated !== "string" || translated.trim() === "") {
627
+ return `${active.file} locale "${code}" has an empty or invalid message entry.`;
628
+ }
629
+ }
630
+ if (
631
+ catalog.allowIdentical !== undefined &&
632
+ (!Array.isArray(catalog.allowIdentical) ||
633
+ catalog.allowIdentical.some((id) => typeof id !== "string" || id.trim() === ""))
634
+ ) {
635
+ return `${active.file} locale "${code}" allowIdentical must be an array of non-empty ids.`;
636
+ }
637
+ const allowed = catalog.allowIdentical ?? [];
638
+ if (new Set(allowed).size !== allowed.length) {
639
+ return `${active.file} locale "${code}" allowIdentical contains duplicate ids.`;
640
+ }
641
+ const stale = allowed.find((id) => typeof catalog.messages?.[id] !== "string");
642
+ if (stale !== undefined) {
643
+ return `${active.file} locale "${code}" allowIdentical names missing message "${stale}".`;
644
+ }
645
+ }
646
+ return null;
647
+ }
648
+
649
+ function objectStringProperty(node, name) {
650
+ const property = node.properties.find(
651
+ (entry) => entry.type === "Property" && propertyName(entry) === name,
652
+ );
653
+ return property ? staticString(property.value) : null;
654
+ }
655
+
656
+ function transDescriptor(node) {
657
+ if (jsxName(node.name) !== "Trans") return null;
658
+ const id = staticStringFromJsxValue(getJsxAttribute(node, "id")?.value);
659
+ const message = staticStringFromJsxValue(getJsxAttribute(node, "message")?.value);
660
+ return typeof id === "string" && typeof message === "string" ? { id, message } : null;
661
+ }
662
+
663
+ const localeCatalogsComplete = {
664
+ meta: {
665
+ type: "problem",
666
+ docs: { description: "Require every UiText id in every declared target locale." },
667
+ schema: [],
668
+ },
669
+ create(context) {
670
+ // frontend/i18n.json is singular and rooted beside eslint.config.js. Walking
671
+ // upward from each source file would let a nested i18n.json shadow the app's
672
+ // declared target set and silently waive translations for one subtree.
673
+ const active = activeI18nDeclaration(context.cwd);
674
+ const declarationProblem = active === null ? null : i18nDeclarationProblem(active);
675
+ if (declarationProblem !== null) {
676
+ return {
677
+ Program(node) {
678
+ context.report({ node, message: declarationProblem });
679
+ },
680
+ };
681
+ }
682
+ const declaration = active?.declaration;
683
+
684
+ const check = (node, descriptor) => {
685
+ if (descriptor === null) return;
686
+ if (descriptor.id.trim() === "" || descriptor.message.trim() === "") {
687
+ context.report({
688
+ node,
689
+ message: "UiText descriptors require non-empty static id and message values.",
690
+ });
691
+ return;
692
+ }
693
+ if (declaration === undefined) {
694
+ context.report({
695
+ node,
696
+ message:
697
+ `Translation "${descriptor.id}" has no frontend/i18n.json declaration. ` +
698
+ "Restore the catalog and translate every target locale.",
699
+ });
700
+ return;
701
+ }
702
+ const problems = [];
703
+ for (const [code, catalog] of Object.entries(declaration.locales)) {
704
+ if (code === declaration.sourceLocale) continue;
705
+ const translated = catalog?.messages?.[descriptor.id];
706
+ if (typeof translated !== "string" || translated.trim() === "") {
707
+ problems.push(`${code}: missing`);
708
+ } else if (
709
+ translated === descriptor.message &&
710
+ !(Array.isArray(catalog.allowIdentical) && catalog.allowIdentical.includes(descriptor.id))
711
+ ) {
712
+ problems.push(`${code}: copied source (add allowIdentical only when intentional)`);
713
+ } else if (
714
+ translated !== descriptor.message &&
715
+ Array.isArray(catalog.allowIdentical) &&
716
+ catalog.allowIdentical.includes(descriptor.id)
717
+ ) {
718
+ problems.push(`${code}: stale allowIdentical entry (translation no longer matches source)`);
719
+ }
720
+ }
721
+ if (problems.length > 0) {
722
+ context.report({
723
+ node,
724
+ message: `Translation "${descriptor.id}" is incomplete in frontend/i18n.json (${problems.join(", ")}).`,
725
+ });
726
+ }
727
+ };
728
+ return {
729
+ ObjectExpression(node) {
730
+ const hasId = node.properties.some(
731
+ (entry) => entry.type === "Property" && propertyName(entry) === "id",
732
+ );
733
+ const hasMessage = node.properties.some(
734
+ (entry) => entry.type === "Property" && propertyName(entry) === "message",
735
+ );
736
+ if (!hasId || !hasMessage) return;
737
+ const id = objectStringProperty(node, "id");
738
+ const message = objectStringProperty(node, "message");
739
+ // The id/message pair is also a common dynamic business-data shape. With
740
+ // no authored literal there is nothing for the static catalog to inventory;
741
+ // if such a value is later used as UiText, LocaleProvider is the runtime
742
+ // backstop. One static half does identify a malformed descriptor and must
743
+ // not let its authored copy escape the catalog.
744
+ if (id === null && message === null) return;
745
+ if (id === null || message === null) {
746
+ context.report({
747
+ node,
748
+ message: "UiText descriptors require static non-empty id and message values.",
749
+ });
750
+ return;
751
+ }
752
+ check(node, { id, message });
753
+ },
754
+ JSXOpeningElement(node) {
755
+ if (jsxName(node.name) !== "Trans") return;
756
+ const descriptor = transDescriptor(node);
757
+ if (descriptor === null) {
758
+ context.report({
759
+ node,
760
+ message: "<Trans> requires static non-empty id and message attributes.",
761
+ });
762
+ return;
763
+ }
764
+ check(node, descriptor);
765
+ },
766
+ };
767
+ },
768
+ };
769
+
292
770
  /** Find the app's checked-in layout-contract config upward from *dir*; null = no contract. */
293
771
  export function activeLayoutContract(dir) {
294
772
  let current = dir;
@@ -390,10 +868,12 @@ const layoutContract = {
390
868
 
391
869
  const terpPlugin = {
392
870
  rules: {
871
+ "locale-catalogs-complete": localeCatalogsComplete,
393
872
  "layout-contract": layoutContract,
394
873
  "no-cross-module-imports": noCrossModuleImports,
395
874
  "no-dom-html-injection": noDomHtmlInjection,
396
875
  "no-eval": noEval,
876
+ "no-untranslated-ui": noUntranslatedUi,
397
877
  "no-unsafe-href": noUnsafeHref,
398
878
  "no-unsafe-target-blank": noUnsafeTargetBlank,
399
879
  },
@@ -722,12 +1202,12 @@ function escapeHatchProcessor() {
722
1202
  export function terpBoundaries() {
723
1203
  return [
724
1204
  {
725
- files: BOUNDARY_SPEC.moduleFiles,
1205
+ files: BOUNDARY_SPEC.appFiles,
726
1206
  processor: escapeHatchProcessor(),
727
1207
  },
728
1208
  {
729
- files: BOUNDARY_SPEC.moduleFiles,
730
- // Inline `eslint-disable` comments are inert in app modules; the justified
1209
+ files: BOUNDARY_SPEC.appFiles,
1210
+ // Inline `eslint-disable` comments are inert in app source; the justified
731
1211
  // `terp-allow-*` marker (budget-governed) is the *only* escape hatch (ADR 0059).
732
1212
  linterOptions: { noInlineConfig: true },
733
1213
  languageOptions: {
@@ -735,6 +1215,19 @@ export function terpBoundaries() {
735
1215
  parserOptions: { ecmaFeatures: { jsx: true }, sourceType: "module" },
736
1216
  },
737
1217
  plugins: { terp: terpPlugin },
1218
+ rules: {
1219
+ "terp/locale-catalogs-complete": "error",
1220
+ "terp/no-untranslated-ui": "error",
1221
+ },
1222
+ },
1223
+ {
1224
+ files: BOUNDARY_SPEC.moduleFiles,
1225
+ linterOptions: { noInlineConfig: true },
1226
+ languageOptions: {
1227
+ parser: tseslint.parser,
1228
+ parserOptions: { ecmaFeatures: { jsx: true }, sourceType: "module" },
1229
+ },
1230
+ plugins: { terp: terpPlugin },
738
1231
  rules: {
739
1232
  "terp/layout-contract": "error",
740
1233
  "terp/no-cross-module-imports": "error",
package/src/index.test.js CHANGED
@@ -1,7 +1,8 @@
1
+ import fs from "node:fs";
1
2
  import path from "node:path";
2
3
 
3
4
  import { ESLint } from "eslint";
4
- import { describe, expect, it } from "vitest";
5
+ import { afterAll, describe, expect, it } from "vitest";
5
6
 
6
7
  import terpBoundaries from "./index.js";
7
8
 
@@ -9,17 +10,26 @@ import terpBoundaries from "./index.js";
9
10
  // a violating fixture (and stays quiet on clean, out-of-module code), so "enforced" is real.
10
11
  // File paths are resolved under the cwd so ESLint's `files` globs match (the parser then applies).
11
12
 
12
- const MODULE_FILE = path.resolve("src/modules/widgets/Widget.tsx");
13
- const OUTSIDE_FILE = path.resolve("src/main.tsx");
13
+ const LINT_ROOT = path.resolve("node_modules/.cache/terp-index-tests");
14
+ fs.rmSync(LINT_ROOT, { recursive: true, force: true });
15
+ fs.mkdirSync(LINT_ROOT, { recursive: true });
16
+ fs.writeFileSync(
17
+ path.join(LINT_ROOT, "i18n.json"),
18
+ JSON.stringify({ sourceLocale: "en", locales: { en: {} } }),
19
+ );
20
+ const MODULE_FILE = path.join(LINT_ROOT, "src/modules/widgets/Widget.tsx");
21
+ const OUTSIDE_FILE = path.join(LINT_ROOT, "src/main.tsx");
22
+
23
+ afterAll(() => fs.rmSync(LINT_ROOT, { recursive: true, force: true }));
14
24
 
15
25
  async function lint(code, filePath = MODULE_FILE) {
16
- const eslint = new ESLint({ overrideConfigFile: true, overrideConfig: terpBoundaries });
26
+ const eslint = new ESLint({ cwd: LINT_ROOT, overrideConfigFile: true, overrideConfig: terpBoundaries });
17
27
  const [result] = await eslint.lintText(code, { filePath });
18
28
  return result.messages.map((message) => message.ruleId);
19
29
  }
20
30
 
21
31
  async function lintMessages(code, filePath = MODULE_FILE) {
22
- const eslint = new ESLint({ overrideConfigFile: true, overrideConfig: terpBoundaries });
32
+ const eslint = new ESLint({ cwd: LINT_ROOT, overrideConfigFile: true, overrideConfig: terpBoundaries });
23
33
  const [result] = await eslint.lintText(code, { filePath });
24
34
  return result.messages.map((message) => message.message);
25
35
  }
@@ -27,11 +37,11 @@ async function lintMessages(code, filePath = MODULE_FILE) {
27
37
  describe("terpBoundaries", () => {
28
38
  it("passes clean module code (react-core components + generated client)", async () => {
29
39
  const code = [
30
- 'import { Button, Select, Textarea, useTerpClient } from "@terpjs/react-core";',
40
+ 'import { Button, Select, Textarea, Trans, useTerpClient } from "@terpjs/react-core";',
31
41
  "export function Widget() {",
32
42
  " const client = useTerpClient();",
33
43
  " void client;",
34
- " return <><Button>ok</Button><Select /><Textarea /></>;",
44
+ ' return <><Button><Trans id="widget.ok" message="OK" /></Button><Select /><Textarea /></>;',
35
45
  "}",
36
46
  ].join("\n");
37
47
  expect(await lint(code)).toEqual([]);
@@ -42,6 +52,24 @@ describe("terpBoundaries", () => {
42
52
  expect(await lint(code)).toContain("terp/no-cross-module-imports");
43
53
  });
44
54
 
55
+ it("flags static JSX copy, UI attributes, and UI-bearing object properties", async () => {
56
+ expect(await lint("export const W = () => <Text>Save changes</Text>;"))
57
+ .toContain("terp/no-untranslated-ui");
58
+ expect(await lint('export const W = () => <Page title="Widgets" />;'))
59
+ .toContain("terp/no-untranslated-ui");
60
+ expect(await lint('export const columns = [{ header: "Created at" }];'))
61
+ .toContain("terp/no-untranslated-ui");
62
+ });
63
+
64
+ it("accepts descriptors and Trans as authored localization seams", async () => {
65
+ const code = [
66
+ 'import { Page, Trans } from "@terpjs/react-core";',
67
+ 'const title = { id: "widgets.title", message: "Widgets" };',
68
+ 'export const W = () => <Page title={title}><Trans id="widgets.empty" message="Nothing here yet." /></Page>;',
69
+ ].join("\n");
70
+ expect(await lint(code)).toEqual([]);
71
+ });
72
+
45
73
  it("flags a dynamic import() of a sibling module (no spelling escape)", async () => {
46
74
  const code = 'export const load = () => import("../other/thing");';
47
75
  expect(await lint(code)).toContain("terp/no-cross-module-imports");
@@ -75,7 +103,7 @@ describe("terpBoundaries", () => {
75
103
  });
76
104
 
77
105
  it("allows an external anchor", async () => {
78
- expect(await lint('export const W = () => <a href="https://example.com">docs</a>;')).toEqual([]);
106
+ expect(await lint('export const W = ({ label }) => <a href="https://example.com">{label}</a>;')).toEqual([]);
79
107
  });
80
108
 
81
109
  it("flags className (no side channel into hand-authored CSS)", async () => {
@@ -153,7 +181,7 @@ describe("terpBoundaries", () => {
153
181
  ).toContain("terp/no-unsafe-target-blank");
154
182
  expect(
155
183
  await lint(
156
- 'export const W = () => <a href="https://example.com" target={`_blank`} rel="noopener noreferrer">docs</a>;',
184
+ 'export const W = ({ label }) => <a href="https://example.com" target={`_blank`} rel="noopener noreferrer">{label}</a>;',
157
185
  ),
158
186
  ).toEqual([]);
159
187
  });
@@ -168,7 +196,7 @@ describe("terpBoundaries", () => {
168
196
  expect(await lint('export const W = () => <a href={`javascript:${danger}`}>bad</a>;')).toContain(
169
197
  "terp/no-unsafe-href",
170
198
  );
171
- expect(await lint('export const W = ({ href }) => <a href={href}>ok</a>;')).toEqual([]);
199
+ expect(await lint('export const W = ({ href, label }) => <a href={href}>{label}</a>;')).toEqual([]);
172
200
  });
173
201
 
174
202
  it("flags DOM HTML injection sinks", async () => {
@@ -206,8 +234,9 @@ describe("terpBoundaries", () => {
206
234
 
207
235
  it("suppresses a violation with a justified terp-allow marker on the line above", async () => {
208
236
  const code = [
237
+ 'import { Trans } from "@terpjs/react-core";',
209
238
  "// terp-allow-token-styled-elements: native button needed for a browser extension host",
210
- "export const W = () => <button>x</button>;",
239
+ 'export const W = () => <button><Trans id="widget.action" message="Action" /></button>;',
211
240
  ].join("\n");
212
241
  expect(await lint(code)).toEqual([]);
213
242
  });
@@ -220,8 +249,9 @@ describe("terpBoundaries", () => {
220
249
 
221
250
  it("suppresses a custom terp rule with the reported rule id suffix", async () => {
222
251
  const code = [
252
+ 'import { Trans } from "@terpjs/react-core";',
223
253
  "// terp-allow-no-unsafe-target-blank: external vendor requires opener for a handshake",
224
- 'export const W = () => <a href="https://example.com" target="_blank">docs</a>;',
254
+ 'export const W = () => <a href="https://example.com" target="_blank"><Trans id="widget.docs" message="Docs" /></a>;',
225
255
  ].join("\n");
226
256
  expect(await lint(code)).toEqual([]);
227
257
  });