proteum 2.5.13 → 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 +23 -0
- package/cli/commands/docs.ts +20 -0
- package/eslint.js +223 -15
- package/package.json +1 -1
- package/tests/docs-check.test.cjs +17 -0
- package/tests/error-boundary-rules.test.cjs +228 -0
- package/tests/eslint-rules.test.cjs +44 -0
|
@@ -85,6 +85,8 @@ Write anchors in a leading block comment:
|
|
|
85
85
|
|
|
86
86
|
- `@docs` points at the feature pack that owns the file. Required on every file that default-exports `definePageRoute`, `defineController`, `defineServerRoute`, or `defineServerRoutes`, and on every exported class extending a service base such as `Service` or `UsersManagementService`. Error routes are exempt: they render a status message and carry no feature-specific rule, so requiring a pack for one would manufacture documentation. Add an anchor to an error route only when it really does carry a rule.
|
|
87
87
|
- Components are not covered automatically. A presentational primitive such as `Icon.tsx` or `Card.tsx` owns no feature, so a blanket rule would manufacture documentation for hundreds of files. Cover the component directories that do own a feature by listing them in `includeDocAnchors` when building the ESLint config, for example `['client/components/paywall/**']`.
|
|
88
|
+
- Infrastructure is exempt the same way error routes are. A transport helper, a metrics router or a rate limiter owns no feature contract, so list those paths in `excludeDocAnchors` rather than pointing them at a pack that does not describe them. An excluded file may still declare anchors, and `valid-doc-anchor` keeps checking any it declares.
|
|
89
|
+
- A feature pack that no code will ever point at, such as a retirement record or a document describing audiences rather than modules, opts out of the orphan report by carrying a `RETIRED` note or a `code-owned: false` line in its README. Without that, the pack is reported as orphaned forever and the backlog stops being actionable.
|
|
88
90
|
- `@adr` and `@fix` point at the decision record and fix note that constrain the file. Add them where the decision or the bug actually lives, not on every file in the area.
|
|
89
91
|
- `@rule` states the invariant inline, in full. It is the one anchor that carries content rather than a pointer, because the rule is what an agent needs at the moment of editing. A `@rule` that only says `todo` or repeats the linked title is a defect.
|
|
90
92
|
- Anchors are not a substitute for the documents. Narrative, alternatives, benchmarks and acceptance stay under `docs/**`; the anchor carries the pointer and the single-sentence rule.
|
|
@@ -95,6 +97,27 @@ The read-only MCP owner payloads return these anchors alongside `explain_summary
|
|
|
95
97
|
|
|
96
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.
|
|
97
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
|
+
|
|
98
121
|
## Self-check before finishing
|
|
99
122
|
|
|
100
123
|
Re-scan every touched file against this list before declaring the work done:
|
package/cli/commands/docs.ts
CHANGED
|
@@ -130,6 +130,25 @@ const resolveAdrReference = (value: string, fromDirectory: string, root: string)
|
|
|
130
130
|
);
|
|
131
131
|
};
|
|
132
132
|
|
|
133
|
+
const retiredPackPattern = /^\s*>?\s*RETIRED\b/im;
|
|
134
|
+
const notCodeOwnedPattern = /^\s*>?\s*code-owned:\s*false\s*$/im;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* A pack opts out of the orphan report by saying so in its README, either with
|
|
138
|
+
* a `RETIRED` note or a `code-owned: false` line.
|
|
139
|
+
*
|
|
140
|
+
* Retirement records and intent documents describe decisions and audiences, not
|
|
141
|
+
* modules, so no source file will ever point at them. Reporting those forever
|
|
142
|
+
* trains a reader to ignore the whole list.
|
|
143
|
+
*/
|
|
144
|
+
const packOptsOutOfOrphanReport = (featuresDir: string, pack: string) => {
|
|
145
|
+
const readme = path.join(featuresDir, pack, 'README.md');
|
|
146
|
+
if (!fs.existsSync(readme)) return false;
|
|
147
|
+
|
|
148
|
+
const content = fs.readFileSync(readme, 'utf8');
|
|
149
|
+
return retiredPackPattern.test(content) || notCodeOwnedPattern.test(content);
|
|
150
|
+
};
|
|
151
|
+
|
|
133
152
|
const listFeaturePacks = (root: string) => {
|
|
134
153
|
const featuresDir = path.join(root, 'docs', 'features');
|
|
135
154
|
if (!fs.existsSync(featuresDir)) return [];
|
|
@@ -137,6 +156,7 @@ const listFeaturePacks = (root: string) => {
|
|
|
137
156
|
return fs
|
|
138
157
|
.readdirSync(featuresDir, { withFileTypes: true })
|
|
139
158
|
.filter((entry) => entry.isDirectory())
|
|
159
|
+
.filter((entry) => !packOptsOutOfOrphanReport(featuresDir, entry.name))
|
|
140
160
|
.map((entry) => entry.name);
|
|
141
161
|
};
|
|
142
162
|
|
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',
|
|
@@ -545,6 +735,7 @@ const createRequireDocAnchorRule = () => ({
|
|
|
545
735
|
type: 'object',
|
|
546
736
|
properties: {
|
|
547
737
|
definitions: { type: 'array', items: { type: 'string' } },
|
|
738
|
+
exclude: { type: 'array', items: { type: 'string' } },
|
|
548
739
|
include: { type: 'array', items: { type: 'string' } },
|
|
549
740
|
requiredTags: { type: 'array', items: { enum: docAnchorTags } },
|
|
550
741
|
serviceBasePattern: { type: 'string' },
|
|
@@ -558,15 +749,22 @@ const createRequireDocAnchorRule = () => ({
|
|
|
558
749
|
const definitions = options.definitions || defaultDocAnchorDefinitions;
|
|
559
750
|
const requiredTags = options.requiredTags || ['docs'];
|
|
560
751
|
const includes = options.include || [];
|
|
752
|
+
const excludes = options.exclude || [];
|
|
561
753
|
const serviceBasePattern = new RegExp(options.serviceBasePattern || 'Service$');
|
|
562
754
|
const filename = context.filename || context.getFilename?.() || '';
|
|
563
755
|
|
|
756
|
+
// Infrastructure carries no feature contract, so a project lists those
|
|
757
|
+
// paths here rather than anchoring them to a pack that does not exist.
|
|
758
|
+
// An excluded file may still declare anchors, and `valid-doc-anchor`
|
|
759
|
+
// keeps checking them.
|
|
760
|
+
const excluded = matchesIncludeGlob(filename, excludes);
|
|
761
|
+
|
|
564
762
|
let reported = false;
|
|
565
763
|
|
|
566
764
|
const reportMissing = (node, subject) => {
|
|
567
765
|
// One report per file: a service class and an include glob can both
|
|
568
766
|
// match, and repeating the same instruction adds no information.
|
|
569
|
-
if (reported) return;
|
|
767
|
+
if (reported || excluded) return;
|
|
570
768
|
|
|
571
769
|
const anchors = collectFileDocAnchors(context);
|
|
572
770
|
if (anchors.entries.length === 0) {
|
|
@@ -661,7 +859,13 @@ const createValidDocAnchorRule = () => ({
|
|
|
661
859
|
},
|
|
662
860
|
});
|
|
663
861
|
|
|
664
|
-
const createProteumEslintConfig = ({
|
|
862
|
+
const createProteumEslintConfig = ({
|
|
863
|
+
docAnchors = 'warn',
|
|
864
|
+
errorReporters = defaultErrorReporters,
|
|
865
|
+
excludeDocAnchors = [],
|
|
866
|
+
includeDocAnchors = [],
|
|
867
|
+
ignores = [],
|
|
868
|
+
} = {}) => [
|
|
665
869
|
{
|
|
666
870
|
ignores: [...defaultIgnores, ...ignores],
|
|
667
871
|
},
|
|
@@ -687,6 +891,7 @@ const createProteumEslintConfig = ({ docAnchors = 'warn', includeDocAnchors = []
|
|
|
687
891
|
proteum: {
|
|
688
892
|
rules: {
|
|
689
893
|
'no-app-import': createNoAppImportRule(),
|
|
894
|
+
'no-loose-unknown': createNoLooseUnknownRule(),
|
|
690
895
|
'no-swallowed-caught-error': createSwallowedErrorRule(),
|
|
691
896
|
'require-doc-anchor': createRequireDocAnchorRule(),
|
|
692
897
|
'valid-doc-anchor': createValidDocAnchorRule(),
|
|
@@ -699,24 +904,27 @@ const createProteumEslintConfig = ({ docAnchors = 'warn', includeDocAnchors = []
|
|
|
699
904
|
rules: {
|
|
700
905
|
'@typescript-eslint/no-explicit-any': 'error',
|
|
701
906
|
'proteum/no-app-import': 'error',
|
|
702
|
-
'proteum/no-swallowed-caught-error': 'error',
|
|
907
|
+
'proteum/no-swallowed-caught-error': ['error', { reporters: errorReporters }],
|
|
703
908
|
// Missing anchors warn by default so adopting apps see the backlog
|
|
704
909
|
// without a failing build; pass `docAnchors: 'error'` once backfilled.
|
|
705
910
|
// Routes, controllers and service classes are covered automatically.
|
|
706
911
|
// `includeDocAnchors` opts in extra paths, which is how a project
|
|
707
912
|
// covers the feature-owning components without dragging in every
|
|
708
913
|
// presentational primitive.
|
|
709
|
-
'proteum/require-doc-anchor': [
|
|
914
|
+
'proteum/require-doc-anchor': [
|
|
915
|
+
docAnchors,
|
|
916
|
+
{ exclude: excludeDocAnchors, include: includeDocAnchors },
|
|
917
|
+
],
|
|
710
918
|
// A stale anchor is always an error: it only fires on files that
|
|
711
919
|
// already opted in, and a pointer to a deleted document is worse
|
|
712
920
|
// than no pointer at all.
|
|
713
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',
|
|
714
926
|
'no-restricted-syntax': [
|
|
715
927
|
'error',
|
|
716
|
-
{
|
|
717
|
-
selector: 'TSUnknownKeyword',
|
|
718
|
-
message: 'Do not use `unknown`; define an explicit type instead.',
|
|
719
|
-
},
|
|
720
928
|
{
|
|
721
929
|
selector: createZodTypeFactorySelector('any'),
|
|
722
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",
|
|
@@ -160,6 +160,23 @@ test('docs check reports a feature pack that no code points at', () => {
|
|
|
160
160
|
assert.deepEqual(orphans.map((finding) => finding.subject), ['docs/features/orphan']);
|
|
161
161
|
});
|
|
162
162
|
|
|
163
|
+
test('docs check lets a retired or non-code pack opt out of the orphan report', () => {
|
|
164
|
+
const root = createRoot();
|
|
165
|
+
writeFile(root, 'docs/features/search/README.md', '# Search\n');
|
|
166
|
+
writeFile(root, 'docs/features/pro-preview/README.md', '# Pro Preview\n\n> RETIRED 2026-07-07. Superseded.\n');
|
|
167
|
+
writeFile(root, 'docs/features/persona-journeys/README.md', '# Personas\n\ncode-owned: false\n');
|
|
168
|
+
writeFile(root, 'docs/features/still-orphaned/README.md', '# Orphan\n');
|
|
169
|
+
writeFile(
|
|
170
|
+
root,
|
|
171
|
+
'apps/product/server/controllers/search.ts',
|
|
172
|
+
'/**\n * @docs docs/features/search\n */\nexport default {};\n',
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
const orphans = kinds(buildDocsCheckReport(root), 'orphan-feature-pack');
|
|
176
|
+
|
|
177
|
+
assert.deepEqual(orphans.map((finding) => finding.subject), ['docs/features/still-orphaned']);
|
|
178
|
+
});
|
|
179
|
+
|
|
163
180
|
test('docs check ignores generated and vendored directories', () => {
|
|
164
181
|
const root = createRoot();
|
|
165
182
|
writeFile(root, 'docs/features/search/README.md', '# Search\n');
|
|
@@ -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
|
+
});
|
|
@@ -541,6 +541,50 @@ test('proteum lint accepts an included file once it carries an anchor', () => {
|
|
|
541
541
|
assert.equal(messagesFor(messages, requireDocAnchorRuleId).length, 0);
|
|
542
542
|
});
|
|
543
543
|
|
|
544
|
+
test('proteum lint exempts infrastructure paths listed in excludeDocAnchors', () => {
|
|
545
|
+
const { root } = createDocProject();
|
|
546
|
+
const options = { docAnchors: 'warn', excludeDocAnchors: ['server/services/Utils/**', 'server/routes/debug.ts'] };
|
|
547
|
+
|
|
548
|
+
const utilService = lint(
|
|
549
|
+
`export default class FetchService extends Service {}`,
|
|
550
|
+
path.join(root, 'server', 'services', 'Utils', 'Fetch', 'index.ts'),
|
|
551
|
+
options,
|
|
552
|
+
);
|
|
553
|
+
assert.equal(messagesFor(utilService, requireDocAnchorRuleId).length, 0);
|
|
554
|
+
|
|
555
|
+
const debugRoute = lint(
|
|
556
|
+
`export default defineServerRoutes(() => null);`,
|
|
557
|
+
path.join(root, 'server', 'routes', 'debug.ts'),
|
|
558
|
+
options,
|
|
559
|
+
);
|
|
560
|
+
assert.equal(messagesFor(debugRoute, requireDocAnchorRuleId).length, 0);
|
|
561
|
+
|
|
562
|
+
// A service outside the excluded paths is still required to anchor.
|
|
563
|
+
const covered = lint(
|
|
564
|
+
`export default class SearchService extends Service {}`,
|
|
565
|
+
path.join(root, 'server', 'services', 'Domains', 'search', 'index.ts'),
|
|
566
|
+
options,
|
|
567
|
+
);
|
|
568
|
+
assert.equal(messagesFor(covered, requireDocAnchorRuleId).length, 1);
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
test('proteum lint still validates anchors declared on an excluded file', () => {
|
|
572
|
+
const { root } = createDocProject();
|
|
573
|
+
const messages = lint(
|
|
574
|
+
`
|
|
575
|
+
/**
|
|
576
|
+
* @docs docs/features/deleted-feature
|
|
577
|
+
*/
|
|
578
|
+
export default class FetchService extends Service {}
|
|
579
|
+
`,
|
|
580
|
+
path.join(root, 'server', 'services', 'Utils', 'Fetch', 'index.ts'),
|
|
581
|
+
{ docAnchors: 'warn', excludeDocAnchors: ['server/services/Utils/**'] },
|
|
582
|
+
);
|
|
583
|
+
|
|
584
|
+
assert.equal(messagesFor(messages, requireDocAnchorRuleId).length, 0);
|
|
585
|
+
assert.equal(messagesFor(messages, validDocAnchorRuleId).length, 1);
|
|
586
|
+
});
|
|
587
|
+
|
|
544
588
|
test('proteum lint does not require a doc anchor on error routes', () => {
|
|
545
589
|
const { root } = createDocProject();
|
|
546
590
|
const messages = lint(
|