@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/package.json +41 -0
- package/src/budget.js +215 -0
- package/src/budget.test.js +207 -0
- package/src/corpus-harness.js +75 -0
- package/src/corpus.test.js +92 -0
- package/src/findings.js +194 -0
- package/src/findings.test.js +346 -0
- package/src/index.js +770 -0
- package/src/index.test.js +318 -0
- package/src/layouts.js +85 -0
- package/src/layouts.test.js +106 -0
- package/src/scorecard.js +154 -0
- package/src/scorecard.test.js +61 -0
- package/src/spec.js +84 -0
- package/src/surface.test.js +192 -0
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import { ESLint } from "eslint";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
import terpBoundaries from "./index.js";
|
|
7
|
+
|
|
8
|
+
// The frontend analog of the arch harness's meta-tests: prove each boundary rule actually fires on
|
|
9
|
+
// a violating fixture (and stays quiet on clean, out-of-module code), so "enforced" is real.
|
|
10
|
+
// File paths are resolved under the cwd so ESLint's `files` globs match (the parser then applies).
|
|
11
|
+
|
|
12
|
+
const MODULE_FILE = path.resolve("src/modules/widgets/Widget.tsx");
|
|
13
|
+
const OUTSIDE_FILE = path.resolve("src/main.tsx");
|
|
14
|
+
|
|
15
|
+
async function lint(code, filePath = MODULE_FILE) {
|
|
16
|
+
const eslint = new ESLint({ overrideConfigFile: true, overrideConfig: terpBoundaries });
|
|
17
|
+
const [result] = await eslint.lintText(code, { filePath });
|
|
18
|
+
return result.messages.map((message) => message.ruleId);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("terpBoundaries", () => {
|
|
22
|
+
it("passes clean module code (react-core components + generated client)", async () => {
|
|
23
|
+
const code = [
|
|
24
|
+
'import { Button, Select, Textarea, useTerpClient } from "@terpjs/react-core";',
|
|
25
|
+
"export function Widget() {",
|
|
26
|
+
" const client = useTerpClient();",
|
|
27
|
+
" void client;",
|
|
28
|
+
" return <><Button>ok</Button><Select /><Textarea /></>;",
|
|
29
|
+
"}",
|
|
30
|
+
].join("\n");
|
|
31
|
+
expect(await lint(code)).toEqual([]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("flags an import of a sibling module", async () => {
|
|
35
|
+
const code = 'import { x } from "../other/thing";\nexport const W = () => null;';
|
|
36
|
+
expect(await lint(code)).toContain("terp/no-cross-module-imports");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("flags a dynamic import() of a sibling module (no spelling escape)", async () => {
|
|
40
|
+
const code = 'export const load = () => import("../other/thing");';
|
|
41
|
+
expect(await lint(code)).toContain("terp/no-cross-module-imports");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("flags a raw <button> (use the token-styled component)", async () => {
|
|
45
|
+
expect(await lint("export const W = () => <button>x</button>;")).toContain("no-restricted-syntax");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("flags raw form controls that have react-core primitives", async () => {
|
|
49
|
+
expect(await lint("export const W = () => <select />;")).toContain("no-restricted-syntax");
|
|
50
|
+
expect(await lint("export const W = () => <textarea />;")).toContain("no-restricted-syntax");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("flags raw layout-bearing elements that have react-core components", async () => {
|
|
54
|
+
expect(await lint("export const W = () => <table />;")).toContain("no-restricted-syntax");
|
|
55
|
+
expect(await lint("export const W = () => <dialog />;")).toContain("no-restricted-syntax");
|
|
56
|
+
expect(await lint("export const W = () => <form />;")).toContain("no-restricted-syntax");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("flags an in-app anchor (router Link, not a full-reload <a>)", async () => {
|
|
60
|
+
expect(await lint('export const W = () => <a href="/notes">go</a>;')).toContain(
|
|
61
|
+
"no-restricted-syntax",
|
|
62
|
+
);
|
|
63
|
+
expect(await lint('export const W = () => <a href={"/notes"}>go</a>;')).toContain(
|
|
64
|
+
"no-restricted-syntax",
|
|
65
|
+
);
|
|
66
|
+
expect(await lint("export const W = () => <a href={`/notes`}>go</a>;")).toContain(
|
|
67
|
+
"no-restricted-syntax",
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("allows an external anchor", async () => {
|
|
72
|
+
expect(await lint('export const W = () => <a href="https://example.com">docs</a>;')).toEqual([]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("flags className (no side channel into hand-authored CSS)", async () => {
|
|
76
|
+
expect(await lint('export const W = () => <div className="x">y</div>;')).toContain(
|
|
77
|
+
"no-restricted-syntax",
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("flags a module-authored stylesheet import (theming flows from the tokens)", async () => {
|
|
82
|
+
const code = 'import "./widget.css";\nexport const W = () => null;';
|
|
83
|
+
expect(await lint(code)).toContain("no-restricted-imports");
|
|
84
|
+
expect(await lint('import "./widget.css?inline";\nexport const W = () => null;')).toContain(
|
|
85
|
+
"no-restricted-imports",
|
|
86
|
+
);
|
|
87
|
+
expect(await lint('import "./widget.less";\nexport const W = () => null;')).toContain(
|
|
88
|
+
"no-restricted-imports",
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("flags a hardcoded colour (design tokens only)", async () => {
|
|
93
|
+
expect(await lint('export const s = { color: "#ff0000" };')).toContain("no-restricted-syntax");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("flags an inline style attribute (layout via react-core, styling via tokens)", async () => {
|
|
97
|
+
expect(await lint("export const W = () => <div style={{ margin: 0 }}>x</div>;")).toContain(
|
|
98
|
+
"no-restricted-syntax",
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("flags raw fetch (generated client only)", async () => {
|
|
103
|
+
expect(await lint('export const load = () => fetch("/api/x");')).toContain("no-restricted-globals");
|
|
104
|
+
expect(await lint('export const load = () => window.fetch("/api/x");')).toContain(
|
|
105
|
+
"no-restricted-syntax",
|
|
106
|
+
);
|
|
107
|
+
expect(await lint('export const load = () => globalThis.fetch("/api/x");')).toContain(
|
|
108
|
+
"no-restricted-syntax",
|
|
109
|
+
);
|
|
110
|
+
expect(await lint('export const load = () => window["fetch"]("/api/x");')).toContain(
|
|
111
|
+
"no-restricted-syntax",
|
|
112
|
+
);
|
|
113
|
+
expect(await lint("export const load = () => new XMLHttpRequest();")).toContain(
|
|
114
|
+
"no-restricted-syntax",
|
|
115
|
+
);
|
|
116
|
+
expect(await lint('export const load = () => new globalThis["XMLHttpRequest"]();')).toContain(
|
|
117
|
+
"no-restricted-syntax",
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("flags raw browser streaming/beacon request primitives (generated client only)", async () => {
|
|
122
|
+
expect(await lint('export const open = () => new WebSocket("wss://example.com");')).toContain(
|
|
123
|
+
"no-restricted-globals",
|
|
124
|
+
);
|
|
125
|
+
expect(await lint('export const open = () => new window.EventSource("/events");')).toContain(
|
|
126
|
+
"no-restricted-syntax",
|
|
127
|
+
);
|
|
128
|
+
expect(await lint('export const send = () => navigator.sendBeacon("/api/x", "x");')).toContain(
|
|
129
|
+
"no-restricted-syntax",
|
|
130
|
+
);
|
|
131
|
+
expect(
|
|
132
|
+
await lint('export const send = () => window.navigator.sendBeacon("/api/x", "x");'),
|
|
133
|
+
).toContain("no-restricted-syntax");
|
|
134
|
+
expect(
|
|
135
|
+
await lint('export const send = () => globalThis["navigator"]["sendBeacon"]("/api/x", "x");'),
|
|
136
|
+
).toContain("no-restricted-syntax");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("flags target=_blank without rel=noopener", async () => {
|
|
140
|
+
expect(
|
|
141
|
+
await lint('export const W = () => <a href="https://example.com" target="_blank">docs</a>;'),
|
|
142
|
+
).toContain("terp/no-unsafe-target-blank");
|
|
143
|
+
expect(
|
|
144
|
+
await lint(
|
|
145
|
+
'export const W = () => <a href="https://example.com" target={"_blank"} rel="noreferrer">docs</a>;',
|
|
146
|
+
),
|
|
147
|
+
).toContain("terp/no-unsafe-target-blank");
|
|
148
|
+
expect(
|
|
149
|
+
await lint(
|
|
150
|
+
'export const W = () => <a href="https://example.com" target={`_blank`} rel="noopener noreferrer">docs</a>;',
|
|
151
|
+
),
|
|
152
|
+
).toEqual([]);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("flags static javascript href/src values without rejecting dynamic URLs", async () => {
|
|
156
|
+
expect(await lint('export const W = () => <a href=" javascript:alert(1)">bad</a>;')).toContain(
|
|
157
|
+
"terp/no-unsafe-href",
|
|
158
|
+
);
|
|
159
|
+
expect(await lint('export const W = () => <img src={"JaVaScRiPt:alert(1)"} />;')).toContain(
|
|
160
|
+
"terp/no-unsafe-href",
|
|
161
|
+
);
|
|
162
|
+
expect(await lint('export const W = () => <a href={`javascript:${danger}`}>bad</a>;')).toContain(
|
|
163
|
+
"terp/no-unsafe-href",
|
|
164
|
+
);
|
|
165
|
+
expect(await lint('export const W = ({ href }) => <a href={href}>ok</a>;')).toEqual([]);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("flags DOM HTML injection sinks", async () => {
|
|
169
|
+
expect(await lint('export const write = (el, html) => { el.innerHTML = html; };')).toContain(
|
|
170
|
+
"terp/no-dom-html-injection",
|
|
171
|
+
);
|
|
172
|
+
expect(
|
|
173
|
+
await lint('export const write = (el, html) => el.insertAdjacentHTML("beforeend", html);'),
|
|
174
|
+
).toContain("terp/no-dom-html-injection");
|
|
175
|
+
expect(await lint('export const write = (html) => document.write(html);')).toContain(
|
|
176
|
+
"terp/no-dom-html-injection",
|
|
177
|
+
);
|
|
178
|
+
expect(await lint('export const W = ({ html }) => <iframe srcDoc={html} />;')).toContain(
|
|
179
|
+
"terp/no-dom-html-injection",
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("flags eval and Function constructors", async () => {
|
|
184
|
+
expect(await lint('export const run = (code) => eval(code);')).toContain("terp/no-eval");
|
|
185
|
+
expect(await lint('export const run = (code) => new Function(code);')).toContain("terp/no-eval");
|
|
186
|
+
expect(await lint('export const run = (code) => window.eval(code);')).toContain("terp/no-eval");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("flags a deep import into a package's internals", async () => {
|
|
190
|
+
const code = 'import x from "@terp/react-core/src/secret";\nexport const W = () => null;';
|
|
191
|
+
expect(await lint(code)).toContain("no-restricted-imports");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("does not apply the module rules outside src/modules/", async () => {
|
|
195
|
+
// A non-module file matches no config block, so the boundary rules never fire on it.
|
|
196
|
+
const rules = await lint("export const W = () => <button>x</button>;", OUTSIDE_FILE);
|
|
197
|
+
expect(rules).not.toContain("no-restricted-syntax");
|
|
198
|
+
expect(rules).not.toContain("terp/no-cross-module-imports");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("suppresses a violation with a justified terp-allow marker on the line above", async () => {
|
|
202
|
+
const code = [
|
|
203
|
+
"// terp-allow-token-styled-elements: native button needed for a browser extension host",
|
|
204
|
+
"export const W = () => <button>x</button>;",
|
|
205
|
+
].join("\n");
|
|
206
|
+
expect(await lint(code)).toEqual([]);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("suppresses a violation with a justified terp-allow marker on the same line", async () => {
|
|
210
|
+
const code =
|
|
211
|
+
"export const W = () => <textarea />; // terp-allow-token-styled-elements: measured host quirk";
|
|
212
|
+
expect(await lint(code)).toEqual([]);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("suppresses a custom terp rule with the reported rule id suffix", async () => {
|
|
216
|
+
const code = [
|
|
217
|
+
"// terp-allow-no-unsafe-target-blank: external vendor requires opener for a handshake",
|
|
218
|
+
'export const W = () => <a href="https://example.com" target="_blank">docs</a>;',
|
|
219
|
+
].join("\n");
|
|
220
|
+
expect(await lint(code)).toEqual([]);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("refuses the retired pre-0.6.0 core-id spelling", async () => {
|
|
224
|
+
// The one-release transitional aliases (LEGACY_MARKER_ALIASES) are gone with the
|
|
225
|
+
// 0.6.0 pin: a core-id spelling names no catalog rule, so it waives nothing and
|
|
226
|
+
// is itself reported as an ungoverned marker.
|
|
227
|
+
const code = [
|
|
228
|
+
"// terp-allow-no-restricted-syntax: pre-0.6.0 spelling (migrate to the catalog rule)",
|
|
229
|
+
"export const W = () => <button>x</button>;",
|
|
230
|
+
].join("\n");
|
|
231
|
+
const rules = await lint(code);
|
|
232
|
+
expect(rules).toContain("no-restricted-syntax"); // not suppressed
|
|
233
|
+
expect(rules).toContain("terp/escape-hatch"); // the stale spelling is itself reported
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("reports a marker that names no governed rule instead of honouring it", async () => {
|
|
237
|
+
const code = [
|
|
238
|
+
"// terp-allow-made-up-rule: stale name",
|
|
239
|
+
"export const W = () => <button>x</button>;",
|
|
240
|
+
].join("\n");
|
|
241
|
+
const rules = await lint(code);
|
|
242
|
+
expect(rules).toContain("no-restricted-syntax"); // not suppressed
|
|
243
|
+
expect(rules).toContain("terp/escape-hatch"); // the unknown name is itself reported
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("ignores marker-shaped text inside a string or template literal", async () => {
|
|
247
|
+
// Markers live in real comments only — a marker-shaped string neither
|
|
248
|
+
// suppresses the next line nor its own line.
|
|
249
|
+
const viaString = [
|
|
250
|
+
'const doc = "// terp-allow-no-eval: not a comment";',
|
|
251
|
+
"export const run = (code) => eval(code); export { doc };",
|
|
252
|
+
].join("\n");
|
|
253
|
+
expect(await lint(viaString)).toContain("terp/no-eval");
|
|
254
|
+
const viaTemplate = [
|
|
255
|
+
"const doc = `// terp-allow-no-eval: not a comment`;",
|
|
256
|
+
"export const run = (code) => eval(code); export { doc };",
|
|
257
|
+
].join("\n");
|
|
258
|
+
expect(await lint(viaTemplate)).toContain("terp/no-eval");
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("one catalog marker covers every detection path of its rule (egress family)", async () => {
|
|
262
|
+
// Bare fetch reports via no-restricted-globals; window.fetch via no-restricted-syntax.
|
|
263
|
+
// Both are frontend/generated-client-only, so ONE marker name waives either path.
|
|
264
|
+
const viaGlobals = [
|
|
265
|
+
"// terp-allow-generated-client-only: sanctioned health probe",
|
|
266
|
+
'export const ping = () => fetch("/healthz");',
|
|
267
|
+
].join("\n");
|
|
268
|
+
expect(await lint(viaGlobals)).toEqual([]);
|
|
269
|
+
const viaSyntax = [
|
|
270
|
+
"// terp-allow-generated-client-only: sanctioned health probe",
|
|
271
|
+
'export const ping = () => window.fetch("/healthz");',
|
|
272
|
+
].join("\n");
|
|
273
|
+
expect(await lint(viaSyntax)).toEqual([]);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("reports an unjustified terp-allow marker instead of honouring it", async () => {
|
|
277
|
+
const code = [
|
|
278
|
+
"// terp-allow-token-styled-elements",
|
|
279
|
+
"export const W = () => <button>x</button>;",
|
|
280
|
+
].join("\n");
|
|
281
|
+
const rules = await lint(code);
|
|
282
|
+
expect(rules).toContain("no-restricted-syntax"); // not suppressed
|
|
283
|
+
expect(rules).toContain("terp/escape-hatch"); // the bare marker is itself reported
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("does not let a marker for one rule suppress another rule", async () => {
|
|
287
|
+
const code = [
|
|
288
|
+
"// terp-allow-no-cross-module-imports: wrong rule name",
|
|
289
|
+
"export const W = () => <button>x</button>;",
|
|
290
|
+
].join("\n");
|
|
291
|
+
expect(await lint(code)).toContain("no-restricted-syntax");
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("does not let a sibling catalog rule's marker cross a shared core rule id", async () => {
|
|
295
|
+
// token-styled-elements and no-inline-styling both report as no-restricted-syntax;
|
|
296
|
+
// a marker for one must never waive the other.
|
|
297
|
+
const code = [
|
|
298
|
+
"// terp-allow-token-styled-elements: wrong sibling",
|
|
299
|
+
'export const W = () => <div style={{ color: "red" }}>x</div>;',
|
|
300
|
+
].join("\n");
|
|
301
|
+
expect(await lint(code)).toContain("no-restricted-syntax");
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("ignores inline eslint-disable directives (the budgeted marker is the only opt-out)", async () => {
|
|
305
|
+
// Without noInlineConfig, a plain `eslint-disable` would skip the gate with zero budget
|
|
306
|
+
// accounting — the exact drift ADR 0059 refuses. The directive must be inert.
|
|
307
|
+
const code = [
|
|
308
|
+
"// eslint-disable-next-line no-restricted-syntax",
|
|
309
|
+
"export const W = () => <button>x</button>;",
|
|
310
|
+
].join("\n");
|
|
311
|
+
expect(await lint(code)).toContain("no-restricted-syntax");
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("ignores a file-wide eslint-disable block comment", async () => {
|
|
315
|
+
const code = ["/* eslint-disable */", "export const W = () => <button>x</button>;"].join("\n");
|
|
316
|
+
expect(await lint(code)).toContain("no-restricted-syntax");
|
|
317
|
+
});
|
|
318
|
+
});
|
package/src/layouts.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slot-typed layout contracts, declared **as data** (ADR 0079) — the layout analog of
|
|
3
|
+
* ./spec.js. A contract names, per governed page archetype ("slot owner"), the react-core
|
|
4
|
+
* components its body slot accepts; everything else is refused by BOTH halves of the
|
|
5
|
+
* two-layer control:
|
|
6
|
+
*
|
|
7
|
+
* - build time — the `terp/layout-contract` ESLint rule (./index.js) checks the static
|
|
8
|
+
* JSX children of each slot owner against the contract, and
|
|
9
|
+
* - runtime — react-core's archetypes verify the rendered DOM children (each
|
|
10
|
+
* sanctioned component stamps a `data-terp` marker) and refuse the view, fail closed.
|
|
11
|
+
*
|
|
12
|
+
* Both halves phrase the SAME agent-directive message (see {@link slotViolationMessage}),
|
|
13
|
+
* so a failing check *tells the author how to build the screen*, wherever it fires.
|
|
14
|
+
*
|
|
15
|
+
* Contracts are opt-in and backwards compatible: no checked-in `layout-contract.json`
|
|
16
|
+
* (and no `layoutContract` option at runtime) means today's behavior. The react-core
|
|
17
|
+
* runtime carries a TypeScript mirror of this table (src/layoutContract.ts); a parity
|
|
18
|
+
* test in react-core keeps the two byte-equal, so the data cannot drift.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** The checked-in config file that activates a contract for an app (lint side). */
|
|
22
|
+
export const LAYOUT_CONTRACT_FILE = "layout-contract.json";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Every layout contract, keyed by id. Per slot owner (a page archetype), `components`
|
|
26
|
+
* maps each allowed react-core component name to the `data-terp` marker it stamps on
|
|
27
|
+
* its root element — the lint checks the names, the runtime checks the markers.
|
|
28
|
+
*/
|
|
29
|
+
export const LAYOUT_CONTRACTS = {
|
|
30
|
+
standard: {
|
|
31
|
+
description:
|
|
32
|
+
"The standard three-level shape: hub bodies are card grids (HubCard only), " +
|
|
33
|
+
"overview bodies are data collections (DataView / ResourceList + framework " +
|
|
34
|
+
"states), detail bodies are record sections (DetailList / Stack / Tabs + " +
|
|
35
|
+
"framework states). A bespoke screen composes the plain Page, which the " +
|
|
36
|
+
"contract deliberately leaves unconstrained.",
|
|
37
|
+
slots: {
|
|
38
|
+
HubPage: {
|
|
39
|
+
components: { HubCard: "hubcard" },
|
|
40
|
+
},
|
|
41
|
+
OverviewPage: {
|
|
42
|
+
components: {
|
|
43
|
+
DataView: "dataview",
|
|
44
|
+
ResourceList: "resource-list",
|
|
45
|
+
ModuleNav: "module-nav",
|
|
46
|
+
Stack: "stack",
|
|
47
|
+
EmptyState: "empty-state",
|
|
48
|
+
ErrorState: "error-state",
|
|
49
|
+
LoadingState: "loading-state",
|
|
50
|
+
Alert: "alert",
|
|
51
|
+
ConfirmDialog: "dialog",
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
DetailPage: {
|
|
55
|
+
components: {
|
|
56
|
+
DetailList: "detail-list",
|
|
57
|
+
Stack: "stack",
|
|
58
|
+
Tabs: "tabs",
|
|
59
|
+
ModuleNav: "module-nav",
|
|
60
|
+
DataView: "dataview",
|
|
61
|
+
EmptyState: "empty-state",
|
|
62
|
+
ErrorState: "error-state",
|
|
63
|
+
LoadingState: "loading-state",
|
|
64
|
+
Alert: "alert",
|
|
65
|
+
ConfirmDialog: "dialog",
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The one agent-directive violation message both enforcement halves phrase: the
|
|
74
|
+
* contract, the slot, what was found, what is allowed, and the concrete fix.
|
|
75
|
+
*/
|
|
76
|
+
export function slotViolationMessage(contractId, slotOwner, found) {
|
|
77
|
+
const allowed = Object.keys(LAYOUT_CONTRACTS[contractId].slots[slotOwner].components);
|
|
78
|
+
return (
|
|
79
|
+
`Layout contract "${contractId}": the ${slotOwner} body slot accepts only ` +
|
|
80
|
+
`${allowed.join(" / ")}; found ${found}. Compose the body from those react-core ` +
|
|
81
|
+
"components (recipe: terp guide layouts), move bespoke content to a plain Page, " +
|
|
82
|
+
"or opt out on this line with a justified // terp-allow-layout-contract: <reason> " +
|
|
83
|
+
"marker (counted by the escape-hatch budget)."
|
|
84
|
+
);
|
|
85
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import { ESLint } from "eslint";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
import terpBoundaries from "./index.js";
|
|
7
|
+
import { LAYOUT_CONTRACTS, slotViolationMessage } from "./layouts.js";
|
|
8
|
+
|
|
9
|
+
// The build-time half of the slot-typed layout contract control (ADR 0079): prove the
|
|
10
|
+
// `terp/layout-contract` rule fires on a non-conforming slot child, stays quiet on
|
|
11
|
+
// conforming screens, and stays fully inert when the app has not opted into a contract.
|
|
12
|
+
// The rule option stands in for the checked-in layout-contract.json here (the file
|
|
13
|
+
// lookup is exercised by consumers; tests must not depend on the repo's cwd).
|
|
14
|
+
|
|
15
|
+
const MODULE_FILE = path.resolve("src/modules/widgets/Widget.tsx");
|
|
16
|
+
|
|
17
|
+
function configWithContract(contract) {
|
|
18
|
+
return terpBoundaries.map((entry) =>
|
|
19
|
+
entry.rules?.["terp/layout-contract"]
|
|
20
|
+
? {
|
|
21
|
+
...entry,
|
|
22
|
+
rules: { ...entry.rules, "terp/layout-contract": ["error", { contract }] },
|
|
23
|
+
}
|
|
24
|
+
: entry,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function lint(code, config = terpBoundaries) {
|
|
29
|
+
const eslint = new ESLint({ overrideConfigFile: true, overrideConfig: config });
|
|
30
|
+
const [result] = await eslint.lintText(code, { filePath: MODULE_FILE });
|
|
31
|
+
return result.messages;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("terp/layout-contract", () => {
|
|
35
|
+
it("is inert without an opted-in contract (backwards compatible)", async () => {
|
|
36
|
+
const code =
|
|
37
|
+
'import { HubPage } from "@terpjs/react-core";\n' +
|
|
38
|
+
"export const W = () => <HubPage title='x'><div /></HubPage>;";
|
|
39
|
+
expect((await lint(code)).map((m) => m.ruleId)).toEqual([]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("refuses a non-conforming child in a HubPage body, with the directive message", async () => {
|
|
43
|
+
const code =
|
|
44
|
+
'import { HubPage, Stack } from "@terpjs/react-core";\n' +
|
|
45
|
+
"export const W = () => <HubPage title='x'><Stack /></HubPage>;";
|
|
46
|
+
const messages = await lint(code, configWithContract("standard"));
|
|
47
|
+
expect(messages.map((m) => m.ruleId)).toContain("terp/layout-contract");
|
|
48
|
+
expect(messages[0].message).toBe(slotViolationMessage("standard", "HubPage", "<Stack>"));
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("passes a conforming hub / overview / detail composition", async () => {
|
|
52
|
+
const code = [
|
|
53
|
+
'import { DataView, DetailList, HubCard, HubPage, OverviewPage, DetailPage, Stack } from "@terpjs/react-core";',
|
|
54
|
+
"export const H = () => <HubPage title='x'><HubCard to='/a' title='a' /></HubPage>;",
|
|
55
|
+
"export const O = () => <OverviewPage title='x'><DataView /></OverviewPage>;",
|
|
56
|
+
"export const D = () => <DetailPage title='x' parents={[]}><Stack><DetailList items={[]} /></Stack></DetailPage>;",
|
|
57
|
+
].join("\n");
|
|
58
|
+
expect((await lint(code, configWithContract("standard"))).map((m) => m.ruleId)).toEqual([]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("refuses raw text and recurses through fragments; dynamic children are left to the runtime half", async () => {
|
|
62
|
+
const text =
|
|
63
|
+
'import { OverviewPage } from "@terpjs/react-core";\n' +
|
|
64
|
+
"export const W = () => <OverviewPage title='x'>loose text</OverviewPage>;";
|
|
65
|
+
expect((await lint(text, configWithContract("standard"))).map((m) => m.ruleId)).toContain(
|
|
66
|
+
"terp/layout-contract",
|
|
67
|
+
);
|
|
68
|
+
const fragment =
|
|
69
|
+
'import { HubPage } from "@terpjs/react-core";\n' +
|
|
70
|
+
"export const W = () => <HubPage title='x'><><span /></></HubPage>;";
|
|
71
|
+
expect((await lint(fragment, configWithContract("standard"))).map((m) => m.ruleId)).toContain(
|
|
72
|
+
"terp/layout-contract",
|
|
73
|
+
);
|
|
74
|
+
const dynamic =
|
|
75
|
+
'import { HubPage } from "@terpjs/react-core";\n' +
|
|
76
|
+
"export const W = ({items}) => <HubPage title='x'>{items}</HubPage>;";
|
|
77
|
+
expect((await lint(dynamic, configWithContract("standard"))).map((m) => m.ruleId)).toEqual([]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("reports an unknown contract id, fail closed", async () => {
|
|
81
|
+
const messages = await lint("export const W = () => null;", configWithContract("ghost"));
|
|
82
|
+
expect(messages.map((m) => m.ruleId)).toContain("terp/layout-contract");
|
|
83
|
+
expect(messages[0].message).toContain('Unknown layout contract "ghost"');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("honours a justified escape-hatch marker (and only a justified one)", async () => {
|
|
87
|
+
const code =
|
|
88
|
+
'import { HubPage } from "@terpjs/react-core";\n' +
|
|
89
|
+
"export const W = () => <HubPage title='x'>\n" +
|
|
90
|
+
" {/* terp-allow-layout-contract: legacy widget pending HubCard port */}\n" +
|
|
91
|
+
" <div />\n" +
|
|
92
|
+
"</HubPage>;";
|
|
93
|
+
expect((await lint(code, configWithContract("standard"))).map((m) => m.ruleId)).toEqual([]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("declares a marker-named runtime marker for every allowed component (data sanity)", () => {
|
|
97
|
+
for (const contract of Object.values(LAYOUT_CONTRACTS)) {
|
|
98
|
+
for (const slot of Object.values(contract.slots)) {
|
|
99
|
+
for (const [name, marker] of Object.entries(slot.components)) {
|
|
100
|
+
expect(name).toMatch(/^[A-Z]/);
|
|
101
|
+
expect(marker).toMatch(/^[a-z][a-z-]*$/);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
});
|
package/src/scorecard.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Emit the Terp Standard conformance scorecard for `@terp/eslint-boundaries`
|
|
4
|
+
* (the frontend checker).
|
|
5
|
+
*
|
|
6
|
+
* The scorecard (`scorecard.schema.json` in the spec) turns "certified against
|
|
7
|
+
* spec X.Y.Z" into a verifiable artifact: one entry per frontend catalog rule
|
|
8
|
+
* with its pass/fail verdict over the violation corpus, plus the detector
|
|
9
|
+
* residuals the adapter relies on (held to a subset of the spec's recorded
|
|
10
|
+
* `corpus/RESIDUALS.json`). A consumer re-runs the corpus and reproduces it.
|
|
11
|
+
*
|
|
12
|
+
* A certification-context tool, not an app tool: it needs `@terp/spec` (a dev
|
|
13
|
+
* dependency of the platform repo) and runs the SAME harness the corpus test
|
|
14
|
+
* uses (./corpus-harness.js), so the scorecard can never disagree with the
|
|
15
|
+
* suite. Self-validates and refuses to write an invalid or failing scorecard
|
|
16
|
+
* (exit 1).
|
|
17
|
+
*
|
|
18
|
+
* node packages/frontend/eslint-boundaries/src/scorecard.js --out scorecard.json
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import fs from "node:fs";
|
|
22
|
+
import { createRequire } from "node:module";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
import process from "node:process";
|
|
25
|
+
import { pathToFileURL } from "node:url";
|
|
26
|
+
|
|
27
|
+
import { lintCaseFindings } from "./corpus-harness.js";
|
|
28
|
+
|
|
29
|
+
const require = createRequire(import.meta.url);
|
|
30
|
+
|
|
31
|
+
const RULE_ID_RE = /^(backend\/[a-z0-9_]+|frontend\/[a-z0-9-]+)$/;
|
|
32
|
+
const SEMVER_RE = /^\d+\.\d+\.\d+$/;
|
|
33
|
+
|
|
34
|
+
/** The @terp/spec root — resolved lazily so importing this module never requires it. */
|
|
35
|
+
function specRoot() {
|
|
36
|
+
return path.dirname(require.resolve("@terp/spec/package.json"));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Build the @terp/eslint-boundaries scorecard over the frontend corpus. */
|
|
40
|
+
export async function buildScorecard() {
|
|
41
|
+
const root = specRoot();
|
|
42
|
+
const catalogDir = path.join(root, "catalog", "frontend");
|
|
43
|
+
const corpusDir = path.join(root, "corpus", "frontend");
|
|
44
|
+
const residuals = JSON.parse(
|
|
45
|
+
fs.readFileSync(path.join(root, "corpus", "RESIDUALS.json"), "utf8"),
|
|
46
|
+
).residuals;
|
|
47
|
+
const packageVersion = JSON.parse(
|
|
48
|
+
fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"),
|
|
49
|
+
).version;
|
|
50
|
+
|
|
51
|
+
const rules = [];
|
|
52
|
+
const entries = fs
|
|
53
|
+
.readdirSync(catalogDir)
|
|
54
|
+
.filter((name) => name.endsWith(".json"))
|
|
55
|
+
.map((name) => JSON.parse(fs.readFileSync(path.join(catalogDir, name), "utf8")))
|
|
56
|
+
.filter((entry) => entry.corpus);
|
|
57
|
+
for (const entry of entries) {
|
|
58
|
+
const ruleDir = path.join(corpusDir, entry.id.split("/")[1]);
|
|
59
|
+
let pass = true;
|
|
60
|
+
for (const caseName of fs.readdirSync(ruleDir).sort()) {
|
|
61
|
+
const caseDir = path.join(ruleDir, caseName);
|
|
62
|
+
if (!fs.statSync(caseDir).isDirectory()) continue;
|
|
63
|
+
const fired = (await lintCaseFindings(caseDir)).map((finding) => finding.rule);
|
|
64
|
+
if (caseName.startsWith("violation-") && !fired.includes(entry.id)) pass = false;
|
|
65
|
+
if (caseName.startsWith("compliant-") && fired.length > 0) pass = false;
|
|
66
|
+
}
|
|
67
|
+
const claim = { rule: entry.id, pass };
|
|
68
|
+
if (residuals[entry.id]?.length) claim.residuals_claimed = [...residuals[entry.id]];
|
|
69
|
+
rules.push(claim);
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
spec_version: fs.readFileSync(path.join(root, "VERSION"), "utf8").trim(),
|
|
73
|
+
checker: { tool: "@terp/eslint-boundaries", version: packageVersion },
|
|
74
|
+
rules,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Hold the scorecard to its published contract; returns the problems. */
|
|
79
|
+
export function validateScorecard(scorecard) {
|
|
80
|
+
const problems = [];
|
|
81
|
+
if (!SEMVER_RE.test(String(scorecard.spec_version ?? ""))) {
|
|
82
|
+
problems.push("spec_version is not a semver string");
|
|
83
|
+
}
|
|
84
|
+
if (!scorecard.checker?.tool || !scorecard.checker?.version) {
|
|
85
|
+
problems.push("checker must carry tool and version");
|
|
86
|
+
}
|
|
87
|
+
const root = specRoot();
|
|
88
|
+
const residuals = JSON.parse(
|
|
89
|
+
fs.readFileSync(path.join(root, "corpus", "RESIDUALS.json"), "utf8"),
|
|
90
|
+
).residuals;
|
|
91
|
+
const catalogued = fs
|
|
92
|
+
.readdirSync(path.join(root, "catalog", "frontend"))
|
|
93
|
+
.filter((name) => name.endsWith(".json"))
|
|
94
|
+
.map((name) =>
|
|
95
|
+
JSON.parse(fs.readFileSync(path.join(root, "catalog", "frontend", name), "utf8")),
|
|
96
|
+
)
|
|
97
|
+
.filter((entry) => entry.corpus)
|
|
98
|
+
.map((entry) => entry.id);
|
|
99
|
+
const rules = scorecard.rules ?? [];
|
|
100
|
+
if (rules.length === 0) problems.push("a scorecard without rule claims certifies nothing");
|
|
101
|
+
const claimed = new Set();
|
|
102
|
+
for (const claim of rules) {
|
|
103
|
+
claimed.add(claim.rule);
|
|
104
|
+
if (!RULE_ID_RE.test(String(claim.rule ?? ""))) {
|
|
105
|
+
problems.push(`bad rule id: ${claim.rule}`);
|
|
106
|
+
}
|
|
107
|
+
if (typeof claim.pass !== "boolean") {
|
|
108
|
+
problems.push(`${claim.rule}: pass must be a boolean`);
|
|
109
|
+
} else if (!claim.pass) {
|
|
110
|
+
problems.push(`${claim.rule}: the reference adapter must pass its own corpus`);
|
|
111
|
+
}
|
|
112
|
+
for (const residual of claim.residuals_claimed ?? []) {
|
|
113
|
+
if (!(residuals[claim.rule] ?? []).includes(residual)) {
|
|
114
|
+
problems.push(`${claim.rule}: claims an unrecorded residual: ${residual}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const missing = catalogued.filter((id) => !claimed.has(id));
|
|
119
|
+
if (missing.length > 0) {
|
|
120
|
+
problems.push(`corpus-covered rules missing a claim: ${missing.join(", ")}`);
|
|
121
|
+
}
|
|
122
|
+
return problems;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function main() {
|
|
126
|
+
const args = process.argv.slice(2);
|
|
127
|
+
const outIndex = args.indexOf("--out");
|
|
128
|
+
const out = outIndex !== -1 ? args[outIndex + 1] : "";
|
|
129
|
+
const scorecard = await buildScorecard();
|
|
130
|
+
const problems = validateScorecard(scorecard);
|
|
131
|
+
if (problems.length > 0) {
|
|
132
|
+
for (const problem of problems) process.stderr.write(`scorecard: ${problem}\n`);
|
|
133
|
+
process.exitCode = 1;
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const rendered = `${JSON.stringify(scorecard, null, 2)}\n`;
|
|
137
|
+
if (out) {
|
|
138
|
+
fs.writeFileSync(out, rendered);
|
|
139
|
+
process.stderr.write(
|
|
140
|
+
`wrote ${out} (${scorecard.rules.length} rule claims, spec ${scorecard.spec_version})\n`,
|
|
141
|
+
);
|
|
142
|
+
} else {
|
|
143
|
+
process.stdout.write(rendered);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Run main() only when invoked as a CLI, not on import (the findings.js pattern).
|
|
148
|
+
const entry = process.argv[1] ? pathToFileURL(fs.realpathSync(process.argv[1])).href : "";
|
|
149
|
+
if (entry === import.meta.url) {
|
|
150
|
+
main().catch((error) => {
|
|
151
|
+
console.error(String(error));
|
|
152
|
+
process.exitCode = 2;
|
|
153
|
+
});
|
|
154
|
+
}
|