docguard-cli 0.31.0 β†’ 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 CHANGED
@@ -62,7 +62,7 @@ DocGuard enforces **Canonical-Driven Development (CDD)** β€” a methodology where
62
62
 
63
63
  DocGuard is an official [GitHub Spec Kit](https://github.com/github/spec-kit) community extension. It validates the artifacts that Spec Kit creates, ensuring your specs stay high-quality throughout the development lifecycle.
64
64
 
65
- πŸ“– **[Philosophy](PHILOSOPHY.md)** Β· πŸ“‹ **[CDD Standard](STANDARD.md)** Β· βš–οΈ **[Comparisons](COMPARISONS.md)** Β· πŸ—ΊοΈ **[Roadmap](ROADMAP.md)**
65
+ πŸ“– **[Philosophy](PHILOSOPHY.md)** Β· πŸ“‹ **[CDD Standard](STANDARD.md)** Β· βš–οΈ **[Comparisons](COMPARISONS.md)** Β· πŸ”¬ **[Validation](VALIDATION.md)** Β· πŸ—ΊοΈ **[Roadmap](ROADMAP.md)**
66
66
 
67
67
  ### Architecture
68
68
 
@@ -274,7 +274,7 @@ DocGuard ships **18 commands** (the "Daily 5" + 13 situational tools, including
274
274
  | `verify --semantic` | Extract documented numbers/limits/enums (retention days, rate limits, GSI/role counts, status enums) as a task list for an agent to check against code β€” the semantic-drift class regex/AST can't see |
275
275
  | `verify --instructions` | Audit AGENTS.md/CLAUDE.md themselves for drift: duplicate rules, never-vs-always contradictions, stale file pointers, unknown commands β€” plus clustered rule pairs as agent judgment tasks |
276
276
  | `feedback` | Report likely false positives back to DocGuard β€” local-first record + a 1-click prefilled, redacted GitHub issue (zero typing) |
277
- | `mcp` | MCP server over stdio β€” exposes guard/score/explain/verify/diagnose as native tools for Claude, Cursor, and any MCP client. Setup: `claude mcp add docguard -- npx docguard-cli mcp` |
277
+ | `mcp` | MCP server β€” exposes guard/score/explain/verify/diagnose as native tools for Claude, Cursor, and any MCP client. Stdio: `claude mcp add docguard -- npx docguard-cli mcp`. Team-shared HTTP: `docguard mcp --transport http --port 8585` (loopback by default; non-loopback binds require `--api-key`) |
278
278
  | `memory` | Per-domain accuracy headline (endpoints / entities / env / tech) |
279
279
  | `memory --diff` | Drill into which specific claims don't match code |
280
280
  | `memory --pack` | Write `.docguard/context-pack.md` β€” compact, code-truth-stamped session-start context for AI agents |
@@ -325,6 +325,9 @@ Run them solo (`docguard init --with hooks`) or stacked (`docguard init --with a
325
325
  | `--apply` | Actually run the migration | upgrade |
326
326
  | `--pr` | Open a PR with the migration | upgrade |
327
327
  | `--reverse <file>` | Reverse traceability (code β†’ docs) | trace |
328
+ | `--no-indirect` | Skip the reverse-import-graph analysis (docs about modules that import a changed file) | impact, diff --since |
329
+ | `--prs` | Open-PR doc-conflict analysis β€” two PRs impacting the same canonical doc = merge-order risk (needs the `gh` CLI) | impact |
330
+ | `--transport http` `--port` `--host` `--api-key` `--path` | Serve MCP over Streamable HTTP instead of stdio (team-shared server; loopback-only unless an api-key is set) | mcp |
328
331
  | `--history` | Show fix audit log | fix |
329
332
 
330
333
  ### Example Output
@@ -383,7 +386,7 @@ DocGuard runs **27 automated validators** on every `guard` check. Every one is *
383
386
  | 17 | **TODO-Tracking** | Untracked TODOs/FIXMEs and skipped tests (skips test files by default) | βœ… On |
384
387
  | 18 | **Schema-Sync** | Database models documented in DATA-MODEL.md | βœ… On |
385
388
  | 19 | **Spec-Kit** | Spec quality validation (FR-IDs, mandatory sections, phased tasks) | βœ… On |
386
- | 20 | **Cross-Reference** | Internal markdown links + anchors resolve (with "did you mean?" hints) | βœ… On |
389
+ | 20 | **Cross-Reference** | Internal markdown links + anchors resolve (with "did you mean?" hints); Obsidian wikilinks validated when the repo uses them as file links (`.obsidian` present or a target resolves) | βœ… On |
387
390
  | 21 | **Generated-Staleness** | `source=code` sections match scanner output; `status: draft` doc age | βœ… On |
388
391
  | 22 | **Canonical-Sync** | DocGuard's own README count claims match code-truth (DocGuard repo only β€” N/A elsewhere) | βœ… On |
389
392
  | 23 | **Metrics-Consistency** | Hardcoded numbers match actual counts | βœ… On |
@@ -450,6 +453,19 @@ DocGuard works with **every major AI coding agent**. All canonical docs are plai
450
453
  | Google Gemini CLI | βœ… | `docguard agents --agent gemini` |
451
454
  | Kiro (AWS) | βœ… | β€” |
452
455
 
456
+ ### Always-on nudge hook (Claude Code)
457
+
458
+ ```bash
459
+ docguard hooks --claude # install (remove: docguard hooks --claude --remove)
460
+ ```
461
+
462
+ Registers a `PostToolUse` hook in the project's `.claude/settings.json`. After the
463
+ agent edits a canonical doc it is nudged to run `docguard guard --changed-only`;
464
+ after it edits a code file the docs reference, it is nudged toward `docguard impact`.
465
+ Merge-safe (only DocGuard's own entry is ever added/removed), throttled to one nudge
466
+ per file per 30 minutes, and the hook runtime can never break a session (errors are
467
+ silent by contract). Explicit opt-in β€” `init` never installs it for you.
468
+
453
469
  ---
454
470
 
455
471
  ## ⚑ Slash Commands
@@ -3,7 +3,7 @@
3
3
  * Creates git hooks that run guard/score before commits.
4
4
  */
5
5
 
6
- import { existsSync, writeFileSync, mkdirSync, chmodSync, readFileSync, unlinkSync } from 'node:fs';
6
+ import { existsSync, writeFileSync, mkdirSync, chmodSync, readFileSync, unlinkSync, readdirSync } from 'node:fs';
7
7
 
8
8
  // v0.16-P3: managed-block markers. Letting users extend the hook with their
9
9
  // own commands (data-file guards, lint checks, etc.) without us clobbering
@@ -54,7 +54,7 @@ function spliceManagedBlock(existing, newBody) {
54
54
  const bodyNoShebang = newBody.replace(/^#!.*\n/, '');
55
55
  return `${before}${BEGIN_MARKER}\n${bodyNoShebang.replace(/\n+$/, '')}\n${END_MARKER}${after}`;
56
56
  }
57
- import { resolve } from 'node:path';
57
+ import { resolve, relative, basename } from 'node:path';
58
58
  import { c } from '../shared.mjs';
59
59
  import { getHooksDir } from '../shared-git.mjs';
60
60
 
@@ -217,6 +217,15 @@ export function runHooks(projectDir, config, flags) {
217
217
  console.log(`${c.bold}πŸͺ DocGuard Hooks β€” ${config.projectName}${c.reset}`);
218
218
  console.log(`${c.dim} Directory: ${projectDir}${c.reset}\n`);
219
219
 
220
+ // ── Claude Code agent nudge: `docguard hooks --claude` ──
221
+ // Separate path from git hooks: it edits .claude/settings.json, needs no
222
+ // git repo, and is explicitly opt-in (writing agent config unasked is a
223
+ // trust break β€” same class as the ensureSkills READ_ONLY_COMMANDS rule).
224
+ if (flags.claude) {
225
+ installClaudeNudge(projectDir, { remove: !!flags.remove });
226
+ return;
227
+ }
228
+
220
229
  // Resolve the real hooks dir via git β€” NOT `<projectDir>/.git/hooks`, which
221
230
  // is wrong inside a linked worktree (where `.git` is a file, not a dir) and
222
231
  // ignores a custom core.hooksPath.
@@ -337,3 +346,159 @@ export function runHooks(projectDir, config, flags) {
337
346
 
338
347
  console.log('');
339
348
  }
349
+
350
+ // ── Claude Code agent nudge ─────────────────────────────────────────────────
351
+ //
352
+ // `docguard hooks --claude` registers a PostToolUse hook in the PROJECT's
353
+ // .claude/settings.json. After the agent edits a canonical doc (or a code
354
+ // file the docs reference), the hook nudges it toward the right DocGuard
355
+ // command β€” the graphify "query-first hook" distribution pattern, pointed at
356
+ // doc integrity instead of graph queries.
357
+ //
358
+ // Trust rules:
359
+ // - Explicit opt-in only (never installed by ensureSkills/init).
360
+ // - Merge-safe: parses the existing settings.json and adds/removes ONLY the
361
+ // entry whose command contains the NUDGE_HOOK_COMMAND marker. A file that
362
+ // doesn't parse is never touched.
363
+ // - The runtime (`docguard nudge-hook`) is throttled and can never break an
364
+ // agent session: any internal error exits 0 with no output.
365
+
366
+ const NUDGE_HOOK_COMMAND = 'docguard nudge-hook';
367
+ const NUDGE_THROTTLE_MS = 30 * 60 * 1000; // one nudge per file per 30 min
368
+ const NUDGE_STATE_PATH = '.docguard/nudge-state.json';
369
+ const NUDGE_CODE_EXT = /\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|rb|php|cs|swift)$/;
370
+ const NUDGE_AGENT_FILES = new Set(['AGENTS.md', 'CLAUDE.md', 'GEMINI.md']);
371
+
372
+ function isOurNudgeGroup(group) {
373
+ return Array.isArray(group?.hooks) &&
374
+ group.hooks.some(h => typeof h?.command === 'string' && h.command.includes(NUDGE_HOOK_COMMAND));
375
+ }
376
+
377
+ export function installClaudeNudge(projectDir, { remove = false } = {}) {
378
+ const settingsDir = resolve(projectDir, '.claude');
379
+ const settingsPath = resolve(settingsDir, 'settings.json');
380
+
381
+ let settings = {};
382
+ if (existsSync(settingsPath)) {
383
+ try {
384
+ settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
385
+ } catch {
386
+ console.log(` ${c.red}❌ .claude/settings.json exists but is not valid JSON β€” refusing to touch it.${c.reset}`);
387
+ console.log(` ${c.dim}Fix the file, then re-run docguard hooks --claude.${c.reset}\n`);
388
+ process.exitCode = 1;
389
+ return;
390
+ }
391
+ }
392
+
393
+ const groups = Array.isArray(settings.hooks?.PostToolUse) ? settings.hooks.PostToolUse : [];
394
+ const present = groups.some(isOurNudgeGroup);
395
+
396
+ if (remove) {
397
+ if (!present) {
398
+ console.log(` ${c.dim}⏭️ No DocGuard nudge hook found in .claude/settings.json β€” nothing to remove.${c.reset}\n`);
399
+ return;
400
+ }
401
+ settings.hooks.PostToolUse = groups.filter(g => !isOurNudgeGroup(g));
402
+ if (settings.hooks.PostToolUse.length === 0) delete settings.hooks.PostToolUse;
403
+ if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
404
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
405
+ console.log(` ${c.yellow}πŸ—‘οΈ Removed the DocGuard nudge hook from .claude/settings.json${c.reset} ${c.dim}(everything else preserved)${c.reset}\n`);
406
+ return;
407
+ }
408
+
409
+ if (present) {
410
+ console.log(` ${c.green}βœ… DocGuard nudge hook already installed${c.reset} ${c.dim}(.claude/settings.json β€” idempotent)${c.reset}\n`);
411
+ return;
412
+ }
413
+
414
+ if (!settings.hooks) settings.hooks = {};
415
+ if (!Array.isArray(settings.hooks.PostToolUse)) settings.hooks.PostToolUse = [];
416
+ settings.hooks.PostToolUse.push({
417
+ matcher: 'Edit|Write|MultiEdit',
418
+ hooks: [{ type: 'command', command: NUDGE_HOOK_COMMAND }],
419
+ });
420
+
421
+ if (!existsSync(settingsDir)) mkdirSync(settingsDir, { recursive: true });
422
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
423
+ console.log(` ${c.green}βœ… Installed the DocGuard nudge hook${c.reset} β†’ .claude/settings.json (PostToolUse)`);
424
+ console.log(` ${c.dim}After an agent edits a canonical doc (or code the docs reference), it is${c.reset}`);
425
+ console.log(` ${c.dim}nudged toward docguard guard --changed-only / docguard impact.${c.reset}`);
426
+ console.log(` ${c.dim}Throttled: one nudge per file per 30 minutes. Remove: docguard hooks --claude --remove${c.reset}\n`);
427
+ }
428
+
429
+ /**
430
+ * Runtime for the PostToolUse hook (`docguard nudge-hook`). Reads the Claude
431
+ * Code hook payload from stdin, classifies the edited file, and emits a
432
+ * `{"decision":"block","reason":…}` nudge on stdout when β€” and only when β€”
433
+ * DocGuard has something specific to say. stdout is the machine channel:
434
+ * nothing else may be printed. Errors NEVER propagate (exit 0, silent) β€” a
435
+ * broken nudge must not degrade the agent session.
436
+ */
437
+ export function runNudgeHook(projectDir) {
438
+ try {
439
+ const payload = JSON.parse(readFileSync(0, 'utf-8'));
440
+ const toolInput = payload.tool_input || payload.toolInput || {};
441
+ const filePath = toolInput.file_path || toolInput.filePath;
442
+ if (!filePath) return;
443
+
444
+ const rel = relative(projectDir, resolve(projectDir, filePath)).replace(/\\/g, '/');
445
+ if (!rel || rel.startsWith('..')) return;
446
+
447
+ let reason = null;
448
+ const base = basename(rel);
449
+ const isCanonicalDoc =
450
+ (rel.startsWith('docs-canonical/') && rel.endsWith('.md')) ||
451
+ (NUDGE_AGENT_FILES.has(base) && !rel.includes('/'));
452
+
453
+ if (isCanonicalDoc) {
454
+ reason = `DocGuard: you edited ${rel} β€” a canonical/agent doc that gates this repo. ` +
455
+ `Before finishing, run \`docguard guard --changed-only\` to confirm links, anchors, and count claims still hold.`;
456
+ } else if (NUDGE_CODE_EXT.test(rel)) {
457
+ const referencingDocs = docsReferencingFile(projectDir, base);
458
+ if (referencingDocs.length > 0) {
459
+ reason = `DocGuard: ${referencingDocs.slice(0, 3).join(', ')} reference${referencingDocs.length === 1 ? 's' : ''} ` +
460
+ `\`${base}\` β€” after your edits, run \`docguard impact\` to see which doc sections to re-read or update.`;
461
+ }
462
+ }
463
+ if (!reason) return;
464
+
465
+ // Throttle per file β€” iterative edits must not spam the agent.
466
+ const statePath = resolve(projectDir, NUDGE_STATE_PATH);
467
+ let state = {};
468
+ try { state = JSON.parse(readFileSync(statePath, 'utf-8')); } catch { /* first run */ }
469
+ const now = Date.now();
470
+ if (state[rel] && now - state[rel] < NUDGE_THROTTLE_MS) return;
471
+ state[rel] = now;
472
+ try {
473
+ mkdirSync(resolve(projectDir, '.docguard'), { recursive: true });
474
+ writeFileSync(statePath, JSON.stringify(state, null, 2) + '\n', 'utf-8');
475
+ } catch { /* state is best-effort; still nudge */ }
476
+
477
+ process.stdout.write(JSON.stringify({ decision: 'block', reason }) + '\n');
478
+ } catch {
479
+ // Silent by contract.
480
+ }
481
+ }
482
+
483
+ /** Which canonical/agent docs mention this basename? Cheap line scan. */
484
+ function docsReferencingFile(projectDir, base) {
485
+ const docs = [];
486
+ const check = (name, full) => {
487
+ try {
488
+ if (readFileSync(full, 'utf-8').includes(base)) docs.push(name);
489
+ } catch { /* unreadable */ }
490
+ };
491
+ const dir = resolve(projectDir, 'docs-canonical');
492
+ if (existsSync(dir)) {
493
+ try {
494
+ for (const f of readdirSync(dir)) {
495
+ if (f.endsWith('.md')) check(f, resolve(dir, f));
496
+ }
497
+ } catch { /* unreadable dir */ }
498
+ }
499
+ for (const a of NUDGE_AGENT_FILES) {
500
+ const p = resolve(projectDir, a);
501
+ if (existsSync(p)) check(a, p);
502
+ }
503
+ return docs;
504
+ }
@@ -30,13 +30,23 @@
30
30
  * @req SC-S11-004 β€” non-code files (.md, .json, etc.) are skipped from impact analysis
