@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/package.json +2 -2
- package/src/budget.js +10 -8
- package/src/budget.test.js +14 -1
- package/src/corpus-harness.js +8 -3
- package/src/corpus.test.js +12 -4
- package/src/findings.js +25 -3
- package/src/findings.test.js +44 -4
- package/src/i18n.test.js +307 -0
- package/src/index.js +503 -10
- package/src/index.test.js +42 -12
- package/src/layouts.test.js +9 -9
- package/src/scorecard.js +13 -1
- package/src/spec.js +6 -3
- package/src/surface.test.js +9 -4
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
|
-
|
|
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.
|
|
1205
|
+
files: BOUNDARY_SPEC.appFiles,
|
|
726
1206
|
processor: escapeHatchProcessor(),
|
|
727
1207
|
},
|
|
728
1208
|
{
|
|
729
|
-
files: BOUNDARY_SPEC.
|
|
730
|
-
// Inline `eslint-disable` comments are inert in app
|
|
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
|
|
13
|
-
|
|
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
|
-
|
|
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">
|
|
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">
|
|
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}>
|
|
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
|
-
|
|
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"
|
|
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
|
});
|