docguard-cli 0.29.0 → 0.30.1
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 +18 -1
- package/cli/commands/mcp.mjs +20 -0
- package/cli/commands/score.mjs +9 -4
- package/cli/commands/setup.mjs +0 -13
- package/cli/commands/trace.mjs +364 -1
- package/cli/commands/verify.mjs +93 -6
- package/cli/docguard.mjs +11 -3
- package/cli/findings.mjs +12 -0
- package/cli/scanners/instruction-audit.mjs +320 -0
- package/cli/scanners/speckit.mjs +346 -1
- package/cli/shared-git.mjs +8 -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 +2 -1
- package/schemas/docguard-config.schema.json +11 -0
- package/templates/ci/gitlab-component.yml +90 -0
package/cli/commands/verify.mjs
CHANGED
|
@@ -1,22 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Verify Command — `docguard verify
|
|
2
|
+
* Verify Command — `docguard verify` (LLM field reports #5, #11).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Two modes, same division of labour (DocGuard does the deterministic
|
|
5
|
+
* discovery; the LLM does the judgment — like `docguard agent`):
|
|
6
|
+
*
|
|
7
|
+
* --semantic (default) Surface the semantic claims in the canonical docs
|
|
8
|
+
* (documented numbers, limits, enums) as a verification
|
|
9
|
+
* task list for the agent to check against the code.
|
|
10
|
+
*
|
|
11
|
+
* --instructions Audit the agent instruction files themselves
|
|
12
|
+
* (AGENTS.md, CLAUDE.md) for drift: duplicate rules,
|
|
13
|
+
* direct never/always contradictions, stale file
|
|
14
|
+
* pointers, and unknown docguard commands are found
|
|
15
|
+
* deterministically; topically-clustered rule pairs
|
|
16
|
+
* become agent tasks ("do these contradict in
|
|
17
|
+
* practice?"). Inspired by spec-kit's MemoryLint.
|
|
8
18
|
*
|
|
9
19
|
* Read-only. JSON is the machine artifact (the agent-executable task list);
|
|
10
20
|
* text is the human summary.
|
|
11
21
|
*
|
|
12
|
-
* docguard verify [--semantic] [--format json]
|
|
22
|
+
* docguard verify [--semantic | --instructions] [--format json]
|
|
13
23
|
*/
|
|
14
24
|
|
|
15
25
|
import { c } from '../shared.mjs';
|
|
16
26
|
import { detectAgentMode } from '../ensure-skills.mjs';
|
|
17
27
|
import { extractSemanticClaims, buildSemanticVerifyTasks } from '../scanners/semantic-claims.mjs';
|
|
28
|
+
import { auditInstructions } from '../scanners/instruction-audit.mjs';
|
|
18
29
|
|
|
19
30
|
export function runVerify(projectDir, config, flags) {
|
|
31
|
+
if (flags.instructions) {
|
|
32
|
+
runInstructionAudit(projectDir, config, flags);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
20
36
|
const isJson = flags.format === 'json';
|
|
21
37
|
const claims = extractSemanticClaims(projectDir, config);
|
|
22
38
|
const tasks = buildSemanticVerifyTasks(claims);
|
|
@@ -65,3 +81,74 @@ export function runVerify(projectDir, config, flags) {
|
|
|
65
81
|
console.log(` ${c.dim}This is the highest-value bug class and DocGuard can't judge it — an agent must.${c.reset}`);
|
|
66
82
|
console.log(` ${c.dim}Get the machine task list: ${c.cyan}${cmd}${c.dim}, then read each cited file and confirm the value.${c.reset}\n`);
|
|
67
83
|
}
|
|
84
|
+
|
|
85
|
+
// ── verify --instructions: agent-instruction drift/conflict audit ───────────
|
|
86
|
+
|
|
87
|
+
function runInstructionAudit(projectDir, config, flags) {
|
|
88
|
+
const isJson = flags.format === 'json';
|
|
89
|
+
const { rules, deterministic, tasks } = auditInstructions(projectDir, config);
|
|
90
|
+
const { duplicates, negations, stalePointers, staleCommands } = deterministic;
|
|
91
|
+
const findingCount = duplicates.length + negations.length + stalePointers.length + staleCommands.length;
|
|
92
|
+
|
|
93
|
+
if (isJson) {
|
|
94
|
+
console.log(JSON.stringify({
|
|
95
|
+
command: 'verify --instructions',
|
|
96
|
+
project: config.projectName,
|
|
97
|
+
ruleCount: rules.length,
|
|
98
|
+
findingCount,
|
|
99
|
+
findings: deterministic,
|
|
100
|
+
taskCount: tasks.length,
|
|
101
|
+
// How to act on this: findings are proven; tasks need judgment.
|
|
102
|
+
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.',
|
|
103
|
+
tasks,
|
|
104
|
+
}, null, 2));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
console.log(`${c.bold}🔬 DocGuard Verify — instruction audit${c.reset}`);
|
|
109
|
+
console.log(`${c.dim} ${config.projectName} · duplicate / contradictory / stale rules in AGENTS.md + CLAUDE.md${c.reset}\n`);
|
|
110
|
+
|
|
111
|
+
if (rules.length === 0) {
|
|
112
|
+
console.log(` ${c.green}✅ No instruction rules found (no AGENTS.md/CLAUDE.md, or nothing imperative in them).${c.reset}\n`);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
console.log(` ${c.dim}${rules.length} rule(s) extracted from ${[...new Set(rules.map(r => r.file))].join(' + ')}${c.reset}\n`);
|
|
117
|
+
|
|
118
|
+
if (findingCount === 0) {
|
|
119
|
+
console.log(` ${c.green}✅ No duplicate, directly-contradictory, or stale rules found.${c.reset}\n`);
|
|
120
|
+
} else {
|
|
121
|
+
console.log(` ${c.yellow}${findingCount} deterministic finding(s):${c.reset}\n`);
|
|
122
|
+
for (const d of duplicates) {
|
|
123
|
+
const where = d.rules.map(r => `${r.file}:${r.line}`).join(` ${c.dim}≡${c.reset} `);
|
|
124
|
+
console.log(` ${c.yellow}⚠${c.reset} duplicate rule — ${where}: ${c.dim}"${d.rules[0].text}"${c.reset}`);
|
|
125
|
+
}
|
|
126
|
+
for (const n of negations) {
|
|
127
|
+
console.log(` ${c.yellow}⚠${c.reset} negation conflict — ${n.a.file}:${n.a.line} ${c.dim}⇄${c.reset} ${n.b.file}:${n.b.line}: ${c.dim}"${n.a.text}" vs "${n.b.text}"${c.reset}`);
|
|
128
|
+
}
|
|
129
|
+
for (const s of stalePointers) {
|
|
130
|
+
console.log(` ${c.yellow}⚠${c.reset} stale pointer — ${s.file}:${s.line}: ${c.cyan}${s.path}${c.reset} does not exist`);
|
|
131
|
+
}
|
|
132
|
+
for (const s of staleCommands) {
|
|
133
|
+
console.log(` ${c.yellow}⚠${c.reset} stale command — ${s.file}:${s.line}: ${c.cyan}docguard ${s.command}${c.reset} is not a docguard command`);
|
|
134
|
+
}
|
|
135
|
+
console.log('');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (tasks.length > 0) {
|
|
139
|
+
console.log(` ${c.yellow}${tasks.length} rule pair(s) for the agent to judge:${c.reset}\n`);
|
|
140
|
+
for (const t of tasks) {
|
|
141
|
+
console.log(` ${c.bold}${t.a.file}:${t.a.line} ↔ ${t.b.file}:${t.b.line}${c.reset} ${c.dim}(shared: ${t.sharedTerms.join(', ')})${c.reset}`);
|
|
142
|
+
console.log(` ${c.yellow}A${c.reset} ${c.dim}${t.a.section ? `[${t.a.section}] ` : ''}${c.reset}"${t.a.text}"`);
|
|
143
|
+
console.log(` ${c.yellow}B${c.reset} ${c.dim}${t.b.section ? `[${t.b.section}] ` : ''}${c.reset}"${t.b.text}"`);
|
|
144
|
+
console.log('');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const mode = detectAgentMode(projectDir);
|
|
148
|
+
const cmd = mode === 'llm' ? '/docguard.verify' : 'docguard verify --instructions --format json';
|
|
149
|
+
console.log(` ${c.dim}Whether clustered rules contradict in practice is judgment DocGuard can't make — an agent must.${c.reset}`);
|
|
150
|
+
console.log(` ${c.dim}Get the machine task list: ${c.cyan}${cmd}${c.dim}, then judge each pair and report which rule should win.${c.reset}\n`);
|
|
151
|
+
} else if (findingCount === 0) {
|
|
152
|
+
console.log(` ${c.dim}(Looks for duplicate/negated rules, dead file pointers, unknown docguard commands, and topically-clustered rule pairs.)${c.reset}\n`);
|
|
153
|
+
}
|
|
154
|
+
}
|
package/cli/docguard.mjs
CHANGED
|
@@ -93,7 +93,7 @@ ${c.bold}Tools (situational, but day-to-day useful)${c.reset}
|
|
|
93
93
|
${c.green}feedback${c.reset} Report likely false positives back to DocGuard (local-first + 1-click prefilled issue)
|
|
94
94
|
${c.green}mcp${c.reset} MCP server over stdio — guard/score/explain/verify/diagnose as agent tools
|
|
95
95
|
${c.green}memory${c.reset} Show what DocGuard remembers (${c.cyan}--diff${c.reset} drills into drift)
|
|
96
|
-
${c.green}trace${c.reset} Requirements traceability matrix (${c.cyan}--reverse${c.reset} for code→doc map)
|
|
96
|
+
${c.green}trace${c.reset} Requirements traceability matrix (${c.cyan}--reverse${c.reset} for code→doc map, ${c.cyan}--features${c.reset} for per-feature adherence)
|
|
97
97
|
${c.green}upgrade${c.reset} Migrate ${c.cyan}.docguard.json${c.reset} schema + CLI (${c.cyan}--apply --pr${c.reset} for team-wide PR)
|
|
98
98
|
${c.green}watch${c.reset} Live mode: re-run guard on file changes
|
|
99
99
|
|
|
@@ -258,7 +258,7 @@ const COMMAND_HELP = {
|
|
|
258
258
|
},
|
|
259
259
|
trace: {
|
|
260
260
|
summary: 'Requirements traceability matrix.',
|
|
261
|
-
usage: 'docguard trace [--reverse]',
|
|
261
|
+
usage: 'docguard trace [--reverse] [--features]',
|
|
262
262
|
flags: [['--reverse', 'Code→doc map instead of doc→code']],
|
|
263
263
|
examples: ['docguard trace', 'docguard trace --reverse'],
|
|
264
264
|
},
|
|
@@ -294,9 +294,10 @@ const COMMAND_HELP = {
|
|
|
294
294
|
},
|
|
295
295
|
verify: {
|
|
296
296
|
summary: 'Extract the semantic claims in your canonical docs — documented numbers, limits, and enums (retention days, rate limits, GSI/role counts, status enums) — as a verification task list the agent checks against the code. This is the highest-value bug class (a doc value that drifted from code) and the one regex/AST cannot judge. DocGuard finds the claims; the LLM confirms them.',
|
|
297
|
-
usage: 'docguard verify [--semantic] [--format json]',
|
|
297
|
+
usage: 'docguard verify [--semantic|--instructions] [--format json]',
|
|
298
298
|
flags: [
|
|
299
299
|
['--semantic', 'Extract documented numbers/limits/enums to verify against code (the current — and default — mode)'],
|
|
300
|
+
['--instructions', 'Audit AGENTS.md/CLAUDE.md for duplicate, contradictory, and stale-pointer rules (deterministic findings + agent conflict tasks)'],
|
|
300
301
|
['--format json', 'Machine-readable task list (the agent-executable artifact)'],
|
|
301
302
|
],
|
|
302
303
|
examples: ['docguard verify --semantic', 'docguard verify --semantic --format json'],
|
|
@@ -377,6 +378,10 @@ async function main() {
|
|
|
377
378
|
// v0.28 (field report #5): `docguard verify --semantic` extracts
|
|
378
379
|
// documented numbers/enums/limits for the agent to check against code.
|
|
379
380
|
flags.semantic = true;
|
|
381
|
+
} else if (args[i] === '--instructions') {
|
|
382
|
+
// v0.30: `docguard verify --instructions` audits AGENTS.md/CLAUDE.md for
|
|
383
|
+
// duplicate/contradictory/stale rules (MemoryLint-inspired).
|
|
384
|
+
flags.instructions = true;
|
|
380
385
|
} else if (args[i] === '--full') {
|
|
381
386
|
// v0.29: `docguard llms --full` emits llms-full.txt (inline doc bodies,
|
|
382
387
|
// the Mintlify-popularized companion to the llms.txt index).
|
|
@@ -409,6 +414,9 @@ async function main() {
|
|
|
409
414
|
flags.changedOnly = true;
|
|
410
415
|
} else if (args[i] === '--reverse') {
|
|
411
416
|
flags.reverse = true;
|
|
417
|
+
} else if (args[i] === '--features') {
|
|
418
|
+
// v0.30: `docguard trace --features` — per-feature spec-adherence report.
|
|
419
|
+
flags.features = true;
|
|
412
420
|
} else if (args[i] === '--history') {
|
|
413
421
|
flags.history = true;
|
|
414
422
|
} else if (args[i] === '--force-redo') {
|
package/cli/findings.mjs
CHANGED
|
@@ -474,6 +474,18 @@ export const CODES = {
|
|
|
474
474
|
help: 'constitution.md exists but there is no AGENTS.md. AI agents look to AGENTS.md for project rules — create one (e.g. via `docguard init`) and reference the constitution from it.',
|
|
475
475
|
suppress: null,
|
|
476
476
|
},
|
|
477
|
+
SPK008: {
|
|
478
|
+
validator: 'specKit',
|
|
479
|
+
title: 'Phantom completion — checked task with no implementation evidence',
|
|
480
|
+
help: 'A tasks.md task marked [x] names a deliverable path that does not exist, and no evidence tier confirms the work landed: no matching basename anywhere in the repo (moved file), no named code symbol in source, no plan.md/spec.md tie to an existing artifact, no task-ID annotation in source, and no task-ID in the git log. A checked task with no artifact corrupts agent memory — later sessions trust the checkbox and skip the work. Uncheck the task or land the implementation. Flagged low-confidence — report a false positive if the deliverable was renamed beyond recognition. Opt out with `"specKit": { "phantomCheck": false }` in .docguard.json.',
|
|
481
|
+
suppress: null,
|
|
482
|
+
},
|
|
483
|
+
SPK009: {
|
|
484
|
+
validator: 'specKit',
|
|
485
|
+
title: 'Additional phantom completions elided',
|
|
486
|
+
help: 'Guard reports at most 10 phantom-completion findings (SPK008) per run to avoid noise; this line counts the remainder. Fix or uncheck the reported tasks and re-run guard to surface more, or set `"specKit": { "phantomCheck": false }` in .docguard.json to disable the check.',
|
|
487
|
+
suppress: null,
|
|
488
|
+
},
|
|
477
489
|
XRF001: {
|
|
478
490
|
validator: 'crossReference',
|
|
479
491
|
title: 'Broken doc link',
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Instruction Audit scanner — drift/conflict audit WITHIN agent instruction
|
|
3
|
+
* files (AGENTS.md, CLAUDE.md). Inspired by spec-kit's MemoryLint.
|
|
4
|
+
*
|
|
5
|
+
* Agent instruction files rot in a specific way: rules get duplicated across
|
|
6
|
+
* AGENTS.md and CLAUDE.md, then one copy is edited and the other isn't — an
|
|
7
|
+
* agent reading both now holds two contradictory orders and silently picks
|
|
8
|
+
* one. Rules also point at files that were renamed away, and at docguard
|
|
9
|
+
* subcommands that no longer exist. None of the doc↔code validators see this
|
|
10
|
+
* class: the drift is doc↔doc, inside the instruction layer itself.
|
|
11
|
+
*
|
|
12
|
+
* DocGuard's split applies (sibling of semantic-claims.mjs):
|
|
13
|
+
* - DETERMINISTIC: extract the rules, then flag what string logic can prove
|
|
14
|
+
* — exact-normalized duplicates, direct never/always negation pairs,
|
|
15
|
+
* pointers to nonexistent files, references to unknown docguard commands.
|
|
16
|
+
* - LLM JUDGMENT: rule pairs in the same topical cluster (≥2 shared
|
|
17
|
+
* significant stems) become tasks for the agent running
|
|
18
|
+
* `docguard verify --instructions` — "do these contradict in practice?".
|
|
19
|
+
* Cross-file pairs are prioritized: AGENTS-vs-CLAUDE divergence is the
|
|
20
|
+
* classic drift.
|
|
21
|
+
*
|
|
22
|
+
* Precision over recall: a line is only a rule when it carries an
|
|
23
|
+
* imperative/modal signal (must/never/always/…); a pointer is only checked
|
|
24
|
+
* when it lexes as a relative file path; a docguard command is only checked
|
|
25
|
+
* inside backticks (prose like "docguard should never…" is not an
|
|
26
|
+
* invocation). Files generated by `docguard agents --sync` (they carry the
|
|
27
|
+
* docguard:agents-sync marker) are skipped — auditing a generated mirror
|
|
28
|
+
* against its source would flag every rule as a duplicate.
|
|
29
|
+
*
|
|
30
|
+
* Zero npm dependencies — pure Node.js built-ins.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
34
|
+
import { resolve, join, dirname } from 'node:path';
|
|
35
|
+
import { fileURLToPath } from 'node:url';
|
|
36
|
+
|
|
37
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
38
|
+
|
|
39
|
+
const INSTRUCTION_FILES = ['AGENTS.md', 'CLAUDE.md'];
|
|
40
|
+
const GENERATED_MARKER = 'docguard:agents-sync';
|
|
41
|
+
|
|
42
|
+
/** A line/sentence is only a rule when it carries an imperative/modal signal. */
|
|
43
|
+
const SIGNAL_RE = /\b(must|never|always|do not|don't|should|require[sd]?|forbid|only)\b/i;
|
|
44
|
+
|
|
45
|
+
const MAX_RULES = 400;
|
|
46
|
+
const MAX_TASKS = 40;
|
|
47
|
+
|
|
48
|
+
// ── Normalization ───────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
/** lowercase, drop apostrophes (don't → dont), all other punctuation → space. */
|
|
51
|
+
function normalizeRule(text) {
|
|
52
|
+
return text
|
|
53
|
+
.toLowerCase()
|
|
54
|
+
.replace(/['’]/g, '')
|
|
55
|
+
.replace(/[^a-z0-9\s]/g, ' ')
|
|
56
|
+
.replace(/\s+/g, ' ')
|
|
57
|
+
.trim();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Negation tokens (counted for parity) vs the full polarity set (stripped for
|
|
61
|
+
// comparison). "do not" must precede "not"/"do" in the alternation so it
|
|
62
|
+
// matches as one token.
|
|
63
|
+
const NEG_RE = /\b(never|dont|do not|not)\b/g;
|
|
64
|
+
const POLARITY_RE = /\b(never|dont|do not|not|always|must|do|should|shall)\b/g;
|
|
65
|
+
|
|
66
|
+
const negationCount = (norm) => (norm.match(NEG_RE) || []).length;
|
|
67
|
+
const stripPolarity = (norm) => norm.replace(POLARITY_RE, ' ').replace(/\s+/g, ' ').trim();
|
|
68
|
+
|
|
69
|
+
// ── Rule extraction (deterministic) ─────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Parse AGENTS.md + CLAUDE.md into rules: list items or paragraph sentences
|
|
73
|
+
* carrying an imperative/modal signal. Skips fenced code, tables, HTML
|
|
74
|
+
* comments, and files generated by `docguard agents --sync`.
|
|
75
|
+
* @returns {Array<{ file, line, section, text }>}
|
|
76
|
+
*/
|
|
77
|
+
export function extractInstructionRules(projectDir) {
|
|
78
|
+
const rules = [];
|
|
79
|
+
for (const file of INSTRUCTION_FILES) {
|
|
80
|
+
let content;
|
|
81
|
+
try { content = readFileSync(resolve(projectDir, file), 'utf-8'); } catch { continue; }
|
|
82
|
+
if (content.includes(GENERATED_MARKER)) continue; // generated mirror of AGENTS.md — audit the source, not the copy
|
|
83
|
+
|
|
84
|
+
const lines = content.split('\n');
|
|
85
|
+
let section = '';
|
|
86
|
+
let inFence = false;
|
|
87
|
+
for (let i = 0; i < lines.length; i++) {
|
|
88
|
+
const line = lines[i];
|
|
89
|
+
if (/^\s*```/.test(line)) { inFence = !inFence; continue; }
|
|
90
|
+
if (inFence) continue; // rules in code samples are examples, not orders
|
|
91
|
+
const h = line.match(/^(#{2,3})\s+(.+)$/);
|
|
92
|
+
if (h) { section = h[2].trim(); continue; }
|
|
93
|
+
const t = line.trim();
|
|
94
|
+
if (!t || t.startsWith('#') || t.startsWith('<!--') || t.startsWith('|')) continue;
|
|
95
|
+
|
|
96
|
+
// A list item is one candidate rule; a paragraph line splits into sentences.
|
|
97
|
+
const li = t.match(/^(?:[-*+]|\d+[.)])\s+(.*)$/);
|
|
98
|
+
const candidates = li ? [li[1]] : t.replace(/^>\s*/, '').split(/(?<=[.!?])\s+/);
|
|
99
|
+
for (const cand of candidates) {
|
|
100
|
+
const text = cand.trim();
|
|
101
|
+
if (text.length < 8 || !SIGNAL_RE.test(text)) continue;
|
|
102
|
+
rules.push({ file, line: i + 1, section, text: text.slice(0, 200) });
|
|
103
|
+
if (rules.length >= MAX_RULES) return rules;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return rules;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── Deterministic findings ──────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
const PATH_EXTS = 'md|mjs|cjs|js|ts|tsx|jsx|json|ya?ml|py|sh|toml|txt|rs|go|css|html';
|
|
113
|
+
// Backticked token: no spaces, lexes as a relative path with a known extension.
|
|
114
|
+
const PATH_LIKE_RE = new RegExp(`^[\\w.-][\\w./-]*\\.(?:${PATH_EXTS})$`, 'i');
|
|
115
|
+
// Bare (unbackticked) token: requires a directory separator for precision.
|
|
116
|
+
const BARE_PATH_RE = new RegExp(`(?:^|[\\s("'])([\\w.-]+\\/[\\w./-]+\\.(?:${PATH_EXTS}))\\b`, 'gi');
|
|
117
|
+
const BACKTICK_RE = /`([^`]+)`/g;
|
|
118
|
+
const DOCGUARD_CMD_RE = /\bdocguard\s+([a-z][a-z0-9-]*)\b/g;
|
|
119
|
+
|
|
120
|
+
/** File-path candidates referenced by a rule (anchors/line refs stripped). */
|
|
121
|
+
function pathCandidates(text) {
|
|
122
|
+
const found = new Set();
|
|
123
|
+
BACKTICK_RE.lastIndex = 0;
|
|
124
|
+
let m;
|
|
125
|
+
while ((m = BACKTICK_RE.exec(text)) !== null) {
|
|
126
|
+
const tok = m[1].replace(/[#:].*$/, '').trim();
|
|
127
|
+
if (!tok.includes(' ') && PATH_LIKE_RE.test(tok)) found.add(tok);
|
|
128
|
+
}
|
|
129
|
+
BARE_PATH_RE.lastIndex = 0;
|
|
130
|
+
while ((m = BARE_PATH_RE.exec(text)) !== null) found.add(m[1].replace(/[#:].*$/, ''));
|
|
131
|
+
return [...found];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Known docguard subcommands: cli/commands/*.mjs basenames + permanent
|
|
136
|
+
* aliases. Read from disk at runtime so the list can't drift from the code.
|
|
137
|
+
* Returns null when unreadable — the caller then SKIPS the check (a wrong
|
|
138
|
+
* "unknown command" is worse than a missed one).
|
|
139
|
+
*/
|
|
140
|
+
function knownDocguardCommands() {
|
|
141
|
+
try {
|
|
142
|
+
const names = readdirSync(join(__dirname, '..', 'commands'))
|
|
143
|
+
.filter(f => f.endsWith('.mjs'))
|
|
144
|
+
.map(f => f.slice(0, -'.mjs'.length));
|
|
145
|
+
return new Set([...names, 'audit', 'dx']);
|
|
146
|
+
} catch { return null; }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The findings string logic can prove — no LLM involved.
|
|
151
|
+
* @returns {{ duplicates, negations, stalePointers, staleCommands }}
|
|
152
|
+
*/
|
|
153
|
+
export function findDeterministicFindings(rules, projectDir) {
|
|
154
|
+
// duplicates: exact-normalized matches, within or across files.
|
|
155
|
+
const byNorm = new Map();
|
|
156
|
+
for (const r of rules) {
|
|
157
|
+
const norm = normalizeRule(r.text);
|
|
158
|
+
if (!norm) continue;
|
|
159
|
+
if (!byNorm.has(norm)) byNorm.set(norm, []);
|
|
160
|
+
byNorm.get(norm).push(r);
|
|
161
|
+
}
|
|
162
|
+
const duplicates = [...byNorm.entries()]
|
|
163
|
+
.filter(([, rs]) => rs.length >= 2)
|
|
164
|
+
.map(([normalized, rs]) => ({ normalized, rules: rs }));
|
|
165
|
+
|
|
166
|
+
// direct-negation pairs: identical after stripping polarity tokens, with
|
|
167
|
+
// opposite negation-count parity ("never use tabs" vs "always use tabs").
|
|
168
|
+
const negations = [];
|
|
169
|
+
const byStripped = new Map();
|
|
170
|
+
for (const r of rules) {
|
|
171
|
+
const norm = normalizeRule(r.text);
|
|
172
|
+
const stripped = stripPolarity(norm);
|
|
173
|
+
if (stripped.split(' ').length < 2) continue; // "never" vs "always" alone proves nothing
|
|
174
|
+
if (!byStripped.has(stripped)) byStripped.set(stripped, []);
|
|
175
|
+
byStripped.get(stripped).push({ r, norm, parity: negationCount(norm) % 2 });
|
|
176
|
+
}
|
|
177
|
+
for (const [stripped, group] of byStripped) {
|
|
178
|
+
if (group.length < 2) continue;
|
|
179
|
+
for (let i = 0; i < group.length; i++) {
|
|
180
|
+
for (let j = i + 1; j < group.length; j++) {
|
|
181
|
+
const A = group[i], B = group[j];
|
|
182
|
+
if (A.parity === B.parity || A.norm === B.norm) continue; // same polarity → duplicate territory, not a conflict
|
|
183
|
+
negations.push({ a: A.r, b: B.r, common: stripped });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// stale pointers: referenced file paths that don't exist in the repo.
|
|
189
|
+
const stalePointers = [];
|
|
190
|
+
const seenPtr = new Set();
|
|
191
|
+
for (const r of rules) {
|
|
192
|
+
for (const p of pathCandidates(r.text)) {
|
|
193
|
+
const key = `${r.file}:${r.line}:${p}`;
|
|
194
|
+
if (seenPtr.has(key)) continue;
|
|
195
|
+
seenPtr.add(key);
|
|
196
|
+
if (!existsSync(resolve(projectDir, p))) {
|
|
197
|
+
stalePointers.push({ file: r.file, line: r.line, section: r.section, text: r.text, path: p });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// stale commands: `docguard <cmd>` (backticked — an invocation, not prose)
|
|
203
|
+
// where <cmd> is not a known command.
|
|
204
|
+
const staleCommands = [];
|
|
205
|
+
const known = knownDocguardCommands();
|
|
206
|
+
if (known) {
|
|
207
|
+
const seenCmd = new Set();
|
|
208
|
+
for (const r of rules) {
|
|
209
|
+
BACKTICK_RE.lastIndex = 0;
|
|
210
|
+
let span;
|
|
211
|
+
while ((span = BACKTICK_RE.exec(r.text)) !== null) {
|
|
212
|
+
DOCGUARD_CMD_RE.lastIndex = 0;
|
|
213
|
+
let m;
|
|
214
|
+
while ((m = DOCGUARD_CMD_RE.exec(span[1])) !== null) {
|
|
215
|
+
const command = m[1];
|
|
216
|
+
const key = `${r.file}:${r.line}:${command}`;
|
|
217
|
+
if (known.has(command) || seenCmd.has(key)) continue;
|
|
218
|
+
seenCmd.add(key);
|
|
219
|
+
staleCommands.push({ file: r.file, line: r.line, section: r.section, text: r.text, command });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return { duplicates, negations, stalePointers, staleCommands };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ── LLM tasks (topical-cluster pairs) ───────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
// Function words + polarity/signal tokens. Content verbs (use, run, commit,
|
|
231
|
+
// write, read…) stay significant — "never use tabs" / "always use spaces"
|
|
232
|
+
// should cluster on use+indentation, not be filtered to nothing.
|
|
233
|
+
const STOPWORDS = new Set([
|
|
234
|
+
'the', 'a', 'an', 'to', 'of', 'in', 'for', 'and', 'or', 'with', 'without',
|
|
235
|
+
'on', 'at', 'by', 'from', 'as', 'is', 'are', 'be', 'been', 'being', 'it',
|
|
236
|
+
'its', 'this', 'that', 'these', 'those', 'you', 'your', 'we', 'our', 'all',
|
|
237
|
+
'any', 'each', 'every', 'when', 'where', 'while', 'if', 'then', 'than',
|
|
238
|
+
'so', 'but', 'not', 'no', 'never', 'always', 'must', 'should', 'shall',
|
|
239
|
+
'do', 'dont', 'does', 'did', 'done', 'can', 'cannot', 'cant', 'may',
|
|
240
|
+
'might', 'will', 'would', 'could', 'only', 'before', 'after', 'into',
|
|
241
|
+
'over', 'under', 'via', 'per', 'also', 'ever', 'instead', 'rather',
|
|
242
|
+
'avoid', 'ensure', 'require', 'requires', 'required', 'forbid', 'forbidden',
|
|
243
|
+
'e', 'g', 'i', 'etc', 'please',
|
|
244
|
+
]);
|
|
245
|
+
|
|
246
|
+
/** Cheap suffix stemmer — consistency matters, not linguistics. */
|
|
247
|
+
function stem(w) {
|
|
248
|
+
let s = w;
|
|
249
|
+
if (s.length > 5 && s.endsWith('ing')) s = s.slice(0, -3);
|
|
250
|
+
else if (s.length > 4 && (s.endsWith('ed') || s.endsWith('es'))) s = s.slice(0, -2);
|
|
251
|
+
else if (s.length > 3 && s.endsWith('s')) s = s.slice(0, -1);
|
|
252
|
+
if (s.length > 3 && s[s.length - 1] === s[s.length - 2]) s = s.slice(0, -1); // committ → commit
|
|
253
|
+
return s;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function significantStems(norm) {
|
|
257
|
+
const stems = new Set();
|
|
258
|
+
for (const w of norm.split(' ')) {
|
|
259
|
+
if (w.length < 3 || STOPWORDS.has(w) || /^\d+$/.test(w)) continue;
|
|
260
|
+
stems.add(stem(w));
|
|
261
|
+
}
|
|
262
|
+
return stems;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Rule pairs in the same topical cluster (≥2 shared significant stems) become
|
|
267
|
+
* agent judgment tasks. Pairs already proven by the deterministic pass (exact
|
|
268
|
+
* duplicates, direct negations) are excluded — no LLM needed there. Cross-file
|
|
269
|
+
* pairs first (AGENTS-vs-CLAUDE divergence is the classic drift), capped.
|
|
270
|
+
*/
|
|
271
|
+
export function buildInstructionAuditTasks(rules) {
|
|
272
|
+
const enriched = rules.map(r => {
|
|
273
|
+
const norm = normalizeRule(r.text);
|
|
274
|
+
return { r, norm, stripped: stripPolarity(norm), parity: negationCount(norm) % 2, stems: significantStems(norm) };
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
const pairs = [];
|
|
278
|
+
for (let i = 0; i < enriched.length; i++) {
|
|
279
|
+
for (let j = i + 1; j < enriched.length; j++) {
|
|
280
|
+
const A = enriched[i], B = enriched[j];
|
|
281
|
+
if (A.norm === B.norm) continue; // exact duplicate — deterministic finding
|
|
282
|
+
if (A.stripped === B.stripped && A.parity !== B.parity) continue; // direct negation — deterministic finding
|
|
283
|
+
const shared = [...A.stems].filter(s => B.stems.has(s));
|
|
284
|
+
if (shared.length < 2) continue;
|
|
285
|
+
pairs.push({ a: A.r, b: B.r, shared, crossFile: A.r.file !== B.r.file });
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
pairs.sort((p, q) =>
|
|
290
|
+
(q.crossFile - p.crossFile) ||
|
|
291
|
+
(q.shared.length - p.shared.length) ||
|
|
292
|
+
(p.a.line - q.a.line) || (p.b.line - q.b.line));
|
|
293
|
+
|
|
294
|
+
return pairs.slice(0, MAX_TASKS).map((p, i) => {
|
|
295
|
+
const at = (r) => `${r.file}:${r.line}${r.section ? ` (section "${r.section}")` : ''}`;
|
|
296
|
+
return {
|
|
297
|
+
id: `verify.instructions.${i + 1}`,
|
|
298
|
+
a: p.a,
|
|
299
|
+
b: p.b,
|
|
300
|
+
sharedTerms: p.shared,
|
|
301
|
+
crossFile: p.crossFile,
|
|
302
|
+
instruction: `Judge whether these two agent-instruction rules contradict in practice. Rule A — ${at(p.a)}: "${p.a.text}". Rule B — ${at(p.b)}: "${p.b.text}". They share the terms: ${p.shared.join(', ')}. If they conflict, report which rule should win, why, and which file to edit; if one merely duplicates the other, say which copy to delete; if they complement each other, say so.`,
|
|
303
|
+
confidence: 'requires-human',
|
|
304
|
+
};
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ── Entry point ─────────────────────────────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Full instruction audit: extract rules, prove what string logic can prove,
|
|
312
|
+
* and stage the semantic-conflict judgments for the agent.
|
|
313
|
+
* @returns {{ rules, deterministic: {duplicates,negations,stalePointers,staleCommands}, tasks }}
|
|
314
|
+
*/
|
|
315
|
+
export function auditInstructions(projectDir, config = {}) {
|
|
316
|
+
const rules = extractInstructionRules(projectDir);
|
|
317
|
+
const deterministic = findDeterministicFindings(rules, projectDir);
|
|
318
|
+
const tasks = buildInstructionAuditTasks(rules);
|
|
319
|
+
return { rules, deterministic, tasks };
|
|
320
|
+
}
|