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.
- package/README.md +24 -2
- package/agents/pan-conductor.md +1 -2
- package/agents/pan-counterfactual.md +1 -2
- package/agents/pan-debugger.md +1 -2
- package/agents/pan-distiller.md +1 -2
- package/agents/pan-document_code.md +1 -0
- package/agents/pan-executor.md +1 -0
- package/agents/pan-experiment-runner.md +1 -2
- package/agents/pan-hardener.md +1 -2
- package/agents/pan-integration-checker.md +1 -2
- package/agents/pan-knowledge.md +1 -2
- package/agents/pan-meta-reviewer.md +1 -2
- package/agents/pan-optimizer.md +1 -0
- package/agents/pan-phase-researcher.md +1 -0
- package/agents/pan-plan-checker.md +1 -2
- package/agents/pan-planner.md +1 -0
- package/agents/pan-previewer.md +1 -2
- package/agents/pan-project-researcher.md +6 -0
- package/agents/pan-research-synthesizer.md +7 -0
- package/agents/pan-reviewer.md +2 -3
- package/agents/pan-roadmapper.md +1 -0
- package/agents/pan-verifier.md +1 -2
- package/bin/install-lib.cjs +661 -46
- package/bin/install.js +722 -116
- package/commands/pan/experiment.md +2 -0
- package/commands/pan/links.md +102 -0
- package/commands/pan/profile.md +2 -0
- package/hooks/dist/pan-cost-logger.js +22 -7
- package/package.json +5 -4
- package/pan-wizard-core/bin/lib/codebase.cjs +2 -0
- package/pan-wizard-core/bin/lib/commands-learnings.cjs +544 -0
- package/pan-wizard-core/bin/lib/commands.cjs +12 -523
- package/pan-wizard-core/bin/lib/core.cjs +69 -0
- package/pan-wizard-core/bin/lib/cost.cjs +62 -8
- package/pan-wizard-core/bin/lib/experiment.cjs +1 -0
- package/pan-wizard-core/bin/lib/git.cjs +6 -1
- package/pan-wizard-core/bin/lib/links.cjs +549 -0
- package/pan-wizard-core/bin/lib/lock.cjs +108 -0
- package/pan-wizard-core/bin/lib/milestone.cjs +3 -2
- package/pan-wizard-core/bin/lib/phase-remove.cjs +392 -0
- package/pan-wizard-core/bin/lib/phase.cjs +4 -369
- package/pan-wizard-core/bin/lib/runner.cjs +6 -0
- package/pan-wizard-core/bin/lib/state.cjs +10 -1
- package/pan-wizard-core/bin/lib/verify-deploy.cjs +181 -0
- package/pan-wizard-core/bin/lib/verify-drift.cjs +255 -0
- package/pan-wizard-core/bin/lib/verify-preflight.cjs +261 -0
- package/pan-wizard-core/bin/lib/verify-retro.cjs +177 -0
- package/pan-wizard-core/bin/lib/verify.cjs +33 -797
- package/pan-wizard-core/bin/pan-tools.cjs +35 -1
- package/pan-wizard-core/workflows/plan-phase.md +11 -0
- package/scripts/build-plugin.js +105 -0
- package/scripts/git-hooks/pre-commit +40 -0
- package/scripts/install-git-hooks.js +64 -0
- 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-
|
|
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
|
-
|
|
53
|
-
'
|
|
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
|
-
//
|
|
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:
|
|
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
|
};
|
|
@@ -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
|
-
|
|
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
|
+
};
|