proteum 2.5.10 → 2.5.12

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/eslint.js CHANGED
@@ -1,8 +1,19 @@
1
+ const fs = require('node:fs');
2
+ const path = require('node:path');
1
3
  const tseslint = require('typescript-eslint');
2
4
  const reactPlugin = require('eslint-plugin-react');
3
5
  const reactHooksPlugin = require('eslint-plugin-react-hooks');
4
6
  const jsxA11yPlugin = require('eslint-plugin-jsx-a11y');
5
7
 
8
+ const {
9
+ buildDocAnchorGroups,
10
+ docAnchorTags,
11
+ hasDocAnchorTag,
12
+ isPathDocAnchorTag,
13
+ minDocAnchorRuleLength,
14
+ parseDocAnchorComment,
15
+ } = require('./docAnchors.js');
16
+
6
17
  const defaultIgnores = [
7
18
  '**/node_modules/**',
8
19
  '**/bin/**',
@@ -307,7 +318,350 @@ const createNoAppImportRule = () => ({
307
318
  },
308
319
  });
309
320
 
310
- const createProteumEslintConfig = ({ ignores = [] } = {}) => [
321
+ // Error routes are deliberately absent: a `_messages/404` page renders a status
322
+ // message and carries no feature-specific rule, so requiring a feature pack for
323
+ // one would manufacture documentation to satisfy the linter. An error page that
324
+ // does carry a real rule can still add an anchor, and `valid-doc-anchor` keeps
325
+ // checking it.
326
+ const defaultDocAnchorDefinitions = [
327
+ 'defineController',
328
+ 'definePageRoute',
329
+ 'defineServerRoute',
330
+ 'defineServerRoutes',
331
+ ];
332
+
333
+ const docsRootCache = new Map();
334
+ const directoryEntriesCache = new Map();
335
+
336
+ const getSourceCode = (context) => context.sourceCode || context.getSourceCode?.();
337
+
338
+ const getContextCwd = (context) => context.cwd || context.getCwd?.() || process.cwd();
339
+
340
+ const pathExists = (candidate) => {
341
+ try {
342
+ return fs.existsSync(candidate);
343
+ } catch (_error) {
344
+ return false;
345
+ }
346
+ };
347
+
348
+ const readDirectoryEntries = (directory) => {
349
+ if (directoryEntriesCache.has(directory)) return directoryEntriesCache.get(directory);
350
+
351
+ let entries = [];
352
+ try {
353
+ entries = fs.readdirSync(directory);
354
+ } catch (_error) {
355
+ entries = [];
356
+ }
357
+
358
+ directoryEntriesCache.set(directory, entries);
359
+ return entries;
360
+ };
361
+
362
+ /**
363
+ * Collect every ancestor of a linted file that holds a `docs/` directory,
364
+ * nearest first.
365
+ *
366
+ * All of them are candidates, not just the nearest: a monorepo app commonly has
367
+ * its own `apps/<app>/docs/` alongside the repository-level corpus, and stopping
368
+ * at the first match would make every anchor aimed at the shared corpus fail to
369
+ * resolve.
370
+ */
371
+ const findDocsRoots = (startDirectory) => {
372
+ if (docsRootCache.has(startDirectory)) return docsRootCache.get(startDirectory);
373
+
374
+ const resolved = [];
375
+ let current = startDirectory;
376
+ while (current) {
377
+ if (pathExists(path.join(current, 'docs'))) resolved.push(current);
378
+
379
+ const parent = path.dirname(current);
380
+ if (parent === current) break;
381
+ current = parent;
382
+ }
383
+
384
+ docsRootCache.set(startDirectory, resolved);
385
+ return resolved;
386
+ };
387
+
388
+ const resolveAnchorRoots = (context) => {
389
+ const filename = context.filename || context.getFilename?.() || '';
390
+ const fileDirectory = filename ? path.dirname(filename) : undefined;
391
+ const cwd = getContextCwd(context);
392
+ const roots = [];
393
+
394
+ const addRoot = (root) => {
395
+ if (root && !roots.includes(root)) roots.push(root);
396
+ };
397
+
398
+ if (fileDirectory) findDocsRoots(fileDirectory).forEach(addRoot);
399
+ addRoot(cwd);
400
+ addRoot(fileDirectory);
401
+
402
+ return roots;
403
+ };
404
+
405
+ const resolvePathAnchor = (value, roots) => {
406
+ if (path.isAbsolute(value)) return pathExists(value);
407
+
408
+ return roots.some((root) => pathExists(path.resolve(root, value)));
409
+ };
410
+
411
+ const resolveAdrAnchor = (value, roots) => {
412
+ const normalized = value.trim().toLowerCase();
413
+ if (!normalized) return false;
414
+
415
+ const decisionDirectories = roots
416
+ .map((root) => path.join(root, 'docs', 'decisions'))
417
+ .filter((directory) => pathExists(directory));
418
+
419
+ // Projects without a decisions corpus never fail this check, so the rule
420
+ // stays silent instead of inventing a convention the app has not adopted.
421
+ if (decisionDirectories.length === 0) return true;
422
+
423
+ return decisionDirectories.some((directory) =>
424
+ readDirectoryEntries(directory).some((entry) => entry.toLowerCase().startsWith(normalized)),
425
+ );
426
+ };
427
+
428
+ const collectFileDocAnchors = (context) => {
429
+ const sourceCode = getSourceCode(context);
430
+ if (!sourceCode) return buildDocAnchorGroups([]);
431
+
432
+ const entries = [];
433
+ sourceCode.getAllComments().forEach((comment) => {
434
+ if (comment.type !== 'Block') return;
435
+ entries.push(...parseDocAnchorComment(comment.value, comment.loc?.start?.line || 1));
436
+ });
437
+
438
+ return buildDocAnchorGroups(entries);
439
+ };
440
+
441
+ const unwrapExpression = (node) => {
442
+ let current = node;
443
+ while (
444
+ current &&
445
+ (current.type === 'TSAsExpression' ||
446
+ current.type === 'TSSatisfiesExpression' ||
447
+ current.type === 'TSNonNullExpression')
448
+ ) {
449
+ current = current.expression;
450
+ }
451
+
452
+ return current;
453
+ };
454
+
455
+ const getDefinitionCalleeName = (node, definitions) => {
456
+ const expression = unwrapExpression(node);
457
+ if (expression?.type !== 'CallExpression') return null;
458
+
459
+ const name = getCalleePropertyName(expression.callee);
460
+ return name && definitions.includes(name) ? name : null;
461
+ };
462
+
463
+ const getSuperClassName = (node) => {
464
+ const superClass = node?.superClass;
465
+ if (!superClass) return null;
466
+ if (superClass.type === 'Identifier') return superClass.name;
467
+ // `extends Service<Config, Hooks, App, object>` parses the generic call as a
468
+ // member or call expression depending on the parser path.
469
+ if (superClass.type === 'CallExpression') return getCalleePropertyName(superClass.callee);
470
+ if (superClass.type === 'MemberExpression') return getMemberPropertyName(superClass);
471
+
472
+ return null;
473
+ };
474
+
475
+ /**
476
+ * An exported class extending a Proteum service base owns business logic, so it
477
+ * carries the same documentation obligation as a route or controller. Matching
478
+ * on the base-class suffix covers both `extends Service` and app-specific bases
479
+ * such as `extends UsersManagementService`.
480
+ */
481
+ const isServiceClass = (node, pattern) => {
482
+ if (node?.type !== 'ClassDeclaration') return false;
483
+
484
+ const superClassName = getSuperClassName(node);
485
+ return Boolean(superClassName && pattern.test(superClassName));
486
+ };
487
+
488
+ const globToRegExp = (glob) => {
489
+ let pattern = '';
490
+
491
+ for (let index = 0; index < glob.length; index += 1) {
492
+ const character = glob[index];
493
+
494
+ if (character === '*') {
495
+ if (glob[index + 1] === '*') {
496
+ // `**/` spans any number of directories; a trailing `**` spans
497
+ // the rest of the path, separators included.
498
+ if (glob[index + 2] === '/') {
499
+ pattern += '(?:[^/]*\/)*';
500
+ index += 2;
501
+ continue;
502
+ }
503
+
504
+ pattern += '.*';
505
+ index += 1;
506
+ continue;
507
+ }
508
+
509
+ pattern += '[^/]*';
510
+ continue;
511
+ }
512
+
513
+ pattern += '.+^${}()|[]\\?'.includes(character) ? `\\${character}` : character;
514
+ }
515
+
516
+ return new RegExp(`(^|/)${pattern}$`);
517
+ };
518
+
519
+ const includeMatchers = new Map();
520
+
521
+ const matchesIncludeGlob = (filename, includes) => {
522
+ if (!filename || includes.length === 0) return false;
523
+
524
+ const normalized = filename.replace(/\\/g, '/');
525
+ return includes.some((glob) => {
526
+ if (!includeMatchers.has(glob)) includeMatchers.set(glob, globToRegExp(glob));
527
+ return includeMatchers.get(glob).test(normalized);
528
+ });
529
+ };
530
+
531
+ const createRequireDocAnchorRule = () => ({
532
+ meta: {
533
+ type: 'suggestion',
534
+ docs: {
535
+ description: 'Require Proteum definition files to anchor the documentation that governs them.',
536
+ },
537
+ messages: {
538
+ missingAnchor:
539
+ '`{{definition}}` files must carry a doc anchor. Add a leading block comment with `@docs <path to the feature pack>`, plus `@rule <one-line invariant>` when a fix or decision constrains this file.',
540
+ missingTag:
541
+ '`{{definition}}` files must carry a `@{{tag}}` doc anchor in a leading block comment.',
542
+ },
543
+ schema: [
544
+ {
545
+ type: 'object',
546
+ properties: {
547
+ definitions: { type: 'array', items: { type: 'string' } },
548
+ include: { type: 'array', items: { type: 'string' } },
549
+ requiredTags: { type: 'array', items: { enum: docAnchorTags } },
550
+ serviceBasePattern: { type: 'string' },
551
+ },
552
+ additionalProperties: false,
553
+ },
554
+ ],
555
+ },
556
+ create(context) {
557
+ const options = context.options?.[0] || {};
558
+ const definitions = options.definitions || defaultDocAnchorDefinitions;
559
+ const requiredTags = options.requiredTags || ['docs'];
560
+ const includes = options.include || [];
561
+ const serviceBasePattern = new RegExp(options.serviceBasePattern || 'Service$');
562
+ const filename = context.filename || context.getFilename?.() || '';
563
+
564
+ let reported = false;
565
+
566
+ const reportMissing = (node, subject) => {
567
+ // One report per file: a service class and an include glob can both
568
+ // match, and repeating the same instruction adds no information.
569
+ if (reported) return;
570
+
571
+ const anchors = collectFileDocAnchors(context);
572
+ if (anchors.entries.length === 0) {
573
+ reported = true;
574
+ context.report({ node, messageId: 'missingAnchor', data: { definition: subject } });
575
+ return;
576
+ }
577
+
578
+ const missingTag = requiredTags.find((tag) => !hasDocAnchorTag(anchors, tag));
579
+ if (missingTag) {
580
+ reported = true;
581
+ context.report({ node, messageId: 'missingTag', data: { definition: subject, tag: missingTag } });
582
+ }
583
+ };
584
+
585
+ const checkClass = (node) => {
586
+ if (!isServiceClass(node, serviceBasePattern)) return;
587
+ reportMissing(node, getSuperClassName(node));
588
+ };
589
+
590
+ return {
591
+ ExportDefaultDeclaration(node) {
592
+ if (node.declaration?.type === 'ClassDeclaration') {
593
+ checkClass(node.declaration);
594
+ return;
595
+ }
596
+
597
+ const definition = getDefinitionCalleeName(node.declaration, definitions);
598
+ if (definition) reportMissing(node, definition);
599
+ },
600
+ ExportNamedDeclaration(node) {
601
+ if (node.declaration?.type === 'ClassDeclaration') checkClass(node.declaration);
602
+ },
603
+ 'Program:exit'(node) {
604
+ if (!matchesIncludeGlob(filename, includes)) return;
605
+ reportMissing(node, path.basename(filename));
606
+ },
607
+ };
608
+ },
609
+ });
610
+
611
+ const createValidDocAnchorRule = () => ({
612
+ meta: {
613
+ type: 'problem',
614
+ docs: {
615
+ description: 'Require doc anchors to point at documentation that still exists.',
616
+ },
617
+ messages: {
618
+ unresolvedPath:
619
+ 'Doc anchor `@{{tag}} {{value}}` does not resolve to a file or directory. Update the anchor to the current documentation path, or remove it.',
620
+ unresolvedAdr:
621
+ 'Doc anchor `@adr {{value}}` matches no decision record under `docs/decisions`. Use the current ADR identifier.',
622
+ emptyRule:
623
+ 'Doc anchor `@rule` must state the invariant in full so an agent editing this file can apply it without opening the linked document.',
624
+ },
625
+ schema: [],
626
+ },
627
+ create(context) {
628
+ return {
629
+ 'Program:exit'() {
630
+ const anchors = collectFileDocAnchors(context);
631
+ if (anchors.entries.length === 0) return;
632
+
633
+ const roots = resolveAnchorRoots(context);
634
+ anchors.entries.forEach((entry) => {
635
+ const loc = { line: entry.line, column: 0 };
636
+
637
+ if (entry.tag === 'rule') {
638
+ if (entry.value.length < minDocAnchorRuleLength) {
639
+ context.report({ loc, messageId: 'emptyRule' });
640
+ }
641
+ return;
642
+ }
643
+
644
+ if (isPathDocAnchorTag(entry.tag)) {
645
+ if (!resolvePathAnchor(entry.value, roots)) {
646
+ context.report({
647
+ loc,
648
+ messageId: 'unresolvedPath',
649
+ data: { tag: entry.tag, value: entry.value },
650
+ });
651
+ }
652
+ return;
653
+ }
654
+
655
+ if (entry.tag === 'adr' && !resolveAdrAnchor(entry.value, roots)) {
656
+ context.report({ loc, messageId: 'unresolvedAdr', data: { value: entry.value } });
657
+ }
658
+ });
659
+ },
660
+ };
661
+ },
662
+ });
663
+
664
+ const createProteumEslintConfig = ({ docAnchors = 'warn', includeDocAnchors = [], ignores = [] } = {}) => [
311
665
  {
312
666
  ignores: [...defaultIgnores, ...ignores],
313
667
  },
@@ -334,6 +688,8 @@ const createProteumEslintConfig = ({ ignores = [] } = {}) => [
334
688
  rules: {
335
689
  'no-app-import': createNoAppImportRule(),
336
690
  'no-swallowed-caught-error': createSwallowedErrorRule(),
691
+ 'require-doc-anchor': createRequireDocAnchorRule(),
692
+ 'valid-doc-anchor': createValidDocAnchorRule(),
337
693
  },
338
694
  },
339
695
  react: reactPlugin,
@@ -344,6 +700,17 @@ const createProteumEslintConfig = ({ ignores = [] } = {}) => [
344
700
  '@typescript-eslint/no-explicit-any': 'error',
345
701
  'proteum/no-app-import': 'error',
346
702
  'proteum/no-swallowed-caught-error': 'error',
703
+ // Missing anchors warn by default so adopting apps see the backlog
704
+ // without a failing build; pass `docAnchors: 'error'` once backfilled.
705
+ // Routes, controllers and service classes are covered automatically.
706
+ // `includeDocAnchors` opts in extra paths, which is how a project
707
+ // covers the feature-owning components without dragging in every
708
+ // presentational primitive.
709
+ 'proteum/require-doc-anchor': [docAnchors, { include: includeDocAnchors }],
710
+ // A stale anchor is always an error: it only fires on files that
711
+ // already opted in, and a pointer to a deleted document is worse
712
+ // than no pointer at all.
713
+ 'proteum/valid-doc-anchor': docAnchors === 'off' ? 'off' : 'error',
347
714
  'no-restricted-syntax': [
348
715
  'error',
349
716
  {
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.10",
4
+ "version": "2.5.12",
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,115 @@
1
+ const assert = require('node:assert/strict');
2
+
3
+ const {
4
+ buildDocAnchorGroups,
5
+ collectDocAnchors,
6
+ hasDocAnchorTag,
7
+ isPathDocAnchorTag,
8
+ parseDocAnchorComment,
9
+ } = require('../docAnchors.js');
10
+
11
+ test('doc anchor parser reads every supported tag from a block comment', () => {
12
+ const groups = collectDocAnchors(`
13
+ /**
14
+ * @docs docs/features/search
15
+ * @adr ADR-0004
16
+ * @fix docs/fixes/2026-06-09-keyword-search-semantic-order.md
17
+ * @rule Composite ordering stays alias-aware.
18
+ */
19
+ export default definePageRoute({ path: '/browse' });
20
+ `);
21
+
22
+ assert.deepEqual(groups.docs, ['docs/features/search']);
23
+ assert.deepEqual(groups.adr, ['ADR-0004']);
24
+ assert.deepEqual(groups.fix, ['docs/fixes/2026-06-09-keyword-search-semantic-order.md']);
25
+ assert.deepEqual(groups.rules, ['Composite ordering stays alias-aware.']);
26
+ });
27
+
28
+ test('doc anchor parser joins multi-line rule invariants into one value', () => {
29
+ const groups = collectDocAnchors(`
30
+ /**
31
+ * @rule Composite ordering stays alias-aware.
32
+ * Never rewrite ORDER BY with regex.
33
+ */
34
+ `);
35
+
36
+ assert.deepEqual(groups.rules, ['Composite ordering stays alias-aware. Never rewrite ORDER BY with regex.']);
37
+ });
38
+
39
+ test('doc anchor parser reports the line each anchor sits on', () => {
40
+ const groups = collectDocAnchors(['const a = 1;', '', '/**', ' * @docs docs/features/search', ' */'].join('\n'));
41
+
42
+ assert.equal(groups.entries.length, 1);
43
+ assert.equal(groups.entries[0].line, 4);
44
+ });
45
+
46
+ test('doc anchor parser ignores unrelated jsdoc tags', () => {
47
+ const groups = collectDocAnchors(`
48
+ /**
49
+ * @param input The request payload.
50
+ * @returns The parsed row.
51
+ */
52
+ `);
53
+
54
+ assert.equal(groups.entries.length, 0);
55
+ });
56
+
57
+ test('doc anchor parser stops a value at a blank comment line', () => {
58
+ const groups = collectDocAnchors(`
59
+ /**
60
+ * @rule Never rewrite ORDER BY with regex.
61
+ *
62
+ * Unrelated prose that must not join the invariant.
63
+ */
64
+ `);
65
+
66
+ assert.deepEqual(groups.rules, ['Never rewrite ORDER BY with regex.']);
67
+ });
68
+
69
+ test('doc anchor parser collects anchors from several comments and drops duplicates', () => {
70
+ const groups = collectDocAnchors(`
71
+ /** @docs docs/features/search */
72
+ const first = 1;
73
+ /** @docs docs/features/search */
74
+ /** @docs docs/features/billing */
75
+ const second = 2;
76
+ `);
77
+
78
+ assert.deepEqual(groups.docs, ['docs/features/search', 'docs/features/billing']);
79
+ });
80
+
81
+ test('doc anchor parser ignores tags with no value', () => {
82
+ const groups = collectDocAnchors(`
83
+ /**
84
+ * @docs
85
+ * @rule
86
+ */
87
+ `);
88
+
89
+ assert.equal(groups.entries.length, 0);
90
+ });
91
+
92
+ test('doc anchor parser caps how much source it scans', () => {
93
+ const padding = `${'// filler\n'.repeat(200)}`;
94
+ const source = `${padding}/** @docs docs/features/search */`;
95
+
96
+ assert.equal(collectDocAnchors(source, { maxLength: 50 }).entries.length, 0);
97
+ assert.equal(collectDocAnchors(source).docs.length, 1);
98
+ });
99
+
100
+ test('doc anchor parser tolerates empty and missing input', () => {
101
+ assert.equal(collectDocAnchors('').entries.length, 0);
102
+ assert.equal(collectDocAnchors(undefined).entries.length, 0);
103
+ assert.equal(parseDocAnchorComment(undefined).length, 0);
104
+ });
105
+
106
+ test('doc anchor helpers expose the tag contract', () => {
107
+ const groups = buildDocAnchorGroups(parseDocAnchorComment('* @docs docs/features/search', 1));
108
+
109
+ assert.equal(hasDocAnchorTag(groups, 'docs'), true);
110
+ assert.equal(hasDocAnchorTag(groups, 'rule'), false);
111
+ assert.equal(isPathDocAnchorTag('docs'), true);
112
+ assert.equal(isPathDocAnchorTag('fix'), true);
113
+ assert.equal(isPathDocAnchorTag('adr'), false);
114
+ assert.equal(isPathDocAnchorTag('rule'), false);
115
+ });
@@ -0,0 +1,138 @@
1
+ const assert = require('node:assert/strict');
2
+ const fs = require('node:fs');
3
+ const os = require('node:os');
4
+ const path = require('node:path');
5
+
6
+ const coreRoot = path.resolve(__dirname, '..');
7
+ process.env.TS_NODE_PROJECT = path.join(coreRoot, 'cli', 'tsconfig.json');
8
+ process.env.TS_NODE_TRANSPILE_ONLY = '1';
9
+ require('ts-node/register/transpile-only');
10
+ require('../cli/context.ts');
11
+
12
+ const { buildDocsCheckReport } = require('../cli/commands/docs.ts');
13
+
14
+ const createRoot = () => fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-docs-check-'));
15
+
16
+ const writeFile = (root, filepath, content) => {
17
+ const full = path.join(root, filepath);
18
+ fs.mkdirSync(path.dirname(full), { recursive: true });
19
+ fs.writeFileSync(full, content);
20
+ };
21
+
22
+ const kinds = (report, kind) => report.findings.filter((finding) => finding.kind === kind);
23
+
24
+ test('docs check accepts anchors that resolve against the corpus', () => {
25
+ const root = createRoot();
26
+ writeFile(root, 'docs/features/search/README.md', '# Search\n');
27
+ writeFile(
28
+ root,
29
+ 'apps/product/server/controllers/search.ts',
30
+ '/**\n * @docs docs/features/search\n */\nexport default {};\n',
31
+ );
32
+
33
+ const report = buildDocsCheckReport(root);
34
+
35
+ assert.equal(kinds(report, 'unresolved-anchor').length, 0);
36
+ assert.equal(kinds(report, 'orphan-feature-pack').length, 0);
37
+ assert.equal(report.anchoredFiles, 1);
38
+ });
39
+
40
+ test('docs check reports an anchor pointing at a deleted document', () => {
41
+ const root = createRoot();
42
+ writeFile(root, 'docs/features/search/README.md', '# Search\n');
43
+ writeFile(
44
+ root,
45
+ 'apps/product/server/controllers/search.ts',
46
+ '/**\n * @docs docs/features/deleted\n */\nexport default {};\n',
47
+ );
48
+
49
+ const report = buildDocsCheckReport(root);
50
+ const unresolved = kinds(report, 'unresolved-anchor');
51
+
52
+ assert.equal(unresolved.length, 1);
53
+ assert.equal(unresolved[0].detail, '@docs docs/features/deleted');
54
+ });
55
+
56
+ test('docs check resolves anchors from an app that owns a nested docs directory', () => {
57
+ const root = createRoot();
58
+ writeFile(root, 'docs/features/search/README.md', '# Search\n');
59
+ writeFile(root, 'apps/product/docs/fixes/local.md', '# Local\n');
60
+ writeFile(
61
+ root,
62
+ 'apps/product/server/controllers/search.ts',
63
+ '/**\n * @docs docs/features/search\n */\nexport default {};\n',
64
+ );
65
+
66
+ assert.equal(kinds(buildDocsCheckReport(root), 'unresolved-anchor').length, 0);
67
+ });
68
+
69
+ test('docs check resolves anchors aimed at the repo corpus when run from an app root', () => {
70
+ const root = createRoot();
71
+ writeFile(root, 'docs/features/search/README.md', '# Search\n');
72
+ writeFile(
73
+ root,
74
+ 'apps/product/server/controllers/search.ts',
75
+ '/**\n * @docs docs/features/search\n */\nexport default {};\n',
76
+ );
77
+
78
+ // Running from the app root must not orphan an anchor that points at the
79
+ // repository-level corpus one directory up.
80
+ const report = buildDocsCheckReport(path.join(root, 'apps', 'product'));
81
+
82
+ assert.equal(kinds(report, 'unresolved-anchor').length, 0);
83
+ assert.equal(report.anchoredFiles, 1);
84
+ });
85
+
86
+ test('docs check reports a fix note whose invariant no code anchors', () => {
87
+ const root = createRoot();
88
+ writeFile(root, 'docs/fixes/2026-06-14-watchdog.md', '# Fix\n\n## Agent warning\n\nDo not remove the guards.\n');
89
+ writeFile(root, 'apps/daemon/src/rdap.ts', 'export default {};\n');
90
+
91
+ const unanchored = kinds(buildDocsCheckReport(root), 'unanchored-fix-note');
92
+
93
+ assert.equal(unanchored.length, 1);
94
+ assert.equal(unanchored[0].subject, 'docs/fixes/2026-06-14-watchdog.md');
95
+ });
96
+
97
+ test('docs check clears a fix note once code anchors it', () => {
98
+ const root = createRoot();
99
+ writeFile(root, 'docs/fixes/2026-06-14-watchdog.md', '# Fix\n\n## Agent warning\n\nDo not remove the guards.\n');
100
+ writeFile(
101
+ root,
102
+ 'apps/daemon/src/rdap.ts',
103
+ '/**\n * @fix docs/fixes/2026-06-14-watchdog.md\n * @rule Do not remove the guards.\n */\nexport default {};\n',
104
+ );
105
+
106
+ assert.equal(kinds(buildDocsCheckReport(root), 'unanchored-fix-note').length, 0);
107
+ });
108
+
109
+ test('docs check reports a feature pack that no code points at', () => {
110
+ const root = createRoot();
111
+ writeFile(root, 'docs/features/search/README.md', '# Search\n');
112
+ writeFile(root, 'docs/features/orphan/README.md', '# Orphan\n');
113
+ writeFile(
114
+ root,
115
+ 'apps/product/server/controllers/search.ts',
116
+ '/**\n * @docs docs/features/search\n */\nexport default {};\n',
117
+ );
118
+
119
+ const orphans = kinds(buildDocsCheckReport(root), 'orphan-feature-pack');
120
+
121
+ assert.deepEqual(orphans.map((finding) => finding.subject), ['docs/features/orphan']);
122
+ });
123
+
124
+ test('docs check ignores generated and vendored directories', () => {
125
+ const root = createRoot();
126
+ writeFile(root, 'docs/features/search/README.md', '# Search\n');
127
+ writeFile(
128
+ root,
129
+ 'node_modules/some-package/index.ts',
130
+ '/**\n * @docs docs/features/nope\n */\nexport default {};\n',
131
+ );
132
+ writeFile(root, 'apps/product/var/generated.ts', '/**\n * @docs docs/features/nope\n */\nexport default {};\n');
133
+
134
+ const report = buildDocsCheckReport(root);
135
+
136
+ assert.equal(kinds(report, 'unresolved-anchor').length, 0);
137
+ assert.equal(report.anchoredFiles, 0);
138
+ });