docguard-cli 0.29.0 → 0.30.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 +16 -1
- 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/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
|
@@ -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
|
+
}
|