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.
- package/agents/project/CODING_STYLE.md +23 -0
- package/eslint.js +238 -12
- package/package.json +1 -1
- package/tests/error-boundary-rules.test.cjs +269 -0
|
@@ -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
|
|
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,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.
|
|
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
|
+
});
|