@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,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The scorecard emitter is held to the published certification contract
|
|
3
|
+
* (spec/scorecard.schema.json): the adapter's scorecard validates, claims the
|
|
4
|
+
* whole corpus-covered frontend catalog, passes it, and only relies on the
|
|
5
|
+
* residuals the spec records. The emitter reuses the corpus test's own harness
|
|
6
|
+
* (./corpus-harness.js), so a scorecard can never disagree with the suite.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import { createRequire } from "node:module";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
|
|
13
|
+
import { describe, expect, it } from "vitest";
|
|
14
|
+
|
|
15
|
+
import { buildScorecard, validateScorecard } from "./scorecard.js";
|
|
16
|
+
|
|
17
|
+
const SPEC_ROOT = path.dirname(
|
|
18
|
+
createRequire(import.meta.url).resolve("@terp/spec/package.json"),
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
describe("the @terp/eslint-boundaries scorecard", () => {
|
|
22
|
+
it("claims the whole corpus-covered catalog, green, schema-shaped", async () => {
|
|
23
|
+
const scorecard = await buildScorecard();
|
|
24
|
+
expect(validateScorecard(scorecard)).toEqual([]);
|
|
25
|
+
|
|
26
|
+
// Schema shape, field by field (the spec suite's minimal-validator discipline).
|
|
27
|
+
const schema = JSON.parse(
|
|
28
|
+
fs.readFileSync(path.join(SPEC_ROOT, "scorecard.schema.json"), "utf8"),
|
|
29
|
+
);
|
|
30
|
+
for (const field of schema.required) expect(scorecard).toHaveProperty(field);
|
|
31
|
+
for (const field of Object.keys(scorecard)) {
|
|
32
|
+
expect(Object.keys(schema.properties)).toContain(field);
|
|
33
|
+
}
|
|
34
|
+
expect(scorecard.spec_version).toBe(
|
|
35
|
+
fs.readFileSync(path.join(SPEC_ROOT, "VERSION"), "utf8").trim(),
|
|
36
|
+
);
|
|
37
|
+
expect(scorecard.checker.tool).toBe("@terp/eslint-boundaries");
|
|
38
|
+
const itemProperties = Object.keys(schema.properties.rules.items.properties);
|
|
39
|
+
for (const claim of scorecard.rules) {
|
|
40
|
+
expect(claim.pass).toBe(true);
|
|
41
|
+
for (const field of Object.keys(claim)) expect(itemProperties).toContain(field);
|
|
42
|
+
}
|
|
43
|
+
}, 120_000);
|
|
44
|
+
|
|
45
|
+
it("rejects a scorecard claiming an unrecorded residual or a failing rule", async () => {
|
|
46
|
+
const scorecard = await buildScorecard();
|
|
47
|
+
const failing = {
|
|
48
|
+
...scorecard,
|
|
49
|
+
rules: [{ ...scorecard.rules[0], pass: false }, ...scorecard.rules.slice(1)],
|
|
50
|
+
};
|
|
51
|
+
expect(validateScorecard(failing).join("\n")).toMatch(/must pass its own corpus/);
|
|
52
|
+
const overclaiming = {
|
|
53
|
+
...scorecard,
|
|
54
|
+
rules: [
|
|
55
|
+
{ ...scorecard.rules[0], residuals_claimed: ["a residual the spec never recorded"] },
|
|
56
|
+
...scorecard.rules.slice(1),
|
|
57
|
+
],
|
|
58
|
+
};
|
|
59
|
+
expect(validateScorecard(overclaiming).join("\n")).toMatch(/unrecorded residual/);
|
|
60
|
+
}, 120_000);
|
|
61
|
+
});
|
package/src/spec.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Terp frontend boundary rules, declared **as data** (design §7.1.5). The ESLint adapter in
|
|
3
|
+
* ./index.js realises them for the React stack; a future stack (e.g. Svelte) can realise the same
|
|
4
|
+
* spec with its own adapter. The *rules* are shared; only the *enforcement adapter* is per-stack.
|
|
5
|
+
*
|
|
6
|
+
* They apply to the **app-authored surface** (`src/modules/**`) — the code agents and users write —
|
|
7
|
+
* not the framework packages, which legitimately define the very primitives the rules point back to.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* The Terp Standard version this adapter is certified against — the `spec_version` a
|
|
11
|
+
* check report (`app-check-report.schema.json`) carries. A constant rather than a runtime
|
|
12
|
+
* `@terp/spec` read: the spec data package is a dev/certification dependency of the platform
|
|
13
|
+
* repo, not of a generated app, and the version is a property of the toolchain build. Held
|
|
14
|
+
* equal to the pinned spec release by the framework gate (test_check_json.py — deliberately
|
|
15
|
+
* NOT by this package's own suite, which certification runs against candidate spec releases
|
|
16
|
+
* whose version is allowed to be newer).
|
|
17
|
+
*/
|
|
18
|
+
export const SPEC_VERSION = "0.12.0";
|
|
19
|
+
|
|
20
|
+
export const BOUNDARY_SPEC = {
|
|
21
|
+
/** App module files the boundary + frontend security defaults apply to. */
|
|
22
|
+
moduleFiles: ["**/modules/**/*.{ts,tsx}"],
|
|
23
|
+
/**
|
|
24
|
+
* Raw HTML elements an app module must not author directly, mapped to the token-styled
|
|
25
|
+
* `@terpjs/react-core` replacement (accessible + theme-consistent by construction).
|
|
26
|
+
*/
|
|
27
|
+
restrictedElements: {
|
|
28
|
+
button: "Button",
|
|
29
|
+
input: "Input",
|
|
30
|
+
select: "Select",
|
|
31
|
+
textarea: "Textarea",
|
|
32
|
+
table: "DataView",
|
|
33
|
+
dialog: "ConfirmDialog",
|
|
34
|
+
form: 'Stack as="form"',
|
|
35
|
+
},
|
|
36
|
+
/**
|
|
37
|
+
* JSX attributes an app module must not author — styling lives in the design tokens and the
|
|
38
|
+
* react-core components (`Stack` for layout), never ad-hoc per screen. `className` would be
|
|
39
|
+
* a side channel into hand-authored CSS, so it is refused alongside `style`.
|
|
40
|
+
*/
|
|
41
|
+
restrictedAttributes: ["style", "className"],
|
|
42
|
+
/**
|
|
43
|
+
* Raw in-app anchors (`<a href="/...">`) bypass the router (full reload, no role-aware
|
|
44
|
+
* guard); modules use the stack's `Link`. External `https://...` anchors stay allowed.
|
|
45
|
+
*/
|
|
46
|
+
restrictInAppAnchors: true,
|
|
47
|
+
/** Package internals an app module must not deep-import (import from the package root). */
|
|
48
|
+
internalImportPatterns: ["@terp/*/src/*", "@terp/*/dist/*"],
|
|
49
|
+
/** Module-authored stylesheets are refused — theming flows from the app's token source. */
|
|
50
|
+
styleImportPatterns: [
|
|
51
|
+
"*.css",
|
|
52
|
+
"**/*.css",
|
|
53
|
+
"*.css?*",
|
|
54
|
+
"**/*.css?*",
|
|
55
|
+
"*.scss",
|
|
56
|
+
"**/*.scss",
|
|
57
|
+
"*.scss?*",
|
|
58
|
+
"**/*.scss?*",
|
|
59
|
+
"*.sass",
|
|
60
|
+
"**/*.sass",
|
|
61
|
+
"*.sass?*",
|
|
62
|
+
"**/*.sass?*",
|
|
63
|
+
"*.less",
|
|
64
|
+
"**/*.less",
|
|
65
|
+
"*.less?*",
|
|
66
|
+
"**/*.less?*",
|
|
67
|
+
"*.styl",
|
|
68
|
+
"**/*.styl",
|
|
69
|
+
"*.styl?*",
|
|
70
|
+
"**/*.styl?*",
|
|
71
|
+
],
|
|
72
|
+
/** Browser request/stream globals that would skip the audited, typed client. */
|
|
73
|
+
restrictedGlobals: ["fetch", "XMLHttpRequest", "WebSocket", "EventSource"],
|
|
74
|
+
/**
|
|
75
|
+
* The governed escape hatch (the frontend analog of the backend's `# arch-allow-*`): a
|
|
76
|
+
* justified `// terp-allow-<rule>: <reason>` comment on (or immediately above) a violating
|
|
77
|
+
* line suppresses that rule there. `<rule>` is the Terp Standard CATALOG rule name (the
|
|
78
|
+
* `opt_out` spelling in `spec/catalog/frontend/<rule>.json`), never a tool-internal ESLint
|
|
79
|
+
* id — one marker covers every detection path of its rule and can never waive a sibling
|
|
80
|
+
* rule sharing a core lint id. An unjustified marker is itself reported. Marker counts
|
|
81
|
+
* must exactly match the app's checked-in `escape-hatch-budget.json` (the ratchet).
|
|
82
|
+
*/
|
|
83
|
+
allowMarkerPrefix: "terp-allow-",
|
|
84
|
+
};
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reference adapter is held to the spec's declared refused surface (`spec/restricted-surface.json`).
|
|
3
|
+
*
|
|
4
|
+
* The refused raw frontend surface is spec **data**, not adapter code: the stack-neutral,
|
|
5
|
+
* normative part of the portable prohibition rules is the list of raw primitives an app module
|
|
6
|
+
* must not author; which sanctioned component answers each primitive is per-stack configuration
|
|
7
|
+
* (the catalog entries' non-normative `reference` field). This test locks the two together:
|
|
8
|
+
*
|
|
9
|
+
* - structurally: `BOUNDARY_SPEC` realises exactly the declared surface (no drift in either
|
|
10
|
+
* direction — an element/attribute/global added to one side must be added to the other);
|
|
11
|
+
* - behaviourally: authoring each declared primitive in an app module produces a finding
|
|
12
|
+
* attributed to the expected catalog id (through the published {@link catalogRuleId} mapping,
|
|
13
|
+
* exactly as the corpus contract states).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import { createRequire } from "node:module";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
|
|
20
|
+
import { ESLint } from "eslint";
|
|
21
|
+
import { describe, expect, it } from "vitest";
|
|
22
|
+
|
|
23
|
+
import terpBoundaries, { BOUNDARY_SPEC, catalogRuleId } from "./index.js";
|
|
24
|
+
|
|
25
|
+
// The spec is a declared dependency (@terp/spec, ADR 0082), never a repo-relative path.
|
|
26
|
+
const SPEC_ROOT = path.dirname(
|
|
27
|
+
createRequire(import.meta.url).resolve("@terp/spec/package.json"),
|
|
28
|
+
);
|
|
29
|
+
const SURFACE = JSON.parse(
|
|
30
|
+
fs.readFileSync(path.join(SPEC_ROOT, "restricted-surface.json"), "utf8"),
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
async function lintModuleSource(source) {
|
|
34
|
+
const eslint = new ESLint({ overrideConfigFile: true, overrideConfig: terpBoundaries });
|
|
35
|
+
const filePath = path.resolve("src/modules/widgets/Widget.tsx");
|
|
36
|
+
const [result] = await eslint.lintText(source, { filePath });
|
|
37
|
+
return result.messages.map((message) => catalogRuleId(message));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("structural parity: BOUNDARY_SPEC realises exactly the declared surface", () => {
|
|
41
|
+
it("restricted elements match", () => {
|
|
42
|
+
expect(Object.keys(BOUNDARY_SPEC.restrictedElements).sort()).toEqual(
|
|
43
|
+
[...SURFACE.restrictedElements].sort(),
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("restricted attributes match", () => {
|
|
48
|
+
expect([...BOUNDARY_SPEC.restrictedAttributes].sort()).toEqual(
|
|
49
|
+
[...SURFACE.restrictedAttributes].sort(),
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("restricted globals match", () => {
|
|
54
|
+
expect([...BOUNDARY_SPEC.restrictedGlobals].sort()).toEqual(
|
|
55
|
+
[...SURFACE.restrictedGlobals].sort(),
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("every declared stylesheet extension is refused by the import patterns", () => {
|
|
60
|
+
for (const extension of SURFACE.styleImportExtensions) {
|
|
61
|
+
expect(BOUNDARY_SPEC.styleImportPatterns).toContain(`**/*${extension}`);
|
|
62
|
+
}
|
|
63
|
+
// ...and no pattern refuses an undeclared extension.
|
|
64
|
+
const declared = new Set(SURFACE.styleImportExtensions);
|
|
65
|
+
for (const pattern of BOUNDARY_SPEC.styleImportPatterns) {
|
|
66
|
+
const extension = pattern.replace(/\?\*$/, "").match(/\.[a-z]+$/)?.[0];
|
|
67
|
+
expect(declared.has(extension), `undeclared extension in pattern ${pattern}`).toBe(true);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("every declared deep-import segment is refused by the import patterns", () => {
|
|
72
|
+
for (const segment of SURFACE.deepImportPathSegments) {
|
|
73
|
+
expect(BOUNDARY_SPEC.internalImportPatterns).toContain(`@terp/*/${segment}/*`);
|
|
74
|
+
}
|
|
75
|
+
expect(BOUNDARY_SPEC.internalImportPatterns).toHaveLength(
|
|
76
|
+
SURFACE.deepImportPathSegments.length,
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("behavioural parity: each declared primitive is refused with the right catalog id", () => {
|
|
82
|
+
for (const element of SURFACE.restrictedElements) {
|
|
83
|
+
it(`raw <${element}> -> frontend/token-styled-elements`, async () => {
|
|
84
|
+
expect(await lintModuleSource(`export const W = () => <${element} />;\n`)).toContain(
|
|
85
|
+
"frontend/token-styled-elements",
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
for (const attribute of SURFACE.restrictedAttributes) {
|
|
91
|
+
it(`${attribute} attribute -> frontend/no-inline-styling`, async () => {
|
|
92
|
+
const value = attribute === "style" ? "{{}}" : '"x"';
|
|
93
|
+
expect(await lintModuleSource(`export const W = () => <div ${attribute}=${value} />;\n`)).toContain(
|
|
94
|
+
"frontend/no-inline-styling",
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
for (const globalName of SURFACE.restrictedGlobals) {
|
|
100
|
+
it(`${globalName} -> frontend/generated-client-only`, async () => {
|
|
101
|
+
expect(await lintModuleSource(`export const value = ${globalName};\n`)).toContain(
|
|
102
|
+
"frontend/generated-client-only",
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
for (const memberCall of SURFACE.restrictedMemberCalls) {
|
|
108
|
+
it(`${memberCall}() -> frontend/generated-client-only`, async () => {
|
|
109
|
+
expect(await lintModuleSource(`export const send = () => ${memberCall}("/x", "");\n`)).toContain(
|
|
110
|
+
"frontend/generated-client-only",
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
for (const extension of SURFACE.styleImportExtensions) {
|
|
116
|
+
it(`import of *${extension} -> frontend/no-style-imports`, async () => {
|
|
117
|
+
expect(await lintModuleSource(`import "./widget${extension}";\n`)).toContain(
|
|
118
|
+
"frontend/no-style-imports",
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
for (const segment of SURFACE.deepImportPathSegments) {
|
|
124
|
+
it(`deep import via /${segment}/ -> frontend/no-deep-imports`, async () => {
|
|
125
|
+
expect(
|
|
126
|
+
await lintModuleSource(`import { x } from "@terp/react-core/${segment}/internal";\n`),
|
|
127
|
+
).toContain("frontend/no-deep-imports");
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// opt-out parity: the catalog's declared marker spelling is the one that works.
|
|
134
|
+
// A violating line per rule; the spec's own `opt_out` (a justified marker naming
|
|
135
|
+
// the CATALOG rule) must suppress exactly that rule — and the escape-hatch
|
|
136
|
+
// governance rule declares no opt_out at all (waiving governance is refused).
|
|
137
|
+
// The pinned spec (>= 0.6.0) states this contract.
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
const CATALOG_DIR = path.join(SPEC_ROOT, "catalog", "frontend");
|
|
140
|
+
const CATALOG_ENTRIES = fs
|
|
141
|
+
.readdirSync(CATALOG_DIR)
|
|
142
|
+
.filter((name) => name.endsWith(".json"))
|
|
143
|
+
.map((name) => JSON.parse(fs.readFileSync(path.join(CATALOG_DIR, name), "utf8")));
|
|
144
|
+
|
|
145
|
+
/** A minimal violating line per frontend catalog rule (the marker goes on the line above). */
|
|
146
|
+
const VIOLATION_SNIPPETS = {
|
|
147
|
+
"frontend/token-styled-elements": "export const W = () => <button>x</button>;",
|
|
148
|
+
"frontend/no-inline-styling": 'export const W = () => <div className="x" />;',
|
|
149
|
+
"frontend/router-links": 'export const W = () => <a href="/notes">go</a>;',
|
|
150
|
+
"frontend/generated-client-only": 'export const ping = () => fetch("/healthz");',
|
|
151
|
+
"frontend/no-deep-imports": 'import { x } from "@terp/react-core/src/internal";',
|
|
152
|
+
"frontend/no-style-imports": 'import "./widget.css";',
|
|
153
|
+
"frontend/no-cross-module-imports": 'import { x } from "../other/thing";',
|
|
154
|
+
"frontend/no-dom-html-injection": "export const W = (el, html) => { el.innerHTML = html; };",
|
|
155
|
+
"frontend/no-eval": "export const run = (code) => eval(code);",
|
|
156
|
+
"frontend/no-unsafe-href": 'export const W = () => <a href="javascript:alert(1)">x</a>;',
|
|
157
|
+
"frontend/no-unsafe-target-blank":
|
|
158
|
+
'export const W = () => <a href="https://example.com" target="_blank">x</a>;',
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
describe(
|
|
162
|
+
"opt-out parity: every catalog opt_out spelling suppresses its own rule",
|
|
163
|
+
() => {
|
|
164
|
+
for (const entry of CATALOG_ENTRIES) {
|
|
165
|
+
const optOut = entry.opt_out;
|
|
166
|
+
if (entry.id === "frontend/escape-hatch") {
|
|
167
|
+
it("frontend/escape-hatch declares no opt_out (governance is unwaivable)", () => {
|
|
168
|
+
expect(optOut).toBeUndefined();
|
|
169
|
+
});
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
it(`${entry.id} declares its catalog-derived marker`, () => {
|
|
173
|
+
const name = entry.id.slice("frontend/".length);
|
|
174
|
+
expect(optOut).toBe(`// terp-allow-${name}: <reason>`);
|
|
175
|
+
});
|
|
176
|
+
const snippet = VIOLATION_SNIPPETS[entry.id];
|
|
177
|
+
if (snippet === undefined) {
|
|
178
|
+
// frontend/layout-contract needs the opt-in contract config; its marker
|
|
179
|
+
// behaviour is covered by layouts.test.js with the same spelling.
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
it(`${entry.id}'s declared marker suppresses its violation`, async () => {
|
|
183
|
+
const marker = optOut.replace("<reason>", "recorded parity exception");
|
|
184
|
+
// These snippets violate exactly one rule, so the suppressed result is
|
|
185
|
+
// COMPLETELY clean — the marker waived its rule and nothing else remains.
|
|
186
|
+
expect(await lintModuleSource(`${marker}\n${snippet}\n`)).toEqual([]);
|
|
187
|
+
// ...and the same line without the marker does violate (the fixture is live).
|
|
188
|
+
expect(await lintModuleSource(`${snippet}\n`)).toContain(entry.id);
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
);
|