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
|
@@ -68,6 +68,32 @@ retries++;
|
|
|
68
68
|
// See docs/fixes/2026-06-02-stripe-replay.md.
|
|
69
69
|
```
|
|
70
70
|
|
|
71
|
+
## Doc anchors
|
|
72
|
+
|
|
73
|
+
A why-comment explains one decision. A doc anchor connects the file to the durable documentation that governs it, so an agent that opens the file finds the feature pack, the decision record and the invariant without searching the corpus first.
|
|
74
|
+
|
|
75
|
+
Write anchors in a leading block comment:
|
|
76
|
+
|
|
77
|
+
```typescript
|
|
78
|
+
/**
|
|
79
|
+
* @docs docs/features/search
|
|
80
|
+
* @adr ADR-0004
|
|
81
|
+
* @fix docs/fixes/2026-06-09-keyword-search-semantic-order.md
|
|
82
|
+
* @rule Composite ordering stays alias-aware. Never rewrite ORDER BY with regex.
|
|
83
|
+
*/
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
- `@docs` points at the feature pack that owns the file. Required on every file that default-exports `definePageRoute`, `defineController`, `defineServerRoute`, or `defineServerRoutes`. 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
|
+
- `@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.
|
|
88
|
+
- `@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.
|
|
89
|
+
- 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.
|
|
90
|
+
|
|
91
|
+
Two ESLint rules enforce this. `proteum/require-doc-anchor` reports definition files with no `@docs`, and warns by default so an adopting project sees its backlog without a failing build; pass `docAnchors: 'error'` to `createProteumEslintConfig` once the backfill is done. `proteum/valid-doc-anchor` always errors, because an anchor pointing at a deleted document is worse than no anchor.
|
|
92
|
+
|
|
93
|
+
The read-only MCP owner payloads return these anchors alongside `explain_summary`, `orient`, `route_candidates`, `diagnose`, and `workflow_start`, so the documentation reaches the agent in the same response that identifies the owner.
|
|
94
|
+
|
|
95
|
+
`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.
|
|
96
|
+
|
|
71
97
|
## Self-check before finishing
|
|
72
98
|
|
|
73
99
|
Re-scan every touched file against this list before declaring the work done:
|
|
@@ -75,5 +101,6 @@ Re-scan every touched file against this list before declaring the work done:
|
|
|
75
101
|
- No `any`, `unknown`, or casts introduced; contracts fixed at the boundary.
|
|
76
102
|
- New code sits under the right banner section, and section names still match their content.
|
|
77
103
|
- Every non-obvious decision, workaround, magic value, and bug fix has a why-comment at the site.
|
|
104
|
+
- Every touched definition file carries a `@docs` anchor, and any fix or decision applied in this pass left a `@rule` anchor at the code site it constrains.
|
|
78
105
|
- No comments that restate code; no leftover debug logs or commented-out code.
|
|
79
106
|
- Repeated logic extracted; one class or component per file; catalogs stay canonical.
|
|
@@ -37,6 +37,8 @@ Do not code from assumptions when a source-of-truth document exists.
|
|
|
37
37
|
|
|
38
38
|
Do not duplicate rules across documents. Link to the source of truth when needed.
|
|
39
39
|
|
|
40
|
+
Documentation must be reachable from the code it governs. Every document written under these rules names the code it affects; the code must point back with a doc anchor, so the next agent finds the governing document by opening the file rather than by searching the corpus. Anchors carry pointers plus the single-sentence invariant, never the narrative. The format and the lint rules that enforce it are in `CODING_STYLE.md`.
|
|
41
|
+
|
|
40
42
|
---
|
|
41
43
|
|
|
42
44
|
# 2. Always read these first
|
|
@@ -86,6 +88,7 @@ docs/features/<feature>/acceptance.md
|
|
|
86
88
|
docs/testing/regression-tests.md if a regression test was added
|
|
87
89
|
docs/decisions/ if a major decision changed
|
|
88
90
|
docs/fixes/ if a bug or regression was fixed
|
|
91
|
+
@docs anchor on each definition file the feature owns
|
|
89
92
|
```
|
|
90
93
|
|
|
91
94
|
---
|
|
@@ -108,6 +111,7 @@ After fixing the bug, update:
|
|
|
108
111
|
docs/fixes/YYYY-MM-DD-short-bug-name.md
|
|
109
112
|
docs/testing/regression-tests.md
|
|
110
113
|
affected feature edge-cases.md if a new edge case was discovered
|
|
114
|
+
@rule and @fix anchors at the code site that regressed
|
|
111
115
|
```
|
|
112
116
|
|
|
113
117
|
A bug fix is incomplete if:
|
|
@@ -117,6 +121,7 @@ the root cause is not documented
|
|
|
117
121
|
the implemented solution is not documented
|
|
118
122
|
the regression test is not linked
|
|
119
123
|
future agents cannot tell what pattern must not return
|
|
124
|
+
the invariant lives only in the fix note and not at the code site that regressed
|
|
120
125
|
```
|
|
121
126
|
|
|
122
127
|
---
|
|
@@ -847,12 +852,18 @@ tests/regression/<area>/<bug-name>.test.ts
|
|
|
847
852
|
|
|
848
853
|
## New rule added
|
|
849
854
|
|
|
850
|
-
What should future agents follow?
|
|
855
|
+
What should future agents follow? Both sections below are mandatory, and each one must also be mirrored to a `@rule` doc anchor at the code site it constrains. A rule that lives only in this note reaches no agent editing that file.
|
|
851
856
|
|
|
852
857
|
## Agent warning
|
|
853
858
|
|
|
854
859
|
What pattern must not be reintroduced?
|
|
855
860
|
|
|
861
|
+
## Code anchor
|
|
862
|
+
|
|
863
|
+
```txt
|
|
864
|
+
path/to/file the @rule and @fix anchors added there
|
|
865
|
+
```
|
|
866
|
+
|
|
856
867
|
## Related docs
|
|
857
868
|
|
|
858
869
|
- docs/features/<feature>/
|
|
@@ -1004,11 +1015,17 @@ tests/performance/<benchmark>.test.ts
|
|
|
1004
1015
|
|
|
1005
1016
|
## New rule added
|
|
1006
1017
|
|
|
1007
|
-
What should future agents follow?
|
|
1018
|
+
What should future agents follow? Both sections below are mandatory, and each one must also be mirrored to a `@rule` doc anchor at the code site it constrains. A rule that lives only in this note reaches no agent editing that file.
|
|
1008
1019
|
|
|
1009
1020
|
## Agent warning
|
|
1010
1021
|
|
|
1011
1022
|
What pattern must not be reintroduced?
|
|
1023
|
+
|
|
1024
|
+
## Code anchor
|
|
1025
|
+
|
|
1026
|
+
```txt
|
|
1027
|
+
path/to/file the @rule and @fix anchors added there
|
|
1028
|
+
```
|
|
1012
1029
|
````
|
|
1013
1030
|
|
|
1014
1031
|
---
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { UsageError } from 'clipanion';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
import cli from '..';
|
|
6
|
+
import { renderRows } from '../presentation/layout';
|
|
7
|
+
import { renderStep, renderSuccess, renderTitle, renderWarning } from '../presentation/ink';
|
|
8
|
+
|
|
9
|
+
const { collectDocAnchors } = require('../../docAnchors.js') as {
|
|
10
|
+
collectDocAnchors: (sourceText: string) => {
|
|
11
|
+
docs: string[];
|
|
12
|
+
fix: string[];
|
|
13
|
+
entries: { line: number; tag: string; value: string }[];
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/*----------------------------------
|
|
18
|
+
- TYPES
|
|
19
|
+
----------------------------------*/
|
|
20
|
+
|
|
21
|
+
type TDocsCheckFinding = {
|
|
22
|
+
detail: string;
|
|
23
|
+
kind: 'unresolved-anchor' | 'unanchored-fix-note' | 'orphan-feature-pack';
|
|
24
|
+
subject: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type TDocsCheckReport = {
|
|
28
|
+
anchoredFiles: number;
|
|
29
|
+
findings: TDocsCheckFinding[];
|
|
30
|
+
scannedFiles: number;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/*----------------------------------
|
|
34
|
+
- HELPERS
|
|
35
|
+
----------------------------------*/
|
|
36
|
+
|
|
37
|
+
const skippedDirectories = new Set([
|
|
38
|
+
'.generated',
|
|
39
|
+
'.git',
|
|
40
|
+
'.proteum',
|
|
41
|
+
'bin',
|
|
42
|
+
'bin-dev',
|
|
43
|
+
'dist',
|
|
44
|
+
'node_modules',
|
|
45
|
+
'var',
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
const isSourceFile = (name: string) => /\.(ts|tsx|mts|cts)$/.test(name);
|
|
49
|
+
|
|
50
|
+
const walkSourceFiles = (directory: string, collected: string[] = []) => {
|
|
51
|
+
let entries: { isDirectory: () => boolean; name: string }[] = [];
|
|
52
|
+
try {
|
|
53
|
+
entries = fs.readdirSync(directory, { withFileTypes: true });
|
|
54
|
+
} catch (error) {
|
|
55
|
+
// An unreadable directory is not a documentation failure; skip it and
|
|
56
|
+
// keep scanning so one permission problem cannot mask real findings.
|
|
57
|
+
if (cli.verbose) console.warn(`docs check: skipping ${directory} (${(error as Error).message})`);
|
|
58
|
+
return collected;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
entries.forEach((entry) => {
|
|
62
|
+
if (entry.name.startsWith('.') && entry.name !== '.') return;
|
|
63
|
+
const full = path.join(directory, entry.name);
|
|
64
|
+
if (entry.isDirectory()) {
|
|
65
|
+
if (skippedDirectories.has(entry.name)) return;
|
|
66
|
+
walkSourceFiles(full, collected);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (isSourceFile(entry.name)) collected.push(full);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return collected;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Resolve an anchor value the same way the `proteum/valid-doc-anchor` lint rule
|
|
77
|
+
* does: against every ancestor of the file that holds a `docs/` directory, so a
|
|
78
|
+
* monorepo app can point at the repository-level corpus.
|
|
79
|
+
*/
|
|
80
|
+
const resolveAnchorValue = (value: string, fromDirectory: string, root: string) => {
|
|
81
|
+
if (path.isAbsolute(value)) return fs.existsSync(value);
|
|
82
|
+
|
|
83
|
+
const roots: string[] = [];
|
|
84
|
+
let current = fromDirectory;
|
|
85
|
+
// The walk deliberately continues past `root`: running this from an app root
|
|
86
|
+
// inside a monorepo must still resolve anchors aimed at the repository-level
|
|
87
|
+
// corpus, exactly as the `proteum/valid-doc-anchor` lint rule does.
|
|
88
|
+
while (current) {
|
|
89
|
+
if (fs.existsSync(path.join(current, 'docs'))) roots.push(current);
|
|
90
|
+
const parent = path.dirname(current);
|
|
91
|
+
if (parent === current) break;
|
|
92
|
+
current = parent;
|
|
93
|
+
}
|
|
94
|
+
if (!roots.includes(root)) roots.push(root);
|
|
95
|
+
|
|
96
|
+
return roots.some((candidate) => fs.existsSync(path.resolve(candidate, value)));
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const listFeaturePacks = (root: string) => {
|
|
100
|
+
const featuresDir = path.join(root, 'docs', 'features');
|
|
101
|
+
if (!fs.existsSync(featuresDir)) return [];
|
|
102
|
+
|
|
103
|
+
return fs
|
|
104
|
+
.readdirSync(featuresDir, { withFileTypes: true })
|
|
105
|
+
.filter((entry) => entry.isDirectory())
|
|
106
|
+
.map((entry) => entry.name);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const listFixNotesRequiringAnchor = (root: string) => {
|
|
110
|
+
const fixesDir = path.join(root, 'docs', 'fixes');
|
|
111
|
+
if (!fs.existsSync(fixesDir)) return [];
|
|
112
|
+
|
|
113
|
+
return fs
|
|
114
|
+
.readdirSync(fixesDir)
|
|
115
|
+
.filter((name) => name.endsWith('.md'))
|
|
116
|
+
.filter((name) => /^## Agent warning\s*$/m.test(fs.readFileSync(path.join(fixesDir, name), 'utf8')));
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export const buildDocsCheckReport = (root: string): TDocsCheckReport => {
|
|
120
|
+
const files = walkSourceFiles(root);
|
|
121
|
+
const findings: TDocsCheckFinding[] = [];
|
|
122
|
+
const referencedPacks = new Set<string>();
|
|
123
|
+
const referencedFixNotes = new Set<string>();
|
|
124
|
+
let anchoredFiles = 0;
|
|
125
|
+
|
|
126
|
+
files.forEach((filepath) => {
|
|
127
|
+
const anchors = collectDocAnchors(fs.readFileSync(filepath, 'utf8'));
|
|
128
|
+
if (anchors.entries.length === 0) return;
|
|
129
|
+
|
|
130
|
+
anchoredFiles += 1;
|
|
131
|
+
const relative = path.relative(root, filepath);
|
|
132
|
+
|
|
133
|
+
anchors.docs.forEach((value) => {
|
|
134
|
+
referencedPacks.add(path.basename(value.replace(/\/+$/, '')));
|
|
135
|
+
if (!resolveAnchorValue(value, path.dirname(filepath), root)) {
|
|
136
|
+
findings.push({ detail: `@docs ${value}`, kind: 'unresolved-anchor', subject: relative });
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
anchors.fix.forEach((value) => {
|
|
141
|
+
referencedFixNotes.add(path.basename(value));
|
|
142
|
+
if (!resolveAnchorValue(value, path.dirname(filepath), root)) {
|
|
143
|
+
findings.push({ detail: `@fix ${value}`, kind: 'unresolved-anchor', subject: relative });
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
listFixNotesRequiringAnchor(root).forEach((note) => {
|
|
149
|
+
if (referencedFixNotes.has(note)) return;
|
|
150
|
+
findings.push({
|
|
151
|
+
detail: 'carries an Agent warning that no source file anchors',
|
|
152
|
+
kind: 'unanchored-fix-note',
|
|
153
|
+
subject: `docs/fixes/${note}`,
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
listFeaturePacks(root).forEach((pack) => {
|
|
158
|
+
if (referencedPacks.has(pack)) return;
|
|
159
|
+
findings.push({
|
|
160
|
+
detail: 'no source file points at this pack with @docs',
|
|
161
|
+
kind: 'orphan-feature-pack',
|
|
162
|
+
subject: `docs/features/${pack}`,
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
return { anchoredFiles, findings, scannedFiles: files.length };
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/*----------------------------------
|
|
170
|
+
- COMMAND
|
|
171
|
+
----------------------------------*/
|
|
172
|
+
|
|
173
|
+
const renderFindings = (findings: TDocsCheckFinding[], kind: TDocsCheckFinding['kind'], label: string) => {
|
|
174
|
+
const matching = findings.filter((finding) => finding.kind === kind);
|
|
175
|
+
if (matching.length === 0) return `${label}: none`;
|
|
176
|
+
|
|
177
|
+
return [`${label}: ${matching.length}`, ...matching.map((finding) => ` ${finding.subject} | ${finding.detail}`)].join(
|
|
178
|
+
'\n',
|
|
179
|
+
);
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
export const run = async (): Promise<void> => {
|
|
183
|
+
if (cli.args.action !== 'check') throw new UsageError('Usage: `proteum docs check`');
|
|
184
|
+
|
|
185
|
+
const root = cli.paths.appRoot;
|
|
186
|
+
|
|
187
|
+
console.info(
|
|
188
|
+
[
|
|
189
|
+
await renderTitle('PROTEUM DOCS CHECK', 'Checking that code and documentation still point at each other.'),
|
|
190
|
+
renderRows([{ label: 'root', value: root === process.cwd() ? '.' : root }]),
|
|
191
|
+
await renderStep('[1/1]', 'Resolving doc anchors.'),
|
|
192
|
+
].join('\n\n'),
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
const report = buildDocsCheckReport(root);
|
|
196
|
+
const unresolved = report.findings.filter((finding) => finding.kind === 'unresolved-anchor');
|
|
197
|
+
|
|
198
|
+
console.info(
|
|
199
|
+
[
|
|
200
|
+
renderRows([
|
|
201
|
+
{ label: 'files scanned', value: String(report.scannedFiles) },
|
|
202
|
+
{ label: 'files anchored', value: String(report.anchoredFiles) },
|
|
203
|
+
]),
|
|
204
|
+
renderFindings(report.findings, 'unresolved-anchor', 'Unresolved anchors'),
|
|
205
|
+
renderFindings(report.findings, 'unanchored-fix-note', 'Fix notes with no code anchor'),
|
|
206
|
+
renderFindings(report.findings, 'orphan-feature-pack', 'Feature packs with no inbound anchor'),
|
|
207
|
+
].join('\n\n'),
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
// Only a broken pointer fails the command. Missing coverage is reported as a
|
|
211
|
+
// backlog so a project can adopt anchors without a red build on day one.
|
|
212
|
+
if (unresolved.length > 0)
|
|
213
|
+
throw new Error(
|
|
214
|
+
`Proteum docs check failed: ${unresolved.length} doc anchor(s) do not resolve. Update or remove them.`,
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
const backlog = report.findings.length;
|
|
218
|
+
console.info(
|
|
219
|
+
backlog > 0
|
|
220
|
+
? await renderWarning(`Anchors resolve. ${backlog} coverage gap(s) reported above.`)
|
|
221
|
+
: await renderSuccess('All doc anchors resolve, and coverage is complete.'),
|
|
222
|
+
);
|
|
223
|
+
};
|
|
@@ -14,6 +14,7 @@ export const proteumCommandNames = [
|
|
|
14
14
|
'typecheck',
|
|
15
15
|
'lint',
|
|
16
16
|
'check',
|
|
17
|
+
'docs',
|
|
17
18
|
'e2e',
|
|
18
19
|
'connect',
|
|
19
20
|
'doctor',
|
|
@@ -301,6 +302,19 @@ export const proteumCommands: Record<TProteumCommandName, TProteumCommandDoc> =
|
|
|
301
302
|
notes: ['This command executes refresh, typecheck, then lint in that order.', 'From a monorepo wrapper root, check runs once per discovered Proteum app.'],
|
|
302
303
|
status: 'stable',
|
|
303
304
|
},
|
|
305
|
+
docs: {
|
|
306
|
+
name: 'docs',
|
|
307
|
+
category: 'Quality gates',
|
|
308
|
+
summary: 'Check that code and documentation still point at each other.',
|
|
309
|
+
usage: 'proteum docs check',
|
|
310
|
+
bestFor: 'Confirming doc anchors resolve and that fix notes and feature packs are reachable from code.',
|
|
311
|
+
examples: [{ description: 'Check every doc anchor in the repository', command: 'proteum docs check' }],
|
|
312
|
+
notes: [
|
|
313
|
+
'Only an anchor that no longer resolves fails the command; missing coverage is reported as a backlog.',
|
|
314
|
+
'Anchors are the `@docs`, `@adr`, `@fix`, and `@rule` tags described in `CODING_STYLE.md`.',
|
|
315
|
+
],
|
|
316
|
+
status: 'stable',
|
|
317
|
+
},
|
|
304
318
|
e2e: {
|
|
305
319
|
name: 'e2e',
|
|
306
320
|
category: 'Quality gates',
|
package/cli/runtime/commands.ts
CHANGED
|
@@ -352,6 +352,22 @@ class CheckCommand extends ProteumCommand {
|
|
|
352
352
|
}
|
|
353
353
|
}
|
|
354
354
|
|
|
355
|
+
class DocsCommand extends ProteumCommand {
|
|
356
|
+
public static paths = [['docs']];
|
|
357
|
+
|
|
358
|
+
public static usage = buildUsage('docs');
|
|
359
|
+
|
|
360
|
+
public args = Option.Rest();
|
|
361
|
+
|
|
362
|
+
public async execute() {
|
|
363
|
+
const [action = '', ...restArgs] = this.args;
|
|
364
|
+
|
|
365
|
+
assertNoLegacyArgs('docs', restArgs);
|
|
366
|
+
this.setCliArgs({ action });
|
|
367
|
+
await runCommandModule(() => import('../commands/docs'));
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
355
371
|
class E2eCommand extends ProteumCommand {
|
|
356
372
|
public static paths = [['e2e']];
|
|
357
373
|
|
|
@@ -919,6 +935,7 @@ export const registeredCommands = {
|
|
|
919
935
|
typecheck: TypecheckCommand,
|
|
920
936
|
lint: LintCommand,
|
|
921
937
|
check: CheckCommand,
|
|
938
|
+
docs: DocsCommand,
|
|
922
939
|
e2e: E2eCommand,
|
|
923
940
|
connect: ConnectCommand,
|
|
924
941
|
doctor: DoctorCommand,
|
|
@@ -955,6 +972,7 @@ export const createCli = (version: string) => {
|
|
|
955
972
|
clipanion.register(TypecheckCommand);
|
|
956
973
|
clipanion.register(LintCommand);
|
|
957
974
|
clipanion.register(CheckCommand);
|
|
975
|
+
clipanion.register(DocsCommand);
|
|
958
976
|
clipanion.register(E2eCommand);
|
|
959
977
|
clipanion.register(ConnectCommand);
|
|
960
978
|
clipanion.register(DoctorCommand);
|
|
@@ -144,6 +144,13 @@ const isDocsOnlyFile = (filepath: string) =>
|
|
|
144
144
|
filepath.startsWith('agents/') ||
|
|
145
145
|
docsOnlyExtensions.has(path.extname(filepath));
|
|
146
146
|
|
|
147
|
+
/**
|
|
148
|
+
* A change that can break the code-to-documentation link: either the corpus
|
|
149
|
+
* moved under the anchors, or a source file that may carry anchors changed.
|
|
150
|
+
*/
|
|
151
|
+
const isDocAnchorRelevantFile = (filepath: string) =>
|
|
152
|
+
filepath.startsWith('docs/') || isRelatedSourceFile(filepath);
|
|
153
|
+
|
|
147
154
|
const normalizeGlob = (glob: string) => normalizePath(glob.trim());
|
|
148
155
|
|
|
149
156
|
const escapeRegExp = (value: string) => value.replace(/[|\\{}()[\]^$+?.]/g, '\\$&');
|
|
@@ -350,6 +357,20 @@ export const buildChangedVerificationPlan = ({
|
|
|
350
357
|
if (check) addCheck({ check, selectedChecks });
|
|
351
358
|
}
|
|
352
359
|
|
|
360
|
+
const docAnchorFiles = files.filter(isDocAnchorRelevantFile);
|
|
361
|
+
if (docAnchorFiles.length > 0) {
|
|
362
|
+
const check = createSuiteCheck({
|
|
363
|
+
configRoot: gitRoot,
|
|
364
|
+
files: docAnchorFiles,
|
|
365
|
+
id: 'builtin:doc-anchors',
|
|
366
|
+
reason: 'Changed docs or source files should keep doc anchors resolvable.',
|
|
367
|
+
scope: 'static',
|
|
368
|
+
source: 'builtin',
|
|
369
|
+
suite: 'npx proteum docs check',
|
|
370
|
+
});
|
|
371
|
+
if (check) addCheck({ check, selectedChecks });
|
|
372
|
+
}
|
|
373
|
+
|
|
353
374
|
for (const rule of config.rules || []) {
|
|
354
375
|
const matchedFiles = matchFiles(files, rule.match);
|
|
355
376
|
if (matchedFiles.length === 0) continue;
|
|
@@ -45,8 +45,34 @@ type TNodePath = {
|
|
|
45
45
|
resolve: (...segments: string[]) => string;
|
|
46
46
|
};
|
|
47
47
|
|
|
48
|
+
type TDocAnchorEntry = {
|
|
49
|
+
line: number;
|
|
50
|
+
tag: string;
|
|
51
|
+
value: string;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
type TDocAnchorGroups = {
|
|
55
|
+
adr: string[];
|
|
56
|
+
docs: string[];
|
|
57
|
+
entries: TDocAnchorEntry[];
|
|
58
|
+
fix: string[];
|
|
59
|
+
rules: string[];
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
type TDocAnchorsModule = {
|
|
63
|
+
collectDocAnchors: (sourceText: string, options?: { maxLength?: number }) => TDocAnchorGroups;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export type TOwnerDocAnchors = {
|
|
67
|
+
adr?: string[];
|
|
68
|
+
docs?: string[];
|
|
69
|
+
fix?: string[];
|
|
70
|
+
rules?: string[];
|
|
71
|
+
};
|
|
72
|
+
|
|
48
73
|
const maxInstructionPreviewLength = 360;
|
|
49
74
|
const maxTextLength = 220;
|
|
75
|
+
const maxOwnerDocAnchorRules = 3;
|
|
50
76
|
const nodeRequire = (() => {
|
|
51
77
|
try {
|
|
52
78
|
return eval('require') as NodeRequire;
|
|
@@ -56,6 +82,23 @@ const nodeRequire = (() => {
|
|
|
56
82
|
})();
|
|
57
83
|
const fs = nodeRequire ? (nodeRequire('fs') as TNodeFs) : undefined;
|
|
58
84
|
const path = nodeRequire ? (nodeRequire('path') as TNodePath) : undefined;
|
|
85
|
+
const docAnchors = (() => {
|
|
86
|
+
if (!nodeRequire) return undefined;
|
|
87
|
+
|
|
88
|
+
// Relative resolution covers the linked framework checkout; the package
|
|
89
|
+
// specifier covers an installed copy whose transpiled layout may differ.
|
|
90
|
+
// Owner payloads stay valid without anchors, so a resolution failure
|
|
91
|
+
// degrades to the previous behaviour instead of breaking the tool.
|
|
92
|
+
for (const specifier of ['../../docAnchors.js', 'proteum/docAnchors.js']) {
|
|
93
|
+
try {
|
|
94
|
+
return nodeRequire(specifier) as TDocAnchorsModule;
|
|
95
|
+
} catch (_error) {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return undefined;
|
|
101
|
+
})();
|
|
59
102
|
|
|
60
103
|
const hasNodeFs = () => fs !== undefined;
|
|
61
104
|
const hasNodePath = () => path !== undefined;
|
|
@@ -217,14 +260,49 @@ export const summarizeManifest = (manifest: TProteumManifest | undefined) => {
|
|
|
217
260
|
};
|
|
218
261
|
};
|
|
219
262
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
263
|
+
/**
|
|
264
|
+
* Resolve the doc anchors declared in an owner's source file.
|
|
265
|
+
*
|
|
266
|
+
* This is what makes the documentation corpus discoverable at the point an
|
|
267
|
+
* agent asks who owns a route: the governing feature pack, decision record and
|
|
268
|
+
* fix note arrive with the owner instead of costing a separate search.
|
|
269
|
+
*/
|
|
270
|
+
export const readOwnerDocAnchors = (filepath?: string): TOwnerDocAnchors | undefined => {
|
|
271
|
+
if (!filepath || !docAnchors || fs === undefined || !fileExists(filepath)) return undefined;
|
|
272
|
+
|
|
273
|
+
let groups: TDocAnchorGroups;
|
|
274
|
+
try {
|
|
275
|
+
groups = docAnchors.collectDocAnchors(fs.readFileSync(filepath, 'utf8'));
|
|
276
|
+
} catch (_error) {
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (groups.entries.length === 0) return undefined;
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
docs: groups.docs.length > 0 ? groups.docs : undefined,
|
|
284
|
+
adr: groups.adr.length > 0 ? groups.adr : undefined,
|
|
285
|
+
fix: groups.fix.length > 0 ? groups.fix : undefined,
|
|
286
|
+
rules:
|
|
287
|
+
groups.rules.length > 0
|
|
288
|
+
? compactList(groups.rules, maxOwnerDocAnchorRules).map((rule) => truncateForMcp(rule))
|
|
289
|
+
: undefined,
|
|
290
|
+
};
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const compactOwnerMatch = (match: TExplainOwnerResponse['matches'][number]) => {
|
|
294
|
+
const docs = readOwnerDocAnchors(match.source.filepath);
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
kind: match.kind,
|
|
298
|
+
label: match.label,
|
|
299
|
+
score: match.score,
|
|
300
|
+
scope: match.scopeLabel,
|
|
301
|
+
origin: match.originHint,
|
|
302
|
+
source: match.source,
|
|
303
|
+
...(docs ? { docs } : {}),
|
|
304
|
+
};
|
|
305
|
+
};
|
|
228
306
|
|
|
229
307
|
const compactDiagnostic = (diagnostic: TDoctorResponse['diagnostics'][number]) => ({
|
|
230
308
|
level: diagnostic.level,
|
package/docAnchors.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Shared doc-anchor contract.
|
|
3
|
+
*
|
|
4
|
+
* A doc anchor ties a source file to the durable documentation that governs it,
|
|
5
|
+
* so an agent editing the file sees the governing rule without first reading the
|
|
6
|
+
* whole documentation corpus.
|
|
7
|
+
*
|
|
8
|
+
* Supported tags, written in any leading block comment:
|
|
9
|
+
*
|
|
10
|
+
* @docs docs/features/search
|
|
11
|
+
* @adr ADR-0004
|
|
12
|
+
* @fix docs/fixes/2026-06-09-keyword-search-semantic-order.md
|
|
13
|
+
* @rule Composite ordering stays alias-aware. Never rewrite ORDER BY with regex.
|
|
14
|
+
*
|
|
15
|
+
* `@docs`, `@adr` and `@fix` are pointers resolved by the MCP owner payloads.
|
|
16
|
+
* `@rule` carries the one-line invariant inline, where an agent cannot miss it.
|
|
17
|
+
*
|
|
18
|
+
* This module is plain CommonJS with no dependencies because both the ESLint
|
|
19
|
+
* rules and the TypeScript MCP payload builder load it. Keep it that way so the
|
|
20
|
+
* lint surface and the agent-facing surface can never disagree on the format.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const docAnchorTags = ['docs', 'adr', 'fix', 'rule'];
|
|
24
|
+
const pathDocAnchorTags = ['docs', 'fix'];
|
|
25
|
+
const maxDocAnchorScanLength = 64 * 1024;
|
|
26
|
+
const minDocAnchorRuleLength = 8;
|
|
27
|
+
|
|
28
|
+
const blockCommentPattern = /\/\*[\s\S]*?\*\//g;
|
|
29
|
+
const commentGutterPattern = /^\s*\*+[ \t]?/;
|
|
30
|
+
const tagPattern = /^@([a-zA-Z][\w-]*)[ \t]*(.*)$/;
|
|
31
|
+
|
|
32
|
+
const countLinesBefore = (text, index) => {
|
|
33
|
+
let line = 1;
|
|
34
|
+
for (let cursor = 0; cursor < index; cursor += 1) {
|
|
35
|
+
if (text[cursor] === '\n') line += 1;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return line;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const stripCommentGutter = (line) => line.replace(commentGutterPattern, '').trim();
|
|
42
|
+
|
|
43
|
+
const isDocAnchorTag = (tag) => docAnchorTags.includes(tag);
|
|
44
|
+
const isPathDocAnchorTag = (tag) => pathDocAnchorTags.includes(tag);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Parse the inner text of a single block comment.
|
|
48
|
+
*
|
|
49
|
+
* `startLine` is the 1-based line the comment opens on, so reported entries
|
|
50
|
+
* point at the exact anchor line rather than the top of the file.
|
|
51
|
+
*/
|
|
52
|
+
const parseDocAnchorComment = (commentValue, startLine = 1) => {
|
|
53
|
+
const entries = [];
|
|
54
|
+
const lines = String(commentValue === undefined || commentValue === null ? '' : commentValue).split('\n');
|
|
55
|
+
let current;
|
|
56
|
+
|
|
57
|
+
lines.forEach((rawLine, index) => {
|
|
58
|
+
const line = stripCommentGutter(rawLine);
|
|
59
|
+
if (line === '') {
|
|
60
|
+
current = undefined;
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const tagMatch = line.match(tagPattern);
|
|
65
|
+
if (tagMatch) {
|
|
66
|
+
const tag = tagMatch[1].toLowerCase();
|
|
67
|
+
if (!isDocAnchorTag(tag)) {
|
|
68
|
+
current = undefined;
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
current = { tag, value: tagMatch[2].trim(), line: startLine + index };
|
|
73
|
+
entries.push(current);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// A non-empty line that opens no new tag continues the previous value,
|
|
78
|
+
// which keeps multi-line `@rule` invariants readable in source.
|
|
79
|
+
if (current) current.value = `${current.value} ${line}`.trim();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
return entries.filter((entry) => entry.value !== '');
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const uniqueValues = (entries, tag) => {
|
|
86
|
+
const values = [];
|
|
87
|
+
entries.forEach((entry) => {
|
|
88
|
+
if (entry.tag !== tag || values.includes(entry.value)) return;
|
|
89
|
+
values.push(entry.value);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
return values;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const buildDocAnchorGroups = (entries) => ({
|
|
96
|
+
entries,
|
|
97
|
+
docs: uniqueValues(entries, 'docs'),
|
|
98
|
+
adr: uniqueValues(entries, 'adr'),
|
|
99
|
+
fix: uniqueValues(entries, 'fix'),
|
|
100
|
+
rules: uniqueValues(entries, 'rule'),
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const hasDocAnchorTag = (groups, tag) => groups.entries.some((entry) => entry.tag === tag);
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Collect every doc anchor in raw source text.
|
|
107
|
+
*
|
|
108
|
+
* Used by the MCP owner payloads, which read a file from disk and have no AST.
|
|
109
|
+
* The scan is capped so enriching an owner match stays cheap on large files.
|
|
110
|
+
*/
|
|
111
|
+
const collectDocAnchors = (sourceText, options) => {
|
|
112
|
+
const maxLength = options && options.maxLength ? options.maxLength : maxDocAnchorScanLength;
|
|
113
|
+
const text = String(sourceText === undefined || sourceText === null ? '' : sourceText).slice(0, maxLength);
|
|
114
|
+
const entries = [];
|
|
115
|
+
|
|
116
|
+
blockCommentPattern.lastIndex = 0;
|
|
117
|
+
let match = blockCommentPattern.exec(text);
|
|
118
|
+
while (match !== null) {
|
|
119
|
+
const body = match[0].slice(2, -2);
|
|
120
|
+
entries.push(...parseDocAnchorComment(body, countLinesBefore(text, match.index)));
|
|
121
|
+
match = blockCommentPattern.exec(text);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return buildDocAnchorGroups(entries);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
module.exports = {
|
|
128
|
+
buildDocAnchorGroups,
|
|
129
|
+
collectDocAnchors,
|
|
130
|
+
docAnchorTags,
|
|
131
|
+
hasDocAnchorTag,
|
|
132
|
+
isPathDocAnchorTag,
|
|
133
|
+
minDocAnchorRuleLength,
|
|
134
|
+
parseDocAnchorComment,
|
|
135
|
+
};
|