@terpjs/eslint-boundaries 0.8.0 → 0.10.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 +2 -2
- package/src/index.js +10 -5
- package/src/index.test.js +24 -0
- package/src/layouts.js +44 -3
- package/src/spec.js +37 -1
- package/src/surface.test.js +16 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@terpjs/eslint-boundaries",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"type": "module",
|
|
5
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
6
|
"main": "./src/index.js",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"typescript-eslint": "^8.20.0"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
|
-
"@terpjs/spec": "0.
|
|
28
|
+
"@terpjs/spec": "0.26.1",
|
|
29
29
|
"eslint": "^10.8.0",
|
|
30
30
|
"vitest": "^4.1.9"
|
|
31
31
|
},
|
package/src/index.js
CHANGED
|
@@ -412,11 +412,16 @@ const styleImportMessage =
|
|
|
412
412
|
* {@link catalogRuleId}). {@link restrictedSyntax} strips the tag for the ESLint config.
|
|
413
413
|
*/
|
|
414
414
|
function restrictedSyntaxWithCatalogIds() {
|
|
415
|
-
const rawElements = Object.entries(BOUNDARY_SPEC.restrictedElements).map(([element, use]) =>
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
415
|
+
const rawElements = Object.entries(BOUNDARY_SPEC.restrictedElements).map(([element, use]) => {
|
|
416
|
+
const guidance = BOUNDARY_SPEC.restrictedElementGuidance?.[element];
|
|
417
|
+
return {
|
|
418
|
+
catalogId: "frontend/token-styled-elements",
|
|
419
|
+
selector: `JSXOpeningElement[name.name='${element}']`,
|
|
420
|
+
message:
|
|
421
|
+
`Use ${use} from @terpjs/react-core, not a raw <${element}>.` +
|
|
422
|
+
(guidance === undefined ? "" : ` ${guidance}`),
|
|
423
|
+
};
|
|
424
|
+
});
|
|
420
425
|
const rawAttributes = BOUNDARY_SPEC.restrictedAttributes.map((attribute) => ({
|
|
421
426
|
catalogId: "frontend/no-inline-styling",
|
|
422
427
|
selector: `JSXAttribute[name.name='${attribute}']`,
|
package/src/index.test.js
CHANGED
|
@@ -18,6 +18,12 @@ async function lint(code, filePath = MODULE_FILE) {
|
|
|
18
18
|
return result.messages.map((message) => message.ruleId);
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
async function lintMessages(code, filePath = MODULE_FILE) {
|
|
22
|
+
const eslint = new ESLint({ overrideConfigFile: true, overrideConfig: terpBoundaries });
|
|
23
|
+
const [result] = await eslint.lintText(code, { filePath });
|
|
24
|
+
return result.messages.map((message) => message.message);
|
|
25
|
+
}
|
|
26
|
+
|
|
21
27
|
describe("terpBoundaries", () => {
|
|
22
28
|
it("passes clean module code (react-core components + generated client)", async () => {
|
|
23
29
|
const code = [
|
|
@@ -315,4 +321,22 @@ describe("terpBoundaries", () => {
|
|
|
315
321
|
const code = ["/* eslint-disable */", "export const W = () => <button>x</button>;"].join("\n");
|
|
316
322
|
expect(await lint(code)).toContain("no-restricted-syntax");
|
|
317
323
|
});
|
|
324
|
+
|
|
325
|
+
it("the dialog refusal says what to do instead of naming only ConfirmDialog", async () => {
|
|
326
|
+
// "Use ConfirmDialog" is right for a confirmation and wrong advice for an edit form,
|
|
327
|
+
// and an author who reads it for one concludes the rule cannot be obeyed. The reported
|
|
328
|
+
// evidence was an app that built the editor in an expanded row instead and recorded
|
|
329
|
+
// that as the better outcome — so the refusal carries that guidance, rather than the
|
|
330
|
+
// framework shipping a general modal whose absence produced the better UI.
|
|
331
|
+
const messages = await lintMessages("export const W = () => <dialog>x</dialog>;");
|
|
332
|
+
const refusal = messages.find((message) => message.includes("<dialog>"));
|
|
333
|
+
expect(refusal).toContain("ConfirmDialog");
|
|
334
|
+
expect(refusal).toContain("routed page");
|
|
335
|
+
expect(refusal).toContain("expanded row");
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it("an element with no extra guidance keeps the plain one-line refusal", async () => {
|
|
339
|
+
const messages = await lintMessages("export const W = () => <button>x</button>;");
|
|
340
|
+
expect(messages).toContain("Use Button from @terpjs/react-core, not a raw <button>.");
|
|
341
|
+
});
|
|
318
342
|
});
|
package/src/layouts.js
CHANGED
|
@@ -35,9 +35,15 @@ export const LAYOUT_CONTRACTS = {
|
|
|
35
35
|
description:
|
|
36
36
|
"The standard three-level shape: hub bodies are card grids (HubCard only), " +
|
|
37
37
|
"overview bodies are data collections (DataView / ResourceList + framework " +
|
|
38
|
-
"states), detail bodies are record sections (DetailList / Stack / Tabs
|
|
39
|
-
"framework states); Card is allowed in overview and detail bodies as the " +
|
|
40
|
-
"sanctioned visual separation between sections
|
|
38
|
+
"states), detail bodies are record sections (DetailList / Stack / Grid / Tabs " +
|
|
39
|
+
"+ framework states); Card is allowed in overview and detail bodies as the " +
|
|
40
|
+
"sanctioned visual separation between sections, and Divider / Text as a rule " +
|
|
41
|
+
"between sections and a lead paragraph above them. Three specialised shapes sit " +
|
|
42
|
+
"beside those: a form body is a container (Stack) with optional Grid / Card " +
|
|
43
|
+
"sections and no Field at the top level, so the body is always a container rather " +
|
|
44
|
+
"than a loose run of controls; a settings body is Card sections and holds no " +
|
|
45
|
+
"collection; and a split " +
|
|
46
|
+
"body is two SplitPanes and nothing else. A bespoke screen composes " +
|
|
41
47
|
"the plain Page, which the contract deliberately leaves unconstrained.",
|
|
42
48
|
slots: {
|
|
43
49
|
HubPage: {
|
|
@@ -50,6 +56,8 @@ export const LAYOUT_CONTRACTS = {
|
|
|
50
56
|
ModuleNav: "module-nav",
|
|
51
57
|
Stack: "stack",
|
|
52
58
|
Card: "card",
|
|
59
|
+
Divider: "divider",
|
|
60
|
+
Text: "text",
|
|
53
61
|
EmptyState: "empty-state",
|
|
54
62
|
ErrorState: "error-state",
|
|
55
63
|
LoadingState: "loading-state",
|
|
@@ -57,6 +65,36 @@ export const LAYOUT_CONTRACTS = {
|
|
|
57
65
|
ConfirmDialog: "dialog",
|
|
58
66
|
},
|
|
59
67
|
},
|
|
68
|
+
FormPage: {
|
|
69
|
+
components: {
|
|
70
|
+
Stack: "stack",
|
|
71
|
+
Grid: "grid",
|
|
72
|
+
Card: "card",
|
|
73
|
+
Divider: "divider",
|
|
74
|
+
Text: "text",
|
|
75
|
+
EmptyState: "empty-state",
|
|
76
|
+
ErrorState: "error-state",
|
|
77
|
+
LoadingState: "loading-state",
|
|
78
|
+
Alert: "alert",
|
|
79
|
+
ConfirmDialog: "dialog",
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
SettingsPage: {
|
|
83
|
+
components: {
|
|
84
|
+
Card: "card",
|
|
85
|
+
Stack: "stack",
|
|
86
|
+
Divider: "divider",
|
|
87
|
+
Text: "text",
|
|
88
|
+
EmptyState: "empty-state",
|
|
89
|
+
ErrorState: "error-state",
|
|
90
|
+
LoadingState: "loading-state",
|
|
91
|
+
Alert: "alert",
|
|
92
|
+
ConfirmDialog: "dialog",
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
SplitPage: {
|
|
96
|
+
components: { SplitPane: "splitpane" },
|
|
97
|
+
},
|
|
60
98
|
DetailPage: {
|
|
61
99
|
components: {
|
|
62
100
|
DetailList: "detail-list",
|
|
@@ -65,6 +103,9 @@ export const LAYOUT_CONTRACTS = {
|
|
|
65
103
|
ModuleNav: "module-nav",
|
|
66
104
|
DataView: "dataview",
|
|
67
105
|
Card: "card",
|
|
106
|
+
Grid: "grid",
|
|
107
|
+
Divider: "divider",
|
|
108
|
+
Text: "text",
|
|
68
109
|
EmptyState: "empty-state",
|
|
69
110
|
ErrorState: "error-state",
|
|
70
111
|
LoadingState: "loading-state",
|
package/src/spec.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* NOT by this package's own suite, which certification runs against candidate spec releases
|
|
16
16
|
* whose version is allowed to be newer).
|
|
17
17
|
*/
|
|
18
|
-
export const SPEC_VERSION = "0.
|
|
18
|
+
export const SPEC_VERSION = "0.26.1";
|
|
19
19
|
|
|
20
20
|
export const BOUNDARY_SPEC = {
|
|
21
21
|
/** App module files the boundary + frontend security defaults apply to. */
|
|
@@ -38,6 +38,42 @@ export const BOUNDARY_SPEC = {
|
|
|
38
38
|
* react-core components (`Stack` for layout), never ad-hoc per screen. `className` would be
|
|
39
39
|
* a side channel into hand-authored CSS, so it is refused alongside `style`.
|
|
40
40
|
*/
|
|
41
|
+
/**
|
|
42
|
+
* Extra guidance for an element whose named replacement does not fit every case, so the
|
|
43
|
+
* refusal states what to do instead of implying something is missing.
|
|
44
|
+
*
|
|
45
|
+
* The criterion: a restricted element earns a sentence here when its named replacement is
|
|
46
|
+
* right for some cases and actively misleading for others, so that an author who reads the
|
|
47
|
+
* bare rule concludes the framework ships nothing for their case. Two elements meet it.
|
|
48
|
+
*
|
|
49
|
+
* `dialog` was the first. The replacement is `ConfirmDialog`, which is right for a
|
|
50
|
+
* confirmation and wrong advice for anything else — an author who reads "use ConfirmDialog"
|
|
51
|
+
* for an edit form concludes the framework ships no dialog and the rule cannot be obeyed.
|
|
52
|
+
* The guidance below is what a reporting app arrived at on its own, and recorded as the
|
|
53
|
+
* better outcome: the editor moved into an expanded row, so the finding that sent the author
|
|
54
|
+
* there stayed on screen while they fixed it.
|
|
55
|
+
*
|
|
56
|
+
* `table` is the second (ADR 0099 §4). "Use DataView" reads as disproportionate for five
|
|
57
|
+
* static rows unless the sentence names the two-line recipe, so it does.
|
|
58
|
+
*/
|
|
59
|
+
restrictedElementGuidance: {
|
|
60
|
+
dialog:
|
|
61
|
+
"A modal is for a confirmation (ConfirmDialog) or an explicit post-action moment. " +
|
|
62
|
+
"An edit form or a detail view belongs in a routed page, or in an expanded row " +
|
|
63
|
+
"beside the thing it edits — which keeps the context that sent the author there on " +
|
|
64
|
+
"screen. If you have a case neither covers, say so rather than reaching for a raw " +
|
|
65
|
+
"<dialog>: the modal contract (focus trap, focus restore, Escape, top layer) is " +
|
|
66
|
+
"what ConfirmDialog exists to provide and a hand-rolled one silently drops.",
|
|
67
|
+
table:
|
|
68
|
+
"Use DataView, and if it looks like too much for five static rows, the recipe is " +
|
|
69
|
+
"two lines: variant=\"embedded\" drops the view toggle, the page-size selector and " +
|
|
70
|
+
"the footer, and InMemoryDataViewRepository needs two functions: getRowId and " +
|
|
71
|
+
"getValue. That is worth " +
|
|
72
|
+
"saying because \"use DataView\" on its own reads as wrong advice here, and an " +
|
|
73
|
+
"author who believes it reaches for a raw <table> — which silently gives up " +
|
|
74
|
+
"sorting, column resizing and settings, the mobile card reflow and the row-level " +
|
|
75
|
+
"activation the rest of the app has.",
|
|
76
|
+
},
|
|
41
77
|
restrictedAttributes: ["style", "className"],
|
|
42
78
|
/**
|
|
43
79
|
* Raw in-app anchors (`<a href="/...">`) bypass the router (full reload, no role-aware
|
package/src/surface.test.js
CHANGED
|
@@ -44,6 +44,22 @@ describe("structural parity: BOUNDARY_SPEC realises exactly the declared surface
|
|
|
44
44
|
);
|
|
45
45
|
});
|
|
46
46
|
|
|
47
|
+
it("every guidance sentence belongs to an element that is actually restricted", () => {
|
|
48
|
+
// ADR 0099 §4 makes the WORDING a control: a restricted element whose named replacement is
|
|
49
|
+
// right for some cases and wrong advice for others gets a sentence naming the recipe. A
|
|
50
|
+
// sentence keyed to an element nobody restricts is never emitted, so it reads as shipped
|
|
51
|
+
// guidance while being unreachable — and nothing else here would notice, because the two
|
|
52
|
+
// objects are only ever read one key at a time at lint time.
|
|
53
|
+
const guided = Object.keys(BOUNDARY_SPEC.restrictedElementGuidance).sort();
|
|
54
|
+
const restricted = Object.keys(BOUNDARY_SPEC.restrictedElements);
|
|
55
|
+
expect(guided.filter((element) => !restricted.includes(element))).toEqual([]);
|
|
56
|
+
// And the sentences are non-empty: an empty string is falsy, so it would silently fall back
|
|
57
|
+
// to the bare refusal at the call site.
|
|
58
|
+
for (const element of guided) {
|
|
59
|
+
expect(BOUNDARY_SPEC.restrictedElementGuidance[element].length).toBeGreaterThan(0);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
47
63
|
it("restricted attributes match", () => {
|
|
48
64
|
expect([...BOUNDARY_SPEC.restrictedAttributes].sort()).toEqual(
|
|
49
65
|
[...SURFACE.restrictedAttributes].sort(),
|