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.
@@ -68,6 +68,33 @@ 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`, 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
+ - 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
+ - `@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
+ - `@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
+ - 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.
91
+
92
+ 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.
93
+
94
+ 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.
95
+
96
+ `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
+
71
98
  ## Self-check before finishing
72
99
 
73
100
  Re-scan every touched file against this list before declaring the work done:
@@ -75,5 +102,6 @@ Re-scan every touched file against this list before declaring the work done:
75
102
  - No `any`, `unknown`, or casts introduced; contracts fixed at the boundary.
76
103
  - New code sits under the right banner section, and section names still match their content.
77
104
  - Every non-obvious decision, workaround, magic value, and bug fix has a why-comment at the site.
105
+ - 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
106
  - No comments that restate code; no leftover debug logs or commented-out code.
79
107
  - 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',
@@ -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
- const compactOwnerMatch = (match: TExplainOwnerResponse['matches'][number]) => ({
221
- kind: match.kind,
222
- label: match.label,
223
- score: match.score,
224
- scope: match.scopeLabel,
225
- origin: match.originHint,
226
- source: match.source,
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
+ };