@terpjs/eslint-boundaries 0.21.0 → 0.23.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/i18n.test.js +12 -0
- package/src/index.js +42 -1
- package/src/index.test.js +55 -0
- package/src/spec.js +2 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@terpjs/eslint-boundaries",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.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.69.0"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
|
-
"@terpjs/spec": "0.
|
|
28
|
+
"@terpjs/spec": "0.35.0",
|
|
29
29
|
"eslint": "^10.10.0",
|
|
30
30
|
"vitest": "^5.0.0"
|
|
31
31
|
},
|
package/src/i18n.test.js
CHANGED
|
@@ -144,6 +144,18 @@ describe("locale catalog completeness", () => {
|
|
|
144
144
|
expect(dynamicTrans.find((message) => message.ruleId === "terp/locale-catalogs-complete")?.message)
|
|
145
145
|
.toContain("requires static non-empty id and message");
|
|
146
146
|
|
|
147
|
+
// A spread is still refused - the catalog is inventoried statically - but it is the
|
|
148
|
+
// one shape with a sanctioned alternative, so the refusal names it. Sharing one
|
|
149
|
+
// descriptor between two screens is ordinary; only its JSX-body spelling is not.
|
|
150
|
+
const spreadTrans = await lintWithCatalog(
|
|
151
|
+
{ sourceLocale: "en", locales: { en: {}, nl: {} } },
|
|
152
|
+
'export const W = () => <Trans {...TITLE} />;',
|
|
153
|
+
);
|
|
154
|
+
const spreadMessage = spreadTrans
|
|
155
|
+
.find((message) => message.ruleId === "terp/locale-catalogs-complete")?.message;
|
|
156
|
+
expect(spreadMessage).toContain("useUiText");
|
|
157
|
+
expect(spreadMessage).toContain("text(DESCRIPTOR)");
|
|
158
|
+
|
|
147
159
|
const staleAllowlist = await lintWithCatalog(
|
|
148
160
|
{
|
|
149
161
|
sourceLocale: "en",
|
package/src/index.js
CHANGED
|
@@ -788,9 +788,23 @@ const localeCatalogsComplete = {
|
|
|
788
788
|
if (jsxName(node.name) !== "Trans") return;
|
|
789
789
|
const descriptor = transDescriptor(node);
|
|
790
790
|
if (descriptor === null) {
|
|
791
|
+
// A spread is the one shape with a sanctioned alternative, and the generic
|
|
792
|
+
// message sent authors looking for a way to make the spread work instead.
|
|
793
|
+
// One descriptor shared by two screens is ordinary; what cannot be shared is
|
|
794
|
+
// the JSX-body spelling of it, because the catalog is inventoried statically
|
|
795
|
+
// and `{...DESCRIPTOR}` carries no attributes to read. The resolver renders
|
|
796
|
+
// the same copy from the same constant, so name it rather than the refusal.
|
|
797
|
+
const spread = node.attributes.some(
|
|
798
|
+
(attribute) => attribute.type === "JSXSpreadAttribute",
|
|
799
|
+
);
|
|
791
800
|
context.report({
|
|
792
801
|
node,
|
|
793
|
-
message:
|
|
802
|
+
message: spread
|
|
803
|
+
? "<Trans> reads its id and message as static attributes, so a shared UiText " +
|
|
804
|
+
"constant cannot be spread into it. Render the same constant through the " +
|
|
805
|
+
"resolver instead: const text = useUiText() in the component, then " +
|
|
806
|
+
"{text(DESCRIPTOR)} where the copy goes."
|
|
807
|
+
: "<Trans> requires static non-empty id and message attributes.",
|
|
794
808
|
});
|
|
795
809
|
return;
|
|
796
810
|
}
|
|
@@ -918,6 +932,12 @@ const clipboardMessage =
|
|
|
918
932
|
"TypeError that no .catch and no try around an await ever sees. Use copyText or " +
|
|
919
933
|
"useCopyToClipboard from @terpjs/react-core, which feature-detect, fall back, and " +
|
|
920
934
|
"report a refusal instead of failing silently.";
|
|
935
|
+
const randomUuidMessage =
|
|
936
|
+
"crypto.randomUUID exists only in a secure context and lib.dom declares it "
|
|
937
|
+
+ "unconditionally, so on an http origin this is a call on undefined -- a SYNCHRONOUS "
|
|
938
|
+
+ "TypeError that type-checks cleanly and never fires on localhost, so no test sees it. "
|
|
939
|
+
+ "Use randomUuid from @terpjs/react-core, which falls back to crypto.getRandomValues "
|
|
940
|
+
+ "(not secure-context-gated) for the same entropy.";
|
|
921
941
|
const deepImportMessage =
|
|
922
942
|
"Import from the package root (@terpjs/react-core, @terpjs/contract), not its internals.";
|
|
923
943
|
const styleImportMessage =
|
|
@@ -998,11 +1018,32 @@ function restrictedSyntaxWithCatalogIds() {
|
|
|
998
1018
|
},
|
|
999
1019
|
]
|
|
1000
1020
|
: [];
|
|
1021
|
+
const rawRandomUuid = BOUNDARY_SPEC.restrictRawRandomUuid
|
|
1022
|
+
? [
|
|
1023
|
+
{
|
|
1024
|
+
// Any ACCESS, like the clipboard rule beside it and for the same reason:
|
|
1025
|
+
// `const gen = crypto.randomUUID` binds the same undefined one line before the
|
|
1026
|
+
// call that throws on it.
|
|
1027
|
+
catalogId: "frontend/no-raw-random-uuid",
|
|
1028
|
+
selector:
|
|
1029
|
+
"MemberExpression[object.name='crypto'][property.name='randomUUID'], MemberExpression[object.name='crypto'][computed=true][property.value='randomUUID'], MemberExpression[object.type='MemberExpression'][object.object.name=/^(window|globalThis|self)$/][object.property.name='crypto'][property.name='randomUUID'], MemberExpression[object.type='MemberExpression'][object.object.name=/^(window|globalThis|self)$/][object.computed=true][object.property.value='crypto'][property.name='randomUUID']",
|
|
1030
|
+
message: randomUuidMessage,
|
|
1031
|
+
},
|
|
1032
|
+
{
|
|
1033
|
+
// The destructuring spelling, which no member-expression selector reaches.
|
|
1034
|
+
catalogId: "frontend/no-raw-random-uuid",
|
|
1035
|
+
selector:
|
|
1036
|
+
"VariableDeclarator[init.name='crypto'] ObjectPattern > Property[key.name='randomUUID'], VariableDeclarator[init.type='MemberExpression'][init.property.name='crypto'] ObjectPattern > Property[key.name='randomUUID']",
|
|
1037
|
+
message: randomUuidMessage,
|
|
1038
|
+
},
|
|
1039
|
+
]
|
|
1040
|
+
: [];
|
|
1001
1041
|
return [
|
|
1002
1042
|
...rawElements,
|
|
1003
1043
|
...rawAttributes,
|
|
1004
1044
|
...inAppAnchors,
|
|
1005
1045
|
...rawClipboard,
|
|
1046
|
+
...rawRandomUuid,
|
|
1006
1047
|
{
|
|
1007
1048
|
catalogId: "frontend/no-dom-html-injection",
|
|
1008
1049
|
selector: "JSXAttribute[name.name='dangerouslySetInnerHTML']",
|
package/src/index.test.js
CHANGED
|
@@ -196,6 +196,61 @@ describe("terpBoundaries", () => {
|
|
|
196
196
|
expect(await lint("export const pick = (o) => o.clipboard;")).toEqual([]);
|
|
197
197
|
});
|
|
198
198
|
|
|
199
|
+
it("refuses crypto.randomUUID in every spelling that binds the same undefined", async () => {
|
|
200
|
+
// Secure-context-only, declared unconditionally by lib.dom: on an http origin each of
|
|
201
|
+
// these is a synchronous TypeError that type-checks cleanly and never fires on
|
|
202
|
+
// localhost, so neither tsc nor CI ever sees it.
|
|
203
|
+
expect(await lint("export const id = () => crypto.randomUUID();")).toContain(
|
|
204
|
+
"no-restricted-syntax",
|
|
205
|
+
);
|
|
206
|
+
expect(await lint('export const id = () => crypto["randomUUID"]();')).toContain(
|
|
207
|
+
"no-restricted-syntax",
|
|
208
|
+
);
|
|
209
|
+
expect(await lint("export const id = () => window.crypto.randomUUID();")).toContain(
|
|
210
|
+
"no-restricted-syntax",
|
|
211
|
+
);
|
|
212
|
+
expect(await lint("export const id = () => globalThis.crypto.randomUUID();")).toContain(
|
|
213
|
+
"no-restricted-syntax",
|
|
214
|
+
);
|
|
215
|
+
expect(await lint("export const id = () => self.crypto.randomUUID();")).toContain(
|
|
216
|
+
"no-restricted-syntax",
|
|
217
|
+
);
|
|
218
|
+
expect(
|
|
219
|
+
await lint('export const id = () => globalThis["crypto"].randomUUID();'),
|
|
220
|
+
).toContain("no-restricted-syntax");
|
|
221
|
+
// Bound rather than called: the reference is already the broken one.
|
|
222
|
+
expect(await lint("export const gen = crypto.randomUUID;")).toContain(
|
|
223
|
+
"no-restricted-syntax",
|
|
224
|
+
);
|
|
225
|
+
expect(await lint("export const { randomUUID } = crypto;")).toContain(
|
|
226
|
+
"no-restricted-syntax",
|
|
227
|
+
);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("accepts the react-core uuid seam, and the API that is not gated", async () => {
|
|
231
|
+
expect(
|
|
232
|
+
await lint(
|
|
233
|
+
'import { randomUuid } from "@terpjs/react-core";\nexport const id = () => randomUuid();',
|
|
234
|
+
),
|
|
235
|
+
).toEqual([]);
|
|
236
|
+
// getRandomValues is NOT secure-context-gated, so it is not this defect and the seam
|
|
237
|
+
// itself is built on it. Flagging it would make the rule a word filter on "crypto".
|
|
238
|
+
expect(
|
|
239
|
+
await lint("export const bytes = () => crypto.getRandomValues(new Uint8Array(16));"),
|
|
240
|
+
).toEqual([]);
|
|
241
|
+
// And a `randomUUID` on something that is not `crypto` is somebody else's function.
|
|
242
|
+
expect(await lint("export const id = (lib) => lib.randomUUID();")).toEqual([]);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("honours the uuid rule's declared escape hatch, and only its own name", async () => {
|
|
246
|
+
const call = "export const id = () => crypto.randomUUID();";
|
|
247
|
+
expect(
|
|
248
|
+
await lint(`// terp-allow-no-raw-random-uuid: https-only admin origin\n${call}`),
|
|
249
|
+
).toEqual([]);
|
|
250
|
+
const wrong = await lint(`// terp-allow-no-random-uuid: typo\n${call}`);
|
|
251
|
+
expect(wrong).toContain("no-restricted-syntax");
|
|
252
|
+
});
|
|
253
|
+
|
|
199
254
|
it("honours the clipboard rule's declared escape hatch, and only its own name", async () => {
|
|
200
255
|
// The catalog entry declares `// terp-allow-no-raw-clipboard: <reason>`, and a
|
|
201
256
|
// declared opt-out that does not actually suppress is a false promise in the
|
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.35.0";
|
|
20
20
|
|
|
21
21
|
export const BOUNDARY_SPEC = {
|
|
22
22
|
/** Every app-authored TypeScript source file whose user-facing copy must be cataloged. */
|
|
@@ -120,6 +120,7 @@ export const BOUNDARY_SPEC = {
|
|
|
120
120
|
* `copyText` / `useCopyToClipboard` feature-detect and report a refusal instead.
|
|
121
121
|
*/
|
|
122
122
|
restrictRawClipboard: true,
|
|
123
|
+
restrictRawRandomUuid: true,
|
|
123
124
|
/**
|
|
124
125
|
* The governed escape hatch (the frontend analog of the backend's `# arch-allow-*`): a
|
|
125
126
|
* justified `// terp-allow-<rule>: <reason>` comment on (or immediately above) a violating
|