docguard-cli 0.27.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.
Files changed (74) hide show
  1. package/README.es.md +102 -0
  2. package/README.md +65 -31
  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/explain.mjs +8 -6
  8. package/cli/commands/generate.mjs +14 -1001
  9. package/cli/commands/guard.mjs +149 -15
  10. package/cli/commands/init.mjs +23 -1
  11. package/cli/commands/llms.mjs +67 -5
  12. package/cli/commands/mcp.mjs +263 -0
  13. package/cli/commands/memory.mjs +115 -0
  14. package/cli/commands/score.mjs +76 -12
  15. package/cli/commands/sync-tests.mjs +272 -0
  16. package/cli/commands/sync.mjs +6 -0
  17. package/cli/commands/verify.mjs +67 -0
  18. package/cli/docguard.mjs +62 -5
  19. package/cli/findings.mjs +499 -0
  20. package/cli/scanners/agent-readability.mjs +202 -0
  21. package/cli/scanners/semantic-claims.mjs +160 -0
  22. package/cli/scanners/speckit.mjs +98 -28
  23. package/cli/shared-ignore.mjs +148 -16
  24. package/cli/shared.mjs +45 -1
  25. package/cli/validators/api-surface.mjs +182 -29
  26. package/cli/validators/architecture.mjs +91 -56
  27. package/cli/validators/canonical-sync.mjs +59 -28
  28. package/cli/validators/changelog.mjs +41 -17
  29. package/cli/validators/cross-reference.mjs +28 -11
  30. package/cli/validators/doc-quality.mjs +78 -44
  31. package/cli/validators/docs-coverage.mjs +90 -63
  32. package/cli/validators/docs-diff.mjs +63 -64
  33. package/cli/validators/docs-sync.mjs +48 -33
  34. package/cli/validators/drift.mjs +40 -34
  35. package/cli/validators/environment.mjs +67 -27
  36. package/cli/validators/freshness.mjs +12 -5
  37. package/cli/validators/generated-staleness.mjs +26 -10
  38. package/cli/validators/metadata-sync.mjs +28 -25
  39. package/cli/validators/metrics-consistency.mjs +89 -47
  40. package/cli/validators/schema-sync.mjs +37 -32
  41. package/cli/validators/security.mjs +7 -20
  42. package/cli/validators/spec-kit.mjs +3 -0
  43. package/cli/validators/structure.mjs +58 -23
  44. package/cli/validators/surface-sync.mjs +34 -15
  45. package/cli/validators/test-spec.mjs +87 -29
  46. package/cli/validators/todo-tracking.mjs +83 -74
  47. package/cli/validators/traceability.mjs +67 -39
  48. package/cli/writers/doc-generators.mjs +853 -0
  49. package/cli/writers/generate-io.mjs +142 -0
  50. package/cli/writers/sarif.mjs +129 -0
  51. package/commands/docguard.fix.md +56 -53
  52. package/commands/docguard.guard.md +53 -47
  53. package/commands/docguard.review.md +49 -31
  54. package/docs/ai-integration.md +133 -134
  55. package/docs/commands.md +49 -3
  56. package/docs/configuration.md +38 -0
  57. package/docs/faq.md +15 -0
  58. package/extensions/spec-kit-docguard/extension.yml +1 -1
  59. package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +2 -2
  60. package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +2 -2
  61. package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
  62. package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
  63. package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +2 -2
  64. package/package.json +1 -1
  65. package/schemas/docguard-config.schema.json +17 -0
  66. package/templates/ENVIRONMENT.md.template +5 -0
  67. package/templates/REQUIREMENTS.md.template +2 -0
  68. package/templates/SECURITY.md.template +6 -1
  69. package/templates/TEST-SPEC.md.template +5 -0
  70. package/templates/commands/docguard.fix.md +33 -10
  71. package/templates/commands/docguard.guard.md +40 -26
  72. package/templates/commands/docguard.init.md +23 -11
  73. package/templates/commands/docguard.review.md +25 -8
  74. 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,160 @@
