pan-wizard 3.5.2 → 3.8.0

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.
Files changed (98) hide show
  1. package/README.md +28 -9
  2. package/agents/pan-executor.md +18 -0
  3. package/agents/pan-experiment-runner.md +126 -0
  4. package/agents/pan-phase-researcher.md +16 -0
  5. package/agents/pan-plan-checker.md +80 -0
  6. package/agents/pan-planner.md +19 -0
  7. package/agents/pan-reviewer.md +2 -0
  8. package/agents/pan-verifier.md +41 -0
  9. package/bin/install-lib.cjs +55 -0
  10. package/bin/install.js +71 -22
  11. package/commands/pan/debug.md +1 -1
  12. package/commands/pan/experiment.md +219 -0
  13. package/commands/pan/health.md +1 -1
  14. package/commands/pan/learn.md +15 -1
  15. package/commands/pan/links.md +102 -0
  16. package/commands/pan/optimize.md +13 -0
  17. package/commands/pan/patches.md +10 -1
  18. package/commands/pan/phase-tests.md +1 -4
  19. package/commands/pan/todo-add.md +1 -1
  20. package/commands/pan/todo-check.md +1 -1
  21. package/hooks/dist/pan-cost-logger.js +54 -4
  22. package/hooks/dist/pan-trace-logger.js +72 -3
  23. package/package.json +67 -66
  24. package/pan-wizard-core/bin/lib/codebase.cjs +2 -0
  25. package/pan-wizard-core/bin/lib/commands.cjs +8 -0
  26. package/pan-wizard-core/bin/lib/config.cjs +13 -2
  27. package/pan-wizard-core/bin/lib/context-budget.cjs +73 -0
  28. package/pan-wizard-core/bin/lib/core.cjs +13 -0
  29. package/pan-wizard-core/bin/lib/doc-lint/frontmatter.js +270 -0
  30. package/pan-wizard-core/bin/lib/doc-lint/reporter.js +45 -0
  31. package/pan-wizard-core/bin/lib/doc-lint/schema.js +202 -0
  32. package/pan-wizard-core/bin/lib/doc-lint/validate.js +190 -0
  33. package/pan-wizard-core/bin/lib/doc-lint/walk.js +135 -0
  34. package/pan-wizard-core/bin/lib/doc-lint.cjs +287 -0
  35. package/pan-wizard-core/bin/lib/experiment.cjs +502 -0
  36. package/pan-wizard-core/bin/lib/learn-index.cjs +235 -0
  37. package/pan-wizard-core/bin/lib/learn-lint.cjs +292 -0
  38. package/pan-wizard-core/bin/lib/links.cjs +549 -0
  39. package/pan-wizard-core/bin/lib/optimize.cjs +474 -1
  40. package/pan-wizard-core/bin/lib/runner.cjs +473 -0
  41. package/pan-wizard-core/bin/lib/verify.cjs +23 -0
  42. package/pan-wizard-core/bin/pan-tools.cjs +247 -3
  43. package/pan-wizard-core/learnings/README.md +70 -0
  44. package/pan-wizard-core/learnings/index.json +540 -0
  45. package/pan-wizard-core/learnings/internal/.gitkeep +2 -0
  46. package/pan-wizard-core/learnings/internal/experiment-runner.md +81 -0
  47. package/pan-wizard-core/learnings/internal/external-research.md +93 -0
  48. package/pan-wizard-core/learnings/internal/loop-design.md +33 -0
  49. package/pan-wizard-core/learnings/internal/pan-dev-bugs.md +181 -0
  50. package/pan-wizard-core/learnings/universal/.gitkeep +2 -0
  51. package/pan-wizard-core/learnings/universal/atomic-state.md +21 -0
  52. package/pan-wizard-core/learnings/universal/binary-io.md +21 -0
  53. package/pan-wizard-core/learnings/universal/comment-syntax.md +21 -0
  54. package/pan-wizard-core/learnings/universal/composition.md +33 -0
  55. package/pan-wizard-core/learnings/universal/concurrency.md +33 -0
  56. package/pan-wizard-core/learnings/universal/dag-scheduler.md +33 -0
  57. package/pan-wizard-core/learnings/universal/data-driven-design.md +21 -0
  58. package/pan-wizard-core/learnings/universal/design-process.md +21 -0
  59. package/pan-wizard-core/learnings/universal/empirical-spike.md +21 -0
  60. package/pan-wizard-core/learnings/universal/error-handling.md +23 -0
  61. package/pan-wizard-core/learnings/universal/error-paths.md +21 -0
  62. package/pan-wizard-core/learnings/universal/glob-semantics.md +21 -0
  63. package/pan-wizard-core/learnings/universal/idempotency.md +21 -0
  64. package/pan-wizard-core/learnings/universal/invariants.md +21 -0
  65. package/pan-wizard-core/learnings/universal/io-patterns.md +21 -0
  66. package/pan-wizard-core/learnings/universal/numeric-edge-cases.md +21 -0
  67. package/pan-wizard-core/learnings/universal/output-conventions.md +21 -0
  68. package/pan-wizard-core/learnings/universal/parser-design.md +21 -0
  69. package/pan-wizard-core/learnings/universal/phase-locking.md +21 -0
  70. package/pan-wizard-core/learnings/universal/pipe-friendly-cli.md +21 -0
  71. package/pan-wizard-core/learnings/universal/schema-design.md +21 -0
  72. package/pan-wizard-core/learnings/universal/secret-handling.md +21 -0
  73. package/pan-wizard-core/learnings/universal/streaming-io.md +21 -0
  74. package/pan-wizard-core/learnings/universal/test-patterns.md +57 -0
  75. package/pan-wizard-core/learnings/universal/test-strategy.md +33 -0
  76. package/pan-wizard-core/learnings/universal/unicode.md +21 -0
  77. package/pan-wizard-core/learnings/universal/vendor-pattern.md +21 -0
  78. package/pan-wizard-core/references/guardrails.md +58 -0
  79. package/pan-wizard-core/references/handoff-decisions.md +156 -0
  80. package/pan-wizard-core/references/schemas/pan-command.schema.yml +39 -0
  81. package/pan-wizard-core/references/verification-patterns.md +31 -0
  82. package/pan-wizard-core/templates/config.json +2 -1
  83. package/pan-wizard-core/templates/idea.md +52 -0
  84. package/pan-wizard-core/templates/summary-complex.md +14 -5
  85. package/pan-wizard-core/templates/summary-minimal.md +6 -0
  86. package/pan-wizard-core/templates/summary-standard.md +14 -3
  87. package/pan-wizard-core/workflows/discuss-phase.md +108 -1
  88. package/pan-wizard-core/workflows/exec-phase.md +37 -1
  89. package/pan-wizard-core/workflows/execute-plan.md +14 -0
  90. package/pan-wizard-core/workflows/health.md +23 -0
  91. package/pan-wizard-core/workflows/new-project.md +65 -81
  92. package/pan-wizard-core/workflows/plan-phase.md +58 -0
  93. package/pan-wizard-core/workflows/transition.md +102 -7
  94. package/pan-wizard-core/workflows/verify-phase.md +14 -0
  95. package/scripts/build-hooks.js +7 -1
  96. package/scripts/generate-skills-docs.py +10 -8
  97. package/scripts/git-hooks/pre-commit +40 -0
  98. package/scripts/release-check.js +184 -0
