proteum 2.5.15 → 2.5.17

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.
@@ -107,6 +107,8 @@ Two rules police the edges of the type system and the error path. Both allow the
107
107
  - the input of a type guard, since narrowing an untrusted value is the guard's whole job;
108
108
  - anything carrying a `@boundary` tag in its doc comment.
109
109
 
110
+ Test files are exempt from this rule entirely: a fixture built with `as unknown as X` is a test technique, not a trust boundary. The other rules still apply in tests.
111
+
110
112
  Use `@boundary` when the value genuinely arrives from outside: a parsed response, a provider payload, a third-party shim. State where it comes from, so the codebase's trust boundaries stay greppable:
111
113
 
112
114
  ```typescript
package/eslint.js CHANGED
@@ -183,6 +183,8 @@ const hasOptionalCallBoundary = (callExpression, ancestors = []) => {
183
183
  return hasOptional || ancestors.some(({ node }) => node.type === 'ChainExpression');
184
184
  };
185
185
 
186
+ // A loop body is iteration, not a condition. `for (const item of batch) item.reject(error)`
187
+ // preserves the error for every item there is, so it is not a swallow.
186
188
  const isUnderConditionalControlFlow = (ancestors = []) =>
187
189
  ancestors.some(({ node, childKey }) => {
188
190
  if (node.type === 'IfStatement') return childKey === 'consequent' || childKey === 'alternate';
@@ -190,13 +192,33 @@ const isUnderConditionalControlFlow = (ancestors = []) =>
190
192
  if (node.type === 'LogicalExpression') return childKey === 'right';
191
193
  if (node.type === 'SwitchCase') return childKey === 'consequent';
192
194
 
193
- return (
194
- ['ForInStatement', 'ForOfStatement', 'ForStatement', 'WhileStatement', 'DoWhileStatement'].includes(
195
- node.type,
196
- ) && childKey === 'body'
197
- );
195
+ return false;
196
+ });
197
+
198
+ /**
199
+ * Does the handler throw, whatever it throws?
200
+ *
201
+ * `catch { throw new Error('must be an absolute URL') }` translates a failure
202
+ * into the domain's own vocabulary. The original object is dropped, but the
203
+ * failure still propagates and nothing continues silently, which is what this
204
+ * rule exists to prevent. Attaching the original as `cause` is better practice,
205
+ * not a separate correctness question for this rule.
206
+ */
207
+ const handlerThrows = (node) => {
208
+ let throws = false;
209
+
210
+ traverseNode(node, (child, _parent, _parentKey, ancestors) => {
211
+ if (child.type === 'ThrowStatement' && !isUnderConditionalControlFlow(ancestors)) throws = true;
198
212
  });
199
213
 
214
+ return throws;
215
+ };
216
+
217
+ // A returned fallback is deliberately NOT accepted. `catch { return null }` is
218
+ // the textbook swallow, and no structural signal separates it from
219
+ // `.catch(() => [])`. Where a fallback really is correct, the call site says so
220
+ // with a disable comment and a reason, which stays greppable.
221
+
200
222
  const defaultErrorReporters = ['app.reportError', 'app.handleError'];
201
223
 
202
224
  /**
@@ -363,6 +385,11 @@ const createSwallowedErrorRule = () => ({
363
385
  const reportHandler = (node, params, body) => {
364
386
  const names = params.flatMap((param) => collectPatternNames(param));
365
387
  if (names.length === 0) {
388
+ // Nothing was bound, so nothing can be routed. Still accepted when
389
+ // the handler translates the failure into a throw, because the
390
+ // failure keeps propagating rather than being continued past.
391
+ if (handlerThrows(body)) return;
392
+
366
393
  context.report({ node, messageId: 'missingParam' });
367
394
  return;
368
395
  }
@@ -373,6 +400,8 @@ const createSwallowedErrorRule = () => ({
373
400
  return;
374
401
  }
375
402
 
403
+ if (handlerThrows(body)) return;
404
+
376
405
  if (!handlerPreservesCaughtError(body, collectDerivedErrorNames(body, names), side, reporters)) {
377
406
  context.report({ node, messageId: 'unpreserved', data: { name: referencedName } });
378
407
  }
@@ -407,14 +436,39 @@ const boundaryTagPattern = /(^|\s)@boundary(\s|$)/;
407
436
  * value is DomainField` is the correct signature. Any narrower input type would
408
437
  * defeat the guard it belongs to.
409
438
  */
