@terpjs/eslint-boundaries 0.18.0 → 0.20.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 +5 -5
- package/src/i18n.test.js +30 -0
- package/src/index.js +63 -1
- package/src/index.test.js +61 -0
- package/src/spec.js +11 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@terpjs/eslint-boundaries",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.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",
|
|
@@ -22,12 +22,12 @@
|
|
|
22
22
|
"eslint": ">=9"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"typescript-eslint": "^8.
|
|
25
|
+
"typescript-eslint": "^8.69.0"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
|
-
"@terpjs/spec": "0.
|
|
29
|
-
"eslint": "^10.
|
|
30
|
-
"vitest": "^
|
|
28
|
+
"@terpjs/spec": "0.33.0",
|
|
29
|
+
"eslint": "^10.10.0",
|
|
30
|
+
"vitest": "^5.0.0"
|
|
31
31
|
},
|
|
32
32
|
"license": "Apache-2.0",
|
|
33
33
|
"repository": {
|
package/src/i18n.test.js
CHANGED
|
@@ -241,6 +241,36 @@ describe("untranslated UI coverage", () => {
|
|
|
241
241
|
expect(messages.map((message) => message.ruleId)).toContain("terp/no-untranslated-ui");
|
|
242
242
|
});
|
|
243
243
|
|
|
244
|
+
it.each([
|
|
245
|
+
['<Text>{status === "paused" && <Trans id="w.p" message="Paused" />}</Text>', "==="],
|
|
246
|
+
['<Text>{status !== "archived" && <Trans id="w.a" message="Live" />}</Text>', "!=="],
|
|
247
|
+
['<Text>{kind == "draft" && <Trans id="w.d" message="Draft" />}</Text>', "=="],
|
|
248
|
+
['<Text>{kind != "draft" && <Trans id="w.f" message="Final" />}</Text>', "!="],
|
|
249
|
+
['<Text>{stage > "Stage 2" && <Trans id="w.l" message="Late" />}</Text>', ">"],
|
|
250
|
+
['<Text>{"paused" === status && <Trans id="w.r" message="Paused" />}</Text>', "reversed"],
|
|
251
|
+
['<Page title={"Loading widgets" && loading} />', "&& left operand"],
|
|
252
|
+
])("accepts a state token compared in a guard (%s)", async (jsx) => {
|
|
253
|
+
const messages = await lintWithCatalog(
|
|
254
|
+
declaration,
|
|
255
|
+
`export const W = ({ status, kind, stage, loading }) => ${jsx};`,
|
|
256
|
+
);
|
|
257
|
+
expect(messages.filter((message) => message.ruleId === "terp/no-untranslated-ui"))
|
|
258
|
+
.toHaveLength(0);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it.each([
|
|
262
|
+
['<Page title={loading && "Loading widgets"} />', "&& right operand renders"],
|
|
263
|
+
['<Page title={label || "Untitled widget"} />', "|| renders either side"],
|
|
264
|
+
['<Page title={label ?? "Untitled widget"} />', "?? renders either side"],
|
|
265
|
+
['<Page title={"Page " + index} />', "+ concatenates into copy"],
|
|
266
|
+
])("still refuses copy the operator does render (%s)", async (jsx) => {
|
|
267
|
+
const messages = await lintWithCatalog(
|
|
268
|
+
declaration,
|
|
269
|
+
`export const W = ({ loading, label, index }) => ${jsx};`,
|
|
270
|
+
);
|
|
271
|
+
expect(messages.map((message) => message.ruleId)).toContain("terp/no-untranslated-ui");
|
|
272
|
+
});
|
|
273
|
+
|
|
244
274
|
it("refuses conditional object-property copy and unwraps TypeScript const assertions", async () => {
|
|
245
275
|
const conditional = await lintWithCatalog(
|
|
246
276
|
declaration,
|
package/src/index.js
CHANGED
|
@@ -451,6 +451,25 @@ function isStaticDescriptor(node) {
|
|
|
451
451
|
return staticString(named("id")?.value) !== null && staticString(named("message")?.value) !== null;
|
|
452
452
|
}
|
|
453
453
|
|
|
454
|
+
/**
|
|
455
|
+
* Binary operators whose operands are never rendered. The result is a boolean, so an
|
|
456
|
+
* authored-looking literal on either side is a value being *tested* -- the rule's subject
|
|
457
|
+
* is "static app-authored UI copy", and a state token compared against is neither authored
|
|
458
|
+
* copy nor rendered.
|
|
459
|
+
*/
|
|
460
|
+
const NON_RENDERING_COMPARISONS = new Set([
|
|
461
|
+
"===",
|
|
462
|
+
"!==",
|
|
463
|
+
"==",
|
|
464
|
+
"!=",
|
|
465
|
+
"<",
|
|
466
|
+
"<=",
|
|
467
|
+
">",
|
|
468
|
+
">=",
|
|
469
|
+
"in",
|
|
470
|
+
"instanceof",
|
|
471
|
+
]);
|
|
472
|
+
|
|
454
473
|
/** Authored string fragments in expressions that render or feed a known UiText property. */
|
|
455
474
|
function containsStaticAuthoredCopy(node) {
|
|
456
475
|
const value = unwrapExpression(node);
|
|
@@ -465,7 +484,21 @@ function containsStaticAuthoredCopy(node) {
|
|
|
465
484
|
if (value.type === "ConditionalExpression") {
|
|
466
485
|
return containsStaticAuthoredCopy(value.consequent) || containsStaticAuthoredCopy(value.alternate);
|
|
467
486
|
}
|
|
468
|
-
if (value.type === "
|
|
487
|
+
if (value.type === "BinaryExpression") {
|
|
488
|
+
// A comparison renders neither operand: the expression evaluates to a boolean, so the
|
|
489
|
+
// literal in `status === "paused"` is a state token being tested and never reaches a
|
|
490
|
+
// screen in any locale. Every other binary operator keeps the broad reading -- `+` is
|
|
491
|
+
// string concatenation and can render either side.
|
|
492
|
+
if (NON_RENDERING_COMPARISONS.has(value.operator)) return false;
|
|
493
|
+
return containsStaticAuthoredCopy(value.left) || containsStaticAuthoredCopy(value.right);
|
|
494
|
+
}
|
|
495
|
+
if (value.type === "LogicalExpression") {
|
|
496
|
+
// `&&` is the same story on one side only: the left operand is the test and the right
|
|
497
|
+
// is what renders, so `{loading && "Loading"}` is copy and `{"Loading" && loading}`
|
|
498
|
+
// evaluates to `loading` and is not. `||` and `??` can render either side and keep the
|
|
499
|
+
// broad reading. This is the distinction the ConditionalExpression branch above already
|
|
500
|
+
// draws by walking `consequent` / `alternate` and not `test`.
|
|
501
|
+
if (value.operator === "&&") return containsStaticAuthoredCopy(value.right);
|
|
469
502
|
return containsStaticAuthoredCopy(value.left) || containsStaticAuthoredCopy(value.right);
|
|
470
503
|
}
|
|
471
504
|
if (value.type === "ArrayExpression") {
|
|
@@ -879,6 +912,12 @@ const terpPlugin = {
|
|
|
879
912
|
},
|
|
880
913
|
};
|
|
881
914
|
|
|
915
|
+
const clipboardMessage =
|
|
916
|
+
"navigator.clipboard is absent outside a secure context and lib.dom types it as always " +
|
|
917
|
+
"present, so on an http origin this is a property access on undefined -- a SYNCHRONOUS " +
|
|
918
|
+
"TypeError that no .catch and no try around an await ever sees. Use copyText or " +
|
|
919
|
+
"useCopyToClipboard from @terpjs/react-core, which feature-detect, fall back, and " +
|
|
920
|
+
"report a refusal instead of failing silently.";
|
|
882
921
|
const deepImportMessage =
|
|
883
922
|
"Import from the package root (@terpjs/react-core, @terpjs/contract), not its internals.";
|
|
884
923
|
const styleImportMessage =
|
|
@@ -937,10 +976,33 @@ function restrictedSyntaxWithCatalogIds() {
|
|
|
937
976
|
},
|
|
938
977
|
]
|
|
939
978
|
: [];
|
|
979
|
+
const rawClipboard = BOUNDARY_SPEC.restrictRawClipboard
|
|
980
|
+
? [
|
|
981
|
+
{
|
|
982
|
+
// Any ACCESS, not just a call: `navigator.clipboard.writeText(...)` throws on
|
|
983
|
+
// the property lookup, so `const c = navigator.clipboard` is the same defect
|
|
984
|
+
// one line earlier. Matching the member expression covers both, and covers
|
|
985
|
+
// `readText` and anything else the API grows without naming methods here.
|
|
986
|
+
catalogId: "frontend/no-raw-clipboard",
|
|
987
|
+
selector:
|
|
988
|
+
"MemberExpression[object.name='navigator'][property.name='clipboard'], MemberExpression[object.name='navigator'][computed=true][property.value='clipboard'], MemberExpression[object.type='MemberExpression'][object.object.name=/^(window|globalThis)$/][object.property.name='navigator'][property.name='clipboard'], MemberExpression[object.type='MemberExpression'][object.object.name=/^(window|globalThis)$/][object.computed=true][object.property.value='navigator'][property.name='clipboard']",
|
|
989
|
+
message: clipboardMessage,
|
|
990
|
+
},
|
|
991
|
+
{
|
|
992
|
+
// The destructuring spelling, which no member-expression selector reaches:
|
|
993
|
+
// `const { clipboard } = navigator` binds the same undefined under a new name.
|
|
994
|
+
catalogId: "frontend/no-raw-clipboard",
|
|
995
|
+
selector:
|
|
996
|
+
"VariableDeclarator[init.name='navigator'] ObjectPattern > Property[key.name='clipboard'], VariableDeclarator[init.type='MemberExpression'][init.property.name='navigator'] ObjectPattern > Property[key.name='clipboard']",
|
|
997
|
+
message: clipboardMessage,
|
|
998
|
+
},
|
|
999
|
+
]
|
|
1000
|
+
: [];
|
|
940
1001
|
return [
|
|
941
1002
|
...rawElements,
|
|
942
1003
|
...rawAttributes,
|
|
943
1004
|
...inAppAnchors,
|
|
1005
|
+
...rawClipboard,
|
|
944
1006
|
{
|
|
945
1007
|
catalogId: "frontend/no-dom-html-injection",
|
|
946
1008
|
selector: "JSXAttribute[name.name='dangerouslySetInnerHTML']",
|
package/src/index.test.js
CHANGED
|
@@ -152,6 +152,67 @@ describe("terpBoundaries", () => {
|
|
|
152
152
|
);
|
|
153
153
|
});
|
|
154
154
|
|
|
155
|
+
it("flags raw navigator.clipboard (the seam feature-detects; the API does not)", async () => {
|
|
156
|
+
// Every spelling reaches the same undefined on an http origin. The direct call is
|
|
157
|
+
// the one that gets written; the others are what it becomes when someone "tidies"
|
|
158
|
+
// it, and a rule that missed them would push the defect around rather than out.
|
|
159
|
+
expect(await lint('export const c = () => navigator.clipboard.writeText("x");')).toContain(
|
|
160
|
+
"no-restricted-syntax",
|
|
161
|
+
);
|
|
162
|
+
expect(await lint("export const c = () => navigator.clipboard.readText();")).toContain(
|
|
163
|
+
"no-restricted-syntax",
|
|
164
|
+
);
|
|
165
|
+
expect(await lint('export const c = () => navigator["clipboard"].writeText("x");')).toContain(
|
|
166
|
+
"no-restricted-syntax",
|
|
167
|
+
);
|
|
168
|
+
expect(
|
|
169
|
+
await lint('export const c = () => window.navigator.clipboard.writeText("x");'),
|
|
170
|
+
).toContain("no-restricted-syntax");
|
|
171
|
+
expect(
|
|
172
|
+
await lint('export const c = () => globalThis["navigator"].clipboard.writeText("x");'),
|
|
173
|
+
).toContain("no-restricted-syntax");
|
|
174
|
+
// Bound to a name rather than called: the throw already happened on the lookup.
|
|
175
|
+
expect(await lint("export const c = navigator.clipboard;")).toContain(
|
|
176
|
+
"no-restricted-syntax",
|
|
177
|
+
);
|
|
178
|
+
expect(await lint("export const { clipboard } = navigator;")).toContain(
|
|
179
|
+
"no-restricted-syntax",
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("accepts the react-core clipboard seam", async () => {
|
|
184
|
+
// The other half of the rule: it must be satisfiable. A rule whose only compliant
|
|
185
|
+
// program is one that does not copy anything would be obeyed by dropping the
|
|
186
|
+
// feature -- so this asserts the sanctioned import is clean, not merely unflagged
|
|
187
|
+
// by the clipboard selector.
|
|
188
|
+
expect(
|
|
189
|
+
await lint(
|
|
190
|
+
'import { useCopyToClipboard } from "@terpjs/react-core";\n' +
|
|
191
|
+
"export const useCopy = () => useCopyToClipboard();",
|
|
192
|
+
),
|
|
193
|
+
).toEqual([]);
|
|
194
|
+
// A property named `clipboard` on something that is not `navigator` is not this
|
|
195
|
+
// defect, and flagging it would make the rule a word filter.
|
|
196
|
+
expect(await lint("export const pick = (o) => o.clipboard;")).toEqual([]);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("honours the clipboard rule's declared escape hatch, and only its own name", async () => {
|
|
200
|
+
// The catalog entry declares `// terp-allow-no-raw-clipboard: <reason>`, and a
|
|
201
|
+
// declared opt-out that does not actually suppress is a false promise in the
|
|
202
|
+
// Standard. Asserted here rather than trusted: the marker resolves through the
|
|
203
|
+
// catalog rule name, not the ESLint rule id, and several catalog rules share
|
|
204
|
+
// `no-restricted-syntax` — so this could have silently waived a sibling or nothing.
|
|
205
|
+
const call = 'export const c = () => navigator.clipboard.writeText("x");';
|
|
206
|
+
expect(await lint(`// terp-allow-no-raw-clipboard: legacy embed target\n${call}`)).toEqual(
|
|
207
|
+
[],
|
|
208
|
+
);
|
|
209
|
+
// A near-miss name must not waive it, and is itself reported as an unjustified
|
|
210
|
+
// marker — otherwise a typo would read as compliance.
|
|
211
|
+
const wrong = await lint(`// terp-allow-no-clipboard: typo\n${call}`);
|
|
212
|
+
expect(wrong).toContain("no-restricted-syntax");
|
|
213
|
+
expect(wrong).toContain("terp/escape-hatch");
|
|
214
|
+
});
|
|
215
|
+
|
|
155
216
|
it("flags raw browser streaming/beacon request primitives (generated client only)", async () => {
|
|
156
217
|
expect(await lint('export const open = () => new WebSocket("wss://example.com");')).toContain(
|
|
157
218
|
"no-restricted-globals",
|
package/src/spec.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* NOT by this package's own suite, which certification runs against candidate spec releases
|
|
17
17
|
* whose version is allowed to be newer).
|
|
18
18
|
*/
|
|
19
|
-
export const SPEC_VERSION = "0.
|
|
19
|
+
export const SPEC_VERSION = "0.33.0";
|
|
20
20
|
|
|
21
21
|
export const BOUNDARY_SPEC = {
|
|
22
22
|
/** Every app-authored TypeScript source file whose user-facing copy must be cataloged. */
|
|
@@ -110,6 +110,16 @@ export const BOUNDARY_SPEC = {
|
|
|
110
110
|
],
|
|
111
111
|
/** Browser request/stream globals that would skip the audited, typed client. */
|
|
112
112
|
restrictedGlobals: ["fetch", "XMLHttpRequest", "WebSocket", "EventSource"],
|
|
113
|
+
/**
|
|
114
|
+
* Refuse `navigator.clipboard` in app code, in every spelling.
|
|
115
|
+
*
|
|
116
|
+
* A flag rather than a list because it is one API with one defect, the way
|
|
117
|
+
* `restrictInAppAnchors` is: the DOM lib types the property as always present and it is
|
|
118
|
+
* absent outside a secure context, so an access on a plain-http origin is a property
|
|
119
|
+
* lookup on `undefined` -- a synchronous throw the type checker cannot see. The stack's
|
|
120
|
+
* `copyText` / `useCopyToClipboard` feature-detect and report a refusal instead.
|
|
121
|
+
*/
|
|
122
|
+
restrictRawClipboard: true,
|
|
113
123
|
/**
|
|
114
124
|
* The governed escape hatch (the frontend analog of the backend's `# arch-allow-*`): a
|
|
115
125
|
* justified `// terp-allow-<rule>: <reason>` comment on (or immediately above) a violating
|