docguard-cli 0.28.0 → 0.29.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.es.md +102 -0
- package/README.md +64 -31
- package/README.pt-BR.md +101 -0
- package/STANDARD.md +20 -10
- package/cli/commands/agents.mjs +149 -0
- package/cli/commands/diff.mjs +6 -15
- package/cli/commands/generate.mjs +14 -1001
- package/cli/commands/guard.mjs +136 -8
- package/cli/commands/llms.mjs +67 -5
- package/cli/commands/mcp.mjs +263 -0
- package/cli/commands/memory.mjs +115 -0
- package/cli/commands/score.mjs +76 -12
- package/cli/docguard.mjs +31 -2
- package/cli/findings.mjs +499 -0
- package/cli/scanners/agent-readability.mjs +202 -0
- package/cli/scanners/semantic-claims.mjs +7 -1
- package/cli/scanners/speckit.mjs +98 -28
- package/cli/shared-ignore.mjs +148 -16
- package/cli/shared.mjs +45 -1
- package/cli/validators/api-surface.mjs +113 -26
- package/cli/validators/architecture.mjs +66 -43
- package/cli/validators/canonical-sync.mjs +59 -28
- package/cli/validators/changelog.mjs +41 -17
- package/cli/validators/cross-reference.mjs +28 -11
- package/cli/validators/doc-quality.mjs +78 -44
- package/cli/validators/docs-coverage.mjs +90 -63
- package/cli/validators/docs-diff.mjs +63 -64
- package/cli/validators/docs-sync.mjs +48 -33
- package/cli/validators/drift.mjs +40 -34
- package/cli/validators/environment.mjs +67 -27
- package/cli/validators/freshness.mjs +12 -5
- package/cli/validators/generated-staleness.mjs +26 -10
- package/cli/validators/metadata-sync.mjs +28 -25
- package/cli/validators/metrics-consistency.mjs +89 -47
- package/cli/validators/schema-sync.mjs +37 -32
- package/cli/validators/security.mjs +7 -20
- package/cli/validators/spec-kit.mjs +3 -0
- package/cli/validators/structure.mjs +58 -23
- package/cli/validators/surface-sync.mjs +34 -15
- package/cli/validators/test-spec.mjs +87 -29
- package/cli/validators/todo-tracking.mjs +83 -74
- package/cli/validators/traceability.mjs +67 -39
- package/cli/writers/doc-generators.mjs +853 -0
- package/cli/writers/generate-io.mjs +142 -0
- package/cli/writers/sarif.mjs +129 -0
- package/commands/docguard.fix.md +56 -53
- package/commands/docguard.guard.md +53 -47
- package/commands/docguard.review.md +49 -31
- package/docs/ai-integration.md +133 -134
- package/docs/commands.md +49 -3
- package/docs/configuration.md +38 -0
- package/docs/faq.md +15 -0
- package/extensions/spec-kit-docguard/extension.yml +1 -1
- package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +2 -2
- package/package.json +1 -1
- package/schemas/docguard-config.schema.json +17 -0
- package/templates/commands/docguard.fix.md +33 -10
- package/templates/commands/docguard.guard.md +40 -26
- package/templates/commands/docguard.init.md +23 -11
- package/templates/commands/docguard.review.md +25 -8
- 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
|
+
}
|
|
@@ -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
|
-
|
|
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,
|
package/cli/scanners/speckit.mjs
CHANGED
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
import { existsSync, readFileSync, readdirSync, statSync, copyFileSync, writeFileSync } from 'node:fs';
|
|
26
|
-
import { resolve, join } from 'node:path';
|
|
26
|
+
import { resolve, join, relative } from 'node:path';
|
|
27
|
+
import { mkFinding, resultFromFindings } from '../findings.mjs';
|
|
27
28
|
|
|
28
29
|
// ──── Spec Kit Mandatory Sections ────
|
|
29
30
|
// Based on spec-kit's spec-template.md, plan-template.md, tasks-template.md
|
|
@@ -385,95 +386,164 @@ export function generateFromSpecKit(projectDir, config, flags) {
|
|
|
385
386
|
* - Checks constitution → AGENTS.md mapping
|
|
386
387
|
*
|
|
387
388
|
* @returns {{ errors: string[], warnings: string[], passed: number, total: number }}
|
|
389
|
+
*
|
|
390
|
+
* v0.29: migrated to structured findings (SPK001–SPK007). Messages are
|
|
391
|
+
* byte-identical to the legacy strings — resultFromFindings derives the
|
|
392
|
+
* errors/warnings arrays from the same findings, so counts, exit codes, and
|
|
393
|
+
* existing tests are unaffected; guard just renders richer output.
|
|
388
394
|
*/
|
|
389
395
|
export function validateSpecKitIntegration(projectDir, config) {
|
|
390
|
-
const
|
|
396
|
+
const findings = [];
|
|
397
|
+
let passed = 0;
|
|
398
|
+
let total = 0;
|
|
391
399
|
|
|
392
400
|
const speckit = detectSpecKit(projectDir);
|
|
393
401
|
|
|
394
402
|
// If no Spec Kit detected, suggest it
|
|
395
403
|
if (!speckit.detected) {
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
404
|
+
total++;
|
|
405
|
+
findings.push(mkFinding({
|
|
406
|
+
code: 'SPK001',
|
|
407
|
+
validator: 'specKit',
|
|
408
|
+
severity: 'warn',
|
|
409
|
+
message: 'No Spec Kit artifacts detected. Consider `specify init` for spec-driven development (github.com/github/spec-kit)',
|
|
410
|
+
location: null,
|
|
411
|
+
suggestion: { kind: 'review', text: 'Adopt spec-driven development by initializing Spec Kit', command: 'specify init' },
|
|
412
|
+
}));
|
|
413
|
+
return resultFromFindings(findings, { passed, total });
|
|
401
414
|
}
|
|
402
415
|
|
|
403
416
|
// ── Check 1: .specify/ directory exists ──
|
|
404
|
-
|
|
417
|
+
total++;
|
|
405
418
|
if (speckit.specifyDir) {
|
|
406
|
-
|
|
419
|
+
passed++;
|
|
407
420
|
} else {
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
421
|
+
findings.push(mkFinding({
|
|
422
|
+
code: 'SPK002',
|
|
423
|
+
validator: 'specKit',
|
|
424
|
+
severity: 'warn',
|
|
425
|
+
message: 'Spec Kit artifacts found but .specify/ directory missing. Run `specify init` to create standard structure',
|
|
426
|
+
location: '.specify',
|
|
427
|
+
suggestion: { kind: 'fix', text: 'Create the standard Spec Kit structure', command: 'specify init' },
|
|
428
|
+
}));
|
|
411
429
|
}
|
|
412
430
|
|
|
413
431
|
// ── Check 2: Validate each spec's quality ──
|
|
414
432
|
for (const spec of speckit.specs) {
|
|
415
433
|
// 2a: spec.md quality
|
|
416
434
|
if (spec.hasSpec && spec.specPath) {
|
|
417
|
-
|
|
435
|
+
total++;
|
|
436
|
+
const loc = relative(projectDir, spec.specPath);
|
|
418
437
|
try {
|
|
419
438
|
const issues = validateSpecQuality(spec.specPath);
|
|
420
439
|
if (issues.length === 0) {
|
|
421
|
-
|
|
440
|
+
passed++;
|
|
422
441
|
} else {
|
|
423
442
|
for (const issue of issues) {
|
|
424
|
-
|
|
443
|
+
findings.push(mkFinding({
|
|
444
|
+
code: 'SPK003',
|
|
445
|
+
validator: 'specKit',
|
|
446
|
+
severity: 'warn',
|
|
447
|
+
message: `specs/${spec.name}/spec.md: ${issue}`,
|
|
448
|
+
location: loc,
|
|
449
|
+
suggestion: { kind: 'fix', text: 'Bring the spec up to the spec-kit spec-template.md shape (sections, FR-/SC- IDs)' },
|
|
450
|
+
}));
|
|
425
451
|
}
|
|
426
452
|
}
|
|
427
453
|
} catch {
|
|
428
|
-
|
|
454
|
+
findings.push(mkFinding({
|
|
455
|
+
code: 'SPK006',
|
|
456
|
+
validator: 'specKit',
|
|
457
|
+
severity: 'warn',
|
|
458
|
+
message: `specs/${spec.name}/spec.md: Could not read file`,
|
|
459
|
+
location: loc,
|
|
460
|
+
suggestion: { kind: 'review', text: 'Check the file exists and is readable (permissions/encoding)' },
|
|
461
|
+
}));
|
|
429
462
|
}
|
|
430
463
|
}
|
|
431
464
|
|
|
432
465
|
// 2b: plan.md quality
|
|
433
466
|
if (spec.hasPlan && spec.planPath) {
|
|
434
|
-
|
|
467
|
+
total++;
|
|
468
|
+
const loc = relative(projectDir, spec.planPath);
|
|
435
469
|
try {
|
|
436
470
|
const issues = validatePlanQuality(spec.planPath);
|
|
437
471
|
if (issues.length === 0) {
|
|
438
|
-
|
|
472
|
+
passed++;
|
|
439
473
|
} else {
|
|
440
474
|
for (const issue of issues) {
|
|
441
|
-
|
|
475
|
+
findings.push(mkFinding({
|
|
476
|
+
code: 'SPK004',
|
|
477
|
+
validator: 'specKit',
|
|
478
|
+
severity: 'warn',
|
|
479
|
+
message: `specs/${spec.name}/plan.md: ${issue}`,
|
|
480
|
+
location: loc,
|
|
481
|
+
suggestion: { kind: 'fix', text: 'Add the missing section per spec-kit plan-template.md' },
|
|
482
|
+
}));
|
|
442
483
|
}
|
|
443
484
|
}
|
|
444
485
|
} catch {
|
|
445
|
-
|
|
486
|
+
findings.push(mkFinding({
|
|
487
|
+
code: 'SPK006',
|
|
488
|
+
validator: 'specKit',
|
|
489
|
+
severity: 'warn',
|
|
490
|
+
message: `specs/${spec.name}/plan.md: Could not read file`,
|
|
491
|
+
location: loc,
|
|
492
|
+
suggestion: { kind: 'review', text: 'Check the file exists and is readable (permissions/encoding)' },
|
|
493
|
+
}));
|
|
446
494
|
}
|
|
447
495
|
}
|
|
448
496
|
|
|
449
497
|
// 2c: tasks.md quality
|
|
450
498
|
if (spec.hasTasks && spec.tasksPath) {
|
|
451
|
-
|
|
499
|
+
total++;
|
|
500
|
+
const loc = relative(projectDir, spec.tasksPath);
|
|
452
501
|
try {
|
|
453
502
|
const issues = validateTasksQuality(spec.tasksPath);
|
|
454
503
|
if (issues.length === 0) {
|
|
455
|
-
|
|
504
|
+
passed++;
|
|
456
505
|
} else {
|
|
457
506
|
for (const issue of issues) {
|
|
458
|
-
|
|
507
|
+
findings.push(mkFinding({
|
|
508
|
+
code: 'SPK005',
|
|
509
|
+
validator: 'specKit',
|
|
510
|
+
severity: 'warn',
|
|
511
|
+
message: `specs/${spec.name}/tasks.md: ${issue}`,
|
|
512
|
+
location: loc,
|
|
513
|
+
suggestion: { kind: 'fix', text: 'Add a phased breakdown with T-IDs per spec-kit tasks-template.md' },
|
|
514
|
+
}));
|
|
459
515
|
}
|
|
460
516
|
}
|
|
461
517
|
} catch {
|
|
462
|
-
|
|
518
|
+
findings.push(mkFinding({
|
|
519
|
+
code: 'SPK006',
|
|
520
|
+
validator: 'specKit',
|
|
521
|
+
severity: 'warn',
|
|
522
|
+
message: `specs/${spec.name}/tasks.md: Could not read file`,
|
|
523
|
+
location: loc,
|
|
524
|
+
suggestion: { kind: 'review', text: 'Check the file exists and is readable (permissions/encoding)' },
|
|
525
|
+
}));
|
|
463
526
|
}
|
|
464
527
|
}
|
|
465
528
|
}
|
|
466
529
|
|
|
467
530
|
// ── Check 3: Constitution → AGENTS.md mapping ──
|
|
468
531
|
if (speckit.constitution) {
|
|
469
|
-
|
|
532
|
+
total++;
|
|
470
533
|
const agentsPath = resolve(projectDir, 'AGENTS.md');
|
|
471
534
|
if (existsSync(agentsPath)) {
|
|
472
|
-
|
|
535
|
+
passed++;
|
|
473
536
|
} else {
|
|
474
|
-
|
|
537
|
+
findings.push(mkFinding({
|
|
538
|
+
code: 'SPK007',
|
|
539
|
+
validator: 'specKit',
|
|
540
|
+
severity: 'warn',
|
|
541
|
+
message: 'constitution.md exists but no AGENTS.md found. Create one for AI agent rules',
|
|
542
|
+
location: 'AGENTS.md',
|
|
543
|
+
suggestion: { kind: 'fix', text: 'Create an AGENTS.md that references the constitution', command: 'docguard init' },
|
|
544
|
+
}));
|
|
475
545
|
}
|
|
476
546
|
}
|
|
477
547
|
|
|
478
|
-
return
|
|
548
|
+
return resultFromFindings(findings, { passed, total });
|
|
479
549
|
}
|
package/cli/shared-ignore.mjs
CHANGED
|
@@ -89,8 +89,8 @@ export function isNonProductPath(relPath, config = {}) {
|
|
|
89
89
|
*
|
|
90
90
|
* Returns [] if the file is missing or unreadable — never throws.
|
|
91
91
|
*/
|
|
92
|
-
import { readFileSync, existsSync } from 'node:fs';
|
|
93
|
-
import { resolve as resolvePath, relative as relativePath, sep } from 'node:path';
|
|
92
|
+
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
|
|
93
|
+
import { resolve as resolvePath, relative as relativePath, join as joinPath, sep } from 'node:path';
|
|
94
94
|
|
|
95
95
|
/**
|
|
96
96
|
* Project-relative path with POSIX (`/`) separators — the canonical form that
|
|
@@ -212,26 +212,62 @@ export function shouldIgnore(relPath, config, validatorKey) {
|
|
|
212
212
|
}
|
|
213
213
|
|
|
214
214
|
/**
|
|
215
|
-
*
|
|
216
|
-
* Unlike globToRegex (used for ignore filtering), this anchors the match
|
|
217
|
-
* to the full relative path from the project root.
|
|
215
|
+
* THE canonical anchored glob compiler (v0.29 consolidation).
|
|
218
216
|
*
|
|
219
|
-
*
|
|
217
|
+
* The repo previously carried THREE glob→regex implementations (ignore-side
|
|
218
|
+
* `globToRegex` above, the old `globToMatchRegex` here, and a third private
|
|
219
|
+
* copy in metrics-consistency for collection counting) with subtly different
|
|
220
|
+
* feature sets — a maintenance hazard for exactly the drift class this tool
|
|
221
|
+
* detects in others. This is now the single anchored compiler; the ignore-side
|
|
222
|
+
* `globToRegex` deliberately stays separate because its UNanchored,
|
|
223
|
+
* boundary-substring semantics ("dir" matches at any depth) are a different
|
|
224
|
+
* contract, documented above, with its own bug history.
|
|
225
|
+
*
|
|
226
|
+
* Supports (superset of all prior anchored variants):
|
|
227
|
+
* `**\/` → zero or more path segments → (?:.*\/)?
|
|
228
|
+
* `**` → any chars (incl. /) → .*
|
|
229
|
+
* `*` → any chars except / → [^/]*
|
|
230
|
+
* `?` → one char except / → [^/]
|
|
231
|
+
* `{a,b}` → alternation (non-nested) → (?:a|b)
|
|
232
|
+
* Everything else is regex-escaped. Fully anchored: ^...$.
|
|
220
233
|
*
|
|
221
|
-
* @param {string} pattern - Glob pattern (e.g., "backend/**\/__tests__/**\/*.test.ts")
|
|
234
|
+
* @param {string} pattern - Glob pattern (e.g., "backend/**\/__tests__/**\/*.test.{ts,js}")
|
|
222
235
|
* @returns {RegExp}
|
|
223
236
|
*/
|
|
224
|
-
function
|
|
225
|
-
|
|
226
|
-
let
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
237
|
+
export function compileGlob(pattern) {
|
|
238
|
+
const glob = String(pattern);
|
|
239
|
+
let re = '';
|
|
240
|
+
for (let i = 0; i < glob.length; i++) {
|
|
241
|
+
const ch = glob[i];
|
|
242
|
+
if (ch === '*') {
|
|
243
|
+
if (glob[i + 1] === '*') {
|
|
244
|
+
i++;
|
|
245
|
+
if (glob[i + 1] === '/') { re += '(?:.*/)?'; i++; }
|
|
246
|
+
else re += '.*';
|
|
247
|
+
} else {
|
|
248
|
+
re += '[^/]*';
|
|
249
|
+
}
|
|
250
|
+
} else if (ch === '?') {
|
|
251
|
+
re += '[^/]';
|
|
252
|
+
} else if (ch === '{') {
|
|
253
|
+
const end = glob.indexOf('}', i);
|
|
254
|
+
if (end > i) {
|
|
255
|
+
re += '(?:' + glob.slice(i + 1, end).split(',')
|
|
256
|
+
.map(s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|') + ')';
|
|
257
|
+
i = end;
|
|
258
|
+
} else {
|
|
259
|
+
re += '\\{';
|
|
260
|
+
}
|
|
261
|
+
} else {
|
|
262
|
+
re += ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return new RegExp(`^${re}$`);
|
|
233
266
|
}
|
|
234
267
|
|
|
268
|
+
// Back-compat internal alias — globMatch below always used the anchored form.
|
|
269
|
+
const globToMatchRegex = compileGlob;
|
|
270
|
+
|
|
235
271
|
/**
|
|
236
272
|
* Check if a relative path matches ANY of the given glob patterns.
|
|
237
273
|
* Purpose-built for POSITIVE matching (e.g., "is this a test file?").
|
|
@@ -255,3 +291,99 @@ export function globMatch(relPath, patterns) {
|
|
|
255
291
|
const regexes = patterns.map(p => globToMatchRegex(p));
|
|
256
292
|
return regexes.some(r => r.test(relPath));
|
|
257
293
|
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* THE canonical recursive file walker (v0.29 consolidation).
|
|
297
|
+
*
|
|
298
|
+
* ~13 validators each carried a private recursive walker with its own copied
|
|
299
|
+
* IGNORE_DIRS set and its own error handling — thirteen chances for skip logic
|
|
300
|
+
* to disagree. This is the single shared implementation.
|
|
301
|
+
*
|
|
302
|
+
* Contract:
|
|
303
|
+
* - Skips directory names in `ignoreDirs` (default: DEFAULT_IGNORE_DIRS) and,
|
|
304
|
+
* by default, every dot-prefixed entry (files AND dirs — matches the
|
|
305
|
+
* dominant prior behavior).
|
|
306
|
+
* - Calls `callback(absPath)` for every regular file reached.
|
|
307
|
+
* - NEVER throws. Unreadable entries invoke `onError(err, path)` if given.
|
|
308
|
+
* - Returns `true` iff the walk was COMPLETE (no unreadable entries). Callers
|
|
309
|
+
* computing counts MUST check this: a partial walk that silently under-
|
|
310
|
+
* counts is how a "code has N" assertion becomes confidently wrong — the
|
|
311
|
+
* tool's own worst failure mode.
|
|
312
|
+
*
|
|
313
|
+
* @param {string} dir - Absolute directory to walk
|
|
314
|
+
* @param {(absPath: string) => void} callback
|
|
315
|
+
* @param {{ignoreDirs?: Set<string>, skipDotEntries?: boolean, keepDot?: (entry: string) => boolean, onError?: (err: Error, path: string) => void}} [opts]
|
|
316
|
+
* `keepDot` — exception predicate for dot entries that MUST be walked even
|
|
317
|
+
* with skipDotEntries on. Load-bearing for e.g. the security validator
|
|
318
|
+
* (must scan `.env`) and traceability (`.env*`, `.gitignore`, `.github/`).
|
|
319
|
+
* @returns {boolean} - true if every entry was readable
|
|
320
|
+
*/
|
|
321
|
+
export function walkFiles(dir, callback, opts = {}) {
|
|
322
|
+
const {
|
|
323
|
+
ignoreDirs = DEFAULT_IGNORE_DIRS,
|
|
324
|
+
skipDotEntries = true,
|
|
325
|
+
keepDot = null,
|
|
326
|
+
onError = null,
|
|
327
|
+
} = opts;
|
|
328
|
+
let entries;
|
|
329
|
+
try { entries = readdirSync(dir); } catch (err) {
|
|
330
|
+
if (onError) onError(err, dir);
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
let complete = true;
|
|
334
|
+
for (const entry of entries) {
|
|
335
|
+
if (ignoreDirs.has(entry)) continue;
|
|
336
|
+
if (skipDotEntries && entry.startsWith('.') && !(keepDot && keepDot(entry))) continue;
|
|
337
|
+
const full = joinPath(dir, entry);
|
|
338
|
+
let stat;
|
|
339
|
+
try { stat = statSync(full); } catch (err) {
|
|
340
|
+
if (onError) onError(err, full);
|
|
341
|
+
complete = false;
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
if (stat.isDirectory()) {
|
|
345
|
+
if (!walkFiles(full, callback, opts)) complete = false;
|
|
346
|
+
} else if (stat.isFile()) {
|
|
347
|
+
callback(full);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return complete;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Count files under `projectDir` matching an anchored glob (project-relative).
|
|
355
|
+
* The code-truth side of `config.collections` (metrics-consistency).
|
|
356
|
+
*
|
|
357
|
+
* Walks only from the glob's literal prefix — never the whole repo for a deep
|
|
358
|
+
* pattern. FAIL-SAFE BY CONTRACT:
|
|
359
|
+
* - returns 0 when the base path doesn't exist (unresolved glob — caller skips);
|
|
360
|
+
* - returns -1 when the walk was INCOMPLETE (permission-denied subtree, bad
|
|
361
|
+
* pattern). Previously a partial walk silently under-counted, so a doc
|
|
362
|
+
* saying "19 extractors" could be "corrected" to a wrong lower number.
|
|
363
|
+
* Callers must treat any value <= 0 as "don't assert".
|
|
364
|
+
*
|
|
365
|
+
* @param {string} projectDir
|
|
366
|
+
* @param {string} pattern - e.g. "src/extractors/*.py"
|
|
367
|
+
* @returns {number} match count, 0 = unresolved, -1 = unreliable
|
|
368
|
+
*/
|
|
369
|
+
export function countGlobFiles(projectDir, pattern) {
|
|
370
|
+
const norm = String(pattern).replace(/\\/g, '/').replace(/^\.\//, '');
|
|
371
|
+
if (!norm) return -1;
|
|
372
|
+
const baseSegs = [];
|
|
373
|
+
for (const seg of norm.split('/')) {
|
|
374
|
+
if (/[*?{]/.test(seg)) break;
|
|
375
|
+
baseSegs.push(seg);
|
|
376
|
+
}
|
|
377
|
+
const baseDir = resolvePath(projectDir, baseSegs.join('/') || '.');
|
|
378
|
+
if (!existsSync(baseDir)) return 0;
|
|
379
|
+
let re;
|
|
380
|
+
try { re = compileGlob(norm); } catch { return -1; }
|
|
381
|
+
try {
|
|
382
|
+
if (statSync(baseDir).isFile()) return re.test(norm) ? 1 : 0; // literal file pattern
|
|
383
|
+
} catch { return -1; }
|
|
384
|
+
let n = 0;
|
|
385
|
+
const complete = walkFiles(baseDir, (full) => {
|
|
386
|
+
if (re.test(relPosix(projectDir, full))) n++;
|
|
387
|
+
});
|
|
388
|
+
return complete ? n : -1;
|
|
389
|
+
}
|
package/cli/shared.mjs
CHANGED
|
@@ -253,9 +253,53 @@ export const PROFILES = {
|
|
|
253
253
|
};
|
|
254
254
|
|
|
255
255
|
// ── .docguardignore Support ───────────────────────────────────────────────
|
|
256
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
256
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
257
257
|
import { resolve, relative } from 'node:path';
|
|
258
258
|
|
|
259
|
+
/**
|
|
260
|
+
* Conventional documentation-home directory names. A folder named one of these
|
|
261
|
+
* is unambiguously "docs DocGuard governs" — distinct from arbitrary markdown
|
|
262
|
+
* buried in a non-doc subdir (security/wolf-archive/, vendored toolkits), which
|
|
263
|
+
* the wu-whatsappinbox scoping fix deliberately excludes. We auto-track the
|
|
264
|
+
* former and never blanket-walk the latter.
|
|
265
|
+
*/
|
|
266
|
+
export const DEFAULT_DOC_DIRS = [
|
|
267
|
+
'docs', 'doc', 'documentation', 'docs-canonical', 'docs-implementation',
|
|
268
|
+
'guides', 'guide', 'handbook', 'manual', 'wiki', 'extensions',
|
|
269
|
+
];
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Resolve the documentation-home directories for a project (relative dir paths,
|
|
273
|
+
* no trailing slash). Single source of truth so the claim scanner and the
|
|
274
|
+
* coverage map agree — "tracked" must mean "actually scanned," never a label
|
|
275
|
+
* the scanner ignores.
|
|
276
|
+
*
|
|
277
|
+
* Auto-detects the conventional doc-home names that actually exist at the root,
|
|
278
|
+
* plus the Docusaurus-style `website/docs`. `config.docs.dirs` EXTENDS that set
|
|
279
|
+
* (adds non-standard homes like a project's `wiki/`) rather than replacing it —
|
|
280
|
+
* the least-surprising model, since the whole point is to track MORE clearly-doc
|
|
281
|
+
* folders automatically. To EXCLUDE a conventional dir, use `.docguardignore`.
|
|
282
|
+
* NAMED dirs only — this never walks arbitrary subdirectories (that was the
|
|
283
|
+
* false-positive flood the scoping fix removed).
|
|
284
|
+
*
|
|
285
|
+
* @param {string} projectDir
|
|
286
|
+
* @param {object} [config]
|
|
287
|
+
* @returns {string[]} relative directory paths (e.g. ['docs', 'documentation'])
|
|
288
|
+
*/
|
|
289
|
+
export function resolveDocDirs(projectDir, config = {}) {
|
|
290
|
+
const isDir = (rel) => {
|
|
291
|
+
try { return statSync(resolve(projectDir, rel)).isDirectory(); } catch { return false; }
|
|
292
|
+
};
|
|
293
|
+
const out = new Set(DEFAULT_DOC_DIRS.filter(isDir));
|
|
294
|
+
if (isDir('website/docs')) out.add('website/docs');
|
|
295
|
+
const declared = config && config.docs && Array.isArray(config.docs.dirs) ? config.docs.dirs : [];
|
|
296
|
+
for (const d of declared) {
|
|
297
|
+
const norm = String(d).replace(/\\/g, '/').replace(/\/+$/, '');
|
|
298
|
+
if (norm) out.add(norm);
|
|
299
|
+
}
|
|
300
|
+
return [...out];
|
|
301
|
+
}
|
|
302
|
+
|
|
259
303
|
/**
|
|
260
304
|
* Load ignore patterns from .docguardignore (like .gitignore).
|
|
261
305
|
* Returns a function that checks if a relative path should be ignored.
|