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.
- package/agents/project/CODING_STYLE.md +2 -0
- package/eslint.js +91 -28
- package/package.json +1 -1
- package/tests/error-boundary-rules.test.cjs +81 -0
|
@@ -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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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 = (
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
|
|
417
|
-
|
|
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
|
-
|
|
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
|
|
467
|
-
|
|
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.
|
|
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 () => {
|