410
- const isWithinTypeGuardSignature = (ancestors) =>
411
- ancestors.some(({ node }) => {
412
- const returnType = node?.returnType?.typeAnnotation;
413
- return returnType?.type === 'TSTypePredicate';
414
- });
439
+ const isWithinTypeGuardSignature = (node) => {
440
+ let current = node.parent;
441
+ while (current) {
442
+ if (current.returnType?.typeAnnotation?.type === 'TSTypePredicate') return true;
443
+ current = current.parent;
444
+ }
415
445
 
416
- const isWithinCatchParameter = (ancestors) =>
417
- ancestors.some(({ node, childKey }) => node?.type === 'CatchClause' && childKey === 'param');
446
+ return false;
447
+ };
448
+
449
+ /**
450
+ * Is this `unknown` inside the *parameter* of a catch clause?
451
+ *
452
+ * Range containment rather than a parent-chain shape check, so a destructured or
453
+ * annotated binding qualifies too. The containment test matters: the catch BODY
454
+ * also has the clause as an ancestor, and `unknown` there is not the language's
455
+ * doing and still needs a reason.
456
+ */
457
+ const isWithinCatchParameter = (node) => {
458
+ let current = node.parent;
459
+ while (current) {
460
+ if (current.type === 'CatchClause') {
461
+ return (
462
+ current.param != null &&
463
+ node.range[0] >= current.param.range[0] &&
464
+ node.range[1] <= current.param.range[1]
465
+ );
466
+ }
467
+ current = current.parent;
468
+ }
469
+
470
+ return false;
471
+ };
418
472
 
