docguard-cli 0.30.1 → 0.32.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 +28 -9
- package/cli/commands/explain.mjs +34 -0
- package/cli/commands/guard.mjs +8 -0
- package/cli/commands/hooks.mjs +167 -2
- package/cli/commands/impact.mjs +300 -11
- package/cli/commands/mcp.mjs +179 -53
- package/cli/commands/verify.mjs +63 -1
- package/cli/config.mjs +7 -0
- package/cli/docguard.mjs +46 -3
- package/cli/findings.mjs +32 -0
- package/cli/scanners/agent-readability.mjs +6 -1
- package/cli/scanners/semantic-claims.mjs +10 -2
- package/cli/shared-diff.mjs +209 -0
- package/cli/shared-git.mjs +93 -0
- package/cli/shared-ir.mjs +81 -0
- package/cli/validators/api-doc-smells.mjs +143 -0
- package/cli/validators/architecture.mjs +8 -1
- package/cli/validators/cross-reference.mjs +124 -3
- package/cli/validators/diff-suspicion.mjs +178 -0
- package/cli/validators/reference-existence.mjs +311 -0
- package/cli/validators/traceability.mjs +107 -4
- package/docs/quickstart.md +1 -1
- package/extensions/spec-kit-docguard/extension.yml +1 -1
- package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +2 -2
- package/package.json +1 -1
package/cli/commands/impact.mjs
CHANGED
|
@@ -15,17 +15,38 @@
|
|
|
15
15
|
* JSON mode emits a structured `{ changedFiles, affectedDocs }` payload
|
|
16
16
|
* for CI integrations and PR-comment bots.
|
|
17
17
|
*
|
|
18
|
+
* v0.31.0 — blast radius (feat 1):
|
|
19
|
+
* - Agent-instruction files (AGENTS.md/CLAUDE.md/GEMINI.md) are indexed
|
|
20
|
+
* alongside canonical docs, so a changed code file they reference is
|
|
21
|
+
* surfaced too (agent instructions drift when the code they describe moves).
|
|
22
|
+
* - Doc→doc graph: when a DOC changes, the docs that reference it — INCLUDING
|
|
23
|
+
* agent-instruction files — are flagged as suspect ("blast radius"). This is
|
|
24
|
+
* the unclaimed slice: a change in ARCHITECTURE.md marks the AGENTS.md that
|
|
25
|
+
* points at it for review.
|
|
26
|
+
*
|
|
18
27
|
* @req SC-S11-001 — impact reports per-file → doc mappings
|
|
19
28
|
* @req SC-S11-002 — files with no doc references are listed as "no impact"
|
|
20
29
|
* @req SC-S11-003 — --format json emits parseable structured output
|
|
21
30
|
* @req SC-S11-004 — non-code files (.md, .json, etc.) are skipped from impact analysis
|
|
31
|
+
* @req SC-S11-007 — agent-instruction files participate in impact analysis
|
|
32
|
+
* @req SC-S11-008 — a changed doc flags the docs that reference it (blast radius)
|
|
33
|
+
*
|
|
34
|
+
* Indirect impact (import-graph BFS):
|
|
35
|
+
* A changed file with no doc references can still invalidate docs about the
|
|
36
|
+
* modules that IMPORT it (change shared-git.mjs → the doc describing
|
|
37
|
+
* `impact` is suspect). We walk the reverse import graph up to 2 hops —
|
|
38
|
+
* beyond that, hub modules connect everything and the signal drowns.
|
|
39
|
+
* JS/TS only (the graph builder's scope); `--no-indirect` disables.
|
|
40
|
+
* @req SC-S11-009 — docs referencing an importer of a changed file are flagged as indirect
|
|
22
41
|
*/
|
|
23
42
|
|
|
24
43
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
25
44
|
import { resolve, basename } from 'node:path';
|
|
45
|
+
import { execFileSync } from 'node:child_process';
|
|
26
46
|
|
|
27
47
|
import { c } from '../shared.mjs';
|
|
28
48
|
import { changedFilesSince, isGitRepo } from '../shared-git.mjs';
|
|
49
|
+
import { buildImportGraph } from '../validators/architecture.mjs';
|
|
29
50
|
|
|
30
51
|
/**
|
|
31
52
|
* File extensions we consider "code" for the purposes of impact analysis.
|
|
@@ -33,10 +54,32 @@ import { changedFilesSince, isGitRepo } from '../shared-git.mjs';
|
|
|
33
54
|
*/
|
|
34
55
|
const CODE_EXTENSIONS = /\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|rb|php|cs|swift)$/;
|
|
35
56
|
|
|
57
|
+
// Root agent-instruction files — documentation that names code and other docs.
|
|
58
|
+
const AGENT_FILES = ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md'];
|
|
59
|
+
|
|
36
60
|
function escapeRegex(s) {
|
|
37
61
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
38
62
|
}
|
|
39
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Doc→doc references: which indexed docs reference `changedDocPath` — by its
|
|
66
|
+
* basename (the form used in prose "see ARCHITECTURE.md" and markdown links
|
|
67
|
+
* `](ARCHITECTURE.md)`) or by an extension-less Obsidian wikilink
|
|
68
|
+
* (`[[ARCHITECTURE]]`, `[[ARCHITECTURE#Heading]]`, `[[ARCHITECTURE|alias]]`).
|
|
69
|
+
* Skips self. This is the blast-radius edge set.
|
|
70
|
+
*/
|
|
71
|
+
function docsReferencing(changedDocPath, index) {
|
|
72
|
+
const cbase = basename(changedDocPath);
|
|
73
|
+
const stem = cbase.replace(/\.md$/i, '');
|
|
74
|
+
const wikiRe = new RegExp(`\\[\\[${escapeRegex(stem)}(?:[#|\\]])`);
|
|
75
|
+
const dependents = [];
|
|
76
|
+
for (const [docName, lines] of index) {
|
|
77
|
+
if (docName === cbase || docName === changedDocPath) continue; // not self
|
|
78
|
+
if (lines.some(l => l.includes(cbase) || wikiRe.test(l))) dependents.push(docName);
|
|
79
|
+
}
|
|
80
|
+
return dependents;
|
|
81
|
+
}
|
|
82
|
+
|
|
40
83
|
/**
|
|
41
84
|
* Find canonical doc references for a single file. Reuses the same three
|
|
42
85
|
* match strategies as trace --reverse for consistency: direct path,
|
|
@@ -63,7 +106,135 @@ function findReferences(file, docs) {
|
|
|
63
106
|
return refs;
|
|
64
107
|
}
|
|
65
108
|
|
|
66
|
-
|
|
109
|
+
/**
|
|
110
|
+
* Ancestors of `file` in the reverse import graph, capped at `maxHops`.
|
|
111
|
+
* Returns Map<ancestorPath, {hops, via}> where `via` is the first hop on the
|
|
112
|
+
* path back toward the changed file (for explainable output).
|
|
113
|
+
*/
|
|
114
|
+
function reverseImportAncestors(file, reverseEdges, maxHops = 2) {
|
|
115
|
+
const seen = new Map(); // ancestor → hops from the changed file
|
|
116
|
+
let frontier = [file];
|
|
117
|
+
for (let hop = 1; hop <= maxHops && frontier.length > 0; hop++) {
|
|
118
|
+
const next = [];
|
|
119
|
+
for (const cur of frontier) {
|
|
120
|
+
for (const importer of reverseEdges.get(cur) || []) {
|
|
121
|
+
if (importer === file || seen.has(importer)) continue;
|
|
122
|
+
seen.set(importer, hop);
|
|
123
|
+
next.push(importer);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
frontier = next;
|
|
127
|
+
}
|
|
128
|
+
return seen;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── PR doc-conflict analysis (`impact --prs`) ───────────────────────────────
|
|
132
|
+
//
|
|
133
|
+
// Two open PRs whose changed files impact the SAME canonical doc are a
|
|
134
|
+
// merge-order risk: whichever lands second must re-verify (and often re-edit)
|
|
135
|
+
// a doc the first one already changed the ground truth for. The graph-
|
|
136
|
+
// community version of this idea ships in graphify's `prs --conflicts`; this
|
|
137
|
+
// is the doc-integrity equivalent, computed from DocGuard's own code→doc
|
|
138
|
+
// reference index. Pure function — the `gh` plumbing stays at the edge.
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* @param {Array<{number:number,title:string,files:string[]}>} prs
|
|
142
|
+
* @param {Map<string,string[]>} docsIndex docName → lines[]
|
|
143
|
+
* @returns {{prImpacts: Array, conflicts: Array}}
|
|
144
|
+
*/
|
|
145
|
+
export function computeDocConflicts(prs, docsIndex) {
|
|
146
|
+
const prImpacts = prs.map(pr => {
|
|
147
|
+
const docs = new Set();
|
|
148
|
+
for (const f of pr.files) {
|
|
149
|
+
if (!CODE_EXTENSIONS.test(f)) {
|
|
150
|
+
// A PR that edits a canonical doc directly impacts that doc too.
|
|
151
|
+
if (f.endsWith('.md') && docsIndex.has(basename(f))) docs.add(basename(f));
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
for (const r of findReferences(f, docsIndex)) docs.add(r.doc);
|
|
155
|
+
}
|
|
156
|
+
return { number: pr.number, title: pr.title, docs: [...docs].sort() };
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const conflicts = [];
|
|
160
|
+
for (let i = 0; i < prImpacts.length; i++) {
|
|
161
|
+
for (let j = i + 1; j < prImpacts.length; j++) {
|
|
162
|
+
const shared = prImpacts[i].docs.filter(d => prImpacts[j].docs.includes(d));
|
|
163
|
+
if (shared.length > 0) {
|
|
164
|
+
conflicts.push({ prs: [prImpacts[i].number, prImpacts[j].number], docs: shared });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return { prImpacts, conflicts };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const MAX_PRS = 20; // keep the per-PR file fetches bounded
|
|
172
|
+
|
|
173
|
+
/** Fetch open PRs + their changed files via the gh CLI. Throws with a human message. */
|
|
174
|
+
function fetchOpenPrs(projectDir) {
|
|
175
|
+
const gh = (args) => execFileSync('gh', args, {
|
|
176
|
+
cwd: projectDir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
177
|
+
});
|
|
178
|
+
let list;
|
|
179
|
+
try {
|
|
180
|
+
list = JSON.parse(gh(['pr', 'list', '--state', 'open', '--json', 'number,title', '--limit', String(MAX_PRS)]));
|
|
181
|
+
} catch (err) {
|
|
182
|
+
const msg = String(err?.message || err);
|
|
183
|
+
if (/ENOENT/.test(msg)) throw new Error('the GitHub CLI (gh) is not installed — install it or run impact without --prs');
|
|
184
|
+
throw new Error(`gh pr list failed — is this repo on GitHub and gh authenticated? (${msg.split('\n')[0]})`);
|
|
185
|
+
}
|
|
186
|
+
return list.map(pr => {
|
|
187
|
+
let files = [];
|
|
188
|
+
try {
|
|
189
|
+
files = JSON.parse(gh(['pr', 'view', String(pr.number), '--json', 'files']))
|
|
190
|
+
.files.map(f => f.path);
|
|
191
|
+
} catch { /* PR vanished mid-scan — treat as no files */ }
|
|
192
|
+
return { number: pr.number, title: pr.title, files };
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function runPrConflicts(projectDir, flags, docsIndex) {
|
|
197
|
+
const isJson = flags.format === 'json';
|
|
198
|
+
let prs;
|
|
199
|
+
try {
|
|
200
|
+
prs = fetchOpenPrs(projectDir);
|
|
201
|
+
} catch (err) {
|
|
202
|
+
if (isJson) {
|
|
203
|
+
console.log(JSON.stringify({ error: err.message, prs: [], conflicts: [] }, null, 2));
|
|
204
|
+
} else {
|
|
205
|
+
console.log(` ${c.yellow}⚠ ${err.message}${c.reset}`);
|
|
206
|
+
}
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const { prImpacts, conflicts } = computeDocConflicts(prs, docsIndex);
|
|
211
|
+
|
|
212
|
+
if (isJson) {
|
|
213
|
+
console.log(JSON.stringify({ prs: prImpacts, conflicts, timestamp: new Date().toISOString() }, null, 2));
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
console.log(`${c.bold}📊 DocGuard Impact — open-PR doc conflicts${c.reset}\n`);
|
|
218
|
+
if (prImpacts.length === 0) {
|
|
219
|
+
console.log(` ${c.green}✅ No open PRs.${c.reset}`);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
for (const pr of prImpacts) {
|
|
223
|
+
const docsNote = pr.docs.length > 0 ? pr.docs.join(', ') : `${c.dim}no canonical-doc impact${c.reset}`;
|
|
224
|
+
console.log(` ${c.cyan}#${pr.number}${c.reset} ${pr.title.slice(0, 60)} ${c.dim}→${c.reset} ${docsNote}`);
|
|
225
|
+
}
|
|
226
|
+
if (conflicts.length === 0) {
|
|
227
|
+
console.log(`\n ${c.green}✅ No two open PRs impact the same canonical doc.${c.reset}`);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
console.log(`\n ${c.yellow}⚠ ${conflicts.length} doc-conflict pair(s) — merge order matters:${c.reset}`);
|
|
231
|
+
for (const cf of conflicts) {
|
|
232
|
+
console.log(` ${c.yellow}#${cf.prs[0]} × #${cf.prs[1]}${c.reset} both impact ${c.cyan}${cf.docs.join(', ')}${c.reset}`);
|
|
233
|
+
}
|
|
234
|
+
console.log(` ${c.dim}Whichever lands second should re-run docguard impact before updating the shared doc(s).${c.reset}`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function runImpact(projectDir, config, flags) {
|
|
67
238
|
const isJson = flags.format === 'json';
|
|
68
239
|
const since = flags.since || 'HEAD~1';
|
|
69
240
|
|
|
@@ -81,9 +252,11 @@ export function runImpact(projectDir, _config, flags) {
|
|
|
81
252
|
// impact" in the same sense; they ARE the docs (or config).
|
|
82
253
|
const codeChanged = changed.filter(f => CODE_EXTENSIONS.test(f));
|
|
83
254
|
|
|
84
|
-
// Index canonical docs once
|
|
255
|
+
// Index canonical docs once, PLUS root agent-instruction files (they name
|
|
256
|
+
// code and other docs, so they belong in both code→doc and doc→doc analysis).
|
|
85
257
|
const docsDir = resolve(projectDir, 'docs-canonical');
|
|
86
258
|
const docsIndex = new Map(); // docName → lines[]
|
|
259
|
+
const agentDocs = new Set(); // which indexed docs are agent-instruction files
|
|
87
260
|
if (existsSync(docsDir)) {
|
|
88
261
|
try {
|
|
89
262
|
for (const f of readdirSync(docsDir)) {
|
|
@@ -95,6 +268,17 @@ export function runImpact(projectDir, _config, flags) {
|
|
|
95
268
|
}
|
|
96
269
|
} catch { /* skip if dir unreadable */ }
|
|
97
270
|
}
|
|
271
|
+
for (const a of AGENT_FILES) {
|
|
272
|
+
const p = resolve(projectDir, a);
|
|
273
|
+
if (!existsSync(p)) continue;
|
|
274
|
+
try { docsIndex.set(a, readFileSync(p, 'utf-8').split('\n')); agentDocs.add(a); } catch { /* skip */ }
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// `impact --prs`: cross-PR doc-conflict analysis instead of a --since diff.
|
|
278
|
+
if (flags.prs) {
|
|
279
|
+
runPrConflicts(projectDir, flags, docsIndex);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
98
282
|
|
|
99
283
|
// Compute per-file references
|
|
100
284
|
const fileImpact = []; // { file, references: [{doc, line, kind}] }
|
|
@@ -113,15 +297,84 @@ export function runImpact(projectDir, _config, flags) {
|
|
|
113
297
|
const affectedDocs = Array.from(docMap.entries()).map(([doc, files]) => ({
|
|
114
298
|
doc,
|
|
115
299
|
files: Array.from(files),
|
|
300
|
+
isAgentFile: agentDocs.has(doc),
|
|
116
301
|
}));
|
|
117
302
|
|
|
303
|
+
// ── Indirect impact: docs about the IMPORTERS of a changed file ──
|
|
304
|
+
// Reverse-import BFS (2 hops max). A doc already directly affected by the
|
|
305
|
+
// same changed file is not repeated here — direct wins.
|
|
306
|
+
const indirectDocs = [];
|
|
307
|
+
if (flags.indirect !== false && codeChanged.length > 0) {
|
|
308
|
+
const graph = buildImportGraph(projectDir, config || {});
|
|
309
|
+
if (graph.edges.length > 0) {
|
|
310
|
+
const reverseEdges = new Map(); // to → [from…]
|
|
311
|
+
const outDegree = new Map(); // from → number of imports
|
|
312
|
+
for (const e of graph.edges) {
|
|
313
|
+
if (!reverseEdges.has(e.to)) reverseEdges.set(e.to, []);
|
|
314
|
+
reverseEdges.get(e.to).push(e.from);
|
|
315
|
+
outDegree.set(e.from, (outDegree.get(e.from) || 0) + 1);
|
|
316
|
+
}
|
|
317
|
+
// Hub suppression (dogfooded): an orchestrator that imports many modules
|
|
318
|
+
// (a CLI dispatcher, a barrel index) would flag its docs on EVERY
|
|
319
|
+
// dependency change — recurring noise, not signal. Its doc-relevant
|
|
320
|
+
// surface rarely shifts when one of 30 imports does.
|
|
321
|
+
const HUB_OUT_DEGREE = 15;
|
|
322
|
+
const isHub = (f) => (outDegree.get(f) || 0) > HUB_OUT_DEGREE;
|
|
323
|
+
const changedSet = new Set(codeChanged.map(f => f.replace(/^\.\//, '')));
|
|
324
|
+
const indirectMap = new Map(); // doc → chains[]
|
|
325
|
+
for (const f of codeChanged) {
|
|
326
|
+
const norm = f.replace(/^\.\//, '');
|
|
327
|
+
const directDocs = new Set(
|
|
328
|
+
(fileImpact.find(fi => fi.file === f)?.references || []).map(r => r.doc));
|
|
329
|
+
for (const [ancestor, hops] of reverseImportAncestors(norm, reverseEdges)) {
|
|
330
|
+
if (changedSet.has(ancestor)) continue; // changed files have their own direct row
|
|
331
|
+
if (isHub(ancestor)) continue; // hub modules: noise, not signal
|
|
332
|
+
for (const r of findReferences(ancestor, docsIndex)) {
|
|
333
|
+
if (directDocs.has(r.doc)) continue;
|
|
334
|
+
if (!indirectMap.has(r.doc)) indirectMap.set(r.doc, []);
|
|
335
|
+
const chains = indirectMap.get(r.doc);
|
|
336
|
+
if (!chains.some(ch => ch.changed === norm && ch.via === ancestor)) {
|
|
337
|
+
chains.push({ changed: norm, via: ancestor, hops });
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
for (const [doc, chains] of indirectMap) {
|
|
343
|
+
indirectDocs.push({ doc, isAgentFile: agentDocs.has(doc), chains });
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ── Doc→doc blast radius: a changed DOC flags the docs that reference it ──
|
|
349
|
+
// (including agent-instruction files that point at it). Only meaningful edges
|
|
350
|
+
// are emitted (changed doc with ≥1 dependent).
|
|
351
|
+
//
|
|
352
|
+
// A source must be an INDEXED canonical/agent doc — not any changed `.md`.
|
|
353
|
+
// Otherwise a CHANGELOG.md / README.md / .wolf/*.md change flags every doc
|
|
354
|
+
// that merely mentions it in passing (dogfooding false positives).
|
|
355
|
+
const indexBasenames = new Set(docsIndex.keys());
|
|
356
|
+
const changedDocs = changed.filter(f => f.endsWith('.md') && indexBasenames.has(basename(f)));
|
|
357
|
+
const blastRadius = [];
|
|
358
|
+
for (const cd of changedDocs) {
|
|
359
|
+
const dependents = docsReferencing(cd, docsIndex);
|
|
360
|
+
if (dependents.length > 0) {
|
|
361
|
+
blastRadius.push({
|
|
362
|
+
changedDoc: cd,
|
|
363
|
+
dependents: dependents.map(d => ({ doc: d, isAgentFile: agentDocs.has(d) })),
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
118
368
|
// ── JSON output ──
|
|
119
369
|
if (isJson) {
|
|
120
370
|
console.log(JSON.stringify({
|
|
121
371
|
since,
|
|
122
372
|
changedFiles: codeChanged,
|
|
123
|
-
|
|
373
|
+
changedDocs,
|
|
374
|
+
ignoredFiles: changed.filter(f => !CODE_EXTENSIONS.test(f) && !f.endsWith('.md')),
|
|
124
375
|
affectedDocs,
|
|
376
|
+
indirectDocs,
|
|
377
|
+
blastRadius,
|
|
125
378
|
timestamp: new Date().toISOString(),
|
|
126
379
|
}, null, 2));
|
|
127
380
|
return;
|
|
@@ -134,8 +387,28 @@ export function runImpact(projectDir, _config, flags) {
|
|
|
134
387
|
console.log(` ${c.green}✅ No file changes since ${since}.${c.reset}`);
|
|
135
388
|
return;
|
|
136
389
|
}
|
|
390
|
+
|
|
391
|
+
// Doc→doc blast radius — shown whether or not code changed (a doc-only change
|
|
392
|
+
// can still ripple to the docs / agent files that reference it).
|
|
393
|
+
const printBlast = () => {
|
|
394
|
+
if (blastRadius.length === 0) return;
|
|
395
|
+
console.log(`\n ${c.bold}🌐 Doc blast radius${c.reset} ${c.dim}(${changedDocs.length} doc(s) changed)${c.reset}`);
|
|
396
|
+
for (const { changedDoc, dependents } of blastRadius) {
|
|
397
|
+
console.log(` ${c.cyan}${changedDoc}${c.reset} ${c.dim}changed → review ${dependents.length} dependent doc(s):${c.reset}`);
|
|
398
|
+
for (const dep of dependents.slice(0, 8)) {
|
|
399
|
+
const tag = dep.isAgentFile ? ` ${c.yellow}[agent-instruction]${c.reset}` : '';
|
|
400
|
+
console.log(` ${c.dim}↳${c.reset} ${dep.doc}${tag}`);
|
|
401
|
+
}
|
|
402
|
+
if (dependents.length > 8) console.log(` ${c.dim}... ${dependents.length - 8} more${c.reset}`);
|
|
403
|
+
}
|
|
404
|
+
};
|
|
405
|
+
|
|
137
406
|
if (codeChanged.length === 0) {
|
|
138
|
-
console.log(` ${c.dim}No code files changed (${
|
|
407
|
+
console.log(` ${c.dim}No code files changed (${changedDocs.length} doc(s) + ${changed.length - changedDocs.length} other non-code file(s)).${c.reset}`);
|
|
408
|
+
if (blastRadius.length === 0 && changedDocs.length > 0) {
|
|
409
|
+
console.log(` ${c.green}✅ No other docs reference the changed doc(s).${c.reset}`);
|
|
410
|
+
}
|
|
411
|
+
printBlast();
|
|
139
412
|
return;
|
|
140
413
|
}
|
|
141
414
|
|
|
@@ -146,16 +419,30 @@ export function runImpact(projectDir, _config, flags) {
|
|
|
146
419
|
console.log(` ${c.dim}This often means the changed code is undocumented. Consider:${c.reset}`);
|
|
147
420
|
console.log(` ${c.dim} - Running ${c.cyan}docguard generate --plan${c.dim} to add doc skeletons${c.reset}`);
|
|
148
421
|
console.log(` ${c.dim} - Reviewing whether the change belongs in an existing doc${c.reset}`);
|
|
149
|
-
|
|
422
|
+
} else {
|
|
423
|
+
console.log(` ${c.green}${affectedDocs.length}${c.reset} canonical doc(s) reference the changed files:\n`);
|
|
424
|
+
for (const { doc, files, isAgentFile } of affectedDocs) {
|
|
425
|
+
const tag = isAgentFile ? ` ${c.yellow}[agent-instruction]${c.reset}` : '';
|
|
426
|
+
console.log(` ${c.cyan}${doc}${c.reset}${tag} ${c.dim}(${files.length} file${files.length > 1 ? 's' : ''})${c.reset}`);
|
|
427
|
+
for (const f of files.slice(0, 5)) {
|
|
428
|
+
console.log(` ${c.dim}via${c.reset} ${f}`);
|
|
429
|
+
}
|
|
430
|
+
if (files.length > 5) console.log(` ${c.dim}... ${files.length - 5} more${c.reset}`);
|
|
431
|
+
}
|
|
150
432
|
}
|
|
151
433
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
console.log(
|
|
155
|
-
for (const
|
|
156
|
-
|
|
434
|
+
// Indirect impact — docs about modules that import a changed file.
|
|
435
|
+
if (indirectDocs.length > 0) {
|
|
436
|
+
console.log(`\n ${c.bold}↺ Indirect impact${c.reset} ${c.dim}(docs about modules that import the changed files)${c.reset}`);
|
|
437
|
+
for (const { doc, isAgentFile, chains } of indirectDocs.slice(0, 8)) {
|
|
438
|
+
const tag = isAgentFile ? ` ${c.yellow}[agent-instruction]${c.reset}` : '';
|
|
439
|
+
console.log(` ${c.cyan}${doc}${c.reset}${tag}`);
|
|
440
|
+
for (const ch of chains.slice(0, 3)) {
|
|
441
|
+
console.log(` ${c.dim}↳ describes${c.reset} ${ch.via}${c.dim}, which imports${c.reset} ${ch.changed} ${c.dim}(${ch.hops} hop${ch.hops > 1 ? 's' : ''})${c.reset}`);
|
|
442
|
+
}
|
|
443
|
+
if (chains.length > 3) console.log(` ${c.dim}... ${chains.length - 3} more chain(s)${c.reset}`);
|
|
157
444
|
}
|
|
158
|
-
if (
|
|
445
|
+
if (indirectDocs.length > 8) console.log(` ${c.dim}... ${indirectDocs.length - 8} more doc(s)${c.reset}`);
|
|
159
446
|
}
|
|
160
447
|
|
|
161
448
|
// List code files with NO doc references — these may need new docs
|
|
@@ -166,4 +453,6 @@ export function runImpact(projectDir, _config, flags) {
|
|
|
166
453
|
if (orphaned.length > 5) console.log(` ${c.dim}... ${orphaned.length - 5} more${c.reset}`);
|
|
167
454
|
console.log(` ${c.dim}These may be undocumented — review whether they belong in an existing doc.${c.reset}`);
|
|
168
455
|
}
|
|
456
|
+
|
|
457
|
+
printBlast();
|
|
169
458
|
}
|
package/cli/commands/mcp.mjs
CHANGED
|
@@ -204,62 +204,69 @@ const TOOL_HANDLERS = {
|
|
|
204
204
|
};
|
|
205
205
|
|
|
206
206
|
/**
|
|
207
|
-
*
|
|
207
|
+
* Transport-agnostic JSON-RPC dispatch. Returns the response message for a
|
|
208
|
+
* request, or null for notifications (which get no response by spec). Both
|
|
209
|
+
* the stdio and HTTP transports route through this one dispatcher.
|
|
210
|
+
*/
|
|
211
|
+
function dispatchMessage(msg, projectDir) {
|
|
212
|
+
const result = (id, res) => ({ jsonrpc: '2.0', id, result: res });
|
|
213
|
+
const error = (id, code, message) => ({ jsonrpc: '2.0', id, error: { code, message } });
|
|
214
|
+
|
|
215
|
+
if (!msg || typeof msg !== 'object' || Array.isArray(msg) || msg.jsonrpc !== '2.0' || typeof msg.method !== 'string') {
|
|
216
|
+
return error(msg && msg.id !== undefined ? msg.id : null, E_INVALID_REQUEST, 'Invalid Request');
|
|
217
|
+
}
|
|
218
|
+
const { id, method, params } = msg;
|
|
219
|
+
const isNotification = id === undefined || id === null;
|
|
220
|
+
|
|
221
|
+
switch (method) {
|
|
222
|
+
case 'initialize':
|
|
223
|
+
return result(id, {
|
|
224
|
+
protocolVersion: typeof params?.protocolVersion === 'string' ? params.protocolVersion : PROTOCOL_VERSION,
|
|
225
|
+
capabilities: { tools: {} },
|
|
226
|
+
serverInfo: { name: 'docguard', version: _PKG.version },
|
|
227
|
+
});
|
|
228
|
+
case 'ping':
|
|
229
|
+
return result(id, {});
|
|
230
|
+
case 'tools/list':
|
|
231
|
+
return result(id, { tools: TOOLS });
|
|
232
|
+
case 'tools/call': {
|
|
233
|
+
const handler = TOOL_HANDLERS[params?.name];
|
|
234
|
+
if (!handler) return error(id, E_INVALID_PARAMS, `Unknown tool: ${params?.name}`);
|
|
235
|
+
// In-tool failures are tool RESULTS (isError), not protocol errors —
|
|
236
|
+
// one bad call must never take down the server or the session.
|
|
237
|
+
try {
|
|
238
|
+
const payload = handler(params?.arguments || {}, projectDir);
|
|
239
|
+
return result(id, { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] });
|
|
240
|
+
} catch (err) {
|
|
241
|
+
return result(id, { content: [{ type: 'text', text: String((err && err.message) || err) }], isError: true });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
default:
|
|
245
|
+
// Notifications (initialized, cancelled, …) get no response by spec.
|
|
246
|
+
if (isNotification) return null;
|
|
247
|
+
return error(id, E_METHOD_NOT_FOUND, `Method not found: ${method}`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Serve MCP until the transport closes. The returned promise keeps the
|
|
208
253
|
* dispatcher's `await` (and thus the process) alive for the server's lifetime.
|
|
254
|
+
* Default transport is stdio; `--transport http` serves the same tools over
|
|
255
|
+
* the MCP Streamable HTTP transport so one shared process can serve a team.
|
|
209
256
|
*/
|
|
210
|
-
export function runMcp(projectDir, _config,
|
|
257
|
+
export function runMcp(projectDir, _config, flags = {}) {
|
|
258
|
+
if (flags.transport === 'http') return runMcpHttp(projectDir, flags);
|
|
259
|
+
if (flags.transport && flags.transport !== 'stdio') {
|
|
260
|
+
process.stderr.write(`docguard mcp: unknown transport "${flags.transport}" (expected stdio or http)\n`);
|
|
261
|
+
process.exitCode = 1;
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
211
265
|
const send = (msg) => {
|
|
212
266
|
// A vanished client (EPIPE) is a normal shutdown, not a crash.
|
|
213
267
|
try { process.stdout.write(JSON.stringify(msg) + '\n'); }
|
|
214
268
|
catch { /* client gone — the readline close handler ends the server */ }
|
|
215
269
|
};
|
|
216
|
-
const reply = (id, result) => send({ jsonrpc: '2.0', id, result });
|
|
217
|
-
const replyError = (id, code, message) => send({ jsonrpc: '2.0', id, error: { code, message } });
|
|
218
|
-
|
|
219
|
-
const handleMessage = (msg) => {
|
|
220
|
-
if (!msg || typeof msg !== 'object' || Array.isArray(msg) || msg.jsonrpc !== '2.0' || typeof msg.method !== 'string') {
|
|
221
|
-
replyError(msg && msg.id !== undefined ? msg.id : null, E_INVALID_REQUEST, 'Invalid Request');
|
|
222
|
-
return;
|
|
223
|
-
}
|
|
224
|
-
const { id, method, params } = msg;
|
|
225
|
-
const isNotification = id === undefined || id === null;
|
|
226
|
-
|
|
227
|
-
switch (method) {
|
|
228
|
-
case 'initialize':
|
|
229
|
-
reply(id, {
|
|
230
|
-
protocolVersion: typeof params?.protocolVersion === 'string' ? params.protocolVersion : PROTOCOL_VERSION,
|
|
231
|
-
capabilities: { tools: {} },
|
|
232
|
-
serverInfo: { name: 'docguard', version: _PKG.version },
|
|
233
|
-
});
|
|
234
|
-
return;
|
|
235
|
-
case 'ping':
|
|
236
|
-
reply(id, {});
|
|
237
|
-
return;
|
|
238
|
-
case 'tools/list':
|
|
239
|
-
reply(id, { tools: TOOLS });
|
|
240
|
-
return;
|
|
241
|
-
case 'tools/call': {
|
|
242
|
-
const handler = TOOL_HANDLERS[params?.name];
|
|
243
|
-
if (!handler) {
|
|
244
|
-
replyError(id, E_INVALID_PARAMS, `Unknown tool: ${params?.name}`);
|
|
245
|
-
return;
|
|
246
|
-
}
|
|
247
|
-
// In-tool failures are tool RESULTS (isError), not protocol errors —
|
|
248
|
-
// one bad call must never take down the server or the session.
|
|
249
|
-
try {
|
|
250
|
-
const payload = handler(params?.arguments || {}, projectDir);
|
|
251
|
-
reply(id, { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] });
|
|
252
|
-
} catch (err) {
|
|
253
|
-
reply(id, { content: [{ type: 'text', text: String((err && err.message) || err) }], isError: true });
|
|
254
|
-
}
|
|
255
|
-
return;
|
|
256
|
-
}
|
|
257
|
-
default:
|
|
258
|
-
// Notifications (initialized, cancelled, …) get no response by spec.
|
|
259
|
-
if (isNotification) return;
|
|
260
|
-
replyError(id, E_METHOD_NOT_FOUND, `Method not found: ${method}`);
|
|
261
|
-
}
|
|
262
|
-
};
|
|
263
270
|
|
|
264
271
|
process.stderr.write(`docguard mcp v${_PKG.version} — serving ${TOOLS.length} tools on stdio (project: ${projectDir})\n`);
|
|
265
272
|
|
|
@@ -270,14 +277,133 @@ export function runMcp(projectDir, _config, _flags) {
|
|
|
270
277
|
if (!trimmed) return;
|
|
271
278
|
let msg;
|
|
272
279
|
try { msg = JSON.parse(trimmed); }
|
|
273
|
-
catch {
|
|
274
|
-
try {
|
|
275
|
-
|
|
280
|
+
catch { send({ jsonrpc: '2.0', id: null, error: { code: E_PARSE, message: 'Parse error' } }); return; }
|
|
281
|
+
try {
|
|
282
|
+
const resp = dispatchMessage(msg, projectDir);
|
|
283
|
+
if (resp) send(resp);
|
|
284
|
+
} catch (err) {
|
|
276
285
|
// Last-resort trap: a protocol-handler bug must not kill the server.
|
|
277
286
|
process.stderr.write(`docguard mcp: internal error: ${err && err.stack || err}\n`);
|
|
278
|
-
if (msg && msg.id !== undefined && msg.id !== null)
|
|
287
|
+
if (msg && msg.id !== undefined && msg.id !== null) {
|
|
288
|
+
send({ jsonrpc: '2.0', id: msg.id, error: { code: E_INTERNAL, message: 'Internal error' } });
|
|
289
|
+
}
|
|
279
290
|
}
|
|
280
291
|
});
|
|
281
292
|
rl.on('close', () => done());
|
|
282
293
|
});
|
|
283
294
|
}
|
|
295
|
+
|
|
296
|
+
// ── Streamable HTTP transport ───────────────────────────────────────────────
|
|
297
|
+
//
|
|
298
|
+
// Minimal spec-compliant subset, zero-dep (node:http):
|
|
299
|
+
// - POST <path>: JSON-RPC request/batch in, application/json out. A body of
|
|
300
|
+
// only notifications → 202 Accepted, empty.
|
|
301
|
+
// - GET <path>: 405 — this server does not offer a server-initiated SSE
|
|
302
|
+
// stream (clients that need one fall back to plain request/response).
|
|
303
|
+
// - DELETE <path>: 200 — the server is stateless; nothing to clean up.
|
|
304
|
+
// - `Mcp-Session-Id` is issued on initialize and accepted (not required)
|
|
305
|
+
// afterwards — stateless by design, like `--stateless` HTTP MCP servers.
|
|
306
|
+
//
|
|
307
|
+
// Security posture (Security → Production-readiness → Simplicity):
|
|
308
|
+
// - Default bind 127.0.0.1 (loopback-only).
|
|
309
|
+
// - Binding any non-loopback host REQUIRES --api-key / DOCGUARD_API_KEY —
|
|
310
|
+
// the server refuses to start otherwise, instead of warning and exposing
|
|
311
|
+
// read access to the whole network.
|
|
312
|
+
// - When an api-key is set, every request must carry it
|
|
313
|
+
// (`Authorization: Bearer <key>` or `X-API-Key: <key>`) → else 401.
|
|
314
|
+
// - Origin allow-list on loopback binds (DNS-rebinding guard per the MCP
|
|
315
|
+
// Streamable HTTP security notes): browser-originated cross-site requests
|
|
316
|
+
// are rejected; non-browser clients send no Origin and pass.
|
|
317
|
+
|
|
318
|
+
const HTTP_BODY_CAP = 4 * 1024 * 1024; // 4 MiB — guard payloads are large but bounded
|
|
319
|
+
|
|
320
|
+
function isLoopbackHost(host) {
|
|
321
|
+
return host === '127.0.0.1' || host === 'localhost' || host === '::1';
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function runMcpHttp(projectDir, flags) {
|
|
325
|
+
const { createServer } = await import('node:http');
|
|
326
|
+
const { randomUUID } = await import('node:crypto');
|
|
327
|
+
|
|
328
|
+
const host = flags.host || '127.0.0.1';
|
|
329
|
+
const port = Number.isFinite(Number(flags.port)) && Number(flags.port) >= 0 ? Number(flags.port) : 8585;
|
|
330
|
+
const mountPath = flags.path || '/mcp';
|
|
331
|
+
const apiKey = flags.apiKey || process.env.DOCGUARD_API_KEY || '';
|
|
332
|
+
|
|
333
|
+
if (!isLoopbackHost(host) && !apiKey) {
|
|
334
|
+
process.stderr.write(
|
|
335
|
+
`docguard mcp: refusing to bind ${host} without an API key.\n` +
|
|
336
|
+
`Exposing the server beyond localhost requires --api-key <key> (or DOCGUARD_API_KEY).\n`);
|
|
337
|
+
process.exitCode = 1;
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const authorized = (req) => {
|
|
342
|
+
if (!apiKey) return true;
|
|
343
|
+
const auth = req.headers['authorization'] || '';
|
|
344
|
+
const xkey = req.headers['x-api-key'] || '';
|
|
345
|
+
return auth === `Bearer ${apiKey}` || xkey === apiKey;
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
const originAllowed = (req) => {
|
|
349
|
+
const origin = req.headers['origin'];
|
|
350
|
+
if (!origin) return true; // non-browser clients (MCP SDKs, curl) send none
|
|
351
|
+
try {
|
|
352
|
+
const o = new URL(origin);
|
|
353
|
+
return isLoopbackHost(o.hostname);
|
|
354
|
+
} catch { return false; }
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
const server = createServer((req, res) => {
|
|
358
|
+
const answer = (status, body, headers = {}) => {
|
|
359
|
+
res.writeHead(status, { 'content-type': 'application/json', ...headers });
|
|
360
|
+
res.end(body === undefined ? '' : JSON.stringify(body));
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
const url = (req.url || '').split('?')[0];
|
|
364
|
+
if (url !== mountPath) return answer(404, { error: 'not found' });
|
|
365
|
+
if (!originAllowed(req)) return answer(403, { error: 'origin not allowed' });
|
|
366
|
+
if (!authorized(req)) return answer(401, { error: 'unauthorized' }, { 'www-authenticate': 'Bearer' });
|
|
367
|
+
|
|
368
|
+
if (req.method === 'GET') return answer(405, { error: 'SSE stream not offered — POST JSON-RPC to this endpoint' }, { allow: 'POST, DELETE' });
|
|
369
|
+
if (req.method === 'DELETE') return answer(200, {}); // stateless — nothing to end
|
|
370
|
+
if (req.method !== 'POST') return answer(405, { error: 'method not allowed' }, { allow: 'POST, DELETE' });
|
|
371
|
+
|
|
372
|
+
let size = 0;
|
|
373
|
+
const chunks = [];
|
|
374
|
+
req.on('data', (c) => {
|
|
375
|
+
size += c.length;
|
|
376
|
+
if (size > HTTP_BODY_CAP) { answer(413, { error: 'payload too large' }); req.destroy(); return; }
|
|
377
|
+
chunks.push(c);
|
|
378
|
+
});
|
|
379
|
+
req.on('end', () => {
|
|
380
|
+
if (res.writableEnded) return;
|
|
381
|
+
let parsed;
|
|
382
|
+
try { parsed = JSON.parse(Buffer.concat(chunks).toString('utf-8')); }
|
|
383
|
+
catch { return answer(400, { jsonrpc: '2.0', id: null, error: { code: E_PARSE, message: 'Parse error' } }); }
|
|
384
|
+
|
|
385
|
+
try {
|
|
386
|
+
const messages = Array.isArray(parsed) ? parsed : [parsed];
|
|
387
|
+
const responses = messages.map((m) => dispatchMessage(m, projectDir)).filter(Boolean);
|
|
388
|
+
// New sessions get an id on initialize; we accept any/none afterwards.
|
|
389
|
+
const headers = messages.some((m) => m && m.method === 'initialize')
|
|
390
|
+
? { 'mcp-session-id': randomUUID() } : {};
|
|
391
|
+
if (responses.length === 0) return answer(202, undefined, headers); // notifications only
|
|
392
|
+
return answer(200, Array.isArray(parsed) ? responses : responses[0], headers);
|
|
393
|
+
} catch (err) {
|
|
394
|
+
process.stderr.write(`docguard mcp: internal error: ${err && err.stack || err}\n`);
|
|
395
|
+
return answer(500, { jsonrpc: '2.0', id: null, error: { code: E_INTERNAL, message: 'Internal error' } });
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
return new Promise((done) => {
|
|
401
|
+
server.listen(port, host, () => {
|
|
402
|
+
const addr = server.address();
|
|
403
|
+
process.stderr.write(
|
|
404
|
+
`docguard mcp v${_PKG.version} — Streamable HTTP on http://${host}:${addr.port}${mountPath} ` +
|
|
405
|
+
`(project: ${projectDir}${apiKey ? ', api-key required' : ', loopback only'})\n`);
|
|
406
|
+
});
|
|
407
|
+
server.on('close', () => done());
|
|
408
|
+
});
|
|
409
|
+
}
|