@@ -0,0 +1,549 @@
1
+ 'use strict';
2
+ // @pan: ADR-0027
3
+ /**
4
+ * Links — Doc–Code link graph scanner and lint.
5
+ *
6
+ * Implements ADR-0027 (Doc–Code Link Graph).
7
+ * Spec: docs/specs/doc_code_link_graph_featureai.md
8
+ *
9
+ * Three lint passes share one walk pair:
10
+ * - Forward links: inline [[<id>]] in body + must_haves.key_links in frontmatter.
11
+ * - Backlink contract: docs with `require-code-mention: true` must have at
12
+ * least one resolving @pan: anchor.
13
+ * - Anchor-target existence: every @pan: anchor must resolve to a real doc.
14
+ */
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const { safeReadFile, toPosix, output } = require('./core.cjs');
19
+ const { extractFrontmatter, parseMustHavesBlock } = require('./frontmatter.cjs');
20
+ const { walkMarkdownFiles } = require('./doc-lint/walk.js');
21
+
22
+ const DEFAULT_DOC_ROOTS = [
23
+ 'docs',
24
+ 'pan-wizard-core/workflows',
25
+ 'pan-wizard-core/templates',
26
+ 'pan-wizard-core/references',
27
+ 'pan-wizard-core/learnings',
28
+ 'commands',
29
+ 'agents',
30
+ ];
31
+
32
+ const DEFAULT_SOURCE_ROOTS = [
33
+ 'pan-wizard-core',
34
+ 'bin',
35
+ 'hooks',
36
+ 'scripts',
37
+ ];
38
+
39
+ const SOURCE_EXT_TO_LEADER = {
40
+ '.cjs': '//',
41
+ '.js': '//',
42
+ '.mjs': '//',
43
+ '.ts': '//',
44
+ '.sh': '#',
45
+ '.py': '#',
46
+ '.ps1': '#',
47
+ '.md': '<!--',
48
+ '.html': '<!--',
49
+ };
50
+
51
+ const ANCHOR_RES = {
52
+ '//': /^\s*\/\/\s*@pan:\s*([^\s].*?)\s*$/,
53
+ '#': /^\s*#\s*@pan:\s*([^\s].*?)\s*$/,
54
+ '<!--': /^\s*<!--\s*@pan:\s*([^\s].*?)\s*(?:-->)?\s*$/,
55
+ };
56
+
57
+ const INLINE_LINK_RE = /\[\[([^\[\]\s|][^\[\]]*?)\]\]/g;
58
+ const ADR_SHORT_RE = /^ADR-(\d{4})$/i;
59
+
60
+ const SKIP_DIR_NAMES = new Set(['node_modules', '.git', 'dist', '.cache', 'coverage']);
61
+
62
+ // ─── Doc-id resolver ─────────────────────────────────────────────────────────
63
+
64
+ function slugify(s) {
65
+ return String(s).toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
66
+ }
67
+
68
+ function fileHasSection(filePath, section) {
69
+ const content = safeReadFile(filePath);
70
+ if (!content) return false;
71
+ const target = slugify(section);
72
+ const lines = content.split('\n');
73
+ for (const line of lines) {
74
+ const m = line.match(/^#{1,6}\s+(.+?)\s*$/);
75
+ if (m && slugify(m[1]) === target) return true;
76
+ }
77
+ return false;
78
+ }
79
+
80
+ function resolveDocId(rawId, cwd) {
81
+ if (!rawId || !rawId.trim()) return { resolved: false, reason: 'empty id' };
82
+ let id = rawId.trim();
83
+ let section = null;
84
+ const hashIdx = id.indexOf('#');
85
+ if (hashIdx !== -1) {
86
+ section = id.slice(hashIdx + 1).trim();
87
+ id = id.slice(0, hashIdx).trim();
88
+ }
89
+
90
+ // ADR-NNNN shortcut → glob docs/decisions/ADR-NNNN-*.md
91
+ const adrMatch = id.match(ADR_SHORT_RE);
92
+ if (adrMatch) {
93
+ const num = adrMatch[1];
94
+ const decisionsDir = path.join(cwd, 'docs', 'decisions');
95
+ let entries = [];
96
+ try {
97
+ entries = fs.readdirSync(decisionsDir);
98
+ } catch {
99
+ return { resolved: false, reason: 'docs/decisions/ not found' };
100
+ }
101
+ const candidates = entries.filter(f =>
102
+ f.toLowerCase().startsWith(`adr-${num}-`) && f.endsWith('.md')
103
+ );
104
+ if (candidates.length === 0) {
105
+ return { resolved: false, reason: `no ADR-${num}-*.md found` };
106
+ }
107
+ if (candidates.length > 1) {
108
+ return { resolved: false, reason: `ambiguous ADR-${num}: ${candidates.join(', ')}` };
109
+ }
110
+ const relPath = toPosix(path.join('docs', 'decisions', candidates[0]));
111
+ if (section) {
112
+ const fullPath = path.join(cwd, 'docs', 'decisions', candidates[0]);
113
+ if (fileHasSection(fullPath, section)) {
114
+ return { resolved: true, path: relPath, section };
115
+ }
116
+ return { resolved: true, path: relPath, section, sectionMissing: true };
117
+ }
118
+ return { resolved: true, path: relPath };
119
+ }
120
+
121
+ // Direct .md path
122
+ if (id.endsWith('.md')) {
123
+ const fullPath = path.join(cwd, id);
124
+ try { fs.accessSync(fullPath); }
125
+ catch { return { resolved: false, reason: `${id} not found` }; }
126
+ if (section) {
127
+ if (fileHasSection(fullPath, section)) {
128
+ return { resolved: true, path: toPosix(id), section };
129
+ }
130
+ return { resolved: true, path: toPosix(id), section, sectionMissing: true };
131
+ }
132
+ return { resolved: true, path: toPosix(id) };
133
+ }
134
+
135
+ // Try <id>.md, then <id>/README.md
136
+ const candidates = [`${id}.md`, path.join(id, 'README.md')];
137
+ for (const cand of candidates) {
138
+ const fullPath = path.join(cwd, cand);
139
+ try {
140
+ fs.accessSync(fullPath);
141
+ const relCand = toPosix(cand);
142
+ if (section) {
143
+ if (fileHasSection(fullPath, section)) {
144
+ return { resolved: true, path: relCand, section };
145
+ }
146
+ return { resolved: true, path: relCand, section, sectionMissing: true };
147
+ }
148
+ return { resolved: true, path: relCand };
149
+ } catch { /* try next */ }
150
+ }
151
+ return { resolved: false, reason: `${id} (tried ${id}.md and ${id}/README.md)` };
152
+ }
153
+
154
+ // ─── Forward-link scanner ────────────────────────────────────────────────────
155
+
156
+ function stripInlineCodeSpans(line) {
157
+ // Replace `...` spans (and ``...`` etc.) with placeholders so [[...]] inside
158
+ // backticks is not picked up as a real link.
159
+ return line.replace(/(`+)([^`]|(?!\1)`)*?\1/g, m => ' '.repeat(m.length));
160
+ }
161
+
162
+ function parseInlineLinks(text) {
163
+ const out = [];
164
+ const lines = text.split('\n');
165
+ let inFence = false;
166
+ let fenceMarker = '';
167
+ let inFrontmatter = false;
168
+ let frontmatterDone = false;
169
+ for (let i = 0; i < lines.length; i++) {
170
+ const line = lines[i];
171
+ // Skip leading YAML frontmatter (--- on line 1, then content, then closing ---).
172
+ // Only the leading block; subsequent --- in body is unaffected.
173
+ if (i === 0 && line.trim() === '---') { inFrontmatter = true; continue; }
174
+ if (inFrontmatter && !frontmatterDone) {
175
+ if (line.trim() === '---') { inFrontmatter = false; frontmatterDone = true; }
176
+ continue;
177
+ }
178
+ // Toggle fenced-code-block state on lines opening/closing ``` or ~~~
179
+ const fenceMatch = line.match(/^(\s{0,3})(```+|~~~+)(.*)$/);
180
+ if (fenceMatch) {
181
+ const marker = fenceMatch[2];
182
+ if (!inFence) { inFence = true; fenceMarker = marker[0]; continue; }
183
+ if (marker[0] === fenceMarker) { inFence = false; fenceMarker = ''; continue; }
184
+ }
185
+ if (inFence) continue;
186
+ const stripped = stripInlineCodeSpans(line);
187
+ INLINE_LINK_RE.lastIndex = 0;
188
+ let m;
189
+ while ((m = INLINE_LINK_RE.exec(stripped)) !== null) {
190
+ out.push({ rawId: m[1].trim(), line: i + 1 });
191
+ }
192
+ }
193
+ return out;
194
+ }
195
+
196
+ function safeWalkDocs(rootAbs) {
197
+ try {
198
+ return walkMarkdownFiles(rootAbs, { exclude: ['**/node_modules/**'] });
199
+ } catch {
200
+ return null;
201
+ }
202
+ }
203
+
204
+ function scanForwardLinks(docRoots, cwd) {
205
+ const out = [];
206
+ for (const root of docRoots) {
207
+ const fullDir = path.isAbsolute(root) ? root : path.join(cwd, root);
208
+ const files = safeWalkDocs(fullDir);
209
+ if (!files) continue;
210
+ for (const file of files) {
211
+ if (file.readError) continue;
212
+ const relPath = toPosix(path.relative(cwd, file.path));
213
+ for (const link of parseInlineLinks(file.content)) {
214
+ out.push({
215
+ source: relPath,
216
+ sourceLine: link.line,
217
+ rawId: link.rawId,
218
+ via: 'inline',
219
+ });
220
+ }
221
+ try {
222
+ const keyLinks = parseMustHavesBlock(file.content, 'key_links');
223
+ for (const link of keyLinks) {
224
+ if (typeof link === 'string') continue;
225
+ out.push({
226
+ source: relPath,
227
+ sourceLine: 0,
228
+ rawId: link.to || '',
229
+ via: 'key_links',
230
+ from: link.from || '',
231
+ pattern: link.pattern || '',
232
+ });
233
+ }
234
+ } catch { /* malformed frontmatter — skip */ }
235
+ }
236
+ }
237
+ return out;
238
+ }
239
+
240
+ // ─── Source-anchor scanner ───────────────────────────────────────────────────
241
+
242
+ function leaderForFile(filePath) {
243
+ const ext = path.extname(filePath).toLowerCase();
244
+ return SOURCE_EXT_TO_LEADER[ext] || null;
245
+ }
246
+
247
+ function parseAnchorLine(line, leader) {
248
+ const re = ANCHOR_RES[leader];
249
+ if (!re) return null;
250
+ const m = line.match(re);
251
+ return m ? m[1].trim() : null;
252
+ }
253
+
254
+ function walkSourceFiles(rootDir, out) {
255
+ let entries;
256
+ try {
257
+ entries = fs.readdirSync(rootDir, { withFileTypes: true });
258
+ } catch {
259
+ return;
260
+ }
261
+ for (const entry of entries) {
262
+ if (entry.isDirectory()) {
263
+ if (SKIP_DIR_NAMES.has(entry.name)) continue;
264
+ walkSourceFiles(path.join(rootDir, entry.name), out);
265
+ continue;
266
+ }
267
+ if (!entry.isFile()) continue;
268
+ const leader = leaderForFile(entry.name);
269
+ if (!leader) continue;
270
+ out.push({ path: path.join(rootDir, entry.name), leader });
271
+ }
272
+ }
273
+
274
+ function scanAnchors(sourceRoots, cwd) {
275
+ const out = [];
276
+ const files = [];
277
+ for (const root of sourceRoots) {
278
+ const fullDir = path.isAbsolute(root) ? root : path.join(cwd, root);
279
+ walkSourceFiles(fullDir, files);
280
+ }
281
+ for (const { path: fp, leader } of files) {
282
+ const content = safeReadFile(fp);
283
+ if (!content) continue;
284
+ const lines = content.split('\n');
285
+ const relPath = toPosix(path.relative(cwd, fp));
286
+ for (let i = 0; i < lines.length; i++) {
287
+ const id = parseAnchorLine(lines[i], leader);
288
+ if (id !== null) {
289
+ out.push({ source: relPath, sourceLine: i + 1, rawId: id, leader });
290
+ }
291
+ }
292
+ }
293
+ return out;
294
+ }
295
+
296
+ // ─── Lint passes ─────────────────────────────────────────────────────────────
297
+
298
+ function runForwardPass(forwardLinks, cwd) {
299
+ const findings = [];
300
+ for (const link of forwardLinks) {
301
+ if (link.via === 'inline') {
302
+ const r = resolveDocId(link.rawId, cwd);
303
+ if (!r.resolved) {
304
+ findings.push({
305
+ code: 'F-001',
306
+ severity: 'error',
307
+ source: link.source,
308
+ source_line: link.sourceLine,
309
+ target: link.rawId,
310
+ detail: r.reason || 'unresolved',
311
+ });
312
+ } else if (r.sectionMissing) {
313
+ findings.push({
314
+ code: 'F-002',
315
+ severity: 'error',
316
+ source: link.source,
317
+ source_line: link.sourceLine,
318
+ target: link.rawId,
319
+ detail: `Section "#${r.section}" not found in ${r.path}`,
320
+ });
321
+ }
322
+ continue;
323
+ }
324
+ if (link.via === 'key_links') {
325
+ if (link.from) {
326
+ try { fs.accessSync(path.join(cwd, link.from)); }
327
+ catch {
328
+ findings.push({
329
+ code: 'F-003', severity: 'warning',
330
+ source: link.source, source_line: 0, target: link.from,
331
+ detail: `key_links.from path does not exist: ${link.from}`,
332
+ });
333
+ }
334
+ }
335
+ if (link.rawId) {
336
+ try { fs.accessSync(path.join(cwd, link.rawId)); }
337
+ catch {
338
+ findings.push({
339
+ code: 'F-003', severity: 'warning',
340
+ source: link.source, source_line: 0, target: link.rawId,
341
+ detail: `key_links.to path does not exist: ${link.rawId}`,
342
+ });
343
+ }
344
+ }
345
+ if (link.pattern) {
346
+ try { new RegExp(link.pattern); }
347
+ catch (e) {
348
+ findings.push({
349
+ code: 'F-004', severity: 'warning',
350
+ source: link.source, source_line: 0, target: link.rawId,
351
+ detail: `Invalid regex in key_links.pattern: ${e.message}`,
352
+ });
353
+ }
354
+ }
355
+ }
356
+ }
357
+ return findings;
358
+ }
359
+
360
+ function runBacklinkPass(docRoots, anchors, cwd) {
361
+ const findings = [];
362
+
363
+ // Index: resolved doc path → array of anchor source files
364
+ const anchorIdx = new Map();
365
+ for (const a of anchors) {
366
+ const r = resolveDocId(a.rawId, cwd);
367
+ if (!r.resolved) continue;
368
+ if (!anchorIdx.has(r.path)) anchorIdx.set(r.path, []);
369
+ anchorIdx.get(r.path).push(a.source);
370
+ }
371
+
372
+ for (const root of docRoots) {
373
+ const fullDir = path.isAbsolute(root) ? root : path.join(cwd, root);
374
+ const files = safeWalkDocs(fullDir);
375
+ if (!files) continue;
376
+ for (const file of files) {
377
+ if (file.readError) continue;
378
+ const fm = extractFrontmatter(file.content);
379
+ const requireMention = fm['require-code-mention'];
380
+ if (requireMention !== true && requireMention !== 'true') continue;
381
+ const relPath = toPosix(path.relative(cwd, file.path));
382
+ const sources = anchorIdx.get(relPath) || [];
383
+ if (sources.length === 0) {
384
+ findings.push({
385
+ code: 'B-001', severity: 'error',
386
+ source: relPath, source_line: 0, target: null,
387
+ detail: 'require-code-mention is true but no @pan: anchors resolve to this doc',
388
+ });
389
+ continue;
390
+ }
391
+ const unique = new Set(sources);
392
+ if (unique.size === 1) {
393
+ findings.push({
394
+ code: 'B-002', severity: 'warning',
395
+ source: relPath, source_line: 0, target: [...unique][0],
396
+ detail: `Only one source file anchors this doc (${[...unique][0]})`,
397
+ });
398
+ }
399
+ }
400
+ }
401
+ return findings;
402
+ }
403
+
404
+ function runAnchorTargetPass(anchors, cwd) {
405
+ const findings = [];
406
+ for (const a of anchors) {
407
+ if (!a.rawId) {
408
+ findings.push({
409
+ code: 'A-004', severity: 'warning',
410
+ source: a.source, source_line: a.sourceLine,
411
+ target: null, detail: '@pan: anchor has empty id',
412
+ });
413
+ continue;
414
+ }
415
+ const r = resolveDocId(a.rawId, cwd);
416
+ if (!r.resolved) {
417
+ findings.push({
418
+ code: 'A-001', severity: 'error',
419
+ source: a.source, source_line: a.sourceLine,
420
+ target: a.rawId, detail: r.reason || 'unresolved',
421
+ });
422
+ } else if (r.sectionMissing) {
423
+ findings.push({
424
+ code: 'A-002', severity: 'warning',
425
+ source: a.source, source_line: a.sourceLine,
426
+ target: a.rawId,
427
+ detail: `Section "#${r.section}" not found in ${r.path}`,
428
+ });
429
+ }
430
+ }
431
+ return findings;
432
+ }
433
+
434
+ function countBacklinkContracts(docRoots, cwd) {
435
+ let n = 0;
436
+ for (const root of docRoots) {
437
+ const fullDir = path.isAbsolute(root) ? root : path.join(cwd, root);
438
+ const files = safeWalkDocs(fullDir);
439
+ if (!files) continue;
440
+ for (const file of files) {
441
+ if (file.readError) continue;
442
+ const fm = extractFrontmatter(file.content);
443
+ const v = fm['require-code-mention'];
444
+ if (v === true || v === 'true') n++;
445
+ }
446
+ }
447
+ return n;
448
+ }
449
+
450
+ function countDocFiles(docRoots, cwd) {
451
+ let n = 0;
452
+ for (const root of docRoots) {
453
+ const fullDir = path.isAbsolute(root) ? root : path.join(cwd, root);
454
+ const files = safeWalkDocs(fullDir);
455
+ if (files) n += files.length;
456
+ }
457
+ return n;
458
+ }
459
+
460
+ function countSourceFiles(sourceRoots, cwd) {
461
+ const acc = [];
462
+ for (const root of sourceRoots) {
463
+ const fullDir = path.isAbsolute(root) ? root : path.join(cwd, root);
464
+ walkSourceFiles(fullDir, acc);
465
+ }
466
+ return acc.length;
467
+ }
468
+
469
+ // ─── Top-level validateAll ───────────────────────────────────────────────────
470
+
471
+ function validateAll(cwd, opts = {}) {
472
+ const docRoots = opts.docRoots || DEFAULT_DOC_ROOTS;
473
+ const sourceRoots = opts.sourceRoots || DEFAULT_SOURCE_ROOTS;
474
+ const strict = !!opts.strict;
475
+
476
+ const forwardLinks = scanForwardLinks(docRoots, cwd);
477
+ const anchors = scanAnchors(sourceRoots, cwd);
478
+
479
+ const findings = [];
480
+ findings.push(...runForwardPass(forwardLinks, cwd));
481
+ findings.push(...runBacklinkPass(docRoots, anchors, cwd));
482
+ findings.push(...runAnchorTargetPass(anchors, cwd));
483
+
484
+ const errors = findings.filter(f => f.severity === 'error').length;
485
+ const warnings = findings.filter(f => f.severity === 'warning').length;
486
+ // Per spec §5.2: B-002 is informational and does not flip status under --strict.
487
+ const strictWarnings = findings.filter(f => f.severity === 'warning' && f.code !== 'B-002').length;
488
+ let status;
489
+ if (errors > 0) status = 'fail';
490
+ else if (strict && strictWarnings > 0) status = 'fail';
491
+ else status = 'pass';
492
+
493
+ return {
494
+ ok: status === 'pass',
495
+ summary: {
496
+ total_findings: findings.length,
497
+ errors,
498
+ warnings,
499
+ status,
500
+ doc_files_scanned: countDocFiles(docRoots, cwd),
501
+ source_files_scanned: countSourceFiles(sourceRoots, cwd),
502
+ anchors_found: anchors.length,
503
+ forward_links_found: forwardLinks.length,
504
+ backlink_contracts_checked: countBacklinkContracts(docRoots, cwd),
505
+ },
506
+ findings,
507
+ };
508
+ }
509
+
510
+ function cmdLinksValidate(cwd, opts = {}) {
511
+ const result = validateAll(cwd, opts);
512
+ // Bypass core.output() because it unconditionally exits 0; we need exit 1
513
+ // when status is "fail" so CI / hooks can detect violations.
514
+ if (opts.raw) {
515
+ const lines = [
516
+ `Links: ${result.summary.status.toUpperCase()}`,
517
+ ``,
518
+ `Doc files scanned: ${result.summary.doc_files_scanned}`,
519
+ `Source files scanned: ${result.summary.source_files_scanned}`,
520
+ `Forward links: ${result.summary.forward_links_found}`,
521
+ `Anchors: ${result.summary.anchors_found}`,
522
+ `Backlink contracts: ${result.summary.backlink_contracts_checked}`,
523
+ ``,
524
+ `Errors: ${result.summary.errors}`,
525
+ `Warnings: ${result.summary.warnings}`,
526
+ ``,
527
+ ];
528
+ for (const f of result.findings) {
529
+ const where = f.source_line ? `${f.source}:${f.source_line}` : f.source;
530
+ lines.push(`[${f.severity.toUpperCase()}] ${f.code} ${where}: ${f.detail}`);
531
+ }
532
+ process.stdout.write(lines.join('\n'));
533
+ } else {
534
+ process.stdout.write(JSON.stringify(result, null, 2));
535
+ }
536
+ process.exit(result.summary.status === 'fail' ? 1 : 0);
537
+ }
538
+
539
+ module.exports = {
540
+ validateAll,
541
+ cmdLinksValidate,
542
+ scanForwardLinks,
543
+ scanAnchors,
544
+ resolveDocId,
545
+ parseAnchorLine,
546
+ parseInlineLinks,
547
+ DEFAULT_DOC_ROOTS,
548
+ DEFAULT_SOURCE_ROOTS,
549
+ };