1
+ /**
2
+ * Semantic claim extractor (LLM field report #5).
3
+ *
4
+ * The highest-value class of doc bug is SEMANTIC: a documented number/enum/limit
5
+ * that no longer matches the code — DLP retention "30 days" vs code 730, a status
6
+ * enum "PENDING/IDLE" vs "WAITING", "100/min" vs "500 req/s", "29+ roles" vs 44,
7
+ * "4 GSIs" vs 6. Regex/AST can't judge these (the doc value and the code value
8
+ * are both just numbers), so they slip through every deterministic validator.
9
+ *
10
+ * DocGuard is zero-dependency and does NOT call an LLM itself. So this is an
11
+ * EXTRACTOR: it surfaces the verifiable claims — value, unit, doc:line, section,
12
+ * and the nearest cited code path — as a structured task list. The agent running
13
+ * `docguard verify --semantic` does the actual comparison against the code. This
14
+ * mirrors the `docguard agent` task-graph: deterministic discovery, LLM judgment.
15
+ *
16
+ * Precision over recall: a number is only a claim when it carries a recognized
17
+ * unit (days/ms/req-s/GSIs/roles/…); an enum only when it's a list of 2+
18
+ * UPPER_SNAKE tokens in a status/state/enum context. Bare version strings, dates,
19
+ * and prose numbers are ignored.
20
+ *
21
+ * Zero npm dependencies — pure Node.js built-ins.
22
+ */
23
+
24
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
25
+ import { resolve, join } from 'node:path';
26
+
27
+ // Numbers are only claims when adjacent to a recognized unit.
28
+ const NUMBER_PATTERNS = [
29
+ { kind: 'duration', re: /\b(\d+(?:\.\d+)?)\s*(milliseconds?|ms|seconds?|secs?|minutes?|mins?|hours?|hrs?|days?|weeks?|months?|years?)\b/gi },
30
+ { kind: 'rate', re: /\b(\d+)\s*(?:\/|\bper\b|\breq(?:uests?)?\s*\/?)\s*(s|sec|seconds?|min|minutes?|hours?|h)\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 },
38
+ ];
39
+
40
+ // A list of 2+ UPPER_SNAKE tokens separated by / , | or "or" — an enum claim,
41
+ // but only when the line or its heading reads like a status/state/enum context.
42
+ const ENUM_LIST_RE = /\b[A-Z][A-Z0-9_]{2,}(?:\s*(?:\/|,|\||\bor\b)\s*[A-Z][A-Z0-9_]{2,}){1,}\b/g;
43
+ const ENUM_CONTEXT_RE = /\b(status|state|enum|values?|one of|phase|stage|transitions?)\b/i;
44
+
45
+ // A code path mentioned in or near the claim — the agent's starting point.
46
+ const CITED_CODE_RE = /`?([\w./-]+\.(?:ts|tsx|js|mjs|cjs|jsx|py|go|rs|java|kt|rb|php|sql|yaml|yml|json))`?(?::(\d+))?/;
47
+
48
+ const MAX_CLAIMS = 80;
49
+
50
+ /** Canonical docs + the root docs where limits/counts commonly live. */
51
+ function claimSourceDocs(projectDir) {
52
+ const docs = [];
53
+ const canonical = resolve(projectDir, 'docs-canonical');
54
+ if (existsSync(canonical)) {
55
+ try {
56
+ for (const f of readdirSync(canonical)) {
57
+ if (f.toLowerCase().endsWith('.md')) docs.push(`docs-canonical/${f}`);
58
+ }
59
+ } catch { /* ignore */ }
60
+ }
61
+ for (const root of ['README.md', 'AGENTS.md']) {
62
+ if (existsSync(resolve(projectDir, root))) docs.push(root);
63
+ }
64
+ return docs;
65
+ }
66
+
67
+ /** True if a line is inside a fenced code block (toggled by the caller). */
68
+ function findCitedCode(lines, idx) {
69
+ // Search the claim line first, then the immediately adjacent lines. A tight
70
+ // window avoids cross-attributing a path from an unrelated nearby claim (e.g.
71
+ // a rate limit grabbing the retention doc's cited file three lines up).
72
+ for (let d = 0; d <= 1; d++) {
73
+ for (const j of d === 0 ? [idx] : [idx - d, idx + d]) {
74
+ if (j < 0 || j >= lines.length) continue;
75
+ const m = CITED_CODE_RE.exec(lines[j]);
76
+ if (m) return m[2] ? `${m[1]}:${m[2]}` : m[1];
77
+ }
78
+ }
79
+ return null;
80
+ }
81
+
82
+ /**
83
+ * Extract semantic claims from a project's canonical docs.
84
+ * @returns {Array<{ doc, line, section, kind, subkind, value, unit, text, citedCode }>}
85
+ */
86
+ export function extractSemanticClaims(projectDir, config = {}) {
87
+ const claims = [];
88
+ const seen = new Set();
89
+
90
+ for (const doc of claimSourceDocs(projectDir)) {
91
+ let content;
92
+ try { content = readFileSync(resolve(projectDir, doc), 'utf-8'); } catch { continue; }
93
+ const lines = content.split('\n');
94
+ let section = '';
95
+ let inFence = false;
96
+
97
+ for (let i = 0; i < lines.length; i++) {
98
+ const line = lines[i];
99
+ if (/^\s*```/.test(line)) { inFence = !inFence; continue; }
100
+ if (inFence) continue; // numbers in code samples are examples, not claims
101
+ const h = line.match(/^#{1,6}\s+(.*)$/);
102
+ if (h) { section = h[1].trim(); continue; }
103
+
104
+ const lineNo = i + 1;
105
+ const push = (claim) => {
106
+ const key = `${doc}:${lineNo}:${claim.kind}:${claim.value}:${claim.unit || ''}`;
107
+ if (seen.has(key)) return;
108
+ seen.add(key);
109
+ claims.push({ doc, line: lineNo, section, citedCode: findCitedCode(lines, i), text: line.trim().slice(0, 200), ...claim });
110
+ };
111
+
112
+ for (const { kind, re } of NUMBER_PATTERNS) {
113
+ re.lastIndex = 0;
114
+ let m;
115
+ while ((m = re.exec(line)) !== null) {
116
+ push({ kind: 'number', subkind: kind, value: m[1], unit: m[2].toLowerCase() });
117
+ }
118
+ }
119
+
120
+ if (ENUM_CONTEXT_RE.test(line) || ENUM_CONTEXT_RE.test(section)) {
121
+ ENUM_LIST_RE.lastIndex = 0;
122
+ let m;
123
+ while ((m = ENUM_LIST_RE.exec(line)) !== null) {
124
+ // Skip all-caps acronym runs joined by slash that are really one token.
125
+ const values = m[0].split(/\s*(?:\/|,|\||\bor\b)\s*/).filter(Boolean);
126
+ if (values.length >= 2) push({ kind: 'enum', subkind: 'enum-list', value: values.join('/'), unit: null });
127
+ }
128
+ }
129
+
130
+ if (claims.length >= MAX_CLAIMS) return claims;
131
+ }
132
+ }
133
+ return claims;
134
+ }
135
+
136
+ /**
137
+ * Turn extracted claims into agent-executable verification tasks (one per claim).
138
+ * Pure — reused by the command and any task-graph consumer.
139
+ */
140
+ export function buildSemanticVerifyTasks(claims) {
141
+ return claims.map((c, i) => {
142
+ const where = c.citedCode ? ` Start at the cited code: ${c.citedCode}.` : ' No code path is cited nearby — grep the codebase for the relevant constant/config.';
143
+ const what = c.kind === 'enum'
144
+ ? `the enum/status set "${c.value}"`
145
+ : `the ${c.subkind} value ${c.value}${c.unit ? ` ${c.unit}` : ''}`;
146
+ return {
147
+ id: `verify.semantic.${i + 1}`,
148
+ doc: c.doc,
149
+ line: c.line,
150
+ section: c.section,
151
+ kind: c.kind,
152
+ value: c.value,
153
+ unit: c.unit,
154
+ citedCode: c.citedCode,
155
+ claim: c.text,
156
+ instruction: `Verify ${what} documented in ${c.doc}:${c.line}${c.section ? ` (section "${c.section}")` : ''} against the code.${where} If the code disagrees, the doc (or the code) is wrong — report the mismatch with both values.`,
157
+ confidence: 'requires-human',
158
+ };
159
+ });
160
+ }
@@ -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 results = { errors: [], warnings: [], passed: 0, total: 0 };
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
- results.total++;
397
- results.warnings.push(
398
- 'No Spec Kit artifacts detected. Consider `specify init` for spec-driven development (github.com/github/spec-kit)'
399
- );
400
- return results;
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
- results.total++;
417
+ total++;
405
418
  if (speckit.specifyDir) {
406
- results.passed++;
419
+ passed++;
407
420
  } else {
408
- results.warnings.push(
409
- 'Spec Kit artifacts found but .specify/ directory missing. Run `specify init` to create standard structure'
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
- results.total++;
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
- results.passed++;
440
+ passed++;
422
441
  } else {
423
442
  for (const issue of issues) {
424
- results.warnings.push(`specs/${spec.name}/spec.md: ${issue}`);
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
- results.warnings.push(`specs/${spec.name}/spec.md: Could not read file`);
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
- results.total++;
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
- results.passed++;
472
+ passed++;
439
473
  } else {
440
474
  for (const issue of issues) {
441
- results.warnings.push(`specs/${spec.name}/plan.md: ${issue}`);
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
- results.warnings.push(`specs/${spec.name}/plan.md: Could not read file`);
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
- results.total++;
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
- results.passed++;
504
+ passed++;
456
505
  } else {
457
506
  for (const issue of issues) {
458
- results.warnings.push(`specs/${spec.name}/tasks.md: ${issue}`);
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
- results.warnings.push(`specs/${spec.name}/tasks.md: Could not read file`);
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
- results.total++;
532
+ total++;
470
533
  const agentsPath = resolve(projectDir, 'AGENTS.md');
471
534
  if (existsSync(agentsPath)) {
472
- results.passed++;
535
+ passed++;
473
536
  } else {
474
- results.warnings.push('constitution.md exists but no AGENTS.md found. Create one for AI agent rules');
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 results;
548
+ return resultFromFindings(findings, { passed, total });
479
549
  }