pan-wizard 3.7.10 → 3.10.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 (54) hide show
  1. package/README.md +24 -2
  2. package/agents/pan-conductor.md +1 -2
  3. package/agents/pan-counterfactual.md +1 -2
  4. package/agents/pan-debugger.md +1 -2
  5. package/agents/pan-distiller.md +1 -2
  6. package/agents/pan-document_code.md +1 -0
  7. package/agents/pan-executor.md +1 -0
  8. package/agents/pan-experiment-runner.md +1 -2
  9. package/agents/pan-hardener.md +1 -2
  10. package/agents/pan-integration-checker.md +1 -2
  11. package/agents/pan-knowledge.md +1 -2
  12. package/agents/pan-meta-reviewer.md +1 -2
  13. package/agents/pan-optimizer.md +1 -0
  14. package/agents/pan-phase-researcher.md +1 -0
  15. package/agents/pan-plan-checker.md +1 -2
  16. package/agents/pan-planner.md +1 -0
  17. package/agents/pan-previewer.md +1 -2
  18. package/agents/pan-project-researcher.md +6 -0
  19. package/agents/pan-research-synthesizer.md +7 -0
  20. package/agents/pan-reviewer.md +2 -3
  21. package/agents/pan-roadmapper.md +1 -0
  22. package/agents/pan-verifier.md +1 -2
  23. package/bin/install-lib.cjs +661 -46
  24. package/bin/install.js +722 -116
  25. package/commands/pan/experiment.md +2 -0
  26. package/commands/pan/links.md +102 -0
  27. package/commands/pan/profile.md +2 -0
  28. package/hooks/dist/pan-cost-logger.js +22 -7
  29. package/package.json +5 -4
  30. package/pan-wizard-core/bin/lib/codebase.cjs +2 -0
  31. package/pan-wizard-core/bin/lib/commands-learnings.cjs +544 -0
  32. package/pan-wizard-core/bin/lib/commands.cjs +12 -523
  33. package/pan-wizard-core/bin/lib/core.cjs +69 -0
  34. package/pan-wizard-core/bin/lib/cost.cjs +62 -8
  35. package/pan-wizard-core/bin/lib/experiment.cjs +1 -0
  36. package/pan-wizard-core/bin/lib/git.cjs +6 -1
  37. package/pan-wizard-core/bin/lib/links.cjs +549 -0
  38. package/pan-wizard-core/bin/lib/lock.cjs +108 -0
  39. package/pan-wizard-core/bin/lib/milestone.cjs +3 -2
  40. package/pan-wizard-core/bin/lib/phase-remove.cjs +392 -0
  41. package/pan-wizard-core/bin/lib/phase.cjs +4 -369
  42. package/pan-wizard-core/bin/lib/runner.cjs +6 -0
  43. package/pan-wizard-core/bin/lib/state.cjs +10 -1
  44. package/pan-wizard-core/bin/lib/verify-deploy.cjs +181 -0
  45. package/pan-wizard-core/bin/lib/verify-drift.cjs +255 -0
  46. package/pan-wizard-core/bin/lib/verify-preflight.cjs +261 -0
  47. package/pan-wizard-core/bin/lib/verify-retro.cjs +177 -0
  48. package/pan-wizard-core/bin/lib/verify.cjs +33 -797
  49. package/pan-wizard-core/bin/pan-tools.cjs +35 -1
  50. package/pan-wizard-core/workflows/plan-phase.md +11 -0
  51. package/scripts/build-plugin.js +105 -0
  52. package/scripts/git-hooks/pre-commit +40 -0
  53. package/scripts/install-git-hooks.js +64 -0
  54. package/scripts/release-check.js +13 -2
@@ -30,7 +30,7 @@
30
30
  * - hit rate: cache_read / (cache_read + input - cache_write) if any cache activity
31
31
  *
32
32
  * Rate table is approximate — real pricing comes from the provider's API.
33
- * Rates are US dollars per million tokens, indicative as of 2026-04. Users
33
+ * Rates are US dollars per million tokens, indicative as of 2026-06. Users
34
34
  * can override with `.planning/config.json` → `cost.rates`.
35
35
  */
36
36
 
@@ -48,21 +48,32 @@ const TOKENS_FILE = 'tokens.jsonl';
48
48
  * Override per-model in config.json → cost.rates.
