@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/src/findings.js
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The machine-readable boundary lint — the frontend analog of `terp check --format json`.
|
|
4
|
+
*
|
|
5
|
+
* `terp-boundaries-lint` runs the app's own ESLint config (the flat config in the cwd,
|
|
6
|
+
* exactly what `eslint .` would load) **and** the escape-hatch budget ratchet (the same
|
|
7
|
+
* check `terp-boundaries-budget` runs) in one command, and publishes one **findings
|
|
8
|
+
* envelope** on stdout:
|
|
9
|
+
*
|
|
10
|
+
* { "terp_findings": 1, "tool": "@terp/eslint-boundaries",
|
|
11
|
+
* "rules": ["frontend/<rule>", …], // every catalog rule this run evaluated
|
|
12
|
+
* "not_applicable": ["frontend/<rule>", …], // opt-in rules this app has not enabled
|
|
13
|
+
* "findings": [{ rule, path, line, message }, …], // spec findings.schema.json shape
|
|
14
|
+
* "unattributed": [{ path, line, message, reported_as }, …] }
|
|
15
|
+
*
|
|
16
|
+
* `rules` is the evaluated-rule inventory ({@link catalogRuleIds}, minus the opt-in
|
|
17
|
+
* rules listed under `not_applicable` — today `frontend/layout-contract` when the app
|
|
18
|
+
* has no checked-in layout-contract.json, so a consumer never renders an unenforced
|
|
19
|
+
* rule as passing). `findings` are the reported messages attributed to their
|
|
20
|
+
* stack-neutral catalog ids through the adapter's published {@link catalogRuleId}
|
|
21
|
+
* mapping, plus any budget drift attributed to `frontend/escape-hatch`. A message
|
|
22
|
+
* outside the boundary (another configured rule, a parse error) lands in
|
|
23
|
+
* `unattributed` — surfaced, never dropped. The human-readable report goes to stderr,
|
|
24
|
+
* so `npm run lint` failures stay legible while a driving tool (the Studio's gate, a
|
|
25
|
+
* CI annotator) parses stdout.
|
|
26
|
+
*
|
|
27
|
+
* Both halves ALWAYS run — a boundary violation cannot skip the budget ratchet the way
|
|
28
|
+
* an `eslint . && terp-boundaries-budget` chain could — and the exit code is the
|
|
29
|
+
* combined verdict (non-zero when either half failed). An optional positional argument
|
|
30
|
+
* names the budget file (default `escape-hatch-budget.json`, as the budget bin). *
|
|
31
|
+
* `--format check-report` prints the Terp Standard **check report** instead
|
|
32
|
+
* (`app-check-report.schema.json`: the same inventory and findings, self-described with
|
|
33
|
+
* `spec_version`, the checker identity, and the run verdict) — still exactly one JSON
|
|
34
|
+
* document on stdout. The default stays the legacy `terp_findings` envelope, a published
|
|
35
|
+
* seam (ADR 0083) existing consumers parse strictly. */
|
|
36
|
+
|
|
37
|
+
import fs from "node:fs";
|
|
38
|
+
import path from "node:path";
|
|
39
|
+
import process from "node:process";
|
|
40
|
+
import { pathToFileURL } from "node:url";
|
|
41
|
+
|
|
42
|
+
import { ESLint } from "eslint";
|
|
43
|
+
|
|
44
|
+
import { checkBudget } from "./budget.js";
|
|
45
|
+
import { activeLayoutContract, catalogRuleId, catalogRuleIds } from "./index.js";
|
|
46
|
+
import { SPEC_VERSION } from "./spec.js";
|
|
47
|
+
|
|
48
|
+
/** This package's own version — the `checker.version` a check report carries. */
|
|
49
|
+
const PACKAGE_VERSION = JSON.parse(
|
|
50
|
+
fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"),
|
|
51
|
+
).version;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Render a findings envelope as the Terp Standard **check report**
|
|
55
|
+
* (`app-check-report.schema.json`, printed by `--format check-report`): the same
|
|
56
|
+
* inventory and findings, self-described with the spec version the rule ids resolve
|
|
57
|
+
* against, the checker identity, and the run verdict — so a consumer joins per-rule
|
|
58
|
+
* verdicts to the catalog without knowing this toolchain. `unattributed.reported_as`
|
|
59
|
+
* is omitted (never null) per the schema; `ok` covers the standard's own findings
|
|
60
|
+
* (the process exit code stays the combined verdict, unattributed errors included).
|
|
61
|
+
*/
|
|
62
|
+
export function asCheckReport(envelope) {
|
|
63
|
+
return {
|
|
64
|
+
terp_check_report: 1,
|
|
65
|
+
spec_version: SPEC_VERSION,
|
|
66
|
+
checker: { tool: envelope.tool, version: PACKAGE_VERSION },
|
|
67
|
+
ok: envelope.findings.length === 0,
|
|
68
|
+
rules: envelope.rules,
|
|
69
|
+
not_applicable: envelope.not_applicable,
|
|
70
|
+
findings: envelope.findings,
|
|
71
|
+
unattributed: envelope.unattributed.map(({ path: file, line, message, reported_as }) => ({
|
|
72
|
+
path: file,
|
|
73
|
+
...(Number.isInteger(line) && line >= 1 ? { line } : {}),
|
|
74
|
+
message,
|
|
75
|
+
...(typeof reported_as === "string" && reported_as !== "" ? { reported_as } : {}),
|
|
76
|
+
})),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Render lint *results* (ESLint result objects) as the findings envelope plus the
|
|
82
|
+
* human report lines. Paths are cwd-relative with `/` separators on every OS — the
|
|
83
|
+
* envelope is a machine contract, not display text.
|
|
84
|
+
*
|
|
85
|
+
* Options mirror what the bin derives from the app checkout: `layoutContract`
|
|
86
|
+
* (is the opt-in slot-typed contract active? default: the same upward
|
|
87
|
+
* `layout-contract.json` search the ESLint rule performs from *cwd*),
|
|
88
|
+
* `budgetProblems` (escape-hatch budget drift, appended as
|
|
89
|
+
* `frontend/escape-hatch` findings) and `budgetFile` (the path those findings cite).
|
|
90
|
+
*/
|
|
91
|
+
export function renderEnvelope(results, cwd = process.cwd(), options = {}) {
|
|
92
|
+
const {
|
|
93
|
+
layoutContract = activeLayoutContract(cwd) !== null,
|
|
94
|
+
budgetProblems = [],
|
|
95
|
+
budgetFile = "escape-hatch-budget.json",
|
|
96
|
+
} = options;
|
|
97
|
+
const findings = [];
|
|
98
|
+
const unattributed = [];
|
|
99
|
+
const human = [];
|
|
100
|
+
for (const result of results) {
|
|
101
|
+
const relative = path.relative(cwd, result.filePath).split(path.sep).join("/");
|
|
102
|
+
const file = relative === "" ? result.filePath.split(path.sep).join("/") : relative;
|
|
103
|
+
for (const message of result.messages) {
|
|
104
|
+
const rule = catalogRuleId(message);
|
|
105
|
+
const line = Number.isInteger(message.line) && message.line > 0 ? message.line : 1;
|
|
106
|
+
if (rule === null) {
|
|
107
|
+
unattributed.push({
|
|
108
|
+
path: file,
|
|
109
|
+
line,
|
|
110
|
+
message: message.message,
|
|
111
|
+
reported_as: message.ruleId ?? null,
|
|
112
|
+
});
|
|
113
|
+
} else {
|
|
114
|
+
findings.push({ rule, path: file, line, message: message.message });
|
|
115
|
+
}
|
|
116
|
+
human.push(
|
|
117
|
+
`${file}:${line}:${message.column ?? 1} ${message.message} [${rule ?? message.ruleId ?? "parse"}]`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
for (const problem of budgetProblems) {
|
|
122
|
+
findings.push({ rule: "frontend/escape-hatch", path: budgetFile, message: problem });
|
|
123
|
+
human.push(`${budgetFile} ${problem} [frontend/escape-hatch]`);
|
|
124
|
+
}
|
|
125
|
+
// An opt-in rule the app has not enabled is published as not-applicable — never
|
|
126
|
+
// silently kept in `rules`, where "evaluated, zero findings" would read as passing.
|
|
127
|
+
const notApplicable = layoutContract ? [] : ["frontend/layout-contract"];
|
|
128
|
+
return {
|
|
129
|
+
envelope: {
|
|
130
|
+
terp_findings: 1,
|
|
131
|
+
tool: "@terp/eslint-boundaries",
|
|
132
|
+
rules: catalogRuleIds().filter((id) => !notApplicable.includes(id)),
|
|
133
|
+
not_applicable: notApplicable,
|
|
134
|
+
findings,
|
|
135
|
+
unattributed,
|
|
136
|
+
},
|
|
137
|
+
human,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function main() {
|
|
142
|
+
const cwd = process.cwd();
|
|
143
|
+
const args = process.argv.slice(2);
|
|
144
|
+
// `--format check-report` prints the Terp Standard check report
|
|
145
|
+
// (app-check-report.schema.json) instead of the legacy terp_findings envelope —
|
|
146
|
+
// still exactly ONE JSON document on stdout, so strict consumers keep a single
|
|
147
|
+
// parse. The default stays the legacy envelope (a published seam, ADR 0083).
|
|
148
|
+
let format = "findings";
|
|
149
|
+
const formatIndex = args.indexOf("--format");
|
|
150
|
+
if (formatIndex !== -1) {
|
|
151
|
+
const value = args[formatIndex + 1];
|
|
152
|
+
if (value !== "findings" && value !== "check-report") {
|
|
153
|
+
process.stderr.write(
|
|
154
|
+
`terp-boundaries-lint: unsupported --format ${value ?? "(missing)"}; ` +
|
|
155
|
+
"expected findings or check-report\n",
|
|
156
|
+
);
|
|
157
|
+
process.exit(2);
|
|
158
|
+
}
|
|
159
|
+
format = value;
|
|
160
|
+
args.splice(formatIndex, 2);
|
|
161
|
+
}
|
|
162
|
+
const budgetPath = args[0] ?? path.join(cwd, "escape-hatch-budget.json");
|
|
163
|
+
// The app's own config and ignore set, with the same cache the plain CLI used
|
|
164
|
+
// (`--cache --cache-location node_modules/.cache/eslint/`).
|
|
165
|
+
const eslint = new ESLint({ cache: true, cacheLocation: "node_modules/.cache/eslint/" });
|
|
166
|
+
const results = await eslint.lintFiles(["."]);
|
|
167
|
+
// The ratchet runs regardless of the lint verdict — both halves always report.
|
|
168
|
+
const budgetProblems = checkBudget(cwd, budgetPath);
|
|
169
|
+
const relativeBudget = path.relative(cwd, budgetPath).split(path.sep).join("/");
|
|
170
|
+
const { envelope, human } = renderEnvelope(results, cwd, {
|
|
171
|
+
budgetProblems,
|
|
172
|
+
budgetFile: relativeBudget === "" ? budgetPath.split(path.sep).join("/") : relativeBudget,
|
|
173
|
+
});
|
|
174
|
+
if (human.length > 0) {
|
|
175
|
+
process.stderr.write(
|
|
176
|
+
`${human.join("\n")}\n${human.length} problem(s); the findings envelope is on stdout.\n`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
const document = format === "check-report" ? asCheckReport(envelope) : envelope;
|
|
180
|
+
process.stdout.write(`${JSON.stringify(document)}\n`);
|
|
181
|
+
const errors =
|
|
182
|
+
results.reduce((sum, result) => sum + result.errorCount, 0) + budgetProblems.length;
|
|
183
|
+
process.exitCode = errors > 0 ? 1 : 0;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Run main() only when invoked as a CLI (directly or via the npm bin symlink), not on import.
|
|
187
|
+
const entry = process.argv[1] ? pathToFileURL(fs.realpathSync(process.argv[1])).href : "";
|
|
188
|
+
if (entry === import.meta.url) {
|
|
189
|
+
main().catch((error) => {
|
|
190
|
+
// The lint could not run at all (no config, ESLint crash) — distinct from "ran and failed".
|
|
191
|
+
console.error(String(error));
|
|
192
|
+
process.exitCode = 2;
|
|
193
|
+
});
|
|
194
|
+
}
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
import { ESLint } from "eslint";
|
|
8
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
9
|
+
|
|
10
|
+
import terpBoundaries, { catalogRuleIds } from "./index.js";
|
|
11
|
+
import { asCheckReport, renderEnvelope } from "./findings.js";
|
|
12
|
+
import { SPEC_VERSION } from "./spec.js";
|
|
13
|
+
|
|
14
|
+
// The machine-readable boundary lint (the frontend analog of `terp check --format json`):
|
|
15
|
+
// the findings envelope publishes the evaluated-rule inventory + findings attributed to
|
|
16
|
+
// stack-neutral catalog ids, humans keep stderr, and the exit code stays the verdict.
|
|
17
|
+
|
|
18
|
+
const SPEC_ROOT = path.dirname(
|
|
19
|
+
createRequire(import.meta.url).resolve("@terp/spec/package.json"),
|
|
20
|
+
);
|
|
21
|
+
const FINDINGS_BIN = fileURLToPath(new URL("./findings.js", import.meta.url));
|
|
22
|
+
|
|
23
|
+
const roots = [];
|
|
24
|
+
const scratchRoot = path.resolve("node_modules/.cache/terp-findings-tests");
|
|
25
|
+
let rootCounter = 0;
|
|
26
|
+
|
|
27
|
+
function appRoot(files) {
|
|
28
|
+
const root = path.join(scratchRoot, `case-${rootCounter++}`);
|
|
29
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
30
|
+
fs.mkdirSync(root, { recursive: true });
|
|
31
|
+
roots.push(root);
|
|
32
|
+
for (const [relative, text] of Object.entries(files)) {
|
|
33
|
+
const full = path.join(root, relative);
|
|
34
|
+
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
35
|
+
fs.writeFileSync(full, text);
|
|
36
|
+
}
|
|
37
|
+
return root;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
for (const root of roots.splice(0)) {
|
|
42
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
async function lintModule(text) {
|
|
47
|
+
const eslint = new ESLint({ overrideConfigFile: true, overrideConfig: terpBoundaries });
|
|
48
|
+
const filePath = path.resolve("src/modules/sample/View.tsx");
|
|
49
|
+
return eslint.lintText(text, { filePath });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe("catalogRuleIds (the evaluated-rule inventory)", () => {
|
|
53
|
+
it("matches the Terp Standard frontend catalog exactly, both directions", () => {
|
|
54
|
+
// The inventory can't lie: every catalog entry is evaluated, and no evaluated id
|
|
55
|
+
// outlives its catalog entry (the same parity discipline as test_spec_catalog).
|
|
56
|
+
const catalogued = fs
|
|
57
|
+
.readdirSync(path.join(SPEC_ROOT, "catalog", "frontend"))
|
|
58
|
+
.filter((name) => name.endsWith(".json"))
|
|
59
|
+
.map((name) => `frontend/${name.replace(/\.json$/, "")}`)
|
|
60
|
+
.sort();
|
|
61
|
+
expect(catalogRuleIds()).toEqual(catalogued);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("renderEnvelope", () => {
|
|
66
|
+
it("attributes findings to catalog ids and publishes the inventory", async () => {
|
|
67
|
+
const results = await lintModule(
|
|
68
|
+
'export function View() {\n return <button style={{ color: "#fff" }}>x</button>;\n}\n',
|
|
69
|
+
);
|
|
70
|
+
const { envelope, human } = renderEnvelope(results, path.resolve("."), {
|
|
71
|
+
layoutContract: true,
|
|
72
|
+
});
|
|
73
|
+
expect(envelope.terp_findings).toBe(1);
|
|
74
|
+
expect(envelope.tool).toBe("@terp/eslint-boundaries");
|
|
75
|
+
expect(envelope.rules).toEqual(catalogRuleIds());
|
|
76
|
+
expect(envelope.not_applicable).toEqual([]);
|
|
77
|
+
const rules = envelope.findings.map((finding) => finding.rule);
|
|
78
|
+
expect(rules).toContain("frontend/token-styled-elements");
|
|
79
|
+
expect(rules).toContain("frontend/no-inline-styling");
|
|
80
|
+
expect(envelope.unattributed).toEqual([]);
|
|
81
|
+
expect(human.length).toBe(envelope.findings.length);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("publishes an un-opted-in layout contract as not_applicable, never as passing", async () => {
|
|
85
|
+
// The opt-in rule is inert without a checked-in layout-contract.json; keeping it
|
|
86
|
+
// in `rules` would let a consumer render "evaluated, zero findings" = green for a
|
|
87
|
+
// rule that never ran. It moves to `not_applicable` instead.
|
|
88
|
+
const results = await lintModule("export const view = 1;\n");
|
|
89
|
+
const { envelope } = renderEnvelope(results, path.resolve("."), { layoutContract: false });
|
|
90
|
+
expect(envelope.not_applicable).toEqual(["frontend/layout-contract"]);
|
|
91
|
+
expect(envelope.rules).toEqual(
|
|
92
|
+
catalogRuleIds().filter((id) => id !== "frontend/layout-contract"),
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("appends budget drift as frontend/escape-hatch findings", () => {
|
|
97
|
+
const { envelope, human } = renderEnvelope([], path.resolve("."), {
|
|
98
|
+
layoutContract: true,
|
|
99
|
+
budgetProblems: ["unbudgeted marker 'terp-allow-no-eval' used 1 time(s)"],
|
|
100
|
+
budgetFile: "escape-hatch-budget.json",
|
|
101
|
+
});
|
|
102
|
+
expect(envelope.findings).toEqual([
|
|
103
|
+
{
|
|
104
|
+
rule: "frontend/escape-hatch",
|
|
105
|
+
path: "escape-hatch-budget.json",
|
|
106
|
+
message: "unbudgeted marker 'terp-allow-no-eval' used 1 time(s)",
|
|
107
|
+
},
|
|
108
|
+
]);
|
|
109
|
+
expect(human).toHaveLength(1);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("emits spec-shaped findings (findings.schema.json), separator-stable", async () => {
|
|
113
|
+
const schema = JSON.parse(
|
|
114
|
+
fs.readFileSync(path.join(SPEC_ROOT, "findings.schema.json"), "utf8"),
|
|
115
|
+
);
|
|
116
|
+
const item = schema.items;
|
|
117
|
+
const results = await lintModule("export const x = fetch('/api');\n");
|
|
118
|
+
const { envelope } = renderEnvelope(results, path.resolve("."));
|
|
119
|
+
expect(envelope.findings.length).toBeGreaterThan(0);
|
|
120
|
+
for (const finding of envelope.findings) {
|
|
121
|
+
expect(Object.keys(finding).sort()).toEqual(["line", "message", "path", "rule"]);
|
|
122
|
+
expect(finding.rule).toMatch(new RegExp(item.properties.rule.pattern));
|
|
123
|
+
expect(finding.path).toBe("src/modules/sample/View.tsx");
|
|
124
|
+
expect(finding.path).not.toContain("\\");
|
|
125
|
+
expect(Number.isInteger(finding.line)).toBe(true);
|
|
126
|
+
expect(finding.line).toBeGreaterThanOrEqual(1);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("surfaces a non-boundary message as unattributed, never dropped", async () => {
|
|
131
|
+
// A parse error has no boundary attribution; it must stay visible in the envelope.
|
|
132
|
+
const results = await lintModule("export const = broken(\n");
|
|
133
|
+
const { envelope } = renderEnvelope(results, path.resolve("."));
|
|
134
|
+
expect(envelope.findings).toEqual([]);
|
|
135
|
+
expect(envelope.unattributed.length).toBeGreaterThan(0);
|
|
136
|
+
for (const entry of envelope.unattributed) {
|
|
137
|
+
expect(Object.keys(entry).sort()).toEqual(["line", "message", "path", "reported_as"]);
|
|
138
|
+
expect(entry.line).toBeGreaterThanOrEqual(1);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("terp-boundaries-lint (the bin)", () => {
|
|
144
|
+
const config =
|
|
145
|
+
'import terpBoundaries from "@terpjs/eslint-boundaries";\n' +
|
|
146
|
+
'export default [{ ignores: ["node_modules/**"] }, ...terpBoundaries];\n';
|
|
147
|
+
|
|
148
|
+
function runBin(root) {
|
|
149
|
+
return spawnSync(process.execPath, [FINDINGS_BIN], { cwd: root, encoding: "utf8" });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
it("publishes the envelope on stdout, humans on stderr, verdict as exit code", () => {
|
|
153
|
+
const root = appRoot({
|
|
154
|
+
"package.json": '{ "type": "module" }',
|
|
155
|
+
"eslint.config.js": config,
|
|
156
|
+
"escape-hatch-budget.json": "{}",
|
|
157
|
+
"src/modules/sample/View.tsx": "export function View() {\n return <button>x</button>;\n}\n",
|
|
158
|
+
});
|
|
159
|
+
const run = runBin(root);
|
|
160
|
+
expect(run.status).toBe(1);
|
|
161
|
+
const envelope = JSON.parse(run.stdout);
|
|
162
|
+
expect(envelope.terp_findings).toBe(1);
|
|
163
|
+
expect(envelope.findings.map((finding) => finding.rule)).toContain(
|
|
164
|
+
"frontend/token-styled-elements",
|
|
165
|
+
);
|
|
166
|
+
expect(envelope.findings[0].path).toBe("src/modules/sample/View.tsx");
|
|
167
|
+
expect(run.stderr).toMatch(/problem/);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("stays green (exit 0) with an empty findings list on a clean app", () => {
|
|
171
|
+
const root = appRoot({
|
|
172
|
+
"package.json": '{ "type": "module" }',
|
|
173
|
+
"eslint.config.js": config,
|
|
174
|
+
"escape-hatch-budget.json": "{}",
|
|
175
|
+
"src/modules/sample/View.tsx": "export const view = 1;\n",
|
|
176
|
+
});
|
|
177
|
+
const run = runBin(root);
|
|
178
|
+
expect(run.status).toBe(0);
|
|
179
|
+
const envelope = JSON.parse(run.stdout);
|
|
180
|
+
expect(envelope.findings).toEqual([]);
|
|
181
|
+
expect(envelope.unattributed).toEqual([]);
|
|
182
|
+
// No layout-contract.json: the opt-in rule is not applicable, never "passing".
|
|
183
|
+
expect(envelope.not_applicable).toEqual(["frontend/layout-contract"]);
|
|
184
|
+
expect(envelope.rules).toEqual(
|
|
185
|
+
catalogRuleIds().filter((id) => id !== "frontend/layout-contract"),
|
|
186
|
+
);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("reports budget drift even when the boundary lint fails (both halves always run)", () => {
|
|
190
|
+
// The regression the merged bin exists for: with `eslint . && terp-boundaries-budget`
|
|
191
|
+
// a boundary violation short-circuited the ratchet, hiding budget drift from the run.
|
|
192
|
+
const root = appRoot({
|
|
193
|
+
"package.json": '{ "type": "module" }',
|
|
194
|
+
"eslint.config.js": config,
|
|
195
|
+
"escape-hatch-budget.json": "{}",
|
|
196
|
+
"src/modules/sample/View.tsx":
|
|
197
|
+
"// terp-allow-no-eval: measured host quirk\n" +
|
|
198
|
+
"export function View() {\n return <button>x</button>;\n}\n",
|
|
199
|
+
});
|
|
200
|
+
const run = runBin(root);
|
|
201
|
+
expect(run.status).toBe(1);
|
|
202
|
+
const rules = JSON.parse(run.stdout).findings.map((finding) => finding.rule);
|
|
203
|
+
expect(rules).toContain("frontend/token-styled-elements"); // the lint half
|
|
204
|
+
expect(rules).toContain("frontend/escape-hatch"); // the ratchet half, not skipped
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("fails closed on a missing budget file, attributed to frontend/escape-hatch", () => {
|
|
208
|
+
const root = appRoot({
|
|
209
|
+
"package.json": '{ "type": "module" }',
|
|
210
|
+
"eslint.config.js": config,
|
|
211
|
+
"src/modules/sample/View.tsx": "export const view = 1;\n",
|
|
212
|
+
});
|
|
213
|
+
const run = runBin(root);
|
|
214
|
+
expect(run.status).toBe(1);
|
|
215
|
+
const envelope = JSON.parse(run.stdout);
|
|
216
|
+
expect(envelope.findings).toHaveLength(1);
|
|
217
|
+
expect(envelope.findings[0].rule).toBe("frontend/escape-hatch");
|
|
218
|
+
expect(envelope.findings[0].message).toMatch(/budget file not found/);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("includes layout-contract in the inventory when the app has opted in", () => {
|
|
222
|
+
const root = appRoot({
|
|
223
|
+
"package.json": '{ "type": "module" }',
|
|
224
|
+
"eslint.config.js": config,
|
|
225
|
+
"escape-hatch-budget.json": "{}",
|
|
226
|
+
"layout-contract.json": '{ "contract": "standard" }',
|
|
227
|
+
"src/modules/sample/View.tsx": "export const view = 1;\n",
|
|
228
|
+
});
|
|
229
|
+
const run = runBin(root);
|
|
230
|
+
expect(run.status).toBe(0);
|
|
231
|
+
const envelope = JSON.parse(run.stdout);
|
|
232
|
+
expect(envelope.rules).toEqual(catalogRuleIds());
|
|
233
|
+
expect(envelope.not_applicable).toEqual([]);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe("the check report (--format check-report, app-check-report.schema.json)", () => {
|
|
238
|
+
const config =
|
|
239
|
+
'import terpBoundaries from "@terpjs/eslint-boundaries";\n' +
|
|
240
|
+
'export default [{ ignores: ["node_modules/**"] }, ...terpBoundaries];\n';
|
|
241
|
+
|
|
242
|
+
function runBin(root, args) {
|
|
243
|
+
return spawnSync(process.execPath, [FINDINGS_BIN, ...args], { cwd: root, encoding: "utf8" });
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
it("asCheckReport self-describes the envelope in the spec's report shape", async () => {
|
|
247
|
+
const schema = JSON.parse(
|
|
248
|
+
fs.readFileSync(path.join(SPEC_ROOT, "app-check-report.schema.json"), "utf8"),
|
|
249
|
+
);
|
|
250
|
+
const results = await lintModule("export const x = fetch('/api');\n");
|
|
251
|
+
const { envelope } = renderEnvelope(results, path.resolve("."), { layoutContract: true });
|
|
252
|
+
const report = asCheckReport(envelope);
|
|
253
|
+
expect(report.terp_check_report).toBe(1);
|
|
254
|
+
// The certified spec version rides every report. Shape only here — the
|
|
255
|
+
// equality lock against the pinned @terp/spec lives in the framework gate
|
|
256
|
+
// (test_check_json.py), because certification runs THIS suite against
|
|
257
|
+
// candidate spec releases whose version is allowed to be newer.
|
|
258
|
+
expect(report.spec_version).toMatch(/^\d+\.\d+\.\d+$/);
|
|
259
|
+
expect(report.checker.tool).toBe("@terp/eslint-boundaries");
|
|
260
|
+
expect(report.checker.version).toMatch(/^\d+\.\d+\.\d+$/);
|
|
261
|
+
expect(report.ok).toBe(false);
|
|
262
|
+
expect(report.rules).toEqual(catalogRuleIds());
|
|
263
|
+
expect(Object.keys(report).sort()).toEqual(
|
|
264
|
+
Object.keys(schema.properties).sort().filter((key) => key !== "error"),
|
|
265
|
+
);
|
|
266
|
+
const itemProperties = new Set(Object.keys(schema.properties.findings.items.properties));
|
|
267
|
+
for (const finding of report.findings) {
|
|
268
|
+
expect(finding.rule).toMatch(
|
|
269
|
+
new RegExp(schema.properties.findings.items.properties.rule.pattern),
|
|
270
|
+
);
|
|
271
|
+
for (const key of Object.keys(finding)) {
|
|
272
|
+
expect(itemProperties.has(key)).toBe(true);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("omits a null reported_as instead of publishing it (the schema forbids null)", async () => {
|
|
278
|
+
const results = await lintModule("export const = broken(\n");
|
|
279
|
+
const { envelope } = renderEnvelope(results, path.resolve("."));
|
|
280
|
+
const report = asCheckReport(envelope);
|
|
281
|
+
expect(report.unattributed.length).toBeGreaterThan(0);
|
|
282
|
+
for (const entry of report.unattributed) {
|
|
283
|
+
expect("reported_as" in entry).toBe(false);
|
|
284
|
+
expect(Object.keys(entry).sort()).toEqual(["line", "message", "path"]);
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("the bin prints exactly one check-report document under --format check-report", () => {
|
|
289
|
+
const root = appRoot({
|
|
290
|
+
"package.json": '{ "type": "module" }',
|
|
291
|
+
"eslint.config.js": config,
|
|
292
|
+
"escape-hatch-budget.json": "{}",
|
|
293
|
+
"src/modules/sample/View.tsx": "export function View() {\n return <button>x</button>;\n}\n",
|
|
294
|
+
});
|
|
295
|
+
const run = runBin(root, ["--format", "check-report"]);
|
|
296
|
+
expect(run.status).toBe(1);
|
|
297
|
+
const report = JSON.parse(run.stdout); // strict single-document parse
|
|
298
|
+
expect(report.terp_check_report).toBe(1);
|
|
299
|
+
expect(report.spec_version).toBe(SPEC_VERSION);
|
|
300
|
+
expect(report.ok).toBe(false);
|
|
301
|
+
expect(report.findings.map((finding) => finding.rule)).toContain(
|
|
302
|
+
"frontend/token-styled-elements",
|
|
303
|
+
);
|
|
304
|
+
expect(report.not_applicable).toEqual(["frontend/layout-contract"]);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("keeps the default format as the legacy terp_findings envelope", () => {
|
|
308
|
+
const root = appRoot({
|
|
309
|
+
"package.json": '{ "type": "module" }',
|
|
310
|
+
"eslint.config.js": config,
|
|
311
|
+
"escape-hatch-budget.json": "{}",
|
|
312
|
+
"src/modules/sample/View.tsx": "export const view = 1;\n",
|
|
313
|
+
});
|
|
314
|
+
const run = runBin(root, []);
|
|
315
|
+
expect(run.status).toBe(0);
|
|
316
|
+
const envelope = JSON.parse(run.stdout);
|
|
317
|
+
expect(envelope.terp_findings).toBe(1);
|
|
318
|
+
expect("terp_check_report" in envelope).toBe(false);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("refuses an unsupported format (fail closed, exit 2)", () => {
|
|
322
|
+
const root = appRoot({
|
|
323
|
+
"package.json": '{ "type": "module" }',
|
|
324
|
+
"eslint.config.js": config,
|
|
325
|
+
"escape-hatch-budget.json": "{}",
|
|
326
|
+
"src/modules/sample/View.tsx": "export const view = 1;\n",
|
|
327
|
+
});
|
|
328
|
+
const run = runBin(root, ["--format", "yaml"]);
|
|
329
|
+
expect(run.status).toBe(2);
|
|
330
|
+
expect(run.stderr).toMatch(/unsupported --format/);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("still reads a positional budget path alongside the flag", () => {
|
|
334
|
+
const root = appRoot({
|
|
335
|
+
"package.json": '{ "type": "module" }',
|
|
336
|
+
"eslint.config.js": config,
|
|
337
|
+
"custom-budget.json": "{}",
|
|
338
|
+
"src/modules/sample/View.tsx": "export const view = 1;\n",
|
|
339
|
+
});
|
|
340
|
+
const run = runBin(root, ["--format", "check-report", "custom-budget.json"]);
|
|
341
|
+
expect(run.status).toBe(0);
|
|
342
|
+
const report = JSON.parse(run.stdout);
|
|
343
|
+
expect(report.ok).toBe(true);
|
|
344
|
+
expect(report.rules).toContain("frontend/escape-hatch");
|
|
345
|
+
});
|
|
346
|
+
});
|