@terpjs/eslint-boundaries 0.1.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 ADDED
@@ -0,0 +1,770 @@
1
+ /**
2
+ * The ESLint (React stack) adapter that realises {@link BOUNDARY_SPEC}. This is the frontend analog
3
+ * of the backend `terp.arch` harness: it keeps app modules independent and on the centralized
4
+ * contract, so a non-technical user or a coding agent cannot introduce drift or a security gap.
5
+ *
6
+ * There are no modes and no severity dial — every rule is an error, always (exactly like the
7
+ * backend gate). The only pressure valve is the governed escape hatch: a justified
8
+ * `// terp-allow-<rule>: <reason>` marker naming the Terp Standard catalog rule (see
9
+ * {@link suppressWithMarkers}), whose counts must match the app's checked-in
10
+ * `escape-hatch-budget.json` (see ./budget.js).
11
+ *
12
+ * Loaded by Node (an ESLint flat config), so it is plain ESM JavaScript — not TypeScript.
13
+ */
14
+
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+
18
+ import tseslint from "typescript-eslint";
19
+
20
+ import { LAYOUT_CONTRACTS, LAYOUT_CONTRACT_FILE, slotViolationMessage } from "./layouts.js";
21
+ import { BOUNDARY_SPEC } from "./spec.js";
22
+
23
+ /** The app-module name a file/import path belongs to (the segment after `modules/`), or null. */
24
+ function moduleOf(filePath) {
25
+ const parts = String(filePath).split(/[/\\]/);
26
+ const index = parts.lastIndexOf("modules");
27
+ return index !== -1 && index + 1 < parts.length ? parts[index + 1] : null;
28
+ }
29
+
30
+ /**
31
+ * A module never imports a sibling module (leaf domains stay independent) — the frontend analog of
32
+ * `terp.arch`'s `no_cross_module_imports`. Relative imports are resolved before the check, so
33
+ * `../other/thing` from `modules/a/` is caught as a `modules/other` import, not hidden by its spelling.
34
+ */
35
+ const noCrossModuleImports = {
36
+ meta: {
37
+ type: "problem",
38
+ docs: { description: "Disallow imports between sibling app modules (leaf independence)." },
39
+ schema: [],
40
+ },
41
+ create(context) {
42
+ // physicalFilename: the on-disk file (the escape-hatch processor lints a virtual block).
43
+ const filename = context.physicalFilename || context.filename;
44
+ const own = moduleOf(filename);
45
+ if (own === null) {
46
+ return {};
47
+ }
48
+ const check = (node) => {
49
+ const source = node.source && node.source.value;
50
+ if (typeof source !== "string") {
51
+ return;
52
+ }
53
+ const target = source.startsWith(".")
54
+ ? path.resolve(path.dirname(filename), source)
55
+ : source;
56
+ const other = moduleOf(target);
57
+ if (other !== null && other !== own) {
58
+ context.report({
59
+ node,
60
+ message:
61
+ `App module "${own}" must not import sibling module "${other}"; modules stay ` +
62
+ "independent (share through the framework packages, not each other).",
63
+ });
64
+ }
65
+ };
66
+ return {
67
+ ImportDeclaration: check,
68
+ ImportExpression: check,
69
+ ExportNamedDeclaration: (node) => node.source && check(node),
70
+ ExportAllDeclaration: check,
71
+ };
72
+ },
73
+ };
74
+
75
+ const generatedClientMessage =
76
+ "Use the generated typed client (useTerpClient), not a raw request that skips auth and the contract.";
77
+
78
+ function jsxName(node) {
79
+ if (!node) {
80
+ return null;
81
+ }
82
+ if (node.type === "JSXIdentifier") {
83
+ return node.name;
84
+ }
85
+ if (node.type === "JSXMemberExpression") {
86
+ const object = jsxName(node.object);
87
+ return object ? `${object}.${node.property.name}` : node.property.name;
88
+ }
89
+ return null;
90
+ }
91
+
92
+ function getJsxAttribute(openingElement, name) {
93
+ return openingElement.attributes.find(
94
+ (attribute) => attribute.type === "JSXAttribute" && jsxName(attribute.name) === name,
95
+ );
96
+ }
97
+
98
+ function staticStringFromJsxValue(value) {
99
+ if (!value) {
100
+ return null;
101
+ }
102
+ if (value.type === "Literal" && typeof value.value === "string") {
103
+ return value.value;
104
+ }
105
+ 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
+ }
113
+ }
114
+ return null;
115
+ }
116
+
117
+ function templateStartsWithJavascript(value) {
118
+ if (value?.type !== "JSXExpressionContainer") {
119
+ return false;
120
+ }
121
+ const expression = value.expression;
122
+ if (expression.type !== "TemplateLiteral") {
123
+ return false;
124
+ }
125
+ const first = expression.quasis[0]?.value.cooked ?? expression.quasis[0]?.value.raw ?? "";
126
+ return /^\s*javascript\s*:/i.test(first);
127
+ }
128
+
129
+ function memberName(node) {
130
+ if (!node || node.type !== "MemberExpression" || node.computed) {
131
+ return null;
132
+ }
133
+ if (node.property.type === "Identifier") {
134
+ return node.property.name;
135
+ }
136
+ return null;
137
+ }
138
+
139
+ function isObjectNamed(node, names) {
140
+ return node?.type === "Identifier" && names.includes(node.name);
141
+ }
142
+
143
+ function isDocumentObject(node) {
144
+ if (isObjectNamed(node, ["document"])) {
145
+ return true;
146
+ }
147
+ return (
148
+ node?.type === "MemberExpression" &&
149
+ !node.computed &&
150
+ memberName(node) === "document" &&
151
+ isObjectNamed(node.object, ["window", "globalThis"])
152
+ );
153
+ }
154
+
155
+ const noUnsafeTargetBlank = {
156
+ meta: {
157
+ type: "problem",
158
+ docs: { description: "Require rel=noopener on static target=_blank links." },
159
+ schema: [],
160
+ },
161
+ create(context) {
162
+ return {
163
+ JSXOpeningElement(node) {
164
+ const target = getJsxAttribute(node, "target");
165
+ if (staticStringFromJsxValue(target?.value) !== "_blank") {
166
+ return;
167
+ }
168
+ const rel = staticStringFromJsxValue(getJsxAttribute(node, "rel")?.value);
169
+ const tokens = new Set(String(rel ?? "").toLowerCase().split(/\s+/).filter(Boolean));
170
+ if (!tokens.has("noopener")) {
171
+ context.report({
172
+ node: target,
173
+ message:
174
+ 'target="_blank" must include rel="noopener" to prevent opener access; ' +
175
+ 'rel="noopener noreferrer" is recommended.',
176
+ });
177
+ }
178
+ },
179
+ };
180
+ },
181
+ };
182
+
183
+ const noUnsafeHref = {
184
+ meta: {
185
+ type: "problem",
186
+ docs: { description: "Disallow javascript: URLs in static href/src JSX attributes." },
187
+ schema: [],
188
+ },
189
+ create(context) {
190
+ const check = (node) => {
191
+ const name = jsxName(node.name);
192
+ if (name !== "href" && name !== "src") {
193
+ return;
194
+ }
195
+ const literal = staticStringFromJsxValue(node.value);
196
+ if (
197
+ (literal !== null && /^\s*javascript\s*:/i.test(literal)) ||
198
+ templateStartsWithJavascript(node.value)
199
+ ) {
200
+ context.report({
201
+ node,
202
+ message:
203
+ "javascript: URLs are forbidden in href/src attributes; " +
204
+ "route through safe components or typed data.",
205
+ });
206
+ }
207
+ };
208
+ return { JSXAttribute: check };
209
+ },
210
+ };
211
+
212
+ const noDomHtmlInjection = {
213
+ meta: {
214
+ type: "problem",
215
+ docs: { description: "Disallow direct DOM HTML injection sinks." },
216
+ schema: [],
217
+ },
218
+ create(context) {
219
+ const htmlProperties = new Set(["innerHTML", "outerHTML", "insertAdjacentHTML"]);
220
+ return {
221
+ JSXOpeningElement(node) {
222
+ if (jsxName(node.name) === "iframe") {
223
+ const srcDoc = getJsxAttribute(node, "srcDoc");
224
+ if (srcDoc) {
225
+ context.report({
226
+ node: srcDoc,
227
+ message: "iframe srcDoc injects HTML and is forbidden; render trusted components instead.",
228
+ });
229
+ }
230
+ }
231
+ },
232
+ AssignmentExpression(node) {
233
+ const property = memberName(node.left);
234
+ if (htmlProperties.has(property)) {
235
+ context.report({
236
+ node: node.left,
237
+ message: `${property} injects HTML and is forbidden; render text/components or use an allowlisted sanitizer.`,
238
+ });
239
+ }
240
+ },
241
+ CallExpression(node) {
242
+ const callee = node.callee;
243
+ const property = memberName(callee);
244
+ if (
245
+ htmlProperties.has(property) ||
246
+ (isDocumentObject(callee?.object) && ["write", "writeln"].includes(property))
247
+ ) {
248
+ context.report({
249
+ node: callee,
250
+ message: `${property} injects HTML and is forbidden; render text/components or use an allowlisted sanitizer.`,
251
+ });
252
+ }
253
+ },
254
+ };
255
+ },
256
+ };
257
+
258
+ const noEval = {
259
+ meta: {
260
+ type: "problem",
261
+ docs: { description: "Disallow eval and Function constructors." },
262
+ schema: [],
263
+ },
264
+ create(context) {
265
+ return {
266
+ CallExpression(node) {
267
+ if (
268
+ (node.callee.type === "Identifier" && node.callee.name === "eval") ||
269
+ (memberName(node.callee) === "eval" && isObjectNamed(node.callee.object, ["window", "globalThis"]))
270
+ ) {
271
+ context.report({
272
+ node: node.callee,
273
+ message: "eval() is forbidden; execute explicit typed code paths instead.",
274
+ });
275
+ }
276
+ },
277
+ NewExpression(node) {
278
+ if (
279
+ (node.callee.type === "Identifier" && node.callee.name === "Function") ||
280
+ (memberName(node.callee) === "Function" && isObjectNamed(node.callee.object, ["window", "globalThis"]))
281
+ ) {
282
+ context.report({
283
+ node: node.callee,
284
+ message: "new Function() is forbidden; execute explicit typed code paths instead.",
285
+ });
286
+ }
287
+ },
288
+ };
289
+ },
290
+ };
291
+
292
+ /** Find the app's checked-in layout-contract config upward from *dir*; null = no contract. */
293
+ export function activeLayoutContract(dir) {
294
+ let current = dir;
295
+ for (;;) {
296
+ const file = path.join(current, LAYOUT_CONTRACT_FILE);
297
+ if (fs.existsSync(file)) {
298
+ try {
299
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
300
+ return typeof parsed.contract === "string" ? parsed.contract : null;
301
+ } catch {
302
+ return null;
303
+ }
304
+ }
305
+ const parent = path.dirname(current);
306
+ if (parent === current) {
307
+ return null;
308
+ }
309
+ current = parent;
310
+ }
311
+ }
312
+
313
+ /**
314
+ * The build-time half of the slot-typed layout contract control (ADR 0079): when the app
315
+ * has opted into a contract (a checked-in `layout-contract.json`, or the rule option in
316
+ * tests), the static JSX children of each governed page archetype must be components the
317
+ * contract allows in that slot. Dynamic children (`{...}` expressions) are deliberately
318
+ * not resolved here — the react-core runtime half verifies the rendered DOM and refuses
319
+ * a non-conforming view, fail closed. Both halves phrase the same directive message.
320
+ */
321
+ const layoutContract = {
322
+ meta: {
323
+ type: "problem",
324
+ docs: { description: "Enforce the app's opted-in slot-typed layout contract (ADR 0079)." },
325
+ schema: [
326
+ {
327
+ type: "object",
328
+ properties: { contract: { type: "string" } },
329
+ additionalProperties: false,
330
+ },
331
+ ],
332
+ },
333
+ create(context) {
334
+ const filename = context.physicalFilename || context.filename;
335
+ const contractId =
336
+ context.options[0]?.contract ?? activeLayoutContract(path.dirname(filename));
337
+ if (contractId === null || contractId === undefined) {
338
+ return {};
339
+ }
340
+ const contract = LAYOUT_CONTRACTS[contractId];
341
+ if (contract === undefined) {
342
+ return {
343
+ Program(node) {
344
+ context.report({
345
+ node,
346
+ message:
347
+ `Unknown layout contract "${contractId}" (${LAYOUT_CONTRACT_FILE}); ` +
348
+ `known contracts: ${Object.keys(LAYOUT_CONTRACTS).join(", ")}.`,
349
+ });
350
+ },
351
+ };
352
+ }
353
+ const checkChild = (child, slotOwner, allowed) => {
354
+ if (child.type === "JSXText") {
355
+ if (child.value.trim() !== "") {
356
+ context.report({
357
+ node: child,
358
+ message: slotViolationMessage(contractId, slotOwner, "raw text"),
359
+ });
360
+ }
361
+ return;
362
+ }
363
+ if (child.type === "JSXFragment") {
364
+ child.children.forEach((inner) => checkChild(inner, slotOwner, allowed));
365
+ return;
366
+ }
367
+ if (child.type !== "JSXElement") {
368
+ return; // dynamic content ({...}) is the runtime half's job
369
+ }
370
+ const name = jsxName(child.openingElement.name);
371
+ if (name !== null && allowed[name] === undefined) {
372
+ context.report({
373
+ node: child.openingElement,
374
+ message: slotViolationMessage(contractId, slotOwner, `<${name}>`),
375
+ });
376
+ }
377
+ };
378
+ return {
379
+ JSXElement(node) {
380
+ const owner = jsxName(node.openingElement.name);
381
+ const slot = owner !== null ? contract.slots[owner] : undefined;
382
+ if (slot === undefined) {
383
+ return;
384
+ }
385
+ node.children.forEach((child) => checkChild(child, owner, slot.components));
386
+ },
387
+ };
388
+ },
389
+ };
390
+
391
+ const terpPlugin = {
392
+ rules: {
393
+ "layout-contract": layoutContract,
394
+ "no-cross-module-imports": noCrossModuleImports,
395
+ "no-dom-html-injection": noDomHtmlInjection,
396
+ "no-eval": noEval,
397
+ "no-unsafe-href": noUnsafeHref,
398
+ "no-unsafe-target-blank": noUnsafeTargetBlank,
399
+ },
400
+ };
401
+
402
+ const deepImportMessage =
403
+ "Import from the package root (@terpjs/react-core, @terpjs/contract), not its internals.";
404
+ const styleImportMessage =
405
+ "Module-authored stylesheets are forbidden; theming flows from the design tokens " +
406
+ "and layout from the react-core components (Stack, the page archetypes).";
407
+
408
+ /**
409
+ * The `no-restricted-syntax` realisation of the BOUNDARY_SPEC families, each entry tagged
410
+ * with the Terp Standard catalog rule it realises (`spec/catalog/frontend/<rule>.json`) so
411
+ * a reported message stays attributable to its stack-neutral rule id (see
412
+ * {@link catalogRuleId}). {@link restrictedSyntax} strips the tag for the ESLint config.
413
+ */
414
+ function restrictedSyntaxWithCatalogIds() {
415
+ const rawElements = Object.entries(BOUNDARY_SPEC.restrictedElements).map(([element, use]) => ({
416
+ catalogId: "frontend/token-styled-elements",
417
+ selector: `JSXOpeningElement[name.name='${element}']`,
418
+ message: `Use ${use} from @terpjs/react-core, not a raw <${element}>.`,
419
+ }));
420
+ const rawAttributes = BOUNDARY_SPEC.restrictedAttributes.map((attribute) => ({
421
+ catalogId: "frontend/no-inline-styling",
422
+ selector: `JSXAttribute[name.name='${attribute}']`,
423
+ message:
424
+ `The ${attribute} attribute is forbidden in app modules; layout comes from the ` +
425
+ "react-core components (Stack, Page, ...) and styling from the design tokens.",
426
+ }));
427
+ const inAppAnchors = BOUNDARY_SPEC.restrictInAppAnchors
428
+ ? [
429
+ {
430
+ catalogId: "frontend/router-links",
431
+ selector:
432
+ "JSXOpeningElement[name.name='a'] JSXAttribute[name.name='href'][value.value=/^\\u002F/]",
433
+ message:
434
+ 'An in-app <a href="/..."> bypasses the router (full reload, no role-aware guard); ' +
435
+ "use the stack's Link.",
436
+ },
437
+ {
438
+ catalogId: "frontend/router-links",
439
+ selector:
440
+ "JSXOpeningElement[name.name='a'] JSXAttribute[name.name='href'][value.expression.value=/^\\u002F/]",
441
+ message:
442
+ 'An in-app <a href="/..."> bypasses the router (full reload, no role-aware guard); ' +
443
+ "use the stack's Link.",
444
+ },
445
+ {
446
+ catalogId: "frontend/router-links",
447
+ selector:
448
+ "JSXOpeningElement[name.name='a'] JSXAttribute[name.name='href'] TemplateLiteral[quasis.0.value.raw=/^\\u002F/]",
449
+ message:
450
+ 'An in-app <a href="/..."> bypasses the router (full reload, no role-aware guard); ' +
451
+ "use the stack's Link.",
452
+ },
453
+ ]
454
+ : [];
455
+ return [
456
+ ...rawElements,
457
+ ...rawAttributes,
458
+ ...inAppAnchors,
459
+ {
460
+ catalogId: "frontend/no-dom-html-injection",
461
+ selector: "JSXAttribute[name.name='dangerouslySetInnerHTML']",
462
+ message: "dangerouslySetInnerHTML is forbidden (XSS); render text or use an allowlisted sanitizer.",
463
+ },
464
+ {
465
+ catalogId: "frontend/no-inline-styling",
466
+ selector: "Literal[value=/#[0-9a-fA-F]{3,8}/]",
467
+ message: "Use a design token (var(--color-...)), not a hardcoded colour that bypasses the theme.",
468
+ },
469
+ {
470
+ catalogId: "frontend/generated-client-only",
471
+ selector:
472
+ "CallExpression[callee.type='MemberExpression'][callee.object.name=/^(window|globalThis)$/][callee.property.name='fetch'], CallExpression[callee.type='MemberExpression'][callee.object.name=/^(window|globalThis)$/][callee.computed=true][callee.property.value='fetch']",
473
+ message: generatedClientMessage,
474
+ },
475
+ {
476
+ catalogId: "frontend/generated-client-only",
477
+ selector:
478
+ "NewExpression[callee.name=/^(XMLHttpRequest|WebSocket|EventSource)$/], NewExpression[callee.type='MemberExpression'][callee.object.name=/^(window|globalThis)$/][callee.property.name=/^(XMLHttpRequest|WebSocket|EventSource)$/], NewExpression[callee.type='MemberExpression'][callee.object.name=/^(window|globalThis)$/][callee.computed=true][callee.property.value=/^(XMLHttpRequest|WebSocket|EventSource)$/]",
479
+ message: generatedClientMessage,
480
+ },
481
+ {
482
+ catalogId: "frontend/generated-client-only",
483
+ selector:
484
+ "CallExpression[callee.type='MemberExpression'][callee.object.name='navigator'][callee.property.name='sendBeacon']",
485
+ message: generatedClientMessage,
486
+ },
487
+ {
488
+ catalogId: "frontend/generated-client-only",
489
+ selector:
490
+ "CallExpression[callee.type='MemberExpression'][callee.object.type='MemberExpression'][callee.object.object.name=/^(window|globalThis)$/][callee.object.property.name='navigator'][callee.property.name='sendBeacon'], CallExpression[callee.type='MemberExpression'][callee.object.type='MemberExpression'][callee.object.object.name=/^(window|globalThis)$/][callee.object.property.name='navigator'][callee.computed=true][callee.property.value='sendBeacon'], CallExpression[callee.type='MemberExpression'][callee.object.type='MemberExpression'][callee.object.object.name=/^(window|globalThis)$/][callee.object.computed=true][callee.object.property.value='navigator'][callee.property.name='sendBeacon'], CallExpression[callee.type='MemberExpression'][callee.object.type='MemberExpression'][callee.object.object.name=/^(window|globalThis)$/][callee.object.computed=true][callee.object.property.value='navigator'][callee.computed=true][callee.property.value='sendBeacon']",
491
+ message: generatedClientMessage,
492
+ },
493
+ ];
494
+ }
495
+
496
+ function restrictedSyntax() {
497
+ return restrictedSyntaxWithCatalogIds().map(({ selector, message }) => ({ selector, message }));
498
+ }
499
+
500
+ /** Exact `no-restricted-syntax` message -> Terp Standard catalog id (built from one source). */
501
+ const CATALOG_ID_BY_SYNTAX_MESSAGE = new Map(
502
+ restrictedSyntaxWithCatalogIds().map((entry) => [entry.message, entry.catalogId]),
503
+ );
504
+
505
+ /**
506
+ * The Terp Standard catalog rule id (`frontend/<rule>`, per `spec/catalog/frontend/`) a
507
+ * reported ESLint message realises, or null for a message outside the boundary. This is the
508
+ * adapter's published `reported_as -> catalog id` mapping: several catalog rules share a core
509
+ * ESLint rule id (`no-restricted-syntax` / `no-restricted-imports` / `no-restricted-globals`),
510
+ * so the conformance contract — and the corpus harness — attributes findings through this
511
+ * function, never through the raw ESLint rule id.
512
+ */
513
+ export function catalogRuleId(message) {
514
+ const ruleId = String(message.ruleId ?? "");
515
+ const text = String(message.message ?? "");
516
+ if (ruleId.startsWith("terp/")) {
517
+ return `frontend/${ruleId.slice("terp/".length)}`;
518
+ }
519
+ if (ruleId === "no-restricted-globals") {
520
+ return "frontend/generated-client-only";
521
+ }
522
+ if (ruleId === "no-restricted-syntax") {
523
+ return CATALOG_ID_BY_SYNTAX_MESSAGE.get(text) ?? null;
524
+ }
525
+ if (ruleId === "no-restricted-imports") {
526
+ // ESLint prefixes the configured pattern message with its own preamble.
527
+ if (text.includes(styleImportMessage)) {
528
+ return "frontend/no-style-imports";
529
+ }
530
+ if (text.includes(deepImportMessage)) {
531
+ return "frontend/no-deep-imports";
532
+ }
533
+ return null;
534
+ }
535
+ return null;
536
+ }
537
+
538
+ /**
539
+ * Every Terp Standard catalog rule id (`frontend/<rule>`) this adapter evaluates, sorted:
540
+ * the named `terp/*` plugin rules, `terp/escape-hatch` (emitted by the suppression
541
+ * processor), the tagged `no-restricted-syntax` families, and the catalog rules realised
542
+ * through `no-restricted-globals` / `no-restricted-imports`. This is the evaluated-rule
543
+ * inventory a boundary lint run publishes in its findings envelope (see ./findings.js):
544
+ * a consumer joining findings to the catalog reads the inventory from the run itself, so
545
+ * a per-rule "pass" can never be claimed for a rule this adapter never ran (fail closed
546
+ * under version skew). Parity with `spec/catalog/frontend/` is locked by findings.test.js.
547
+ */
548
+ export function catalogRuleIds() {
549
+ return [
550
+ ...new Set([
551
+ ...Object.keys(terpPlugin.rules).map((rule) => `frontend/${rule}`),
552
+ "frontend/escape-hatch",
553
+ ...restrictedSyntaxWithCatalogIds().map((entry) => entry.catalogId),
554
+ "frontend/generated-client-only",
555
+ "frontend/no-deep-imports",
556
+ "frontend/no-style-imports",
557
+ ]),
558
+ ].sort();
559
+ }
560
+
561
+ /** The marker name a message answers to: its catalog rule name, or null when unwaivable.
562
+ *
563
+ * Suppression attributes markers exactly the way findings are attributed — through
564
+ * {@link catalogRuleId} — so a marker names the stack-neutral catalog rule
565
+ * (`terp-allow-token-styled-elements`). One marker therefore covers every detection
566
+ * path of its rule and cannot waive a sibling rule that shares a core lint id.
567
+ * A message outside the boundary is not waivable by the terp escape hatch at all, and
568
+ * `frontend/escape-hatch` itself is excluded: governance cannot be waived by the
569
+ * mechanism it governs (the catalog entry declares no opt_out).
570
+ */
571
+ function suppressibleRuleName(message) {
572
+ const id = catalogRuleId(message);
573
+ if (id === null || id === "frontend/escape-hatch") {
574
+ return null;
575
+ }
576
+ return id.slice("frontend/".length);
577
+ }
578
+
579
+ /** Marker names the escape hatch recognises: exactly the catalog rule names. */
580
+ export function knownMarkerNames() {
581
+ return new Set(
582
+ catalogRuleIds()
583
+ .filter((id) => id !== "frontend/escape-hatch")
584
+ .map((id) => id.slice("frontend/".length)),
585
+ );
586
+ }
587
+
588
+ const MARKER_RE = () =>
589
+ new RegExp(`${BOUNDARY_SPEC.allowMarkerPrefix}([a-z0-9-]+)(?::[ \\t]*(.*?))?\\s*(?:\\*+\\/\\s*}?)?\\s*$`);
590
+
591
+ /**
592
+ * Every escape-hatch marker in *text*: `{ line, rule, reason }` (reason null =
593
+ * unjustified). Markers are read from real COMMENT tokens only — the text is
594
+ * parsed (TSX) and markers are extracted from the comment list, so marker-shaped
595
+ * text inside a string or template literal neither suppresses nor counts. A file
596
+ * that fails to parse yields no markers (fail closed: nothing is waived; the
597
+ * parse error itself is already reported by the lint run).
598
+ */
599
+ export function parseAllowMarkers(text) {
600
+ const source = String(text);
601
+ if (!source.includes(BOUNDARY_SPEC.allowMarkerPrefix)) {
602
+ return [];
603
+ }
604
+ let comments = null;
605
+ for (const jsx of [true, false]) {
606
+ try {
607
+ const { ast } = tseslint.parser.parseForESLint(source, {
608
+ comment: true,
609
+ loc: true,
610
+ range: true,
611
+ tokens: false,
612
+ ecmaFeatures: { jsx },
613
+ });
614
+ comments = ast.comments ?? [];
615
+ break;
616
+ } catch {
617
+ // Retry without JSX (a .ts file whose generics are not valid JSX), else fail closed.
618
+ }
619
+ }
620
+ if (comments === null) {
621
+ return [];
622
+ }
623
+ const markers = [];
624
+ const pattern = MARKER_RE();
625
+ for (const comment of comments) {
626
+ const lines = String(comment.value).split(/\r?\n/);
627
+ lines.forEach((lineText, offset) => {
628
+ if (!lineText.includes(BOUNDARY_SPEC.allowMarkerPrefix)) {
629
+ return;
630
+ }
631
+ const match = pattern.exec(lineText);
632
+ if (match) {
633
+ const reason = match[2]?.trim();
634
+ markers.push({
635
+ line: comment.loc.start.line + offset,
636
+ rule: match[1],
637
+ reason: reason ? reason : null,
638
+ });
639
+ }
640
+ });
641
+ }
642
+ return markers.sort((a, b) => a.line - b.line);
643
+ }
644
+
645
+ /**
646
+ * Apply the governed escape hatch to a lint result (the frontend analog of the backend's
647
+ * justified `# arch-allow-<rule>: <reason>` suppressions): a marker with a reason, on the
648
+ * violating line or the line immediately above, suppresses that rule there. The marker names
649
+ * the CATALOG rule (see {@link suppressibleRuleName}), so `spec/catalog/frontend/<rule>.json`'s
650
+ * `opt_out` spelling is the one that works — for every detection path of the rule at once.
651
+ * An unjustified marker (no reason) is itself reported — never silently honoured — and so
652
+ * is a marker naming no governed rule (a typo, a stale name, or a pre-0.6.0 core-id
653
+ * spelling can never be budgeted into legitimacy). Marker counts are governed by the
654
+ * budget ratchet (./budget.js), so opt-outs stay visible, greppable, and can only shrink.
655
+ */
656
+ export function suppressWithMarkers(messages, text) {
657
+ const markers = parseAllowMarkers(text);
658
+ const known = knownMarkerNames();
659
+ const justified = markers.filter((marker) => marker.reason !== null);
660
+ const kept = messages.filter((message) => {
661
+ const name = suppressibleRuleName(message);
662
+ if (name === null) {
663
+ return true; // not a waivable boundary finding
664
+ }
665
+ return !justified.some(
666
+ (marker) =>
667
+ marker.rule === name &&
668
+ (marker.line === message.line || marker.line === message.line - 1),
669
+ );
670
+ });
671
+ const problems = markers
672
+ .filter((marker) => marker.reason === null || !known.has(marker.rule))
673
+ .map((marker) => ({
674
+ ruleId: "terp/escape-hatch",
675
+ severity: 2,
676
+ line: marker.line,
677
+ column: 1,
678
+ message: !known.has(marker.rule)
679
+ ? `"${BOUNDARY_SPEC.allowMarkerPrefix}${marker.rule}" names no rule with a ` +
680
+ "governed opt-out; markers name the Terp Standard catalog rule " +
681
+ '(e.g. "terp-allow-token-styled-elements"). Remove or fix the marker.'
682
+ : "An escape-hatch marker needs a justification: " +
683
+ `"${BOUNDARY_SPEC.allowMarkerPrefix}${marker.rule}: <reason>". ` +
684
+ "An unjustified marker is reported, never silently honoured.",
685
+ }));
686
+ return [...kept, ...problems];
687
+ }
688
+
689
+ /**
690
+ * The escape-hatch processor: lints the file as-is (one virtual block), then filters the
691
+ * messages through {@link suppressWithMarkers} so a justified marker suppresses its violation
692
+ * and an unjustified marker becomes one.
693
+ */
694
+ function escapeHatchProcessor() {
695
+ const sources = new Map();
696
+ return {
697
+ meta: { name: "terp-escape-hatch" },
698
+ preprocess(text, filename) {
699
+ sources.set(filename, text);
700
+ return [{ text, filename: `0${path.extname(filename)}` }];
701
+ },
702
+ postprocess(messageLists, filename) {
703
+ const text = sources.get(filename) ?? "";
704
+ sources.delete(filename);
705
+ return suppressWithMarkers(messageLists.flat(), text);
706
+ },
707
+ };
708
+ }
709
+
710
+ /**
711
+ * The Terp frontend boundary config (an ESLint flat-config array), scoped to app modules. Spread it
712
+ * into a repo's `eslint.config.js`:
713
+ *
714
+ * import terpBoundaries from "@terpjs/eslint-boundaries";
715
+ * export default [{ ignores: ["dist/**", "src/api/**"] }, ...terpBoundaries];
716
+ */
717
+ export function terpBoundaries() {
718
+ return [
719
+ {
720
+ files: BOUNDARY_SPEC.moduleFiles,
721
+ processor: escapeHatchProcessor(),
722
+ },
723
+ {
724
+ files: BOUNDARY_SPEC.moduleFiles,
725
+ // Inline `eslint-disable` comments are inert in app modules; the justified
726
+ // `terp-allow-*` marker (budget-governed) is the *only* escape hatch (ADR 0059).
727
+ linterOptions: { noInlineConfig: true },
728
+ languageOptions: {
729
+ parser: tseslint.parser,
730
+ parserOptions: { ecmaFeatures: { jsx: true }, sourceType: "module" },
731
+ },
732
+ plugins: { terp: terpPlugin },
733
+ rules: {
734
+ "terp/layout-contract": "error",
735
+ "terp/no-cross-module-imports": "error",
736
+ "terp/no-dom-html-injection": "error",
737
+ "terp/no-eval": "error",
738
+ "terp/no-unsafe-href": "error",
739
+ "terp/no-unsafe-target-blank": "error",
740
+ "no-restricted-syntax": ["error", ...restrictedSyntax()],
741
+ "no-restricted-globals": [
742
+ "error",
743
+ ...BOUNDARY_SPEC.restrictedGlobals.map((name) => ({
744
+ name,
745
+ message: generatedClientMessage,
746
+ })),
747
+ ],
748
+ "no-restricted-imports": [
749
+ "error",
750
+ {
751
+ patterns: [
752
+ {
753
+ group: BOUNDARY_SPEC.internalImportPatterns,
754
+ message: deepImportMessage,
755
+ },
756
+ {
757
+ group: BOUNDARY_SPEC.styleImportPatterns,
758
+ message: styleImportMessage,
759
+ },
760
+ ],
761
+ },
762
+ ],
763
+ },
764
+ },
765
+ ];
766
+ }
767
+
768
+ export { LAYOUT_CONTRACTS, LAYOUT_CONTRACT_FILE, slotViolationMessage } from "./layouts.js";
769
+ export { BOUNDARY_SPEC };
770
+ export default terpBoundaries();