31
31
  * @req SC-S11-007 β€” agent-instruction files participate in impact analysis
32
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
33
41
  */
34
42
 
35
43
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
36
44
  import { resolve, basename } from 'node:path';
45
+ import { execFileSync } from 'node:child_process';
37
46
 
38
47
  import { c } from '../shared.mjs';
39
48
  import { changedFilesSince, isGitRepo } from '../shared-git.mjs';
49
+ import { buildImportGraph } from '../validators/architecture.mjs';
40
50
 
41
51
  /**
42
52
  * File extensions we consider "code" for the purposes of impact analysis.
@@ -52,16 +62,20 @@ function escapeRegex(s) {
52
62
  }
53
63
 
54
64
  /**
55
- * Doc→doc references: which indexed docs reference `changedDocPath` (by its
56
- * basename β€” the form used in prose "see ARCHITECTURE.md" and markdown links
57
- * `](ARCHITECTURE.md)`). Skips self. This is the blast-radius edge set.
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.
58
70
  */
59
71
  function docsReferencing(changedDocPath, index) {
60
72
  const cbase = basename(changedDocPath);
73
+ const stem = cbase.replace(/\.md$/i, '');
74
+ const wikiRe = new RegExp(`\\[\\[${escapeRegex(stem)}(?:[#|\\]])`);
61
75
  const dependents = [];
62
76
  for (const [docName, lines] of index) {
63
77
  if (docName === cbase || docName === changedDocPath) continue; // not self
64
- if (lines.some(l => l.includes(cbase))) dependents.push(docName);
78
+ if (lines.some(l => l.includes(cbase) || wikiRe.test(l))) dependents.push(docName);
65
79
  }
66
80
  return dependents;
67
81
  }
