@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@terpjs/eslint-boundaries",
3
- "version": "0.21.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.33.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: "<Trans> requires static non-empty id and message attributes.",
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.33.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