proteum 2.5.10 → 2.5.11
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 +27 -0
- package/agents/project/DOCUMENTATION.md +19 -2
- package/cli/commands/docs.ts +223 -0
- package/cli/presentation/commands.ts +14 -0
- package/cli/runtime/commands.ts +18 -0
- package/cli/verification/changed.ts +21 -0
- package/common/dev/mcpPayloads.ts +86 -8
- package/docAnchors.js +135 -0
- package/eslint.js +264 -1
- package/package.json +1 -1
- package/tests/doc-anchors.test.cjs +115 -0
- package/tests/docs-check.test.cjs +138 -0
- package/tests/eslint-rules.test.cjs +235 -2
- package/tests/mcp.test.cjs +109 -0
- package/tests/verify-changed.test.cjs +51 -3
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,250 @@ const createNoAppImportRule = () => ({
|
|
|
307
318
|
},
|
|
308
319
|
});
|
|
309
320
|
|
|
310
|
-
|
|
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 createRequireDocAnchorRule = () => ({
|
|
464
|
+
meta: {
|
|
465
|
+
type: 'suggestion',
|
|
466
|
+
docs: {
|
|
467
|
+
description: 'Require Proteum definition files to anchor the documentation that governs them.',
|
|
468
|
+
},
|
|
469
|
+
messages: {
|
|
470
|
+
missingAnchor:
|
|
471
|
+
'`{{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.',
|
|
472
|
+
missingTag:
|
|
473
|
+
'`{{definition}}` files must carry a `@{{tag}}` doc anchor in a leading block comment.',
|
|
474
|
+
},
|
|
475
|
+
schema: [
|
|
476
|
+
{
|
|
477
|
+
type: 'object',
|
|
478
|
+
properties: {
|
|
479
|
+
definitions: { type: 'array', items: { type: 'string' } },
|
|
480
|
+
requiredTags: { type: 'array', items: { enum: docAnchorTags } },
|
|
481
|
+
},
|
|
482
|
+
additionalProperties: false,
|
|
483
|
+
},
|
|
484
|
+
],
|
|
485
|
+
},
|
|
486
|
+
create(context) {
|
|
487
|
+
const options = context.options?.[0] || {};
|
|
488
|
+
const definitions = options.definitions || defaultDocAnchorDefinitions;
|
|
489
|
+
const requiredTags = options.requiredTags || ['docs'];
|
|
490
|
+
|
|
491
|
+
return {
|
|
492
|
+
ExportDefaultDeclaration(node) {
|
|
493
|
+
const definition = getDefinitionCalleeName(node.declaration, definitions);
|
|
494
|
+
if (!definition) return;
|
|
495
|
+
|
|
496
|
+
const anchors = collectFileDocAnchors(context);
|
|
497
|
+
if (anchors.entries.length === 0) {
|
|
498
|
+
context.report({ node, messageId: 'missingAnchor', data: { definition } });
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const missingTag = requiredTags.find((tag) => !hasDocAnchorTag(anchors, tag));
|
|
503
|
+
if (missingTag) {
|
|
504
|
+
context.report({ node, messageId: 'missingTag', data: { definition, tag: missingTag } });
|
|
505
|
+
}
|
|
506
|
+
},
|
|
507
|
+
};
|
|
508
|
+
},
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
const createValidDocAnchorRule = () => ({
|
|
512
|
+
meta: {
|
|
513
|
+
type: 'problem',
|
|
514
|
+
docs: {
|
|
515
|
+
description: 'Require doc anchors to point at documentation that still exists.',
|
|
516
|
+
},
|
|
517
|
+
messages: {
|
|
518
|
+
unresolvedPath:
|
|
519
|
+
'Doc anchor `@{{tag}} {{value}}` does not resolve to a file or directory. Update the anchor to the current documentation path, or remove it.',
|
|
520
|
+
unresolvedAdr:
|
|
521
|
+
'Doc anchor `@adr {{value}}` matches no decision record under `docs/decisions`. Use the current ADR identifier.',
|
|
522
|
+
emptyRule:
|
|
523
|
+
'Doc anchor `@rule` must state the invariant in full so an agent editing this file can apply it without opening the linked document.',
|
|
524
|
+
},
|
|
525
|
+
schema: [],
|
|
526
|
+
},
|
|
527
|
+
create(context) {
|
|
528
|
+
return {
|
|
529
|
+
'Program:exit'() {
|
|
530
|
+
const anchors = collectFileDocAnchors(context);
|
|
531
|
+
if (anchors.entries.length === 0) return;
|
|
532
|
+
|
|
533
|
+
const roots = resolveAnchorRoots(context);
|
|
534
|
+
anchors.entries.forEach((entry) => {
|
|
535
|
+
const loc = { line: entry.line, column: 0 };
|
|
536
|
+
|
|
537
|
+
if (entry.tag === 'rule') {
|
|
538
|
+
if (entry.value.length < minDocAnchorRuleLength) {
|
|
539
|
+
context.report({ loc, messageId: 'emptyRule' });
|
|
540
|
+
}
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (isPathDocAnchorTag(entry.tag)) {
|
|
545
|
+
if (!resolvePathAnchor(entry.value, roots)) {
|
|
546
|
+
context.report({
|
|
547
|
+
loc,
|
|
548
|
+
messageId: 'unresolvedPath',
|
|
549
|
+
data: { tag: entry.tag, value: entry.value },
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (entry.tag === 'adr' && !resolveAdrAnchor(entry.value, roots)) {
|
|
556
|
+
context.report({ loc, messageId: 'unresolvedAdr', data: { value: entry.value } });
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
},
|
|
560
|
+
};
|
|
561
|
+
},
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
const createProteumEslintConfig = ({ docAnchors = 'warn', ignores = [] } = {}) => [
|
|
311
565
|
{
|
|
312
566
|
ignores: [...defaultIgnores, ...ignores],
|
|
313
567
|
},
|
|
@@ -334,6 +588,8 @@ const createProteumEslintConfig = ({ ignores = [] } = {}) => [
|
|
|
334
588
|
rules: {
|
|
335
589
|
'no-app-import': createNoAppImportRule(),
|
|
336
590
|
'no-swallowed-caught-error': createSwallowedErrorRule(),
|
|
591
|
+
'require-doc-anchor': createRequireDocAnchorRule(),
|
|
592
|
+
'valid-doc-anchor': createValidDocAnchorRule(),
|
|
337
593
|
},
|
|
338
594
|
},
|
|
339
595
|
react: reactPlugin,
|
|
@@ -344,6 +600,13 @@ const createProteumEslintConfig = ({ ignores = [] } = {}) => [
|
|
|
344
600
|
'@typescript-eslint/no-explicit-any': 'error',
|
|
345
601
|
'proteum/no-app-import': 'error',
|
|
346
602
|
'proteum/no-swallowed-caught-error': 'error',
|
|
603
|
+
// Missing anchors warn by default so adopting apps see the backlog
|
|
604
|
+
// without a failing build; pass `docAnchors: 'error'` once backfilled.
|
|
605
|
+
'proteum/require-doc-anchor': docAnchors,
|
|
606
|
+
// A stale anchor is always an error: it only fires on files that
|
|
607
|
+
// already opted in, and a pointer to a deleted document is worse
|
|
608
|
+
// than no pointer at all.
|
|
609
|
+
'proteum/valid-doc-anchor': docAnchors === 'off' ? 'off' : 'error',
|
|
347
610
|
'no-restricted-syntax': [
|
|
348
611
|
'error',
|
|
349
612
|
{
|
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.11",
|
|
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
|
+
});
|