419
473
  const createNoLooseUnknownRule = () => ({
420
474
  meta: {
@@ -440,13 +494,14 @@ const createNoLooseUnknownRule = () => ({
440
494
  const comments = sourceCode.getCommentsBefore?.(node) || [];
441
495
  if (comments.some((comment) => boundaryTagPattern.test(comment.value))) return true;
442
496
 
497
+ // Walk to the enclosing declaration rather than a fixed few levels:
498
+ // a nested position such as `{ query(v?: readonly unknown[]): Promise<unknown> }`
499
+ // sits eight or more nodes below the parameter the tag documents.
443
500
  let current = node.parent;
444
- let depth = 0;
445
- while (current && depth < 6) {
501
+ while (current && current.type !== 'Program') {
446
502
  const ancestorComments = sourceCode.getCommentsBefore?.(current) || [];
447
503
  if (ancestorComments.some((comment) => boundaryTagPattern.test(comment.value))) return true;
448
504
  current = current.parent;
449
- depth += 1;
450
505
  }
451
506
 
452
507
  return false;
@@ -454,21 +509,10 @@ const createNoLooseUnknownRule = () => ({
454
509
 
455
510
  return {
456
511
  TSUnknownKeyword(node) {
457
- const ancestors = [];
458
- let current = node.parent;
459
- while (current) {
460
- ancestors.push({ childKey: null, node: current });
461
- current = current.parent;
462
- }
463
-
464
512
  // TypeScript itself types a catch binding as `unknown`, so banning
465
513
  // it there bans the language's own contract.
466
- if (node.parent?.type === 'TSTypeAnnotation' && node.parent.parent?.type === 'Identifier') {
467
- const owner = node.parent.parent.parent;
468
- if (owner?.type === 'CatchClause') return;
469
- }
470
- if (isWithinCatchParameter(ancestors)) return;
471
- if (isWithinTypeGuardSignature(ancestors)) return;
514
+ if (isWithinCatchParameter(node)) return;
515
+ if (isWithinTypeGuardSignature(node)) return;
472
516
  if (hasBoundaryTag(node)) return;
473
517
 
474
518
  context.report({ node, messageId: 'looseUnknown' });
@@ -859,12 +903,20 @@ const createValidDocAnchorRule = () => ({
859
903
  },
860
904
  });
861
905
 
906
+ const defaultTestFilePatterns = [
907
+ '**/*.test.{ts,tsx,mts,cts}',
908
+ '**/*.spec.{ts,tsx,mts,cts}',
909
+ '**/*.node-test.{ts,tsx,mts,cts}',
910
+ '**/tests/**/*.{ts,tsx,mts,cts}',
911
+ ];
912
+
862
913
  const createProteumEslintConfig = ({
863
914
  docAnchors = 'warn',
864
915
  errorReporters = defaultErrorReporters,
865
916
  excludeDocAnchors = [],
866
917
  includeDocAnchors = [],
867
918
  ignores = [],
919
+ testFiles = defaultTestFilePatterns,
868
920
  } = {}) => [
869
921
  {
870
922
  ignores: [...defaultIgnores, ...ignores],
@@ -936,8 +988,19 @@ const createProteumEslintConfig = ({
936
988
  ],
937
989
  },
938
990
  },
991
+ {
992
+ // A test fixture deliberately builds a shape the production types forbid,
993
+ // usually through `as unknown as X` or a partial mock. That is a test
994
+ // technique, not a trust boundary, so demanding a `@boundary` reason for
995
+ // each one would add noise without documenting anything real.
996
+ files: testFiles,
997
+ rules: {
998
+ 'proteum/no-loose-unknown': 'off',
999
+ },
1000
+ },
939
1001
  ];
940
1002
 
941
1003
  module.exports = {
942
1004
  createProteumEslintConfig,
1005
+ defaultTestFilePatterns,
943
1006
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "proteum",
3
3
  "description": "LLM-first Opinionated Typescript Framework for web applications.",
4
- "version": "2.5.15",
4
+ "version": "2.5.17",
5
5
  "author": "Gaetan Le Gac (https://github.com/gaetanlegac)",
6
6
  "repository": "git://github.com/gaetanlegac/proteum.git",
7
7
  "license": "MIT",
@@ -36,6 +36,21 @@ test('unknown is allowed on a catch binding, which is how TypeScript types it',
36
36
  assert.equal(count(messages, looseUnknownRuleId), 0);
37
37
  });
38
38
 
39
+ test('unknown inside a catch BODY still needs a reason, only the binding is exempt', () => {
40
+ const messages = lint(`
41
+ export const run = () => {
42
+ try {
43
+ risky();
44
+ } catch (error: unknown) {
45
+ const detail: unknown = extract(error);
46
+ throw error;
47
+ }
48
+ };
49
+ `);
50
+
51
+ assert.equal(count(messages, looseUnknownRuleId), 1);
52
+ });
53
+
39
54
  test('unknown is allowed as the input of a type guard', () => {
40
55
  const messages = lint(`
41
56
  export const isDomainField = (value: unknown): value is string => typeof value === 'string';
@@ -59,6 +74,32 @@ test('an undocumented parse boundary is still reported', () => {
59
74
  assert.equal(count(messages, looseUnknownRuleId), 1);
60
75
  });
61
76
 
77
+ test('test files may use unknown without a boundary reason', () => {
78
+ const fixture = `const STATUS_RULE = [{ filterId: 'status' }] as unknown as RadarRulesContract;`;
79
+
80
+ assert.equal(count(lint(fixture, 'server/example.ts'), looseUnknownRuleId), 1);
81
+ assert.equal(count(lint(fixture, 'server/example.test.ts'), looseUnknownRuleId), 0);
82
+ assert.equal(count(lint(fixture, 'src/Domains/PendingList.node-test.ts'), looseUnknownRuleId), 0);
83
+ assert.equal(count(lint(fixture, 'tests/unit/scope-builder.ts'), looseUnknownRuleId), 0);
84
+ });
85
+
86
+ test('the other rules still apply inside test files', () => {
87
+ const messages = lint(
88
+ `
89
+ export const run = async () => {
90
+ try {
91
+ await load();
92
+ } catch (error) {
93
+ console.error('load failed', error);
94
+ }
95
+ };
96
+ `,
97
+ 'server/example.test.ts',
98
+ );
99
+
100
+ assert.equal(count(messages, swallowedRuleId), 1);
101
+ });
102
+
62
103
  /*----------------------------------
63
104
  - no-swallowed-caught-error
64
105
  ----------------------------------*/
@@ -199,6 +240,46 @@ test('an error pushed into a result collection counts as preservation', () => {
199
240
  assert.equal(count(messages, swallowedRuleId), 0);
200
241
  });
201
242
 
243
+ test('translating a failure into a new throw counts, even without the original', () => {
244
+ const messages = lint(`
245
+ export const assertUrl = (raw) => {
246
+ try {
247
+ return new URL(raw);
248
+ } catch {
249
+ throw new Error('must be an absolute URL');
250
+ }
251
+ };
252
+ `);
253
+
254
+ assert.equal(count(messages, swallowedRuleId), 0);
255
+ });
256
+
257
+ test('preservation inside a loop body counts, because iteration is not a condition', () => {
258
+ const messages = lint(`
259
+ export const run = (batch) => {
260
+ load().catch((error) => {
261
+ for (const item of batch) {
262
+ item.reject(error);
263
+ }
264
+ });
265
+ };
266
+ `);
267
+
268
+ assert.equal(count(messages, swallowedRuleId), 0);
269
+ });
270
+
271
+ test('a returned fallback is still a swallow, however it is spelled', () => {
272
+ const nullFallback = lint(`
273
+ export const run = () => {
274
+ try { risky(); } catch { return null; }
275
+ };
276
+ `);
277
+ assert.equal(count(nullFallback, swallowedRuleId), 1);
278
+
279
+ const emptyList = lint(`export const run = (d) => dns.resolveMx(d).catch(() => []);`);
280
+ assert.equal(count(emptyList, swallowedRuleId), 1);
281
+ });
282
+
202
283
  test('a console-only catch is still reported', () => {
203
284
  const messages = lint(`
204
285
  export const run = async () => {