@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@terpjs/eslint-boundaries",
3
- "version": "0.18.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.20.0"
25
+ "typescript-eslint": "^8.69.0"
26
26
  },
27
27
  "devDependencies": {
28
- "@terpjs/spec": "0.31.0",
29
- "eslint": "^10.8.0",
30
- "vitest": "^4.1.9"
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 === "LogicalExpression" || value.type === "BinaryExpression") {
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.31.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