proteum 2.5.14 → 2.5.16

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.
@@ -97,6 +97,29 @@ The read-only MCP owner payloads return these anchors alongside `explain_summary
97
97
 
98
98
  `proteum docs check` verifies the whole corpus in both directions: it fails on an anchor that no longer resolves, and reports as a backlog every fix note carrying an `Agent warning` that no code anchors, plus every feature pack nothing points at. `proteum verify changed` selects it automatically whenever `docs/**` or a source file changes, so a renamed document cannot silently orphan an anchor.
99
99
 
100
+ ## Trust boundaries and caught errors
101
+
102
+ Two rules police the edges of the type system and the error path. Both allow the correct idiom and report the lazy one, so read the message before reaching for a disable comment.
103
+
104
+ `proteum/no-loose-unknown` reports `unknown` because it usually hides a contract that should be written down. It allows `unknown` in three places, because there it is the correct and safe answer:
105
+
106
+ - a catch binding, which is how TypeScript types it;
107
+ - the input of a type guard, since narrowing an untrusted value is the guard's whole job;
108
+ - anything carrying a `@boundary` tag in its doc comment.
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
+
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:
113
+
114
+ ```typescript
115
+ /** @boundary HumbleWorth prediction output, shape owned by the provider. */
116
+ export const parseOutput = (output: unknown) => ...
117
+ ```
118
+
119
+ `proteum/no-swallowed-caught-error` requires a caught error to reach somewhere. It accepts a rethrow, `Promise.reject`, `next(error)` for Express propagation, an error surfaced to the caller as a returned or pushed result, and the reporters configured through `errorReporters`. It also accepts preservation guarded by a condition **about the error**, because choosing which failures to report is a decision. It still reports a guard on whether the reporter exists, such as `if (app) app.reportError(error)`, because the error is lost whenever it does not.
120
+
121
+ `console.error(error)` is not error handling and never satisfies either rule.
122
+
100
123
  ## Self-check before finishing
101
124
 
102
125
  Re-scan every touched file against this list before declaring the work done:
package/eslint.js CHANGED
@@ -197,32 +197,131 @@ const isUnderConditionalControlFlow = (ancestors = []) =>
197
197
  );
198
198
  });
199
199
 
200
- const isPreservingCall = (callExpression, names, side, ancestors = []) => {
200
+ const defaultErrorReporters = ['app.reportError', 'app.handleError'];
201
+
202
+ /**
203
+ * Does this call match one of the configured `receiver.method` reporters?
204
+ *
205
+ * The receiver may be a bare identifier or the property of a longer chain, so
206
+ * `app.reportError(...)`, `this.app.reportError(...)` and `ctx.app.report(...)`
207
+ * all match a `app.reportError` entry.
208
+ */
209
+ const matchesConfiguredReporter = (callExpression, reporters) => {
210
+ const method = getMemberPropertyName(callExpression.callee);
211
+ if (!method) return false;
212
+
213
+ // Only project-added reporters are matched here. The built-in two stay with
214
+ // the side logic below, so a server file calling the client's `handleError`
215
+ // is still reported rather than quietly accepted.
216
+ const extraReporters = reporters.filter((reporter) => !defaultErrorReporters.includes(reporter));
217
+
218
+ return extraReporters.some((reporter) => {
219
+ const [receiverName, methodName] = reporter.includes('.') ? reporter.split('.') : [null, reporter];
220
+ if (method !== methodName) return false;
221
+ if (receiverName === null) return true;
222
+
223
+ const receiver = callExpression.callee.object;
224
+ if (receiver?.type === 'Identifier') return receiver.name === receiverName;
225
+
226
+ return getMemberPropertyName(receiver) === receiverName;
227
+ });
228
+ };
229
+
230
+ /**
231
+ * Express hands a caught error to its error middleware with `next(error)`. That
232
+ * is the framework's own propagation path, so treating it as a swallow would
233
+ * report the correct implementation of every route handler.
234
+ */
235
+ const isFrameworkPropagationCall = (callExpression, names) =>
236
+ callExpression.callee?.type === 'Identifier' &&
237
+ callExpression.callee.name === 'next' &&
238
+ (callExpression.arguments || []).some((argument) => nodeReferencesName(argument, names));
239
+
240
+ const isPreservingCall = (callExpression, names, side, ancestors = [], reporters = defaultErrorReporters) => {
201
241
  if (!nodeReferencesName(callExpression, names)) return false;
202
242
  if (hasOptionalCallBoundary(callExpression, ancestors)) return false;
203
- if (isUnderConditionalControlFlow(ancestors)) return false;
204
243
  if (isConsoleMember(callExpression.callee)) return false;
205
244
  if (isPromiseRejectCall(callExpression)) return true;
245
+ if (isFrameworkPropagationCall(callExpression, names)) return true;
246
+ if (matchesConfiguredReporter(callExpression, reporters)) return true;
206
247
  if (side === 'client') return isClientErrorHandlerCall(callExpression);
207
248
  if (side === 'server') return isServerErrorReporterCall(callExpression);
208
249
 
209
250
  return isClientErrorHandlerCall(callExpression) || isServerErrorReporterCall(callExpression);
210
251
  };
211
252
 
212
- const handlerPreservesCaughtError = (node, names, side) => {
253
+ /**
254
+ * Is the caught error handed back to the caller as a value?
255
+ *
256
+ * `return { ok: false, message: error.message }` and
257
+ * `results.push({ state: 'unavailable', message: describeError(error) })` both
258
+ * surface the failure in the API's own vocabulary. That is a designed
259
+ * degradation path, not a loss, so it counts as preservation.
260
+ */
261
+ const handlerSurfacesCaughtError = (node, names) => {
262
+ let surfaces = false;
263
+
264
+ traverseNode(node, (child) => {
265
+ if (child.type === 'ReturnStatement' && nodeReferencesName(child.argument, names)) surfaces = true;
266
+
267
+ if (
268
+ child.type === 'CallExpression' &&
269
+ ['push', 'unshift', 'add', 'set'].includes(getCalleePropertyName(child.callee) || '') &&
270
+ (child.arguments || []).some((argument) => nodeReferencesName(argument, names))
271
+ ) {
272
+ surfaces = true;
273
+ }
274
+ });
275
+
276
+ return surfaces;
277
+ };
278
+
279
+ /**
280
+ * Is preservation guarded by a condition that tests the caught error itself?
281
+ *
282
+ * `if (!(error instanceof ExpectedPause)) report(error)` is deliberate
283
+ * filtering: the author chose which failures are worth reporting, which is a
284
+ * decision rather than a swallow. `if (app) app.reportError(error)` is not: it
285
+ * gates on whether the reporter exists, so the error is lost whenever it does
286
+ * not. Only the first is accepted.
287
+ */
288
+ const isGuardedByErrorPredicate = (ancestors, names) =>
289
+ ancestors.some(({ node, childKey }) => {
290
+ if (node.type === 'IfStatement' && (childKey === 'consequent' || childKey === 'alternate'))
291
+ return nodeReferencesName(node.test, names);
292
+ if (node.type === 'ConditionalExpression' && (childKey === 'consequent' || childKey === 'alternate'))
293
+ return nodeReferencesName(node.test, names);
294
+ if (node.type === 'LogicalExpression' && childKey === 'right') return nodeReferencesName(node.left, names);
295
+ if (node.type === 'SwitchCase' && childKey === 'consequent') return true;
296
+
297
+ return false;
298
+ });
299
+
300
+ const preservationSurvivesControlFlow = (ancestors, names) =>
301
+ !isUnderConditionalControlFlow(ancestors) || isGuardedByErrorPredicate(ancestors, names);
302
+
303
+ const handlerPreservesCaughtError = (node, names, side, reporters = defaultErrorReporters) => {
213
304
  let preserves = false;
214
305
 
215
306
  traverseNode(node, (child, _parent, _parentKey, ancestors) => {
216
307
  if (
217
308
  child.type === 'ThrowStatement' &&
218
309
  nodeReferencesName(child.argument, names) &&
219
- !isUnderConditionalControlFlow(ancestors)
310
+ preservationSurvivesControlFlow(ancestors, names)
311
+ ) {
312
+ preserves = true;
313
+ }
314
+ if (
315
+ child.type === 'CallExpression' &&
316
+ isPreservingCall(child, names, side, ancestors, reporters) &&
317
+ preservationSurvivesControlFlow(ancestors, names)
220
318
  ) {
221
319
  preserves = true;
222
320
  }
223
- if (child.type === 'CallExpression' && isPreservingCall(child, names, side, ancestors)) preserves = true;
224
321
  });
225
322
 
323
+ if (!preserves && handlerSurfacesCaughtError(node, names)) preserves = true;
324
+
226
325
  return preserves;
227
326
  };
228
327
 
@@ -245,10 +344,22 @@ const createSwallowedErrorRule = () => ({
245
344
  unpreserved:
246
345
  'Caught error `{{name}}` is used but not routed through the standard error path. Rethrow it, call app.reportError on the server, or call app.handleError on the client.',
247
346
  },
248
- schema: [],
347
+ schema: [
348
+ {
349
+ type: 'object',
350
+ properties: {
351
+ // `receiver.method` or a bare `method`. A project whose error
352
+ // path is not `app.reportError` names it here rather than
353
+ // being told its own convention is a swallow.
354
+ reporters: { type: 'array', items: { type: 'string' } },
355
+ },
356
+ additionalProperties: false,
357
+ },
358
+ ],
249
359
  },
250
360
  create(context) {
251
361
  const side = getErrorHandlingSide(context.filename || context.getFilename?.() || '');
362
+ const reporters = context.options?.[0]?.reporters || defaultErrorReporters;
252
363
  const reportHandler = (node, params, body) => {
253
364
  const names = params.flatMap((param) => collectPatternNames(param));
254
365
  if (names.length === 0) {
@@ -262,7 +373,7 @@ const createSwallowedErrorRule = () => ({
262
373
  return;
263
374
  }
264
375
 
265
- if (!handlerPreservesCaughtError(body, collectDerivedErrorNames(body, names), side)) {
376
+ if (!handlerPreservesCaughtError(body, collectDerivedErrorNames(body, names), side, reporters)) {
266
377
  context.report({ node, messageId: 'unpreserved', data: { name: referencedName } });
267
378
  }
268
379
  };
@@ -287,6 +398,100 @@ const createSwallowedErrorRule = () => ({
287
398
  },
288
399
  });
289
400
 
401
+ const boundaryTagPattern = /(^|\s)@boundary(\s|$)/;
402
+
403
+ /**
404
+ * Is this `unknown` inside a function whose return type is a type predicate?
405
+ *
406
+ * Narrowing an untrusted value is the entire job of a guard, so `(value: unknown):
407
+ * value is DomainField` is the correct signature. Any narrower input type would
408
+ * defeat the guard it belongs to.
409
+ */
410
+ const isWithinTypeGuardSignature = (node) => {
411
+ let current = node.parent;
412
+ while (current) {
413
+ if (current.returnType?.typeAnnotation?.type === 'TSTypePredicate') return true;
414
+ current = current.parent;
415
+ }
416
+
417
+ return false;
418
+ };
419
+
420
+ /**
421
+ * Is this `unknown` inside the *parameter* of a catch clause?
422
+ *
423
+ * Range containment rather than a parent-chain shape check, so a destructured or
424
+ * annotated binding qualifies too. The containment test matters: the catch BODY
425
+ * also has the clause as an ancestor, and `unknown` there is not the language's
426
+ * doing and still needs a reason.
427
+ */
428
+ const isWithinCatchParameter = (node) => {
429
+ let current = node.parent;
430
+ while (current) {
431
+ if (current.type === 'CatchClause') {
432
+ return (
433
+ current.param != null &&
434
+ node.range[0] >= current.param.range[0] &&
435
+ node.range[1] <= current.param.range[1]
436
+ );
437
+ }
438
+ current = current.parent;
439
+ }
440
+
441
+ return false;
442
+ };
443
+
444
+ const createNoLooseUnknownRule = () => ({
445
+ meta: {
446
+ type: 'problem',
447
+ docs: {
448
+ description: 'Disallow `unknown` except where a value genuinely crosses a trust boundary.',
449
+ },
450
+ messages: {
451
+ looseUnknown:
452
+ '`unknown` hides a contract that should be typed. Define the explicit type, or, when the value really does arrive from outside (a parsed response, a provider payload, a caught error), document it with a `@boundary` comment saying where it comes from.',
453
+ },
454
+ schema: [],
455
+ },
456
+ create(context) {
457
+ const sourceCode = getSourceCode(context);
458
+
459
+ // A `@boundary` tag anywhere above the declaration marks a deliberate
460
+ // trust boundary. Requiring the tag rather than allowing `unknown`
461
+ // silently keeps the boundaries greppable and forces a written reason.
462
+ const hasBoundaryTag = (node) => {
463
+ if (!sourceCode) return false;
464
+
465
+ const comments = sourceCode.getCommentsBefore?.(node) || [];
466
+ if (comments.some((comment) => boundaryTagPattern.test(comment.value))) return true;
467
+
468
+ // Walk to the enclosing declaration rather than a fixed few levels:
469
+ // a nested position such as `{ query(v?: readonly unknown[]): Promise<unknown> }`
470
+ // sits eight or more nodes below the parameter the tag documents.
471
+ let current = node.parent;
472
+ while (current && current.type !== 'Program') {
473
+ const ancestorComments = sourceCode.getCommentsBefore?.(current) || [];
474
+ if (ancestorComments.some((comment) => boundaryTagPattern.test(comment.value))) return true;
475
+ current = current.parent;
476
+ }
477
+
478
+ return false;
479
+ };
480
+
481
+ return {
482
+ TSUnknownKeyword(node) {
483
+ // TypeScript itself types a catch binding as `unknown`, so banning
484
+ // it there bans the language's own contract.
485
+ if (isWithinCatchParameter(node)) return;
486
+ if (isWithinTypeGuardSignature(node)) return;
487
+ if (hasBoundaryTag(node)) return;
488
+
489
+ context.report({ node, messageId: 'looseUnknown' });
490
+ },
491
+ };
492
+ },
493
+ });
494
+
290
495
  const createNoAppImportRule = () => ({
291
496
  meta: {
292
497
  type: 'problem',
@@ -669,11 +874,20 @@ const createValidDocAnchorRule = () => ({
669
874
  },
670
875
  });
671
876
 
877
+ const defaultTestFilePatterns = [
878
+ '**/*.test.{ts,tsx,mts,cts}',
879
+ '**/*.spec.{ts,tsx,mts,cts}',
880
+ '**/*.node-test.{ts,tsx,mts,cts}',
881
+ '**/tests/**/*.{ts,tsx,mts,cts}',
882
+ ];
883
+
672
884
  const createProteumEslintConfig = ({
673
885
  docAnchors = 'warn',
886
+ errorReporters = defaultErrorReporters,
674
887
  excludeDocAnchors = [],
675
888
  includeDocAnchors = [],
676
889
  ignores = [],
890
+ testFiles = defaultTestFilePatterns,
677
891
  } = {}) => [
678
892
  {
679
893
  ignores: [...defaultIgnores, ...ignores],
@@ -700,6 +914,7 @@ const createProteumEslintConfig = ({
700
914
  proteum: {
701
915
  rules: {
702
916
  'no-app-import': createNoAppImportRule(),
917
+ 'no-loose-unknown': createNoLooseUnknownRule(),
703
918
  'no-swallowed-caught-error': createSwallowedErrorRule(),
704
919
  'require-doc-anchor': createRequireDocAnchorRule(),
705
920
  'valid-doc-anchor': createValidDocAnchorRule(),
@@ -712,7 +927,7 @@ const createProteumEslintConfig = ({
712
927
  rules: {
713
928
  '@typescript-eslint/no-explicit-any': 'error',
714
929
  'proteum/no-app-import': 'error',
715
- 'proteum/no-swallowed-caught-error': 'error',
930
+ 'proteum/no-swallowed-caught-error': ['error', { reporters: errorReporters }],
716
931
  // Missing anchors warn by default so adopting apps see the backlog
717
932
  // without a failing build; pass `docAnchors: 'error'` once backfilled.
718
933
  // Routes, controllers and service classes are covered automatically.
@@ -727,12 +942,12 @@ const createProteumEslintConfig = ({
727
942
  // already opted in, and a pointer to a deleted document is worse
728
943
  // than no pointer at all.
729
944
  'proteum/valid-doc-anchor': docAnchors === 'off' ? 'off' : 'error',
945
+ // Replaces the old bare `TSUnknownKeyword` selector. A selector has no
946
+ // context, so it could not tell an internal contract that should be
947
+ // typed from a value that genuinely arrives from outside.
948
+ 'proteum/no-loose-unknown': 'error',
730
949
  'no-restricted-syntax': [
731
950
  'error',
732
- {
733
- selector: 'TSUnknownKeyword',
734
- message: 'Do not use `unknown`; define an explicit type instead.',
735
- },
736
951
  {
737
952
  selector: createZodTypeFactorySelector('any'),
738
953
  message: 'Do not use Zod `any()` schemas; define an explicit schema instead.',
@@ -744,8 +959,19 @@ const createProteumEslintConfig = ({
744
959
  ],
745
960
  },
746
961
  },
962
+ {
963
+ // A test fixture deliberately builds a shape the production types forbid,
964
+ // usually through `as unknown as X` or a partial mock. That is a test
965
+ // technique, not a trust boundary, so demanding a `@boundary` reason for
966
+ // each one would add noise without documenting anything real.
967
+ files: testFiles,
968
+ rules: {
969
+ 'proteum/no-loose-unknown': 'off',
970
+ },
971
+ },
747
972
  ];
748
973
 
749
974
  module.exports = {
750
975
  createProteumEslintConfig,
976
+ defaultTestFilePatterns,
751
977
  };
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.14",
4
+ "version": "2.5.16",
5
5
  "author": "Gaetan Le Gac (https://github.com/gaetanlegac)",
6
6
  "repository": "git://github.com/gaetanlegac/proteum.git",
7
7
  "license": "MIT",
@@ -0,0 +1,269 @@
1
+ const assert = require('node:assert/strict');
2
+ const { Linter } = require('eslint');
3
+
4
+ const { createProteumEslintConfig } = require('../eslint.js');
5
+
6
+ const lint = (code, filename = 'server/example.ts', options) => {
7
+ const linter = new Linter({ configType: 'flat' });
8
+ return linter.verify(code, createProteumEslintConfig(options), { filename });
9
+ };
10
+
11
+ const looseUnknownRuleId = 'proteum/no-loose-unknown';
12
+ const swallowedRuleId = 'proteum/no-swallowed-caught-error';
13
+ const count = (messages, ruleId) => messages.filter((message) => message.ruleId === ruleId).length;
14
+
15
+ /*----------------------------------
16
+ - no-loose-unknown
17
+ ----------------------------------*/
18
+
19
+ test('loose unknown is still reported on an ordinary contract', () => {
20
+ const messages = lint(`export type TRow = { value: unknown };`);
21
+
22
+ assert.equal(count(messages, looseUnknownRuleId), 1);
23
+ });
24
+
25
+ test('unknown is allowed on a catch binding, which is how TypeScript types it', () => {
26
+ const messages = lint(`
27
+ export const run = () => {
28
+ try {
29
+ risky();
30
+ } catch (error: unknown) {
31
+ throw error;
32
+ }
33
+ };
34
+ `);
35
+
36
+ assert.equal(count(messages, looseUnknownRuleId), 0);
37
+ });
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
+
54
+ test('unknown is allowed as the input of a type guard', () => {
55
+ const messages = lint(`
56
+ export const isDomainField = (value: unknown): value is string => typeof value === 'string';
57
+ `);
58
+
59
+ assert.equal(count(messages, looseUnknownRuleId), 0);
60
+ });
61
+
62
+ test('unknown is allowed when the trust boundary is documented', () => {
63
+ const messages = lint(`
64
+ /** @boundary HumbleWorth prediction output, shape owned by the provider. */
65
+ export const parseOutput = (output: unknown) => output;
66
+ `);
67
+
68
+ assert.equal(count(messages, looseUnknownRuleId), 0);
69
+ });
70
+
71
+ test('an undocumented parse boundary is still reported', () => {
72
+ const messages = lint(`export const parseOutput = (output: unknown) => output;`);
73
+
74
+ assert.equal(count(messages, looseUnknownRuleId), 1);
75
+ });
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
+
103
+ /*----------------------------------
104
+ - no-swallowed-caught-error
105
+ ----------------------------------*/
106
+
107
+ test('express next(error) counts as propagation', () => {
108
+ const messages = lint(`
109
+ export const handler = async (req, res, next) => {
110
+ try {
111
+ await load();
112
+ } catch (error) {
113
+ next(error);
114
+ }
115
+ };
116
+ `);
117
+
118
+ assert.equal(count(messages, swallowedRuleId), 0);
119
+ });
120
+
121
+ test('a configured reporter counts as preservation', () => {
122
+ const source = `
123
+ export class Scanner {
124
+ public async run() {
125
+ try {
126
+ await this.scan();
127
+ } catch (error) {
128
+ this.app.reportError(error, { source: 'scan', code: 'failed' });
129
+ }
130
+ }
131
+ }
132
+ `;
133
+
134
+ assert.equal(count(lint(source), swallowedRuleId), 0);
135
+
136
+ // A project whose error path has another name declares it rather than being
137
+ // told its own convention is a swallow.
138
+ const custom = `
139
+ export class Scanner {
140
+ public async run() {
141
+ try {
142
+ await this.scan();
143
+ } catch (error) {
144
+ this.app.report('scan failed', error);
145
+ }
146
+ }
147
+ }
148
+ `;
149
+
150
+ assert.equal(count(lint(custom), swallowedRuleId), 1);
151
+ assert.equal(
152
+ count(lint(custom, 'server/example.ts', { errorReporters: ['app.reportError', 'app.report'] }), swallowedRuleId),
153
+ 0,
154
+ );
155
+ });
156
+
157
+ test('conditional preservation counts, because deliberate filtering is not a swallow', () => {
158
+ const messages = lint(`
159
+ export class Scanner {
160
+ public async run() {
161
+ try {
162
+ await this.scan();
163
+ } catch (error) {
164
+ if (!(error instanceof ExpectedPause)) {
165
+ this.app.reportError(error, { source: 'scan', code: 'failed' });
166
+ }
167
+ }
168
+ }
169
+ }
170
+ `);
171
+
172
+ assert.equal(count(messages, swallowedRuleId), 0);
173
+ });
174
+
175
+ test('a guard on the reporter existing is still a swallow, unlike a guard on the error', () => {
176
+ // Filtering by what the error is: a decision.
177
+ const filtered = lint(`
178
+ export const run = async () => {
179
+ try {
180
+ await load();
181
+ } catch (error) {
182
+ if (error.code !== 'EXPECTED') app.reportError(error);
183
+ }
184
+ };
185
+ `);
186
+ assert.equal(count(filtered, swallowedRuleId), 0);
187
+
188
+ // Gating on whether the reporter exists: the error vanishes when it does not.
189
+ const gated = lint(`
190
+ export const run = async (app) => {
191
+ try {
192
+ await load();
193
+ } catch (error) {
194
+ if (app) app.reportError(error);
195
+ }
196
+ };
197
+ `);
198
+ assert.equal(count(gated, swallowedRuleId), 1);
199
+ });
200
+
201
+ test('a guarded rethrow counts as preservation', () => {
202
+ const messages = lint(`
203
+ export const run = async () => {
204
+ try {
205
+ await load();
206
+ } catch (error) {
207
+ if (error.code !== 'ENOENT') throw error;
208
+ }
209
+ };
210
+ `);
211
+
212
+ assert.equal(count(messages, swallowedRuleId), 0);
213
+ });
214
+
215
+ test('an error surfaced as a returned result counts as preservation', () => {
216
+ const messages = lint(`
217
+ export const run = async () => {
218
+ try {
219
+ await load();
220
+ } catch (error) {
221
+ return { ok: false, message: error.message };
222
+ }
223
+ };
224
+ `);
225
+
226
+ assert.equal(count(messages, swallowedRuleId), 0);
227
+ });
228
+
229
+ test('an error pushed into a result collection counts as preservation', () => {
230
+ const messages = lint(`
231
+ export const run = async (results) => {
232
+ try {
233
+ await load();
234
+ } catch (error) {
235
+ results.push({ state: 'unavailable', message: describeError(error) });
236
+ }
237
+ };
238
+ `);
239
+
240
+ assert.equal(count(messages, swallowedRuleId), 0);
241
+ });
242
+
243
+ test('a console-only catch is still reported', () => {
244
+ const messages = lint(`
245
+ export const run = async () => {
246
+ try {
247
+ await load();
248
+ } catch (error) {
249
+ console.error('load failed', error);
250
+ }
251
+ };
252
+ `);
253
+
254
+ assert.equal(count(messages, swallowedRuleId), 1);
255
+ });
256
+
257
+ test('a discarded error is still reported', () => {
258
+ const messages = lint(`
259
+ export const run = async () => {
260
+ try {
261
+ await load();
262
+ } catch (error) {
263
+ return null;
264
+ }
265
+ };
266
+ `);
267
+
268
+ assert.equal(count(messages, swallowedRuleId), 1);
269
+ });