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.
@@ -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:
@@ -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 isPreservingCall = (callExpression, names, side, ancestors = []) => {
200
+ const defaultErrorReporters = ['app.reportError', 'app.handleError'];
201
+
202
+ /**
203
+ * Does this call match one of the configured `receiver.method` reporters?
204
+ *
205
+ * The receiver may be a bare identifier or the property of a longer chain, so
206
+ * `app.reportError(...)`, `this.app.reportError(...)` and `ctx.app.report(...)`
207
+ * all match a `app.reportError` entry.
208
+ */
209
+ const matchesConfiguredReporter = (callExpression, reporters) => {
210
+ const method = getMemberPropertyName(callExpression.callee);
211
+ if (!method) return false;
212
+
213
+ // Only project-added reporters are matched here. The built-in two stay with
214
+ // the side logic below, so a server file calling the client's `handleError`
215
+ // is still reported rather than quietly accepted.
216
+ const extraReporters = reporters.filter((reporter) => !defaultErrorReporters.includes(reporter));
217
+
218
+ return extraReporters.some((reporter) => {
219
+ const [receiverName, methodName] = reporter.includes('.') ? reporter.split('.') : [null, reporter];
220
+ if (method !== methodName) return false;
221
+ if (receiverName === null) return true;
222
+
223
+ const receiver = callExpression.callee.object;
224
+ if (receiver?.type === 'Identifier') return receiver.name === receiverName;
225
+
226
+ return getMemberPropertyName(receiver) === receiverName;
227
+ });
228
+ };
229
+
230
+ /**
231
+ * Express hands a caught error to its error middleware with `next(error)`. That
232
+ * is the framework's own propagation path, so treating it as a swallow would
233
+ * report the correct implementation of every route handler.
234
+ */
235
+ const isFrameworkPropagationCall = (callExpression, names) =>
236
+ callExpression.callee?.type === 'Identifier' &&
237
+ callExpression.callee.name === 'next' &&
238
+ (callExpression.arguments || []).some((argument) => nodeReferencesName(argument, names));
239
+
240
+ const isPreservingCall = (callExpression, names, side, ancestors = [], reporters = defaultErrorReporters) => {
201
241
  if (!nodeReferencesName(callExpression, names)) return false;
202
242
  if (hasOptionalCallBoundary(callExpression, ancestors)) return false;
203
- if (isUnderConditionalControlFlow(ancestors)) return false;
204
243
  if (isConsoleMember(callExpression.callee)) return false;
205
244
  if (isPromiseRejectCall(callExpression)) return true;
245
+ if (isFrameworkPropagationCall(callExpression, names)) return true;
246
+ if (matchesConfiguredReporter(callExpression, reporters)) return true;
206
247
  if (side === 'client') return isClientErrorHandlerCall(callExpression);
207
248
  if (side === 'server') return isServerErrorReporterCall(callExpression);
208
249
 
209
250
  return isClientErrorHandlerCall(callExpression) || isServerErrorReporterCall(callExpression);
210
251
  };
211
252
 
212
- const handlerPreservesCaughtError = (node, names, side) => {
253
+ /**
254
+ * Is the caught error handed back to the caller as a value?
255
+ *
256
+ * `return { ok: false, message: error.message }` and
257
+ * `results.push({ state: 'unavailable', message: describeError(error) })` both
258
+ * surface the failure in the API's own vocabulary. That is a designed
259
+ * degradation path, not a loss, so it counts as preservation.
260
+ */
261
+ const handlerSurfacesCaughtError = (node, names) => {
262
+ let surfaces = false;
263
+
264
+ traverseNode(node, (child) => {
265
+ if (child.type === 'ReturnStatement' && nodeReferencesName(child.argument, names)) surfaces = true;
266
+
267
+ if (
268
+ child.type === 'CallExpression' &&
269
+ ['push', 'unshift', 'add', 'set'].includes(getCalleePropertyName(child.callee) || '') &&
270
+ (child.arguments || []).some((argument) => nodeReferencesName(argument, names))
271
+ ) {
272
+ surfaces = true;
273
+ }
274
+ });
275
+
276
+ return surfaces;
277
+ };
278
+
279
+ /**
280
+ * Is preservation guarded by a condition that tests the caught error itself?
281
+ *
282
+ * `if (!(error instanceof ExpectedPause)) report(error)` is deliberate
283
+ * filtering: the author chose which failures are worth reporting, which is a
284
+ * decision rather than a swallow. `if (app) app.reportError(error)` is not: it
285
+ * gates on whether the reporter exists, so the error is lost whenever it does
286
+ * not. Only the first is accepted.
287
+ */
288
+ const isGuardedByErrorPredicate = (ancestors, names) =>
289
+ ancestors.some(({ node, childKey }) => {
290
+ if (node.type === 'IfStatement' && (childKey === 'consequent' || childKey === 'alternate'))
291
+ return nodeReferencesName(node.test, names);
292
+ if (node.type === 'ConditionalExpression' && (childKey === 'consequent' || childKey === 'alternate'))
293
+ return nodeReferencesName(node.test, names);
294
+ if (node.type === 'LogicalExpression' && childKey === 'right') return nodeReferencesName(node.left, names);
295
+ if (node.type === 'SwitchCase' && childKey === 'consequent') return true;
296
+
297
+ return false;
298
+ });
299
+
300
+ const preservationSurvivesControlFlow = (ancestors, names) =>
301
+ !isUnderConditionalControlFlow(ancestors) || isGuardedByErrorPredicate(ancestors, names);
302
+
303
+ const handlerPreservesCaughtError = (node, names, side, reporters = defaultErrorReporters) => {
213
304
  let preserves = false;
214
305
 
215
306
  traverseNode(node, (child, _parent, _parentKey, ancestors) => {
216
307
  if (
217
308
  child.type === 'ThrowStatement' &&
218
309
  nodeReferencesName(child.argument, names) &&
219
- !isUnderConditionalControlFlow(ancestors)
310
+ preservationSurvivesControlFlow(ancestors, names)
311
+ ) {
312
+ preserves = true;
313
+ }
314
+ if (
315
+ child.type === 'CallExpression' &&
316
+ isPreservingCall(child, names, side, ancestors, reporters) &&
317
+ preservationSurvivesControlFlow(ancestors, names)
220
318
  ) {
221
319
  preserves = true;
222
320
  }
223
- if (child.type === 'CallExpression' && isPreservingCall(child, names, side, ancestors)) preserves = true;
224
321
  });
225
322
 
323
+ if (!preserves && handlerSurfacesCaughtError(node, names)) preserves = true;
324
+
226
325
  return preserves;
227
326
  };
228
327
 
@@ -245,10 +344,22 @@ const createSwallowedErrorRule = () => ({
245
344
  unpreserved:
246
345
  'Caught error `{{name}}` is used but not routed through the standard error path. Rethrow it, call app.reportError on the server, or call app.handleError on the client.',
247
346
  },
248
- schema: [],
347
+ schema: [
348
+ {
349
+ type: 'object',
350
+ properties: {
351
+ // `receiver.method` or a bare `method`. A project whose error
352
+ // path is not `app.reportError` names it here rather than
353
+ // being told its own convention is a swallow.
354
+ reporters: { type: 'array', items: { type: 'string' } },
355
+ },
356
+ additionalProperties: false,
357
+ },
358
+ ],
249
359
  },
250
360
  create(context) {
251
361
  const side = getErrorHandlingSide(context.filename || context.getFilename?.() || '');
362
+ const reporters = context.options?.[0]?.reporters || defaultErrorReporters;
252
363
  const reportHandler = (node, params, body) => {
253
364
  const names = params.flatMap((param) => collectPatternNames(param));
254
365
  if (names.length === 0) {
@@ -262,7 +373,7 @@ const createSwallowedErrorRule = () => ({
262
373
  return;
263
374
  }
264
375
 
265
- if (!handlerPreservesCaughtError(body, collectDerivedErrorNames(body, names), side)) {
376
+ if (!handlerPreservesCaughtError(body, collectDerivedErrorNames(body, names), side, reporters)) {
266
377
  context.report({ node, messageId: 'unpreserved', data: { name: referencedName } });
267
378
  }
268
379
  };
@@ -287,6 +398,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 = ({ docAnchors = 'warn', includeDocAnchors = [], ignores = [] } = {}) => [
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': [docAnchors, { include: includeDocAnchors }],
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.13",
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(