docguard-cli 0.28.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.
Files changed (69) hide show
  1. package/README.es.md +102 -0
  2. package/README.md +80 -32
  3. package/README.pt-BR.md +101 -0
  4. package/STANDARD.md +20 -10
  5. package/cli/commands/agents.mjs +149 -0
  6. package/cli/commands/diff.mjs +6 -15
  7. package/cli/commands/generate.mjs +14 -1001
  8. package/cli/commands/guard.mjs +136 -8
  9. package/cli/commands/llms.mjs +67 -5
  10. package/cli/commands/mcp.mjs +263 -0
  11. package/cli/commands/memory.mjs +115 -0
  12. package/cli/commands/score.mjs +76 -12
  13. package/cli/commands/trace.mjs +364 -1
  14. package/cli/commands/verify.mjs +93 -6
  15. package/cli/docguard.mjs +42 -5
  16. package/cli/findings.mjs +511 -0
  17. package/cli/scanners/agent-readability.mjs +202 -0
  18. package/cli/scanners/instruction-audit.mjs +320 -0
  19. package/cli/scanners/semantic-claims.mjs +7 -1
  20. package/cli/scanners/speckit.mjs +443 -28
  21. package/cli/shared-ignore.mjs +148 -16
  22. package/cli/shared.mjs +45 -1
  23. package/cli/validators/api-surface.mjs +113 -26
  24. package/cli/validators/architecture.mjs +66 -43
  25. package/cli/validators/canonical-sync.mjs +59 -28
  26. package/cli/validators/changelog.mjs +41 -17
  27. package/cli/validators/cross-reference.mjs +28 -11
  28. package/cli/validators/doc-quality.mjs +78 -44
  29. package/cli/validators/docs-coverage.mjs +90 -63
  30. package/cli/validators/docs-diff.mjs +63 -64
  31. package/cli/validators/docs-sync.mjs +48 -33
  32. package/cli/validators/drift.mjs +40 -34
  33. package/cli/validators/environment.mjs +67 -27
  34. package/cli/validators/freshness.mjs +12 -5
  35. package/cli/validators/generated-staleness.mjs +26 -10
  36. package/cli/validators/metadata-sync.mjs +28 -25
  37. package/cli/validators/metrics-consistency.mjs +89 -47
  38. package/cli/validators/schema-sync.mjs +37 -32
  39. package/cli/validators/security.mjs +7 -20
  40. package/cli/validators/spec-kit.mjs +3 -0
  41. package/cli/validators/structure.mjs +58 -23
  42. package/cli/validators/surface-sync.mjs +34 -15
  43. package/cli/validators/test-spec.mjs +87 -29
  44. package/cli/validators/todo-tracking.mjs +83 -74
  45. package/cli/validators/traceability.mjs +67 -39
  46. package/cli/writers/doc-generators.mjs +853 -0
  47. package/cli/writers/generate-io.mjs +142 -0
  48. package/cli/writers/sarif.mjs +129 -0
  49. package/commands/docguard.fix.md +56 -53
  50. package/commands/docguard.guard.md +53 -47
  51. package/commands/docguard.review.md +49 -31
  52. package/docs/ai-integration.md +133 -134
  53. package/docs/commands.md +49 -3
  54. package/docs/configuration.md +38 -0
  55. package/docs/faq.md +15 -0
  56. package/extensions/spec-kit-docguard/extension.yml +1 -1
  57. package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +2 -2
  58. package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +2 -2
  59. package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
  60. package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
  61. package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +2 -2
  62. package/package.json +2 -1
  63. package/schemas/docguard-config.schema.json +28 -0
  64. package/templates/ci/gitlab-component.yml +90 -0
  65. package/templates/commands/docguard.fix.md +33 -10
  66. package/templates/commands/docguard.guard.md +40 -26
  67. package/templates/commands/docguard.init.md +23 -11
  68. package/templates/commands/docguard.review.md +25 -8
  69. package/templates/commands/docguard.update.md +14 -4
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Agent Readability scanner — how well do this repo's docs serve an AI agent?
3
+ *
4
+ * Human readability (Flesch, passive voice — doc-quality.mjs) asks "can a
5
+ * person read this prose?". This scanner asks the 2026 question: "can an AI
6
+ * consumer FIND, QUOTE, and TRUST this documentation?" — token budgets,
7
+ * section addressability, machine-parseable structure, metadata markers, and
8
+ * unbroken pointers. Deterministic, zero-LLM, zero npm dependencies.
9
+ *
10
+ * DISPLAY-ONLY consumer contract: assessAgentReadability feeds a score display
11
+ * block (like ALCOA+) and must never feed the gating CDD grade — CI thresholds
12
+ * read that.
13
+ */
14
+
15
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
16
+ import { resolve, dirname } from 'node:path';
17
+
18
+ /** chars/4 — the standard rough token estimate; consistency matters more than precision. */
19
+ const estTokens = (s) => Math.ceil(s.length / 4);
20
+
21
+ const GRADES = [[90, 'A'], [75, 'B'], [60, 'C'], [40, 'D']];
22
+ function toGrade(score) {
23
+ for (const [min, g] of GRADES) if (score >= min) return g;
24
+ return 'F';
25
+ }
26
+
27
+ function readIfExists(path) {
28
+ try { return existsSync(path) ? readFileSync(path, 'utf-8') : null; } catch { return null; }
29
+ }
30
+
31
+ function canonicalDocs(projectDir) {
32
+ const dir = resolve(projectDir, 'docs-canonical');
33
+ if (!existsSync(dir)) return [];
34
+ try {
35
+ return readdirSync(dir)
36
+ .filter(f => f.toLowerCase().endsWith('.md'))
37
+ .sort()
38
+ .map(f => ({ name: `docs-canonical/${f}`, content: readIfExists(resolve(dir, f)) }))
39
+ .filter(d => d.content !== null);
40
+ } catch { return []; }
41
+ }
42
+
43
+ /**
44
+ * Split a markdown body into sections at H2/H3 headings.
45
+ * Returns [{heading, body}] — body excludes the heading line itself.
46
+ */
47
+ function splitSections(content) {
48
+ const lines = content.split('\n');
49
+ const sections = [];
50
+ let current = null;
51
+ let inFence = false;
52
+ for (const line of lines) {
53
+ if (/^\s*```/.test(line)) inFence = !inFence;
54
+ const h = !inFence && line.match(/^#{2,3}\s+(.+)$/);
55
+ if (h) {
56
+ if (current) sections.push(current);
57
+ current = { heading: h[1].trim(), body: '' };
58
+ } else if (current) {
59
+ current.body += line + '\n';
60
+ }
61
+ }
62
+ if (current) sections.push(current);
63
+ return sections;
64
+ }
65
+
66
+ /** Fraction of non-blank lines that are structured (table/list/fence/heading/marker). */
67
+ function structuredFraction(content) {
68
+ let structured = 0, total = 0, inFence = false;
69
+ for (const line of content.split('\n')) {
70
+ const t = line.trim();
71
+ if (!t) continue;
72
+ total++;
73
+ if (/^```/.test(t)) { inFence = !inFence; structured++; continue; }
74
+ if (inFence) { structured++; continue; }
75
+ if (/^(\||[-*+]\s|\d+\.\s|#{1,6}\s|>|<!--)/.test(t)) structured++;
76
+ }
77
+ return total === 0 ? 0 : structured / total;
78
+ }
79
+
80
+ /**
81
+ * Assess agent readability for a project.
82
+ * @returns {{ metrics: Array<{key,label,score,detail,fix}>, score: number, grade: string }}
83
+ */
84
+ export function assessAgentReadability(projectDir, config = {}) {
85
+ const metrics = [];
86
+ const agentsMd = readIfExists(resolve(projectDir, 'AGENTS.md'));
87
+ const claudeMd = readIfExists(resolve(projectDir, 'CLAUDE.md'));
88
+ const agentEntry = agentsMd ?? claudeMd;
89
+ const docs = canonicalDocs(projectDir);
90
+
91
+ // 1. agent-entry — without an entry file, an agent starts blind.
92
+ metrics.push({
93
+ key: 'agent-entry',
94
+ label: 'Agent entry file',
95
+ score: agentEntry ? 100 : 0,
96
+ detail: agentsMd ? 'AGENTS.md present' : claudeMd ? 'CLAUDE.md present (no AGENTS.md)' : 'no AGENTS.md or CLAUDE.md',
97
+ fix: agentEntry ? null : 'Create AGENTS.md (docguard init scaffolds it) — agents need an entry point',
98
+ });
99
+
100
+ // 2. token-budget — an entry file beyond the skim budget gets skimmed, not read.
101
+ if (agentEntry) {
102
+ const tokens = estTokens(agentEntry);
103
+ let score, fix = null;
104
+ if (tokens <= 2000) score = 100;
105
+ else if (tokens <= 4000) score = 75;
106
+ else if (tokens <= 8000) { score = 40; fix = 'Trim the agent entry file below ~4k tokens — link out to detail docs instead of inlining'; }
107
+ else { score = 10; fix = 'Agent entry file blows the context skim budget — split into linked canonical docs'; }
108
+ metrics.push({
109
+ key: 'token-budget',
110
+ label: 'Entry-file token budget',
111
+ score,
112
+ detail: `~${tokens} est. tokens (ideal ≤2000, acceptable ≤4000)`,
113
+ fix,
114
+ });
115
+ }
116
+
117
+ // 3. addressability — can a section be quoted alone, and do anchors resolve uniquely?
118
+ if (docs.length > 0) {
119
+ let quotable = 0, totalSections = 0, dupDocs = [];
120
+ for (const d of docs) {
121
+ const sections = splitSections(d.content);
122
+ totalSections += sections.length;
123
+ quotable += sections.filter(s => estTokens(s.body) <= 120).length;
124
+ const seen = new Set();
125
+ for (const s of sections) {
126
+ const slug = s.heading.toLowerCase();
127
+ if (seen.has(slug)) { dupDocs.push(`${d.name} ("${s.heading}")`); break; }
128
+ seen.add(slug);
129
+ }
130
+ }
131
+ const frac = totalSections === 0 ? 0 : quotable / totalSections;
132
+ let score = Math.round(frac * 100);
133
+ if (dupDocs.length > 0) score = Math.max(0, score - 30);
134
+ metrics.push({
135
+ key: 'addressability',
136
+ label: 'Section addressability',
137
+ score,
138
+ detail: `${quotable}/${totalSections} H2/H3 sections quotable alone (≤120 tok)${dupDocs.length ? `; duplicate headings: ${dupDocs[0]}${dupDocs.length > 1 ? ` +${dupDocs.length - 1}` : ''}` : ''}`,
139
+ fix: score >= 60 ? null : dupDocs.length ? 'Make headings unique within each doc — duplicates break anchor links' : 'Split long sections — an agent should be able to quote one section without dragging the whole doc',
140
+ });
141
+ }
142
+
143
+ // 4. structure-density — tables/lists/fences parse; prose walls don't.
144
+ if (docs.length > 0) {
145
+ const fracs = docs.map(d => structuredFraction(d.content));
146
+ const avg = fracs.reduce((a, b) => a + b, 0) / fracs.length;
147
+ const score = Math.min(100, Math.round((avg / 0.3) * 100));
148
+ metrics.push({
149
+ key: 'structure-density',
150
+ label: 'Structured-content density',
151
+ score,
152
+ detail: `${Math.round(avg * 100)}% of canonical-doc lines are structured (target ≥30%)`,
153
+ fix: score >= 60 ? null : 'Convert prose walls to tables/lists — structured content is machine-parseable',
154
+ });
155
+ }
156
+
157
+ // 5. marker-presence — machine-readable metadata density.
158
+ if (docs.length > 0) {
159
+ const marked = docs.filter(d => /docguard:(last-reviewed|section|generated)/.test(d.content)).length;
160
+ metrics.push({
161
+ key: 'marker-presence',
162
+ label: 'Machine markers',
163
+ score: Math.round((marked / docs.length) * 100),
164
+ detail: `${marked}/${docs.length} canonical docs carry docguard markers (last-reviewed / section / generated)`,
165
+ fix: marked === docs.length ? null : 'Add <!-- docguard:last-reviewed YYYY-MM-DD --> to unmarked docs — agents use it to judge trust',
166
+ });
167
+ }
168
+
169
+ // 6. llms-txt — the AI-consumer index standard.
170
+ const hasLlms = existsSync(resolve(projectDir, 'llms.txt'));
171
+ metrics.push({
172
+ key: 'llms-txt',
173
+ label: 'llms.txt index',
174
+ score: hasLlms ? 100 : 0,
175
+ detail: hasLlms ? 'llms.txt present at root' : 'no llms.txt at root',
176
+ fix: hasLlms ? null : 'Run docguard llms — generates the llms.txt AI index from your canonical docs',
177
+ });
178
+
179
+ // 7. self-containedness — broken relative pointers strand an agent mid-task.
180
+ // The entry file lives at the project root, so links resolve against it.
181
+ if (agentEntry) {
182
+ const links = [...agentEntry.matchAll(/\]\(([^)#]+\.md)(?:#[^)]*)?\)/g)]
183
+ .map(m => m[1])
184
+ .filter(l => !/^[a-z]+:\/\//i.test(l));
185
+ if (links.length > 0) {
186
+ const broken = links.filter(l => !existsSync(resolve(projectDir, l)));
187
+ metrics.push({
188
+ key: 'self-containedness',
189
+ label: 'Entry-file link integrity',
190
+ score: Math.round(((links.length - broken.length) / links.length) * 100),
191
+ detail: broken.length === 0
192
+ ? `${links.length}/${links.length} relative doc links resolve`
193
+ : `${broken.length}/${links.length} relative links broken (first: ${broken[0]})`,
194
+ fix: broken.length === 0 ? null : 'Fix the broken links — a dead pointer strands an agent mid-task',
195
+ });
196
+ }
197
+ }
198
+
199
+ const score = metrics.length === 0 ? 0
200
+ : Math.round(metrics.reduce((sum, m) => sum + m.score, 0) / metrics.length);
201
+ return { metrics, score, grade: toGrade(score) };
202
+ }
@@ -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
+ }
@@ -28,7 +28,13 @@ import { resolve, join } from 'node:path';
28
28
  const NUMBER_PATTERNS = [
29
29
  { kind: 'duration', re: /\b(\d+(?:\.\d+)?)\s*(milliseconds?|ms|seconds?|secs?|minutes?|mins?|hours?|hrs?|days?|weeks?|months?|years?)\b/gi },
30
30
  { kind: 'rate', re: /\b(\d+)\s*(?:\/|\bper\b|\breq(?:uests?)?\s*\/?)\s*(s|sec|seconds?|min|minutes?|hours?|h)\b/gi },
31
- { kind: 'count', re: /\b(\d+)\s*\+?\s*(GSIs?|LSIs?|indexes|indices|roles?|permissions?|scopes?|tables?|queues?|topics?|buckets?|endpoints?|routes?|validators?|columns?|fields?|shards?|partitions?|replicas?|retries|workers?|threads?|connections?)\b/gi },
31
+ // Field report #6: the noun list IS the precision mechanism — a number is only
32
+ // a claim when it sits next to a recognized "registered-unit" noun. The gap that
33
+ // shipped a wrong "16 extractors" past every check was simply that "extractors"
34
+ // (and its domain-collection siblings) weren't in this list. Added the common
35
+ // pluggable-architecture nouns. Deliberately NOT added: generic prose nouns that
36
+ // collide with running-text numbers (steps, items, checks, modules, services).
37
+ { kind: 'count', re: /\b(\d+)\s*\+?\s*(GSIs?|LSIs?|indexes|indices|roles?|permissions?|scopes?|tables?|queues?|topics?|buckets?|endpoints?|routes?|validators?|columns?|fields?|shards?|partitions?|replicas?|retries|workers?|threads?|connections?|extractors?|plugins?|detectors?|scanners?|analyzers?|collectors?|commands?|subcommands?|rules?|hooks?|providers?|adapters?|handlers?|middlewares?|transformers?|processors?|generators?|parsers?|exporters?|importers?|integrations?|formatters?|linters?|agents?|skills?)\b/gi },
32
38
  ];
33
39
 
34
40
  // A list of 2+ UPPER_SNAKE tokens separated by / , | or "or" — an enum claim,