49
49
  */
50
50
  const DEFAULT_RATES = {
51
- // Anthropic
52
- 'claude-opus-4-7': { input: 15.0, output: 75.0, cache_read: 1.5, cache_write: 18.75 },
53
- 'claude-opus-4-6': { input: 15.0, output: 75.0, cache_read: 1.5, cache_write: 18.75 },
51
+ // Anthropic — verified against platform pricing 2026-06. Opus 4.6+ is $5/$25
52
+ // (the old $15/$75 Opus pricing ended with the 4.5 generation). Cache rates
53
+ // follow Anthropic's convention: read 0.1× input, write 1.25× input.
54
+ 'claude-fable-5': { input: 10.0, output: 50.0, cache_read: 1.0, cache_write: 12.5 },
55
+ 'claude-opus-4-8': { input: 5.0, output: 25.0, cache_read: 0.5, cache_write: 6.25 },
56
+ 'claude-opus-4-7': { input: 5.0, output: 25.0, cache_read: 0.5, cache_write: 6.25 },
57
+ 'claude-opus-4-6': { input: 5.0, output: 25.0, cache_read: 0.5, cache_write: 6.25 },
54
58
  'claude-sonnet-4-6': { input: 3.0, output: 15.0, cache_read: 0.3, cache_write: 3.75 },
55
59
  'claude-haiku-4-5': { input: 1.0, output: 5.0, cache_read: 0.1, cache_write: 1.25 },
56
60
 
61
+ // OpenAI — verified against published pricing 2026-06 ($5/$30 standard tier).
62
+ // Prompt caching is a 90% input discount with no separate write charge, so
63
+ // cache_write bills at the plain input rate.
64
+ 'gpt-5.5': { input: 5.0, output: 30.0, cache_read: 0.5, cache_write: 5.0 },
65
+
57
66
  // Google Gemini — published rates (per million tokens, approximate; users can override via config.json → cost.rates).
58
- // 2.5 tier uses the <=200K-context tier; long-context calls may be billed at ~2x. Cache rates are Google's context-cache pricing (~25% of input rate).
67
+ // Pro tiers use the <=200K-context tier; long-context calls may be billed at ~2x. Cache rates are Google's context-cache pricing (~25% of input rate).
68
+ // (gemini-1.5-pro removed 2026-06: retired model; records for it fall back to tier rates.)
69
+ 'gemini-3.1-pro': { input: 2.00, output: 12.0, cache_read: 0.50, cache_write: 2.00 },
70
+ 'gemini-3.1-pro-preview': { input: 2.00, output: 12.0, cache_read: 0.50, cache_write: 2.00 },
59
71
  'gemini-2.5-pro': { input: 1.25, output: 10.0, cache_read: 0.3125, cache_write: 1.25 },
60
72
  'gemini-2.5-flash': { input: 0.30, output: 2.50, cache_read: 0.075, cache_write: 0.30 },
61
73
  'gemini-2.5-flash-lite': { input: 0.10, output: 0.40, cache_read: 0.025, cache_write: 0.10 },
62
- 'gemini-1.5-pro': { input: 1.25, output: 5.00, cache_read: 0.3125, cache_write: 1.25 },
63
74
 
64
- // Tier fallbacks when model id is unknown
65
- 'reasoning': { input: 15.0, output: 75.0, cache_read: 1.5, cache_write: 18.75 },
75
+ // Tier fallbacks when model id is unknown (reasoning tracks current Opus pricing)
76
+ 'reasoning': { input: 5.0, output: 25.0, cache_read: 0.5, cache_write: 6.25 },
66
77
  'mid': { input: 3.0, output: 15.0, cache_read: 0.3, cache_write: 3.75 },
67
78
  'fast': { input: 1.0, output: 5.0, cache_read: 0.1, cache_write: 1.25 },
68
79
  };
@@ -81,6 +92,15 @@ function resolveRate(model, tier, configRates) {
81
92
  if (tier && configRates[tier]) return configRates[tier];
82
93
  }
83
94
  if (model && DEFAULT_RATES[model]) return DEFAULT_RATES[model];