@@ -92,7 +106,135 @@ function findReferences(file, docs) {
92
106
  return refs;
93
107
  }
94
108
 
95
- export function runImpact(projectDir, _config, flags) {
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) {
96
238
  const isJson = flags.format === 'json';
97
239
  const since = flags.since || 'HEAD~1';
98
240
 
@@ -132,6 +274,12 @@ export function runImpact(projectDir, _config, flags) {
132
274
  try { docsIndex.set(a, readFileSync(p, 'utf-8').split('\n')); agentDocs.add(a); } catch { /* skip */ }
133
275
  }
134
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
+ }
282
+
135
283
  // Compute per-file references
136
284
  const fileImpact = []; // { file, references: [{doc, line, kind}] }
137
285
  for (const f of codeChanged) {
@@ -152,6 +300,51 @@ export function runImpact(projectDir, _config, flags) {
152
300
  isAgentFile: agentDocs.has(doc),
153
301
  }));
154
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
+
155
348
  // ── Docβ†’doc blast radius: a changed DOC flags the docs that reference it ──
156
349
  // (including agent-instruction files that point at it). Only meaningful edges
157
350
  // are emitted (changed doc with β‰₯1 dependent).
@@ -180,6 +373,7 @@ export function runImpact(projectDir, _config, flags) {
180
373
  changedDocs,
181
374
  ignoredFiles: changed.filter(f => !CODE_EXTENSIONS.test(f) && !f.endsWith('.md')),
182
375
  affectedDocs,
376
+ indirectDocs,
183
377
  blastRadius,
184
378
  timestamp: new Date().toISOString(),
185
379
  }, null, 2));
@@ -237,6 +431,20 @@ export function runImpact(projectDir, _config, flags) {
237
431
  }
238
432
  }
239
433
 
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}`);
444
+ }
445
+ if (indirectDocs.length > 8) console.log(` ${c.dim}... ${indirectDocs.length - 8} more doc(s)${c.reset}`);
446
+ }
447
+
240
448
  // List code files with NO doc references β€” these may need new docs
241
449
  const orphaned = fileImpact.filter(fi => fi.references.length === 0).map(fi => fi.file);
242
450
  if (orphaned.length > 0) {