proteum 2.5.14 → 2.5.15
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/agents/project/CODING_STYLE.md +21 -0
- package/eslint.js +204 -12
- package/package.json +1 -1
- package/tests/error-boundary-rules.test.cjs +228 -0
|
@@ -97,6 +97,27 @@ 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
|
+
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
|
+
|
|
112
|
+
```typescript
|
|
113
|
+
/** @boundary HumbleWorth prediction output, shape owned by the provider. */
|
|
114
|
+
export const parseOutput = (output: unknown) => ...
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`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.
|
|
118
|
+
|
|
119
|
+
`console.error(error)` is not error handling and never satisfies either rule.
|
|
120
|
+
|
|
100
121
|
## Self-check before finishing
|
|
101
122
|
|
|
102
123
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,85 @@ 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 = (ancestors) =>
|
|
411
|
+
ancestors.some(({ node }) => {
|
|
412
|
+
const returnType = node?.returnType?.typeAnnotation;
|
|
413
|
+
return returnType?.type === 'TSTypePredicate';
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
const isWithinCatchParameter = (ancestors) =>
|
|
417
|
+
ancestors.some(({ node, childKey }) => node?.type === 'CatchClause' && childKey === 'param');
|
|
418
|
+
|
|
419
|
+
const createNoLooseUnknownRule = () => ({
|
|
420
|
+
meta: {
|
|
421
|
+
type: 'problem',
|
|
422
|
+
docs: {
|
|
423
|
+
description: 'Disallow `unknown` except where a value genuinely crosses a trust boundary.',
|
|
424
|
+
},
|
|
425
|
+
messages: {
|
|
426
|
+
looseUnknown:
|
|
427
|
+
'`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.',
|
|
428
|
+
},
|
|
429
|
+
schema: [],
|
|
430
|
+
},
|
|
431
|
+
create(context) {
|
|
432
|
+
const sourceCode = getSourceCode(context);
|
|
433
|
+
|
|
434
|
+
// A `@boundary` tag anywhere above the declaration marks a deliberate
|
|
435
|
+
// trust boundary. Requiring the tag rather than allowing `unknown`
|
|
436
|
+
// silently keeps the boundaries greppable and forces a written reason.
|
|
437
|
+
const hasBoundaryTag = (node) => {
|
|
438
|
+
if (!sourceCode) return false;
|
|
439
|
+
|
|
440
|
+
const comments = sourceCode.getCommentsBefore?.(node) || [];
|
|
441
|
+
if (comments.some((comment) => boundaryTagPattern.test(comment.value))) return true;
|
|
442
|
+
|
|
443
|
+
let current = node.parent;
|
|
444
|
+
let depth = 0;
|
|
445
|
+
while (current && depth < 6) {
|
|
446
|
+
const ancestorComments = sourceCode.getCommentsBefore?.(current) || [];
|
|
447
|
+
if (ancestorComments.some((comment) => boundaryTagPattern.test(comment.value))) return true;
|
|
448
|
+
current = current.parent;
|
|
449
|
+
depth += 1;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return false;
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
return {
|
|
456
|
+
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
|
+
// TypeScript itself types a catch binding as `unknown`, so banning
|
|
465
|
+
// 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;
|
|
472
|
+
if (hasBoundaryTag(node)) return;
|
|
473
|
+
|
|
474
|
+
context.report({ node, messageId: 'looseUnknown' });
|
|
475
|
+
},
|
|
476
|
+
};
|
|
477
|
+
},
|
|
478
|
+
});
|
|
479
|
+
|
|
290
480
|
const createNoAppImportRule = () => ({
|
|
291
481
|
meta: {
|
|
292
482
|
type: 'problem',
|
|
@@ -671,6 +861,7 @@ const createValidDocAnchorRule = () => ({
|
|
|
671
861
|
|
|
672
862
|
const createProteumEslintConfig = ({
|
|
673
863
|
docAnchors = 'warn',
|
|
864
|
+
errorReporters = defaultErrorReporters,
|
|
674
865
|
excludeDocAnchors = [],
|
|
675
866
|
includeDocAnchors = [],
|
|
676
867
|
ignores = [],
|
|
@@ -700,6 +891,7 @@ const createProteumEslintConfig = ({
|
|
|
700
891
|
proteum: {
|
|
701
892
|
rules: {
|
|
702
893
|
'no-app-import': createNoAppImportRule(),
|
|
894
|
+
'no-loose-unknown': createNoLooseUnknownRule(),
|
|
703
895
|
'no-swallowed-caught-error': createSwallowedErrorRule(),
|
|
704
896
|
'require-doc-anchor': createRequireDocAnchorRule(),
|
|
705
897
|
'valid-doc-anchor': createValidDocAnchorRule(),
|
|
@@ -712,7 +904,7 @@ const createProteumEslintConfig = ({
|
|
|
712
904
|
rules: {
|
|
713
905
|
'@typescript-eslint/no-explicit-any': 'error',
|
|
714
906
|
'proteum/no-app-import': 'error',
|
|
715
|
-
'proteum/no-swallowed-caught-error': 'error',
|
|
907
|
+
'proteum/no-swallowed-caught-error': ['error', { reporters: errorReporters }],
|
|
716
908
|
// Missing anchors warn by default so adopting apps see the backlog
|
|
717
909
|
// without a failing build; pass `docAnchors: 'error'` once backfilled.
|
|
718
910
|
// Routes, controllers and service classes are covered automatically.
|
|
@@ -727,12 +919,12 @@ const createProteumEslintConfig = ({
|
|
|
727
919
|
// already opted in, and a pointer to a deleted document is worse
|
|
728
920
|
// than no pointer at all.
|
|
729
921
|
'proteum/valid-doc-anchor': docAnchors === 'off' ? 'off' : 'error',
|
|
922
|
+
// Replaces the old bare `TSUnknownKeyword` selector. A selector has no
|
|
923
|
+
// context, so it could not tell an internal contract that should be
|
|
924
|
+
// typed from a value that genuinely arrives from outside.
|
|
925
|
+
'proteum/no-loose-unknown': 'error',
|
|
730
926
|
'no-restricted-syntax': [
|
|
731
927
|
'error',
|
|
732
|
-
{
|
|
733
|
-
selector: 'TSUnknownKeyword',
|
|
734
|
-
message: 'Do not use `unknown`; define an explicit type instead.',
|
|
735
|
-
},
|
|
736
928
|
{
|
|
737
929
|
selector: createZodTypeFactorySelector('any'),
|
|
738
930
|
message: 'Do not use Zod `any()` schemas; define an explicit schema instead.',
|
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.
|
|
4
|
+
"version": "2.5.15",
|
|
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,228 @@
|
|
|
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 is allowed as the input of a type guard', () => {
|
|
40
|
+
const messages = lint(`
|
|
41
|
+
export const isDomainField = (value: unknown): value is string => typeof value === 'string';
|
|
42
|
+
`);
|
|
43
|
+
|
|
44
|
+
assert.equal(count(messages, looseUnknownRuleId), 0);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('unknown is allowed when the trust boundary is documented', () => {
|
|
48
|
+
const messages = lint(`
|
|
49
|
+
/** @boundary HumbleWorth prediction output, shape owned by the provider. */
|
|
50
|
+
export const parseOutput = (output: unknown) => output;
|
|
51
|
+
`);
|
|
52
|
+
|
|
53
|
+
assert.equal(count(messages, looseUnknownRuleId), 0);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('an undocumented parse boundary is still reported', () => {
|
|
57
|
+
const messages = lint(`export const parseOutput = (output: unknown) => output;`);
|
|
58
|
+
|
|
59
|
+
assert.equal(count(messages, looseUnknownRuleId), 1);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/*----------------------------------
|
|
63
|
+
- no-swallowed-caught-error
|
|
64
|
+
----------------------------------*/
|
|
65
|
+
|
|
66
|
+
test('express next(error) counts as propagation', () => {
|
|
67
|
+
const messages = lint(`
|
|
68
|
+
export const handler = async (req, res, next) => {
|
|
69
|
+
try {
|
|
70
|
+
await load();
|
|
71
|
+
} catch (error) {
|
|
72
|
+
next(error);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
`);
|
|
76
|
+
|
|
77
|
+
assert.equal(count(messages, swallowedRuleId), 0);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('a configured reporter counts as preservation', () => {
|
|
81
|
+
const source = `
|
|
82
|
+
export class Scanner {
|
|
83
|
+
public async run() {
|
|
84
|
+
try {
|
|
85
|
+
await this.scan();
|
|
86
|
+
} catch (error) {
|
|
87
|
+
this.app.reportError(error, { source: 'scan', code: 'failed' });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
`;
|
|
92
|
+
|
|
93
|
+
assert.equal(count(lint(source), swallowedRuleId), 0);
|
|
94
|
+
|
|
95
|
+
// A project whose error path has another name declares it rather than being
|
|
96
|
+
// told its own convention is a swallow.
|
|
97
|
+
const custom = `
|
|
98
|
+
export class Scanner {
|
|
99
|
+
public async run() {
|
|
100
|
+
try {
|
|
101
|
+
await this.scan();
|
|
102
|
+
} catch (error) {
|
|
103
|
+
this.app.report('scan failed', error);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
`;
|
|
108
|
+
|
|
109
|
+
assert.equal(count(lint(custom), swallowedRuleId), 1);
|
|
110
|
+
assert.equal(
|
|
111
|
+
count(lint(custom, 'server/example.ts', { errorReporters: ['app.reportError', 'app.report'] }), swallowedRuleId),
|
|
112
|
+
0,
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('conditional preservation counts, because deliberate filtering is not a swallow', () => {
|
|
117
|
+
const messages = lint(`
|
|
118
|
+
export class Scanner {
|
|
119
|
+
public async run() {
|
|
120
|
+
try {
|
|
121
|
+
await this.scan();
|
|
122
|
+
} catch (error) {
|
|
123
|
+
if (!(error instanceof ExpectedPause)) {
|
|
124
|
+
this.app.reportError(error, { source: 'scan', code: 'failed' });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
`);
|
|
130
|
+
|
|
131
|
+
assert.equal(count(messages, swallowedRuleId), 0);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('a guard on the reporter existing is still a swallow, unlike a guard on the error', () => {
|
|
135
|
+
// Filtering by what the error is: a decision.
|
|
136
|
+
const filtered = lint(`
|
|
137
|
+
export const run = async () => {
|
|
138
|
+
try {
|
|
139
|
+
await load();
|
|
140
|
+
} catch (error) {
|
|
141
|
+
if (error.code !== 'EXPECTED') app.reportError(error);
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
`);
|
|
145
|
+
assert.equal(count(filtered, swallowedRuleId), 0);
|
|
146
|
+
|
|
147
|
+
// Gating on whether the reporter exists: the error vanishes when it does not.
|
|
148
|
+
const gated = lint(`
|
|
149
|
+
export const run = async (app) => {
|
|
150
|
+
try {
|
|
151
|
+
await load();
|
|
152
|
+
} catch (error) {
|
|
153
|
+
if (app) app.reportError(error);
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
`);
|
|
157
|
+
assert.equal(count(gated, swallowedRuleId), 1);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('a guarded rethrow counts as preservation', () => {
|
|
161
|
+
const messages = lint(`
|
|
162
|
+
export const run = async () => {
|
|
163
|
+
try {
|
|
164
|
+
await load();
|
|
165
|
+
} catch (error) {
|
|
166
|
+
if (error.code !== 'ENOENT') throw error;
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
`);
|
|
170
|
+
|
|
171
|
+
assert.equal(count(messages, swallowedRuleId), 0);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('an error surfaced as a returned result counts as preservation', () => {
|
|
175
|
+
const messages = lint(`
|
|
176
|
+
export const run = async () => {
|
|
177
|
+
try {
|
|
178
|
+
await load();
|
|
179
|
+
} catch (error) {
|
|
180
|
+
return { ok: false, message: error.message };
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
`);
|
|
184
|
+
|
|
185
|
+
assert.equal(count(messages, swallowedRuleId), 0);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('an error pushed into a result collection counts as preservation', () => {
|
|
189
|
+
const messages = lint(`
|
|
190
|
+
export const run = async (results) => {
|
|
191
|
+
try {
|
|
192
|
+
await load();
|
|
193
|
+
} catch (error) {
|
|
194
|
+
results.push({ state: 'unavailable', message: describeError(error) });
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
`);
|
|
198
|
+
|
|
199
|
+
assert.equal(count(messages, swallowedRuleId), 0);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('a console-only catch is still reported', () => {
|
|
203
|
+
const messages = lint(`
|
|
204
|
+
export const run = async () => {
|
|
205
|
+
try {
|
|
206
|
+
await load();
|
|
207
|
+
} catch (error) {
|
|
208
|
+
console.error('load failed', error);
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
`);
|
|
212
|
+
|
|
213
|
+
assert.equal(count(messages, swallowedRuleId), 1);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('a discarded error is still reported', () => {
|
|
217
|
+
const messages = lint(`
|
|
218
|
+
export const run = async () => {
|
|
219
|
+
try {
|
|
220
|
+
await load();
|
|
221
|
+
} catch (error) {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
`);
|
|
226
|
+
|
|
227
|
+
assert.equal(count(messages, swallowedRuleId), 1);
|
|
228
|
+
});
|