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 +19 -3
- package/cli/commands/hooks.mjs +167 -2
- package/cli/commands/impact.mjs +213 -5
- package/cli/commands/mcp.mjs +179 -53
- package/cli/docguard.mjs +46 -3
- package/cli/findings.mjs +6 -0
- package/cli/scanners/agent-readability.mjs +6 -1
- package/cli/scanners/semantic-claims.mjs +10 -2
- package/cli/validators/architecture.mjs +8 -1
- package/cli/validators/cross-reference.mjs +124 -3
- package/cli/validators/reference-existence.mjs +172 -18
- package/cli/validators/traceability.mjs +63 -0
- 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/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
|
|
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
|
package/cli/commands/hooks.mjs
CHANGED
|
@@ -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
|
+
}
|
package/cli/commands/impact.mjs
CHANGED
|
@@ -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`
|
|
56
|
-
* basename
|
|
57
|
-
* `](ARCHITECTURE.md)`)
|
|
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
|
-
|
|
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) {
|