@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
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@terpjs/eslint-boundaries",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Terp frontend boundary rules (as data) + the ESLint adapter: no cross-module imports, no package internals, design-token-only styling (no style/className/module stylesheets), token-styled components for raw HTML tags, router-only in-app links, generated-client-only, and browser XSS/navigation sink bans. Strict-only (no modes); governed opt-outs via terp-allow markers + the escape-hatch budget ratchet (terp-boundaries-budget).",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"terp-boundaries-budget": "./src/budget.js",
|
|
9
|
+
"terp-boundaries-lint": "./src/findings.js"
|
|
10
|
+
},
|
|
11
|
+
"exports": {
|
|
12
|
+
".": "./src/index.js",
|
|
13
|
+
"./spec": "./src/spec.js",
|
|
14
|
+
"./layouts": "./src/layouts.js",
|
|
15
|
+
"./budget": "./src/budget.js",
|
|
16
|
+
"./findings": "./src/findings.js"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"test": "vitest run"
|
|
20
|
+
},
|
|
21
|
+
"peerDependencies": {
|
|
22
|
+
"eslint": ">=9"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"typescript-eslint": "^8.20.0"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@terp/spec": "github:AITT-NL/terp-spec#v0.12.0",
|
|
29
|
+
"eslint": "^9.18.0",
|
|
30
|
+
"vitest": "^3.0.0"
|
|
31
|
+
},
|
|
32
|
+
"license": "Apache-2.0",
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/AITT-NL/terp-framework.git",
|
|
36
|
+
"directory": "packages/frontend/eslint-boundaries"
|
|
37
|
+
},
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/budget.js
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The escape-hatch budget ratchet — the frontend analog of the backend's governed
|
|
4
|
+
* `# arch-allow-*` budget (design §8): `terp-allow-*` marker counts in the app-authored
|
|
5
|
+
* surface (`src/modules/**`) must match the checked-in `escape-hatch-budget.json` **exactly**.
|
|
6
|
+
* A marker that rose needs a justified budget bump in the same change; one that dropped must
|
|
7
|
+
* be lowered to lock in the win; an unbudgeted marker must be added with a justified count.
|
|
8
|
+
* This keeps every boundary opt-out visible, greppable, and governed.
|
|
9
|
+
*
|
|
10
|
+
* Run it standalone or in CI (`terp-boundaries-budget [budget-path]`); the app lint
|
|
11
|
+
* command (`terp-boundaries-lint`) runs the same {@link checkBudget} in-process, so the
|
|
12
|
+
* ratchet can never be skipped by a failing lint. It exits non-zero on any drift.
|
|
13
|
+
* `--format json` additionally publishes the drift as a findings envelope on stdout,
|
|
14
|
+
* attributed to `frontend/escape-hatch` (see ./findings.js).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import fs from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import process from "node:process";
|
|
20
|
+
import { pathToFileURL } from "node:url";
|
|
21
|
+
|
|
22
|
+
import { BOUNDARY_SPEC } from "./spec.js";
|
|
23
|
+
import { knownMarkerNames, parseAllowMarkers } from "./index.js";
|
|
24
|
+
|
|
25
|
+
const MODULE_FILE_RE = /\.(?:ts|tsx)$/;
|
|
26
|
+
|
|
27
|
+
/** The `review-by:<YYYY-MM-DD>` metadata token in a marker's reason (the Terp
|
|
28
|
+
* Standard's escape-hatch contract): when the exception must be re-justified.
|
|
29
|
+
* The tokens are a convention, not a gate — a reason without one is never
|
|
30
|
+
* rejected — but the spec says a toolchain SHOULD surface *expired* dates. */
|
|
31
|
+
const REVIEW_BY_RE = /review-by:\s*(\d{4}-\d{2}-\d{2})/g;
|
|
32
|
+
|
|
33
|
+
/** A strictly valid calendar date from a `YYYY-MM-DD` token, else null — a
|
|
34
|
+
* malformed date (2026-13-45) is not a well-formed token and never fires. */
|
|
35
|
+
function parsedReviewDate(value) {
|
|
36
|
+
const [year, month, day] = value.split("-").map(Number);
|
|
37
|
+
const date = new Date(Date.UTC(year, month - 1, day));
|
|
38
|
+
const roundTrips =
|
|
39
|
+
date.getUTCFullYear() === year &&
|
|
40
|
+
date.getUTCMonth() === month - 1 &&
|
|
41
|
+
date.getUTCDate() === day;
|
|
42
|
+
return roundTrips ? date : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Every `src/modules/**` TypeScript file under *root*, recursively. */
|
|
46
|
+
function moduleFiles(root) {
|
|
47
|
+
const modulesRoot = path.join(root, "src", "modules");
|
|
48
|
+
if (!fs.existsSync(modulesRoot)) {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
const files = [];
|
|
52
|
+
const walk = (dir) => {
|
|
53
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
54
|
+
const full = path.join(dir, entry.name);
|
|
55
|
+
if (entry.isDirectory()) {
|
|
56
|
+
walk(full);
|
|
57
|
+
} else if (MODULE_FILE_RE.test(entry.name)) {
|
|
58
|
+
files.push(full);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
walk(modulesRoot);
|
|
63
|
+
return files;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Actual `terp-allow-<rule>` marker counts across the app-authored surface. */
|
|
67
|
+
export function countMarkers(root) {
|
|
68
|
+
const counts = {};
|
|
69
|
+
for (const file of moduleFiles(root)) {
|
|
70
|
+
for (const marker of parseAllowMarkers(fs.readFileSync(file, "utf-8"))) {
|
|
71
|
+
const name = `${BOUNDARY_SPEC.allowMarkerPrefix}${marker.rule}`;
|
|
72
|
+
counts[name] = (counts[name] ?? 0) + 1;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return counts;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Compare actual marker counts to the budget; return human-readable problems (empty = clean).
|
|
79
|
+
*
|
|
80
|
+
* A marker (or budget key) that names no rule with a governed opt-out — a typo, a
|
|
81
|
+
* stale name, or the governance rule's own name — is refused outright: an unknown
|
|
82
|
+
* marker can never be budgeted into legitimacy. A marker reason MAY carry the spec's
|
|
83
|
+
* `review-by:<YYYY-MM-DD>` metadata token; one whose date has passed is surfaced as a
|
|
84
|
+
* problem naming the marker's own file:line (re-justify the exception or remove it —
|
|
85
|
+
* a long-lived opt-out is never silently eternal). Reasons without the token are never
|
|
86
|
+
* rejected. *today* is injectable for tests; the default is the real current date.
|
|
87
|
+
*/
|
|
88
|
+
export function checkBudget(root, budgetPath, today = new Date()) {
|
|
89
|
+
let raw;
|
|
90
|
+
try {
|
|
91
|
+
raw = fs.readFileSync(budgetPath, "utf-8");
|
|
92
|
+
} catch {
|
|
93
|
+
return [`budget file not found: ${budgetPath}; create it (e.g. '{}') to govern opt-outs`];
|
|
94
|
+
}
|
|
95
|
+
let budget;
|
|
96
|
+
try {
|
|
97
|
+
budget = JSON.parse(raw);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
return [`budget is not valid JSON: ${error.message}`];
|
|
100
|
+
}
|
|
101
|
+
if (
|
|
102
|
+
budget === null ||
|
|
103
|
+
typeof budget !== "object" ||
|
|
104
|
+
Array.isArray(budget) ||
|
|
105
|
+
!Object.values(budget).every((count) => Number.isInteger(count))
|
|
106
|
+
) {
|
|
107
|
+
return ["budget must be a JSON object mapping each 'terp-allow-*' marker to an integer count"];
|
|
108
|
+
}
|
|
109
|
+
const actual = countMarkers(root);
|
|
110
|
+
const known = knownMarkerNames();
|
|
111
|
+
const problems = [];
|
|
112
|
+
const isGoverned = (name) =>
|
|
113
|
+
name.startsWith(BOUNDARY_SPEC.allowMarkerPrefix) &&
|
|
114
|
+
known.has(name.slice(BOUNDARY_SPEC.allowMarkerPrefix.length));
|
|
115
|
+
for (const name of [...new Set([...Object.keys(budget), ...Object.keys(actual)])].sort()) {
|
|
116
|
+
if (!isGoverned(name)) {
|
|
117
|
+
problems.push(
|
|
118
|
+
`'${name}' names no rule with a governed opt-out; remove the marker/budget entry ` +
|
|
119
|
+
"(opt-out markers name the Terp Standard catalog rule)",
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
for (const [name, count] of Object.entries(budget)) {
|
|
124
|
+
if (!isGoverned(name)) {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const used = actual[name] ?? 0;
|
|
128
|
+
if (used < count) {
|
|
129
|
+
problems.push(
|
|
130
|
+
`marker '${name}' dropped to ${used} (budget ${count}); lower the budget to lock in the win`,
|
|
131
|
+
);
|
|
132
|
+
} else if (used > count) {
|
|
133
|
+
problems.push(
|
|
134
|
+
`marker '${name}' rose to ${used} (budget ${count}); justify the bump in the same change`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
for (const [name, used] of Object.entries(actual)) {
|
|
139
|
+
if (isGoverned(name) && !(name in budget)) {
|
|
140
|
+
problems.push(`unbudgeted marker '${name}' used ${used} time(s); add it with a justified count`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// Date-only comparison (like the backend checker): a review-by dated today
|
|
144
|
+
// is due, not yet passed.
|
|
145
|
+
const deadline = Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate());
|
|
146
|
+
for (const file of moduleFiles(root)) {
|
|
147
|
+
const relative = path.relative(root, file).split(path.sep).join("/");
|
|
148
|
+
for (const marker of parseAllowMarkers(fs.readFileSync(file, "utf-8"))) {
|
|
149
|
+
if (marker.reason === null) {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
for (const match of marker.reason.matchAll(REVIEW_BY_RE)) {
|
|
153
|
+
const reviewBy = parsedReviewDate(match[1]);
|
|
154
|
+
if (reviewBy !== null && reviewBy.getTime() < deadline) {
|
|
155
|
+
problems.push(
|
|
156
|
+
`marker '${BOUNDARY_SPEC.allowMarkerPrefix}${marker.rule}' at ${relative}:${marker.line} ` +
|
|
157
|
+
`has a passed review date (review-by:${match[1]}); re-justify the exception ` +
|
|
158
|
+
"with a new review-by date or remove the marker",
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return problems;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function main() {
|
|
168
|
+
const args = process.argv.slice(2);
|
|
169
|
+
// `--format json` additionally publishes the drift as a findings envelope on stdout
|
|
170
|
+
// (attributed to `frontend/escape-hatch`), so a driving tool joins the budget verdict
|
|
171
|
+
// to the Terp Standard catalog without parsing prose. Humans keep reading stderr.
|
|
172
|
+
let format = "text";
|
|
173
|
+
const formatIndex = args.indexOf("--format");
|
|
174
|
+
if (formatIndex !== -1) {
|
|
175
|
+
const value = args[formatIndex + 1];
|
|
176
|
+
if (value !== "json") {
|
|
177
|
+
console.error(`escape-hatch-budget: unsupported --format ${value ?? "(missing)"}; expected json`);
|
|
178
|
+
process.exit(2);
|
|
179
|
+
}
|
|
180
|
+
format = "json";
|
|
181
|
+
args.splice(formatIndex, 2);
|
|
182
|
+
}
|
|
183
|
+
const root = process.cwd();
|
|
184
|
+
const budgetPath = args[0] ?? path.join(root, "escape-hatch-budget.json");
|
|
185
|
+
const problems = checkBudget(root, budgetPath);
|
|
186
|
+
if (format === "json") {
|
|
187
|
+
const relative = path.relative(root, budgetPath).split(path.sep).join("/");
|
|
188
|
+
const budgetFile = relative === "" ? budgetPath.split(path.sep).join("/") : relative;
|
|
189
|
+
console.log(
|
|
190
|
+
JSON.stringify({
|
|
191
|
+
terp_findings: 1,
|
|
192
|
+
tool: "terp-boundaries-budget",
|
|
193
|
+
rules: ["frontend/escape-hatch"],
|
|
194
|
+
findings: problems.map((problem) => ({
|
|
195
|
+
rule: "frontend/escape-hatch",
|
|
196
|
+
path: budgetFile,
|
|
197
|
+
message: problem,
|
|
198
|
+
})),
|
|
199
|
+
unattributed: [],
|
|
200
|
+
}),
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
if (problems.length > 0) {
|
|
204
|
+
for (const problem of problems) {
|
|
205
|
+
console.error(`escape-hatch-budget: ${problem}`);
|
|
206
|
+
}
|
|
207
|
+
process.exit(1);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Run main() only when invoked as a CLI (directly or via the npm bin symlink), not on import.
|
|
212
|
+
const entry = process.argv[1] ? pathToFileURL(fs.realpathSync(process.argv[1])).href : "";
|
|
213
|
+
if (entry === import.meta.url) {
|
|
214
|
+
main();
|
|
215
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
7
|
+
|
|
8
|
+
import { checkBudget, countMarkers } from "./budget.js";
|
|
9
|
+
|
|
10
|
+
// The frontend analog of the backend budget-ratchet tests: actual `terp-allow-*` marker counts
|
|
11
|
+
// must match the checked-in escape-hatch-budget.json exactly — a rise, a drop, and an
|
|
12
|
+
// unbudgeted marker are each reported, and a missing/invalid budget fails closed.
|
|
13
|
+
|
|
14
|
+
const roots = [];
|
|
15
|
+
const scratchRoot = path.resolve("node_modules/.cache/terp-budget-tests");
|
|
16
|
+
let rootCounter = 0;
|
|
17
|
+
|
|
18
|
+
function appRoot(files, budget) {
|
|
19
|
+
const root = path.join(scratchRoot, `case-${rootCounter++}`);
|
|
20
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
21
|
+
fs.mkdirSync(root, { recursive: true });
|
|
22
|
+
roots.push(root);
|
|
23
|
+
for (const [relative, text] of Object.entries(files)) {
|
|
24
|
+
const full = path.join(root, "src", "modules", relative);
|
|
25
|
+
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
26
|
+
fs.writeFileSync(full, text);
|
|
27
|
+
}
|
|
28
|
+
if (budget !== undefined) {
|
|
29
|
+
fs.writeFileSync(path.join(root, "escape-hatch-budget.json"), budget);
|
|
30
|
+
}
|
|
31
|
+
return root;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function check(root) {
|
|
35
|
+
return checkBudget(root, path.join(root, "escape-hatch-budget.json"));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const MARKED = "// terp-allow-token-styled-elements: measured host quirk\nexport const W = 1;\n";
|
|
39
|
+
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
for (const root of roots.splice(0)) {
|
|
42
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("countMarkers", () => {
|
|
47
|
+
it("counts markers per rule across the module surface", () => {
|
|
48
|
+
const root = appRoot({ "a/x.tsx": MARKED, "b/y.tsx": MARKED });
|
|
49
|
+
expect(countMarkers(root)).toEqual({ "terp-allow-token-styled-elements": 2 });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("counts custom terp rule markers for the same budget ratchet", () => {
|
|
53
|
+
const marked = "// terp-allow-no-unsafe-target-blank: vendor opener handshake\nexport const W = 1;\n";
|
|
54
|
+
const root = appRoot({ "a/x.tsx": marked });
|
|
55
|
+
expect(countMarkers(root)).toEqual({ "terp-allow-no-unsafe-target-blank": 1 });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("is empty for an app with no modules directory", () => {
|
|
59
|
+
const root = path.join(scratchRoot, `case-${rootCounter++}`);
|
|
60
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
61
|
+
fs.mkdirSync(root, { recursive: true });
|
|
62
|
+
roots.push(root);
|
|
63
|
+
expect(countMarkers(root)).toEqual({});
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("checkBudget", () => {
|
|
68
|
+
it("passes when usage matches the budget exactly", () => {
|
|
69
|
+
const root = appRoot({ "a/x.tsx": MARKED }, '{ "terp-allow-token-styled-elements": 1 }');
|
|
70
|
+
expect(check(root)).toEqual([]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("fails when a marker rose above its budget", () => {
|
|
74
|
+
const root = appRoot(
|
|
75
|
+
{ "a/x.tsx": MARKED, "b/y.tsx": MARKED },
|
|
76
|
+
'{ "terp-allow-token-styled-elements": 1 }',
|
|
77
|
+
);
|
|
78
|
+
expect(check(root).join("\n")).toMatch(/rose to 2/);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("fails when a marker dropped below its budget (lock in the win)", () => {
|
|
82
|
+
const root = appRoot({}, '{ "terp-allow-token-styled-elements": 1 }');
|
|
83
|
+
expect(check(root).join("\n")).toMatch(/dropped to 0/);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("fails on an unbudgeted marker", () => {
|
|
87
|
+
const root = appRoot({ "a/x.tsx": MARKED }, "{}");
|
|
88
|
+
expect(check(root).join("\n")).toMatch(/unbudgeted marker/);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("fails closed on a missing budget file", () => {
|
|
92
|
+
const root = appRoot({ "a/x.tsx": MARKED });
|
|
93
|
+
expect(check(root).join("\n")).toMatch(/budget file not found/);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("fails closed on invalid budget JSON", () => {
|
|
97
|
+
const root = appRoot({}, "not json");
|
|
98
|
+
expect(check(root).join("\n")).toMatch(/not valid JSON/);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("fails closed on a non-object budget", () => {
|
|
102
|
+
const root = appRoot({}, '["terp-allow-x"]');
|
|
103
|
+
expect(check(root).join("\n")).toMatch(/must be a JSON object/);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("refuses a marker or budget key that names no governed rule", () => {
|
|
107
|
+
// A typo, a stale name, or the governance rule's own name can never be
|
|
108
|
+
// budgeted into legitimacy — refused on both sides of the ledger.
|
|
109
|
+
const marked = "// terp-allow-made-up-rule: stale\nexport const W = 1;\n";
|
|
110
|
+
const root = appRoot(
|
|
111
|
+
{ "a/x.tsx": marked },
|
|
112
|
+
'{ "terp-allow-made-up-rule": 1, "terp-allow-escape-hatch": 1 }',
|
|
113
|
+
);
|
|
114
|
+
const problems = check(root).join("\n");
|
|
115
|
+
expect(problems).toMatch(/'terp-allow-made-up-rule' names no rule with a governed opt-out/);
|
|
116
|
+
expect(problems).toMatch(/'terp-allow-escape-hatch' names no rule with a governed opt-out/);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("does not count marker-shaped text inside a string literal", () => {
|
|
120
|
+
const viaString = 'export const doc = "// terp-allow-token-styled-elements: in a string";\n';
|
|
121
|
+
const root = appRoot({ "a/x.tsx": viaString }, "{}");
|
|
122
|
+
expect(countMarkers(root)).toEqual({});
|
|
123
|
+
expect(check(root)).toEqual([]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("surfaces an expired review-by date on the marker's own file:line", () => {
|
|
127
|
+
// The spec's escape-hatch contract: a reason MAY carry review-by:<YYYY-MM-DD>;
|
|
128
|
+
// a toolchain SHOULD surface expired dates — so a long-lived opt-out is
|
|
129
|
+
// re-justified on schedule instead of staying silently eternal.
|
|
130
|
+
const marked =
|
|
131
|
+
"// terp-allow-token-styled-elements: host quirk owner:web ticket:APP-9 review-by:2026-01-31\n" +
|
|
132
|
+
"export const W = 1;\n";
|
|
133
|
+
const root = appRoot({ "a/x.tsx": marked }, '{ "terp-allow-token-styled-elements": 1 }');
|
|
134
|
+
const budgetPath = path.join(root, "escape-hatch-budget.json");
|
|
135
|
+
const problems = checkBudget(root, budgetPath, new Date("2026-02-01T12:00:00Z"));
|
|
136
|
+
expect(problems).toHaveLength(1);
|
|
137
|
+
expect(problems[0]).toMatch(/'terp-allow-token-styled-elements' at src\/modules\/a\/x\.tsx:1/);
|
|
138
|
+
expect(problems[0]).toMatch(/review-by:2026-01-31/);
|
|
139
|
+
expect(problems[0]).toMatch(/re-justify/);
|
|
140
|
+
// Due today = not yet passed; a future date is simply fine.
|
|
141
|
+
expect(checkBudget(root, budgetPath, new Date("2026-01-31T23:59:00Z"))).toEqual([]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("treats review-by as a convention, not a gate", () => {
|
|
145
|
+
// Reasons without the token are never rejected (the spec's MUST NOT), and
|
|
146
|
+
// a malformed date is not a well-formed token — neither fires, ever.
|
|
147
|
+
const marked =
|
|
148
|
+
"// terp-allow-token-styled-elements: no token here\nexport const A = 1;\n";
|
|
149
|
+
const broken =
|
|
150
|
+
"// terp-allow-token-styled-elements: review-by:2020-13-45 broken\n" +
|
|
151
|
+
"// terp-allow-token-styled-elements: review-by:someday prose\n" +
|
|
152
|
+
"export const B = 1;\n";
|
|
153
|
+
const root = appRoot(
|
|
154
|
+
{ "a/x.tsx": marked, "b/y.tsx": broken },
|
|
155
|
+
'{ "terp-allow-token-styled-elements": 3 }',
|
|
156
|
+
);
|
|
157
|
+
const budgetPath = path.join(root, "escape-hatch-budget.json");
|
|
158
|
+
expect(checkBudget(root, budgetPath, new Date("2030-01-01T00:00:00Z"))).toEqual([]);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe("terp-boundaries-budget --format json (the findings envelope)", () => {
|
|
163
|
+
const BUDGET_BIN = fileURLToPath(new URL("./budget.js", import.meta.url));
|
|
164
|
+
|
|
165
|
+
function runBin(root, args = []) {
|
|
166
|
+
return spawnSync(process.execPath, [BUDGET_BIN, ...args], { cwd: root, encoding: "utf8" });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
it("attributes drift to frontend/escape-hatch on stdout; humans keep stderr", () => {
|
|
170
|
+
const root = appRoot({ "a/x.tsx": MARKED }, "{}");
|
|
171
|
+
const run = runBin(root, ["--format", "json"]);
|
|
172
|
+
expect(run.status).toBe(1);
|
|
173
|
+
const envelope = JSON.parse(run.stdout);
|
|
174
|
+
expect(envelope.terp_findings).toBe(1);
|
|
175
|
+
expect(envelope.tool).toBe("terp-boundaries-budget");
|
|
176
|
+
expect(envelope.rules).toEqual(["frontend/escape-hatch"]);
|
|
177
|
+
expect(envelope.findings).toHaveLength(1);
|
|
178
|
+
expect(envelope.findings[0].rule).toBe("frontend/escape-hatch");
|
|
179
|
+
expect(envelope.findings[0].path).toBe("escape-hatch-budget.json");
|
|
180
|
+
expect(envelope.findings[0].message).toMatch(/unbudgeted marker/);
|
|
181
|
+
expect(run.stderr).toMatch(/unbudgeted marker/);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("publishes an empty findings list when the budget matches (exit 0)", () => {
|
|
185
|
+
const root = appRoot({ "a/x.tsx": MARKED }, '{ "terp-allow-token-styled-elements": 1 }');
|
|
186
|
+
const run = runBin(root, ["--format", "json"]);
|
|
187
|
+
expect(run.status).toBe(0);
|
|
188
|
+
const envelope = JSON.parse(run.stdout);
|
|
189
|
+
expect(envelope.rules).toEqual(["frontend/escape-hatch"]);
|
|
190
|
+
expect(envelope.findings).toEqual([]);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("still reads a positional budget path alongside the flag", () => {
|
|
194
|
+
const root = appRoot({ "a/x.tsx": MARKED });
|
|
195
|
+
fs.writeFileSync(path.join(root, "other-budget.json"), "{}");
|
|
196
|
+
const run = runBin(root, ["--format", "json", "other-budget.json"]);
|
|
197
|
+
expect(run.status).toBe(1);
|
|
198
|
+
expect(JSON.parse(run.stdout).findings[0].path).toBe("other-budget.json");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("refuses an unsupported format (fail closed, exit 2)", () => {
|
|
202
|
+
const root = appRoot({}, "{}");
|
|
203
|
+
const run = runBin(root, ["--format", "yaml"]);
|
|
204
|
+
expect(run.status).toBe(2);
|
|
205
|
+
expect(run.stderr).toMatch(/unsupported --format/);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The frontend corpus harness: lint a Terp Standard corpus case into spec-shaped
|
|
3
|
+
* findings, exactly as an app's own boundary lint would see it.
|
|
4
|
+
*
|
|
5
|
+
* Shared by the certification test (corpus.test.js) and the scorecard emitter
|
|
6
|
+
* (scorecard.js), so the two can never apply different corpus semantics. The spec
|
|
7
|
+
* is resolved as a declared dependency (@terp/spec, ADR 0082) by the CALLER —
|
|
8
|
+
* this module takes paths, never resolves the spec itself.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
|
|
14
|
+
import { ESLint } from "eslint";
|
|
15
|
+
|
|
16
|
+
import terpBoundaries, { LAYOUT_CONTRACT_FILE, catalogRuleId } from "./index.js";
|
|
17
|
+
|
|
18
|
+
/** Every file below *caseDir*, recursively. */
|
|
19
|
+
export function caseFiles(caseDir) {
|
|
20
|
+
const files = [];
|
|
21
|
+
const walk = (dir) => {
|
|
22
|
+
for (const item of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
23
|
+
const full = path.join(dir, item.name);
|
|
24
|
+
if (item.isDirectory()) walk(full);
|
|
25
|
+
else files.push(full);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
walk(caseDir);
|
|
29
|
+
return files;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A case that ships a `layout-contract.json` at its root has opted into that layout
|
|
34
|
+
* contract (exactly as a real app does, ADR 0079). The harness lints with virtual file
|
|
35
|
+
* paths, so the rule's on-disk upward search cannot see the case's config; it is passed
|
|
36
|
+
* through the rule's `contract` option instead — the same activation, spelled explicitly.
|
|
37
|
+
*/
|
|
38
|
+
export function caseLayoutContract(caseDir) {
|
|
39
|
+
const file = path.join(caseDir, LAYOUT_CONTRACT_FILE);
|
|
40
|
+
if (!fs.existsSync(file)) return null;
|
|
41
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
42
|
+
return typeof parsed.contract === "string" ? parsed.contract : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Lint a corpus case into spec-shaped findings (`findings.schema.json`): rule/path/line/message. */
|
|
46
|
+
export async function lintCaseFindings(caseDir) {
|
|
47
|
+
const contract = caseLayoutContract(caseDir);
|
|
48
|
+
const overrideConfig =
|
|
49
|
+
contract === null
|
|
50
|
+
? terpBoundaries
|
|
51
|
+
: [
|
|
52
|
+
...terpBoundaries,
|
|
53
|
+
{
|
|
54
|
+
files: ["**/modules/**/*.{ts,tsx}"],
|
|
55
|
+
rules: { "terp/layout-contract": ["error", { contract }] },
|
|
56
|
+
},
|
|
57
|
+
];
|
|
58
|
+
const eslint = new ESLint({ overrideConfigFile: true, overrideConfig });
|
|
59
|
+
const findings = [];
|
|
60
|
+
for (const file of caseFiles(caseDir)) {
|
|
61
|
+
if (!/\.tsx?$/.test(file)) continue; // config carriers (layout-contract.json) are not linted
|
|
62
|
+
// Resolve under the cwd so the config's `files` globs match the module path.
|
|
63
|
+
const filePath = path.resolve(path.relative(caseDir, file));
|
|
64
|
+
const [result] = await eslint.lintText(fs.readFileSync(file, "utf8"), { filePath });
|
|
65
|
+
findings.push(
|
|
66
|
+
...result.messages.map((message) => ({
|
|
67
|
+
rule: catalogRuleId(message),
|
|
68
|
+
path: path.relative(caseDir, file).split(path.sep).join("/"),
|
|
69
|
+
line: message.line,
|
|
70
|
+
message: message.message,
|
|
71
|
+
})),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
return findings;
|
|
75
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ESLint adapter is held to the Terp Standard's violation corpus (ADR 0080).
|
|
3
|
+
*
|
|
4
|
+
* `spec/corpus/frontend/<rule>/` holds violating and compliant sample module files — the
|
|
5
|
+
* executable meaning of each frontend catalog entry. This test lints every case with the real
|
|
6
|
+
* boundary config and applies the corpus contract in terms of the **stack-neutral catalog id**
|
|
7
|
+
* (findings are attributed through the adapter's published {@link catalogRuleId} mapping, never
|
|
8
|
+
* the raw ESLint rule id — several catalog rules share a core rule id):
|
|
9
|
+
*
|
|
10
|
+
* - every `violation-*` case produces at least one finding attributed to the entry's catalog id;
|
|
11
|
+
* - every `compliant-*` case is completely clean (no messages at all).
|
|
12
|
+
*
|
|
13
|
+
* The same corpus certifies any future stack's adapter (e.g. a Svelte realisation of
|
|
14
|
+
* BOUNDARY_SPEC). The backend half lives in `tests/architecture/test_spec_corpus.py`.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import fs from "node:fs";
|
|
18
|
+
import { createRequire } from "node:module";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
|
|
21
|
+
import { describe, expect, it } from "vitest";
|
|
22
|
+
|
|
23
|
+
import { lintCaseFindings } from "./corpus-harness.js";
|
|
24
|
+
|
|
25
|
+
// The spec is a declared dependency (@terp/spec, ADR 0082), never a repo-relative path —
|
|
26
|
+
// inside the monorepo it resolves to the workspace member; after a repo split, to the pin.
|
|
27
|
+
const SPEC_ROOT = path.dirname(
|
|
28
|
+
createRequire(import.meta.url).resolve("@terp/spec/package.json"),
|
|
29
|
+
);
|
|
30
|
+
const CATALOG = path.join(SPEC_ROOT, "catalog", "frontend");
|
|
31
|
+
const CORPUS = path.join(SPEC_ROOT, "corpus", "frontend");
|
|
32
|
+
|
|
33
|
+
const entries = fs
|
|
34
|
+
.readdirSync(CATALOG)
|
|
35
|
+
.filter((name) => name.endsWith(".json"))
|
|
36
|
+
.map((name) => JSON.parse(fs.readFileSync(path.join(CATALOG, name), "utf8")))
|
|
37
|
+
.filter((entry) => entry.corpus);
|
|
38
|
+
|
|
39
|
+
async function lintCase(caseDir) {
|
|
40
|
+
return (await lintCaseFindings(caseDir)).map((finding) => finding.rule);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("frontend corpus (spec/corpus/frontend)", () => {
|
|
44
|
+
for (const entry of entries) {
|
|
45
|
+
const rule = entry.id.split("/")[1];
|
|
46
|
+
const ruleDir = path.join(CORPUS, rule);
|
|
47
|
+
for (const caseName of fs.readdirSync(ruleDir).sort()) {
|
|
48
|
+
if (caseName.startsWith("violation-")) {
|
|
49
|
+
it(`${rule}/${caseName} is attributed to ${entry.id}`, async () => {
|
|
50
|
+
expect(await lintCase(path.join(ruleDir, caseName))).toContain(entry.id);
|
|
51
|
+
});
|
|
52
|
+
} else {
|
|
53
|
+
it(`${rule}/${caseName} is completely clean`, async () => {
|
|
54
|
+
expect(await lintCase(path.join(ruleDir, caseName))).toEqual([]);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("findings round-trip (spec/findings.schema.json)", () => {
|
|
62
|
+
// The reference adapter's output, rendered as spec findings, must validate against the
|
|
63
|
+
// published finding format (ADR 0081) — the contract a Level 2 checker is certified on.
|
|
64
|
+
const schema = JSON.parse(
|
|
65
|
+
fs.readFileSync(path.join(SPEC_ROOT, "findings.schema.json"), "utf8"),
|
|
66
|
+
);
|
|
67
|
+
const item = schema.items;
|
|
68
|
+
const rulePattern = new RegExp(item.properties.rule.pattern);
|
|
69
|
+
|
|
70
|
+
it("every violation-case finding conforms to the published finding format", async () => {
|
|
71
|
+
const findings = [];
|
|
72
|
+
for (const entry of entries) {
|
|
73
|
+
const ruleDir = path.join(CORPUS, entry.id.split("/")[1]);
|
|
74
|
+
for (const caseName of fs.readdirSync(ruleDir).sort()) {
|
|
75
|
+
if (!caseName.startsWith("violation-")) continue;
|
|
76
|
+
findings.push(...(await lintCaseFindings(path.join(ruleDir, caseName))));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
expect(findings.length).toBeGreaterThan(0);
|
|
80
|
+
for (const finding of findings) {
|
|
81
|
+
for (const field of item.required) expect(finding).toHaveProperty(field);
|
|
82
|
+
for (const field of Object.keys(finding)) {
|
|
83
|
+
expect(Object.keys(item.properties)).toContain(field); // additionalProperties: false
|
|
84
|
+
}
|
|
85
|
+
expect(finding.rule).toMatch(rulePattern); // attributed to a catalog id, never a tool id
|
|
86
|
+
expect(finding.path).not.toContain("\\"); // forward slashes, relative to the tree root
|
|
87
|
+
expect(Number.isInteger(finding.line)).toBe(true);
|
|
88
|
+
expect(finding.line).toBeGreaterThanOrEqual(item.properties.line.minimum);
|
|
89
|
+
expect(finding.message.trim()).not.toBe("");
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
});
|