95
+ // Transcript/hook-captured ids are versioned ("claude-opus-4-8-20260301",
96
+ // "claude-fable-5[1m]") while the table uses family keys — prefix-match,
97
+ // longest key first so the most specific family wins.
98
+ if (model) {
99
+ const families = Object.keys(DEFAULT_RATES)
100
+ .filter(k => model.startsWith(k))
101
+ .sort((a, b) => b.length - a.length);
102
+ if (families.length > 0) return DEFAULT_RATES[families[0]];
103
+ }
84
104
  if (tier && DEFAULT_RATES[tier]) return DEFAULT_RATES[tier];
85
105
  return null;
86
106
  }
@@ -342,6 +362,37 @@ function cmdCostClear(cwd, raw) {
342
362
  }
343
363
  }
344
364
 
365
+ // ─── Rate-table staleness ───────────────────────────────────────────────────
366
+
367
+ // Date DEFAULT_RATES was last verified against published provider pricing.
368
+ // Bump this whenever the table is re-verified; `models check` flags the table
369
+ // once it is older than RATES_STALE_AFTER_DAYS (provider prices move faster
370
+ // than PAN releases do).
371
+ const RATES_VERIFIED_AT = '2026-06-10';
372
+ const RATES_STALE_AFTER_DAYS = 180;
373
+ const RATE_TIERS = ['reasoning', 'mid', 'fast'];
374
+
375
+ function checkRatesStaleness(now = new Date()) {
376
+ const verified = new Date(RATES_VERIFIED_AT + 'T00:00:00Z');
377
+ const ageDays = Math.floor((now.getTime() - verified.getTime()) / 86400000);
378
+ return {
379
+ rates_verified_at: RATES_VERIFIED_AT,
380
+ age_days: ageDays,
381
+ stale_after_days: RATES_STALE_AFTER_DAYS,
382
+ stale: ageDays > RATES_STALE_AFTER_DAYS,
383
+ models: Object.keys(DEFAULT_RATES).filter(k => !RATE_TIERS.includes(k)),
384
+ tiers: RATE_TIERS,
385
+ };
386
+ }
387
+
388
+ function cmdModelsCheck(raw) {
389
+ const result = checkRatesStaleness();
390
+ const human = result.stale
391
+ ? `Rate table verified ${result.rates_verified_at} (${result.age_days} days ago) — STALE: re-verify provider pricing and bump RATES_VERIFIED_AT in cost.cjs`
392
+ : `Rate table verified ${result.rates_verified_at} (${result.age_days} days ago) — OK`;
393
+ output(result, raw, human);
394
+ }
395
+
345
396
  module.exports = {
346
397
  computeCost,
347
398
  appendRecord,
@@ -350,10 +401,13 @@ module.exports = {
350
401
  renderTable,
351
402
  renderChart,
352
403
  resolveRate,
404
+ checkRatesStaleness,
353
405
  cmdCostReport,
354
406
  cmdCostAppend,
355
407
  cmdCostClear,
408
+ cmdModelsCheck,
356
409
  METRICS_DIR,
357
410
  TOKENS_FILE,
358
411
  DEFAULT_RATES,
412
+ RATES_VERIFIED_AT,
359
413
  };
@@ -1,4 +1,5 @@
1
1
  'use strict';
2
+ // @pan: ADR-0026
2
3
  /**
3
4
  * experiment.cjs — Self-improvement loop W1: experiment scaffolding.
4
5
  *
@@ -272,7 +272,12 @@ function cmdGitTag(cwd, sub, opts, raw) {
272
272
  }
273
273
  if (sub === 'create') {
274
274
  if (!name) { error('--name required for tag create'); }
275
- const args = message ? ['tag', '-m', message, name] : ['tag', name];
275
+ // tag.gpgsign=true in user config would force signing (and fail outright
276
+ // for lightweight tags) in non-interactive runs — PAN tags are automation
277
+ // markers, so signing is explicitly disabled.
278
+ const args = message
279
+ ? ['-c', 'tag.gpgsign=false', 'tag', '-m', message, name]
280
+ : ['-c', 'tag.gpgsign=false', 'tag', name];
276
281
  const r = execGit(cwd, args);
277
282
  if (r.exitCode !== 0) {
278
283
  output({ created: false, tag: name, detail: r.stderr }, raw, 'tag create failed');
@@ -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
+ };