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/verify.mjs
CHANGED
|
@@ -22,10 +22,50 @@
|
|
|
22
22
|
* docguard verify [--semantic | --instructions] [--format json]
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
+
import { basename } from 'node:path';
|
|
25
26
|
import { c } from '../shared.mjs';
|
|
26
27
|
import { detectAgentMode } from '../ensure-skills.mjs';
|
|
27
28
|
import { extractSemanticClaims, buildSemanticVerifyTasks } from '../scanners/semantic-claims.mjs';
|
|
28
29
|
import { auditInstructions } from '../scanners/instruction-audit.mjs';
|
|
30
|
+
import { isGitRepo, getDiffText } from '../shared-git.mjs';
|
|
31
|
+
import { parseUnifiedDiff, activityLabeledDiff } from '../shared-diff.mjs';
|
|
32
|
+
|
|
33
|
+
const CHANGE_CODE_EXT = /\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|rb|php|cs|swift|scala|dart)$/;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Structured change context for staged agent tasks (feat 6). When `--since` is
|
|
37
|
+
* given, decompose the code diff into activity-labeled spans (ordered
|
|
38
|
+
* replace/delete/add) — the CARL-CCI representation shown to beat raw-text
|
|
39
|
+
* diffs (arXiv 2512.19883). The agent judging a claim then sees WHAT changed,
|
|
40
|
+
* not just the claim. Returns null when there's no ref / git / diff.
|
|
41
|
+
*
|
|
42
|
+
* Bounded: caps files and per-activity lines so the JSON stays agent-sized.
|
|
43
|
+
*/
|
|
44
|
+
function buildChangeContext(projectDir, since, { maxFiles = 40, maxLines = 6 } = {}) {
|
|
45
|
+
if (!since || !isGitRepo(projectDir)) return null;
|
|
46
|
+
const files = parseUnifiedDiff(getDiffText(projectDir, since))
|
|
47
|
+
.filter(f => f.newPath && CHANGE_CODE_EXT.test(f.newPath) && f.status !== 'deleted');
|
|
48
|
+
if (files.length === 0) return null;
|
|
49
|
+
const clip = (arr) => arr.slice(0, maxLines).map(s => s.length > 200 ? s.slice(0, 200) + '…' : s);
|
|
50
|
+
const activities = files.slice(0, maxFiles).map(f => ({
|
|
51
|
+
file: f.newPath,
|
|
52
|
+
activities: activityLabeledDiff(f).map(a => ({
|
|
53
|
+
type: a.type,
|
|
54
|
+
...(a.del ? { del: clip(a.del) } : {}),
|
|
55
|
+
...(a.add ? { add: clip(a.add) } : {}),
|
|
56
|
+
})),
|
|
57
|
+
}));
|
|
58
|
+
return { since, changedFiles: files.map(f => f.newPath), activities };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Best-effort: is this semantic-verify task about code that just changed?
|
|
62
|
+
function taskTouchesChange(task, changedSet, changedBasenames) {
|
|
63
|
+
const cite = task.citedCode || '';
|
|
64
|
+
if (!cite) return false;
|
|
65
|
+
if (changedSet.has(cite)) return true;
|
|
66
|
+
const b = basename(cite);
|
|
67
|
+
return changedBasenames.has(b) || [...changedSet].some(p => cite.includes(p) || p.includes(cite));
|
|
68
|
+
}
|
|
29
69
|
|
|
30
70
|
export function runVerify(projectDir, config, flags) {
|
|
31
71
|
if (flags.instructions) {
|
|
@@ -37,13 +77,27 @@ export function runVerify(projectDir, config, flags) {
|
|
|
37
77
|
const claims = extractSemanticClaims(projectDir, config);
|
|
38
78
|
const tasks = buildSemanticVerifyTasks(claims);
|
|
39
79
|
|
|
80
|
+
// Change-aware staging (feat 6): if --since given, attach the structured diff
|
|
81
|
+
// and flag which claims are about just-changed code (verify those first).
|
|
82
|
+
const changeContext = buildChangeContext(projectDir, flags.since);
|
|
83
|
+
if (changeContext) {
|
|
84
|
+
const changedSet = new Set(changeContext.changedFiles);
|
|
85
|
+
const changedBasenames = new Set(changeContext.changedFiles.map(f => basename(f)));
|
|
86
|
+
for (const t of tasks) t.aboutChangedCode = taskTouchesChange(t, changedSet, changedBasenames);
|
|
87
|
+
// Prioritize changed-code claims first.
|
|
88
|
+
tasks.sort((a, b) => (b.aboutChangedCode ? 1 : 0) - (a.aboutChangedCode ? 1 : 0));
|
|
89
|
+
}
|
|
90
|
+
|
|
40
91
|
if (isJson) {
|
|
41
92
|
console.log(JSON.stringify({
|
|
42
93
|
command: 'verify --semantic',
|
|
43
94
|
project: config.projectName,
|
|
44
95
|
claimCount: tasks.length,
|
|
45
96
|
// How to act on this: each task is a claim to confirm against the code.
|
|
46
|
-
howToVerify:
|
|
97
|
+
howToVerify: changeContext
|
|
98
|
+
? 'Claims flagged aboutChangedCode are about code that changed since the ref — verify those FIRST using changeContext.activities (the ordered replace/delete/add spans show exactly what changed). For each task, read the cited code, compare to the documented value, report mismatches with both values.'
|
|
99
|
+
: 'For each task, read the cited code (or grep for the constant/config), compare it to the documented value, and report any mismatch with both values. DocGuard cannot judge these — they require reading the code.',
|
|
100
|
+
...(changeContext ? { changeContext } : {}),
|
|
47
101
|
tasks,
|
|
48
102
|
}, null, 2));
|
|
49
103
|
return;
|
|
@@ -66,6 +120,10 @@ export function runVerify(projectDir, config, flags) {
|
|
|
66
120
|
}
|
|
67
121
|
|
|
68
122
|
console.log(` ${c.yellow}${tasks.length} claim(s) to verify against the code:${c.reset}\n`);
|
|
123
|
+
if (changeContext) {
|
|
124
|
+
const nChanged = tasks.filter(t => t.aboutChangedCode).length;
|
|
125
|
+
console.log(` ${c.cyan}⚡ ${nChanged} claim(s) are about code changed since ${flags.since}${c.reset} ${c.dim}— verify these first (structured diff in --format json).${c.reset}\n`);
|
|
126
|
+
}
|
|
69
127
|
for (const [doc, ts] of byDoc) {
|
|
70
128
|
console.log(` ${c.bold}${doc}${c.reset}`);
|
|
71
129
|
for (const t of ts) {
|
|
@@ -89,6 +147,9 @@ function runInstructionAudit(projectDir, config, flags) {
|
|
|
89
147
|
const { rules, deterministic, tasks } = auditInstructions(projectDir, config);
|
|
90
148
|
const { duplicates, negations, stalePointers, staleCommands } = deterministic;
|
|
91
149
|
const findingCount = duplicates.length + negations.length + stalePointers.length + staleCommands.length;
|
|
150
|
+
// Structured change context helps the agent judge whether a rule about code
|
|
151
|
+
// has been invalidated by a recent change (feat 6).
|
|
152
|
+
const changeContext = buildChangeContext(projectDir, flags.since);
|
|
92
153
|
|
|
93
154
|
if (isJson) {
|
|
94
155
|
console.log(JSON.stringify({
|
|
@@ -100,6 +161,7 @@ function runInstructionAudit(projectDir, config, flags) {
|
|
|
100
161
|
taskCount: tasks.length,
|
|
101
162
|
// How to act on this: findings are proven; tasks need judgment.
|
|
102
163
|
howToVerify: 'The findings are deterministic — fix them directly (delete the duplicate copy, resolve the negation in favour of one rule, repoint or remove stale paths/commands). For each task, read both rules in context and judge whether they contradict in practice; if so, report which should win, why, and which file to edit. DocGuard cannot judge the tasks — they require understanding intent.',
|
|
164
|
+
...(changeContext ? { changeContext } : {}),
|
|
103
165
|
tasks,
|
|
104
166
|
}, null, 2));
|
|
105
167
|
return;
|
package/cli/config.mjs
CHANGED
|
@@ -77,6 +77,13 @@ export function loadConfig(projectDir) {
|
|
|
77
77
|
security: false,
|
|
78
78
|
environment: true,
|
|
79
79
|
freshness: true,
|
|
80
|
+
// v0.31.0 — all three default ON. Soft (confidence:low, never break CI),
|
|
81
|
+
// precise (zero false positives across the 6-repo corpus), and quiet when
|
|
82
|
+
// not applicable (no diff / no API-reference doc). api-doc-smells is
|
|
83
|
+
// low-yield but zero-FP, so on-by-default beats a self-counting split.
|
|
84
|
+
diffSuspicion: true,
|
|
85
|
+
referenceExistence: true,
|
|
86
|
+
apiDocSmells: true,
|
|
80
87
|
},
|
|
81
88
|
};
|
|
82
89
|
|
package/cli/docguard.mjs
CHANGED
|
@@ -29,7 +29,7 @@ import { runScore } from './commands/score.mjs';
|
|
|
29
29
|
import { runDiff } from './commands/diff.mjs';
|
|
30
30
|
import { runAgents } from './commands/agents.mjs';
|
|
31
31
|
import { runGenerate } from './commands/generate.mjs';
|
|
32
|
-
import { runHooks } from './commands/hooks.mjs';
|
|
32
|
+
import { runHooks, runNudgeHook } from './commands/hooks.mjs';
|
|
33
33
|
import { runBadge } from './commands/badge.mjs';
|
|
34
34
|
import { runCI } from './commands/ci.mjs';
|
|
35
35
|
import { runFix } from './commands/fix.mjs';
|
|
@@ -495,6 +495,32 @@ async function main() {
|
|
|
495
495
|
i++;
|
|
496
496
|
} else if (args[i] === '--no-fix') {
|
|
497
497
|
flags.noFix = true;
|
|
498
|
+
} else if (args[i] === '--no-indirect') {
|
|
499
|
+
// impact: skip the reverse-import-graph (indirect code→doc) analysis.
|
|
500
|
+
flags.indirect = false;
|
|
501
|
+
} else if (args[i] === '--prs') {
|
|
502
|
+
// impact: open-PR doc-conflict analysis (needs the gh CLI).
|
|
503
|
+
flags.prs = true;
|
|
504
|
+
} else if (args[i] === '--claude') {
|
|
505
|
+
// hooks: install/remove the Claude Code agent nudge hook.
|
|
506
|
+
flags.claude = true;
|
|
507
|
+
} else if (args[i] === '--transport' && args[i + 1]) {
|
|
508
|
+
// mcp: stdio (default) or http (Streamable HTTP, team-shared server).
|
|
509
|
+
flags.transport = args[i + 1];
|
|
510
|
+
i++;
|
|
511
|
+
} else if (args[i] === '--port' && args[i + 1]) {
|
|
512
|
+
flags.port = args[i + 1];
|
|
513
|
+
i++;
|
|
514
|
+
} else if (args[i] === '--host' && args[i + 1]) {
|
|
515
|
+
flags.host = args[i + 1];
|
|
516
|
+
i++;
|
|
517
|
+
} else if (args[i] === '--api-key' && args[i + 1]) {
|
|
518
|
+
flags.apiKey = args[i + 1];
|
|
519
|
+
i++;
|
|
520
|
+
} else if (args[i] === '--path' && args[i + 1]) {
|
|
521
|
+
// mcp --transport http: HTTP mount path (default /mcp).
|
|
522
|
+
flags.path = args[i + 1];
|
|
523
|
+
i++;
|
|
498
524
|
} else if (args[i] === '--signals') {
|
|
499
525
|
flags.signals = true;
|
|
500
526
|
} else if (args[i] === '--debate') {
|
|
@@ -545,7 +571,9 @@ async function main() {
|
|
|
545
571
|
// `agent` emits a machine task graph (JSON by default) — it must be banner-
|
|
546
572
|
// free and side-effect-free like the other read-only commands.
|
|
547
573
|
// `mcp`: stdout IS the JSON-RPC transport — any banner byte corrupts the stream.
|
|
548
|
-
|
|
574
|
+
// `nudge-hook`: stdout is the Claude Code hook feedback channel — any banner
|
|
575
|
+
// byte corrupts the JSON the hook runner parses.
|
|
576
|
+
const headless = jsonMode || flags.write || flags.checkOnly || flags.changedOnly || flags.quiet || flags.plan || command === 'agent' || command === 'mcp' || command === 'nudge-hook';
|
|
549
577
|
|
|
550
578
|
if (!headless) printBanner();
|
|
551
579
|
|
|
@@ -572,6 +600,9 @@ async function main() {
|
|
|
572
600
|
'verify',
|
|
573
601
|
// mcp serves read-only tools over stdio — scaffolding writes are off-limits.
|
|
574
602
|
'mcp',
|
|
603
|
+
// nudge-hook runs inside an agent's PostToolUse hook — it may write only
|
|
604
|
+
// its own .docguard/nudge-state.json throttle file, never scaffold skills.
|
|
605
|
+
'nudge-hook',
|
|
575
606
|
]);
|
|
576
607
|
|
|
577
608
|
// Silent auto-check: install skills/commands if missing. Skip entirely in
|
|
@@ -624,7 +655,9 @@ async function main() {
|
|
|
624
655
|
process.exit(1);
|
|
625
656
|
}
|
|
626
657
|
|
|
627
|
-
|
|
658
|
+
// `hooks --claude` is a first-class new surface (agent nudge hook), not the
|
|
659
|
+
// deprecated git-hooks alias — no deprecation warning for it.
|
|
660
|
+
if (DEPRECATED_COMMANDS[command] && !flags.quiet && !(command === 'hooks' && flags.claude)) {
|
|
628
661
|
const { since, replacement } = DEPRECATED_COMMANDS[command];
|
|
629
662
|
console.error(`${c.yellow}⚠ Deprecated since v${since}:${c.reset} ${c.cyan}docguard ${command}${c.reset} → use ${c.cyan}${replacement}${c.reset}`);
|
|
630
663
|
console.error(`${c.dim} The old form still works in v0.20.x but will be removed in v1.0. See MIGRATION-v0.20.md.${c.reset}`);
|
|
@@ -671,8 +704,18 @@ async function main() {
|
|
|
671
704
|
runAgent(projectDir, config, flags);
|
|
672
705
|
break;
|
|
673
706
|
case 'hooks':
|
|
707
|
+
if (flags.claude) {
|
|
708
|
+
// Agent nudge hook (.claude/settings.json) — direct path, no wizard.
|
|
709
|
+
runHooks(projectDir, config, flags);
|
|
710
|
+
break;
|
|
711
|
+
}
|
|
674
712
|
await runInit(projectDir, config, { ...flags, with: ['hooks'], skipPrompts: true });
|
|
675
713
|
break;
|
|
714
|
+
case 'nudge-hook':
|
|
715
|
+
// Runtime for the Claude Code PostToolUse hook. stdout is the machine
|
|
716
|
+
// channel (headless — see the jsonMode/banner gate above).
|
|
717
|
+
runNudgeHook(projectDir);
|
|
718
|
+
break;
|
|
676
719
|
case 'badge':
|
|
677
720
|
await runInit(projectDir, config, { ...flags, with: ['badge'], skipPrompts: true });
|
|
678
721
|
break;
|
package/cli/findings.mjs
CHANGED
|
@@ -600,6 +600,38 @@ export const CODES = {
|
|
|
600
600
|
help: 'Over 30% of sentences are conditional (if/unless/when…). Split conditionals into separate, unconditional requirements.',
|
|
601
601
|
suppress: null,
|
|
602
602
|
},
|
|
603
|
+
|
|
604
|
+
// ── v0.31.0 change-driven + IR detectors (all confidence:'low' / soft) ──
|
|
605
|
+
DSP001: {
|
|
606
|
+
validator: 'diff-suspicion',
|
|
607
|
+
title: 'Doc describes code that just changed',
|
|
608
|
+
help: 'A canonical doc (or agent-instruction file) references a code file AND shares wording with symbols removed/changed in that file since the compared revision. Deterministic diff-overlap rule (arXiv 2010.01625, F1 74.7). Low-confidence by design — re-read the doc against the current code; suppress the pairing if it is a false positive.',
|
|
609
|
+
suppress: null,
|
|
610
|
+
},
|
|
611
|
+
REF001: {
|
|
612
|
+
validator: 'reference-existence',
|
|
613
|
+
title: 'Doc references a code symbol that no longer exists',
|
|
614
|
+
help: 'A code-element reference in the doc matched source when the doc was last updated, but matches ZERO source instances at HEAD (two-revision check, arXiv 2212.01479). Excludes the two documented false-positive modes (removed-but-config-relevant flags, and symbols whose literal string was deleted while logic remains). Verify and update the reference.',
|
|
615
|
+
suppress: '<!-- docguard:ignore REF001 — still relevant, e.g. user-facing flag -->',
|
|
616
|
+
},
|
|
617
|
+
REF002: {
|
|
618
|
+
validator: 'reference-existence',
|
|
619
|
+
title: 'Code cites an ADR that has no document',
|
|
620
|
+
help: 'A code comment cites an Architecture Decision Record (e.g. ADR-012) that no ADR document defines — the citation is stale (renumbered, removed) or the ADR was never written. Numbers compare as integers, so ADR-0011 matches ADR-11. IETF RFC citations are deliberately not checked (external registry). Write the ADR, fix the number, or suppress on the citation line.',
|
|
621
|
+
suppress: '// docguard:ignore REF002 — your reason',
|
|
622
|
+
},
|
|
623
|
+
APS001: {
|
|
624
|
+
validator: 'api-doc-smells',
|
|
625
|
+
title: 'Bloated API documentation',
|
|
626
|
+
help: 'An API doc unit is excessively long / over-structured relative to the surface it documents (smell taxonomy, arXiv API-doc-smells; deterministic Bloated detector F1 0.90). Trim to the essential contract.',
|
|
627
|
+
suppress: '<!-- docguard:quality api-smell off — your reason -->',
|
|
628
|
+
},
|
|
629
|
+
APS002: {
|
|
630
|
+
validator: 'api-doc-smells',
|
|
631
|
+
title: 'Lazy API documentation',
|
|
632
|
+
help: 'An API doc unit is vague/generic or barely exceeds the signature it documents (deterministic Lazy detector F1 0.95). Document parameters, return, and errors concretely.',
|
|
633
|
+
suppress: '<!-- docguard:quality api-smell off — your reason -->',
|
|
634
|
+
},
|
|
603
635
|
};
|
|
604
636
|
|
|
605
637
|
/**
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
16
16
|
import { resolve, dirname } from 'node:path';
|
|
17
|
+
import { loadIgnorePatterns } from '../shared.mjs';
|
|
17
18
|
|
|
18
19
|
/** chars/4 — the standard rough token estimate; consistency matters more than precision. */
|
|
19
20
|
const estTokens = (s) => Math.ceil(s.length / 4);
|
|
@@ -31,9 +32,13 @@ function readIfExists(path) {
|
|
|
31
32
|
function canonicalDocs(projectDir) {
|
|
32
33
|
const dir = resolve(projectDir, 'docs-canonical');
|
|
33
34
|
if (!existsSync(dir)) return [];
|
|
35
|
+
// Honor .docguardignore — an excluded doc (e.g. a historical audit) must
|
|
36
|
+
// not drag down the readability metrics either (same rule as the
|
|
37
|
+
// semantic-claim extractor, bug-212).
|
|
38
|
+
const isIgnored = loadIgnorePatterns(projectDir);
|
|
34
39
|
try {
|
|
35
40
|
return readdirSync(dir)
|
|
36
|
-
.filter(f => f.toLowerCase().endsWith('.md'))
|
|
41
|
+
.filter(f => f.toLowerCase().endsWith('.md') && !isIgnored(`docs-canonical/${f}`))
|
|
37
42
|
.sort()
|
|
38
43
|
.map(f => ({ name: `docs-canonical/${f}`, content: readIfExists(resolve(dir, f)) }))
|
|
39
44
|
.filter(d => d.content !== null);
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
|
|
24
24
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
25
25
|
import { resolve, join } from 'node:path';
|
|
26
|
+
import { loadIgnorePatterns } from '../shared.mjs';
|
|
26
27
|
|
|
27
28
|
// Numbers are only claims when adjacent to a recognized unit.
|
|
28
29
|
const NUMBER_PATTERNS = [
|
|
@@ -49,17 +50,24 @@ const MAX_CLAIMS = 80;
|
|
|
49
50
|
|
|
50
51
|
/** Canonical docs + the root docs where limits/counts commonly live. */
|
|
51
52
|
function claimSourceDocs(projectDir) {
|
|
53
|
+
// Honor .docguardignore: a doc the user explicitly excluded from validation
|
|
54
|
+
// (e.g. a historical audit full of point-in-time counts) must not feed the
|
|
55
|
+
// "unverified claims" pool either — it inflated the count and buried the
|
|
56
|
+
// claims that ARE actionable (bug-212).
|
|
57
|
+
const isIgnored = loadIgnorePatterns(projectDir);
|
|
52
58
|
const docs = [];
|
|
53
59
|
const canonical = resolve(projectDir, 'docs-canonical');
|
|
54
60
|
if (existsSync(canonical)) {
|
|
55
61
|
try {
|
|
56
62
|
for (const f of readdirSync(canonical)) {
|
|
57
|
-
if (f.toLowerCase().endsWith('.md')
|
|
63
|
+
if (f.toLowerCase().endsWith('.md') && !isIgnored(`docs-canonical/${f}`)) {
|
|
64
|
+
docs.push(`docs-canonical/${f}`);
|
|
65
|
+
}
|
|
58
66
|
}
|
|
59
67
|
} catch { /* ignore */ }
|
|
60
68
|
}
|
|
61
69
|
for (const root of ['README.md', 'AGENTS.md']) {
|
|
62
|
-
if (existsSync(resolve(projectDir, root))) docs.push(root);
|
|
70
|
+
if (existsSync(resolve(projectDir, root)) && !isIgnored(root)) docs.push(root);
|
|
63
71
|
}
|
|
64
72
|
return docs;
|
|
65
73
|
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Unified-Diff Parser + Tokenizer — zero-dependency foundation for the
|
|
3
|
+
* change-driven detectors added in v0.31.0.
|
|
4
|
+
*
|
|
5
|
+
* ONE parser, consumed by four features so they never re-implement diff
|
|
6
|
+
* scraping (each previously would have grepped `git diff` output ad hoc):
|
|
7
|
+
* - diff-overlap suspicion (validators/diff-suspicion.mjs): does a doc's
|
|
8
|
+
* wording overlap tokens that were DELETED/REPLACED-OLD in the code diff?
|
|
9
|
+
* - reference-existence (validators/reference-existence.mjs): which symbols
|
|
10
|
+
* left the tree between two revisions.
|
|
11
|
+
* - impact blast-radius (commands/impact.mjs): which docs cite changed code.
|
|
12
|
+
* - structured-diff staging (commands/verify.mjs): hand agents an ordered
|
|
13
|
+
* replace/delete/add representation (the CARL-CCI "activity-labeled diff"
|
|
14
|
+
* shown to beat raw-text diffs — arXiv 2512.19883) instead of a raw patch.
|
|
15
|
+
*
|
|
16
|
+
* Pure Node built-ins. No git here — this operates on diff TEXT a caller
|
|
17
|
+
* already produced (see shared-git.getDiffSpans). That keeps it unit-testable
|
|
18
|
+
* without a repo and reusable on any unified-diff string.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
// ── Tokenizer ────────────────────────────────────────────────────────────────
|
|
22
|
+
// Identifier-aware: keeps `getUserById`, `user_id`, `UserService` whole, then
|
|
23
|
+
// also emits their sub-words so a doc saying "user id" still overlaps code
|
|
24
|
+
// token `user_id`. Deterministic, lowercase, stopword-filtered.
|
|
25
|
+
|
|
26
|
+
const STOPWORDS = new Set([
|
|
27
|
+
'the', 'a', 'an', 'and', 'or', 'but', 'if', 'then', 'else', 'for', 'of', 'to',
|
|
28
|
+
'in', 'on', 'at', 'by', 'as', 'is', 'are', 'was', 'were', 'be', 'been', 'this',
|
|
29
|
+
'that', 'these', 'those', 'it', 'its', 'with', 'from', 'not', 'no', 'we', 'you',
|
|
30
|
+
'return', 'returns', 'const', 'let', 'var', 'function', 'class', 'import',
|
|
31
|
+
'export', 'default', 'new', 'true', 'false', 'null', 'void', 'public', 'private',
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Split identifiers into sub-words: getUserById → [get,user,by,id];
|
|
36
|
+
* user_id → [user,id]; HTTPServer → [http,server].
|
|
37
|
+
*/
|
|
38
|
+
export function splitIdentifier(id) {
|
|
39
|
+
return String(id)
|
|
40
|
+
// camelCase / PascalCase / ACRONYMBoundary
|
|
41
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
42
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
|
|
43
|
+
// snake_case, kebab-case, dot.paths
|
|
44
|
+
.replace(/[_\-.]+/g, ' ')
|
|
45
|
+
.toLowerCase()
|
|
46
|
+
.split(/\s+/)
|
|
47
|
+
.filter(Boolean);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Tokenize free text OR code into a lowercase word set. Keeps whole identifiers
|
|
52
|
+
* AND their sub-words. `min` drops tokens shorter than it (default 3) to cut
|
|
53
|
+
* noise; identifiers below `min` after splitting are still dropped.
|
|
54
|
+
*
|
|
55
|
+
* Returns an array (call-site decides Set vs list). Deduped, order-preserving.
|
|
56
|
+
*/
|
|
57
|
+
export function tokenize(text, { min = 3, keepStopwords = false } = {}) {
|
|
58
|
+
const out = [];
|
|
59
|
+
const seen = new Set();
|
|
60
|
+
const raw = String(text).match(/[A-Za-z_][A-Za-z0-9_.-]*/g) || [];
|
|
61
|
+
for (const word of raw) {
|
|
62
|
+
// the whole identifier (lowercased) …
|
|
63
|
+
const whole = word.toLowerCase();
|
|
64
|
+
for (const t of [whole, ...splitIdentifier(word)]) {
|
|
65
|
+
if (t.length < min) continue;
|
|
66
|
+
if (!keepStopwords && STOPWORDS.has(t)) continue;
|
|
67
|
+
if (seen.has(t)) continue;
|
|
68
|
+
seen.add(t);
|
|
69
|
+
out.push(t);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Unified-diff parser ──────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Parse a unified diff (git diff / diff -u) into structured file entries.
|
|
79
|
+
* Handles multi-file diffs, adds/deletes (/dev/null), renames, and the
|
|
80
|
+
* "" marker.
|
|
81
|
+
*
|
|
82
|
+
* Returns: [{ oldPath, newPath, status, hunks: [{ oldStart, newStart,
|
|
83
|
+
* lines: [{ op: ' '|'-'|'+', text }] }] }]
|
|
84
|
+
* status ∈ 'modified' | 'added' | 'deleted' | 'renamed'.
|
|
85
|
+
*/
|
|
86
|
+
export function parseUnifiedDiff(diffText) {
|
|
87
|
+
const files = [];
|
|
88
|
+
if (!diffText) return files;
|
|
89
|
+
const lines = String(diffText).split('\n');
|
|
90
|
+
let cur = null;
|
|
91
|
+
let hunk = null;
|
|
92
|
+
|
|
93
|
+
const pushHunkHeader = (m) => {
|
|
94
|
+
hunk = { oldStart: parseInt(m[1], 10) || 0, newStart: parseInt(m[2], 10) || 0, lines: [] };
|
|
95
|
+
cur.hunks.push(hunk);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
for (const line of lines) {
|
|
99
|
+
if (line.startsWith('diff --git')) {
|
|
100
|
+
// a/<old> b/<new> — quotes possible but rare; keep it simple.
|
|
101
|
+
const m = line.match(/^diff --git a\/(.+?) b\/(.+)$/);
|
|
102
|
+
cur = { oldPath: m ? m[1] : null, newPath: m ? m[2] : null, status: 'modified', hunks: [] };
|
|
103
|
+
files.push(cur);
|
|
104
|
+
hunk = null;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (!cur) continue; // ignore any preamble before the first file
|
|
108
|
+
if (line.startsWith('rename from ')) { cur.status = 'renamed'; cur.oldPath = line.slice(12); continue; }
|
|
109
|
+
if (line.startsWith('rename to ')) { cur.status = 'renamed'; cur.newPath = line.slice(10); continue; }
|
|
110
|
+
if (line.startsWith('--- ')) {
|
|
111
|
+
const p = line.slice(4);
|
|
112
|
+
if (p === '/dev/null') cur.status = 'added';
|
|
113
|
+
else cur.oldPath = p.replace(/^a\//, '');
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (line.startsWith('+++ ')) {
|
|
117
|
+
const p = line.slice(4);
|
|
118
|
+
if (p === '/dev/null') cur.status = 'deleted';
|
|
119
|
+
else cur.newPath = p.replace(/^b\//, '');
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const hm = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
|
123
|
+
if (hm) { pushHunkHeader(hm); continue; }
|
|
124
|
+
if (!hunk) continue;
|
|
125
|
+
if (line.startsWith('\\')) continue; // ""
|
|
126
|
+
const op = line[0];
|
|
127
|
+
if (op === ' ' || op === '+' || op === '-') {
|
|
128
|
+
hunk.lines.push({ op, text: line.slice(1) });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return files;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Decompose a file's hunks into an ORDERED activity list — the CARL-CCI
|
|
136
|
+
* "activity-labeled" representation. Consecutive removed/added runs are grouped:
|
|
137
|
+
* run of '-' then '+' → { type: 'replace', del: [...], add: [...] }
|
|
138
|
+
* run of '-' only → { type: 'delete', del: [...] }
|
|
139
|
+
* run of '+' only → { type: 'add', add: [...] }
|
|
140
|
+
* Context lines are the separators and are not emitted (they're not a change).
|
|
141
|
+
*
|
|
142
|
+
* This is what makes staged agent tasks legible: the agent sees "these lines
|
|
143
|
+
* were replaced by those", not a flat blob.
|
|
144
|
+
*/
|
|
145
|
+
export function activityLabeledDiff(fileDiff) {
|
|
146
|
+
const acts = [];
|
|
147
|
+
for (const h of fileDiff.hunks || []) {
|
|
148
|
+
let del = [];
|
|
149
|
+
let add = [];
|
|
150
|
+
const flush = () => {
|
|
151
|
+
if (del.length && add.length) acts.push({ type: 'replace', del, add });
|
|
152
|
+
else if (del.length) acts.push({ type: 'delete', del });
|
|
153
|
+
else if (add.length) acts.push({ type: 'add', add });
|
|
154
|
+
del = []; add = [];
|
|
155
|
+
};
|
|
156
|
+
for (const ln of h.lines) {
|
|
157
|
+
if (ln.op === '-') {
|
|
158
|
+
if (add.length) flush(); // an add run ended before this delete → boundary
|
|
159
|
+
del.push(ln.text);
|
|
160
|
+
} else if (ln.op === '+') {
|
|
161
|
+
add.push(ln.text);
|
|
162
|
+
} else {
|
|
163
|
+
flush(); // context terminates the current activity
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
flush();
|
|
167
|
+
}
|
|
168
|
+
return acts;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Tokens that LEFT the code in this file diff — union over every '-' line
|
|
173
|
+
* (both pure deletes and the "old" side of replaces). This is the
|
|
174
|
+
* `deleted ∪ replaceOld` span the outdated-comment research keys on: a doc
|
|
175
|
+
* that still talks about these tokens is a drift suspect.
|
|
176
|
+
*/
|
|
177
|
+
export function removedTokens(fileDiff, opts) {
|
|
178
|
+
const set = new Set();
|
|
179
|
+
for (const h of fileDiff.hunks || []) {
|
|
180
|
+
for (const ln of h.lines) {
|
|
181
|
+
if (ln.op === '-') for (const t of tokenize(ln.text, opts)) set.add(t);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return set;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Tokens that were ADDED ('+' lines) — the new-side span. */
|
|
188
|
+
export function addedTokens(fileDiff, opts) {
|
|
189
|
+
const set = new Set();
|
|
190
|
+
for (const h of fileDiff.hunks || []) {
|
|
191
|
+
for (const ln of h.lines) {
|
|
192
|
+
if (ln.op === '+') for (const t of tokenize(ln.text, opts)) set.add(t);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return set;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Overlap score between a doc's tokens and a set of change tokens: the count
|
|
200
|
+
* and the shared tokens themselves (for explainable findings). Deterministic.
|
|
201
|
+
*/
|
|
202
|
+
export function tokenOverlap(docTokens, changeTokenSet) {
|
|
203
|
+
const shared = [];
|
|
204
|
+
const seen = new Set();
|
|
205
|
+
for (const t of docTokens) {
|
|
206
|
+
if (changeTokenSet.has(t) && !seen.has(t)) { seen.add(t); shared.push(t); }
|
|
207
|
+
}
|
|
208
|
+
return { count: shared.length, shared };
|
|
209
|
+
}
|
package/cli/shared-git.mjs
CHANGED
|
@@ -148,6 +148,99 @@ export function changedFilesSince(dir, ref = 'HEAD~1') {
|
|
|
148
148
|
}
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Return the raw unified-diff TEXT between `ref` and HEAD, restricted to code
|
|
153
|
+
* files (docs are excluded — a doc changing is not a code change that could
|
|
154
|
+
* make OTHER docs stale). Consumed by shared-diff.parseUnifiedDiff.
|
|
155
|
+
*
|
|
156
|
+
* `-U0`? No — we want a few lines of context so the parser can group activities
|
|
157
|
+
* and callers can see surrounding tokens; default 3 is fine. Returns '' on
|
|
158
|
+
* error / no diff. Caps output at ~5MB so a giant refactor can't OOM the CLI.
|
|
159
|
+
*/
|
|
160
|
+
export function getDiffText(dir, ref = 'HEAD~1', pathspec = null) {
|
|
161
|
+
try {
|
|
162
|
+
const args = ['diff', '--no-color', '--no-ext-diff', ref, 'HEAD'];
|
|
163
|
+
if (pathspec && pathspec.length) args.push('--', ...pathspec);
|
|
164
|
+
const raw = execFileSync('git', args, {
|
|
165
|
+
cwd: dir, encoding: 'utf-8',
|
|
166
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
167
|
+
maxBuffer: 1024 * 1024 * 5,
|
|
168
|
+
});
|
|
169
|
+
return raw || '';
|
|
170
|
+
} catch {
|
|
171
|
+
return '';
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Read a file's contents AS OF a given revision (e.g. the commit where a doc
|
|
177
|
+
* was last touched), following the `<rev>:<path>` git addressing. Returns null
|
|
178
|
+
* when the path didn't exist at that rev, the rev is unknown, or git is
|
|
179
|
+
* unavailable — callers treat null as "no prior snapshot to compare".
|
|
180
|
+
*
|
|
181
|
+
* This is the backbone of the two-revision reference-existence check: read the
|
|
182
|
+
* source at the doc's last-updated commit vs HEAD and diff symbol presence.
|
|
183
|
+
*/
|
|
184
|
+
export function fileContentAtRev(dir, rev, filePath) {
|
|
185
|
+
try {
|
|
186
|
+
const raw = execFileSync(
|
|
187
|
+
'git',
|
|
188
|
+
['show', `${rev}:${filePath}`],
|
|
189
|
+
{ cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'], maxBuffer: 1024 * 1024 * 10 }
|
|
190
|
+
);
|
|
191
|
+
return raw;
|
|
192
|
+
} catch {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Default source globs for symbol-existence grep (any-language).
|
|
198
|
+
export const CODE_GLOBS = [
|
|
199
|
+
'*.ts', '*.tsx', '*.js', '*.jsx', '*.mjs', '*.cjs', '*.py', '*.go', '*.rs',
|
|
200
|
+
'*.java', '*.kt', '*.rb', '*.php', '*.cs', '*.swift', '*.scala', '*.dart', '*.c', '*.cpp', '*.h',
|
|
201
|
+
];
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* True if `symbol` appears as a whole word in the source tree AS OF `rev`.
|
|
205
|
+
* Uses `git grep -w -F` (fixed string, word boundary) at the given revision —
|
|
206
|
+
* exactly the "whole-word, case-sensitive, exact string match" the two-revision
|
|
207
|
+
* outdated-reference method specifies (arXiv 2212.01479). Restricted to code
|
|
208
|
+
* globs so a symbol still named in prose/docs doesn't count as "present".
|
|
209
|
+
*
|
|
210
|
+
* Returns false when absent, the rev is unknown, or git is unavailable — the
|
|
211
|
+
* caller pairs two calls (doc's last-update rev vs HEAD) to detect present→gone.
|
|
212
|
+
*/
|
|
213
|
+
export function symbolExistsAtRev(dir, symbol, rev, pathspecs = CODE_GLOBS) {
|
|
214
|
+
try {
|
|
215
|
+
execFileSync(
|
|
216
|
+
'git',
|
|
217
|
+
['grep', '-q', '-w', '-F', '-e', symbol, rev, '--', ...pathspecs],
|
|
218
|
+
{ cwd: dir, stdio: ['pipe', 'ignore', 'ignore'] }
|
|
219
|
+
);
|
|
220
|
+
return true; // exit 0 → at least one match
|
|
221
|
+
} catch {
|
|
222
|
+
return false; // exit 1 → no match (or bad rev / no git)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Resolve the commit hash that last touched `filePath` (following renames), or
|
|
228
|
+
* null. Used to anchor "the revision when this doc was last updated" for the
|
|
229
|
+
* two-revision check without re-parsing getFileHistory at every call site.
|
|
230
|
+
*/
|
|
231
|
+
export function lastCommitHash(dir, filePath) {
|
|
232
|
+
try {
|
|
233
|
+
const raw = execFileSync(
|
|
234
|
+
'git',
|
|
235
|
+
['log', '--follow', '-1', '--format=%H', '--', filePath],
|
|
236
|
+
{ cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }
|
|
237
|
+
).trim();
|
|
238
|
+
return raw || null;
|
|
239
|
+
} catch {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
151
244
|
/**
|
|
152
245
|
* Resolve the absolute path to this repo's git hooks directory.
|
|
153
246
|
*
|