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
@@ -28,6 +28,7 @@ import { resolve, basename, join } from 'node:path';
28
28
 
29
29
  import { buildMemoryPlan } from '../scanners/memory-plan.mjs';
30
30
  import { getSection } from '../writers/sections.mjs';
31
+ import { mkFinding, resultFromFindings } from '../findings.mjs';
31
32
 
32
33
  /**
33
34
  * v0.18-P1 fast-path: cheap pre-flight to detect whether ANY canonical doc
@@ -103,7 +104,12 @@ export function validateGeneratedStaleness(projectDir, config = {}) {
103
104
  // `applyMechanicalFixes` can consume it via the new regenerate-section
104
105
  // applier. Lets `fix --write` actually CLOSE the loop on drift instead
105
106
  // of just warning. No AI needed — the scanner already knows the right body.
107
+ //
108
+ // v0.29: migrated to structured findings (GST001–GST002). Messages are
109
+ // byte-identical to the legacy strings; the N/A early returns keep the
110
+ // legacy shape, and `fixes` is preserved for the fix applier.
106
111
  const result = { errors: [], warnings: [], passed: 0, total: 0, fixes: [] };
112
+ const findings = [];
107
113
 
108
114
  // v0.18-P1: cheap pre-flight. If no canonical doc has a source=code marker
109
115
  // AND no doc is in status:draft, this validator has nothing to do — skip
@@ -150,10 +156,15 @@ export function validateGeneratedStaleness(projectDir, config = {}) {
150
156
  const mtime = statSync(fullPath).mtime;
151
157
  const ageDays = (Date.now() - mtime.getTime()) / (1000 * 60 * 60 * 24);
152
158
  if (ageDays > draftThresholdDays) {
153
- result.warnings.push(
154
- `${basename(doc.path)} has been in \`status: draft\` for ${Math.floor(ageDays)} days. ` +
155
- `Promote to status:current or remove. Run \`/docguard.fix --doc ${basename(doc.path)}\` to draft the prose.`
156
- );
159
+ findings.push(mkFinding({
160
+ code: 'GST001',
161
+ validator: 'generatedStaleness',
162
+ severity: 'warn',
163
+ message: `${basename(doc.path)} has been in \`status: draft\` for ${Math.floor(ageDays)} days. ` +
164
+ `Promote to status:current or remove. Run \`/docguard.fix --doc ${basename(doc.path)}\` to draft the prose.`,
165
+ location: doc.path,
166
+ suggestion: { kind: 'review', text: 'Draft the prose and promote to status:current, or delete the forgotten skeleton', command: `/docguard.fix --doc ${basename(doc.path)}` },
167
+ }));
157
168
  } else {
158
169
  result.passed++;
159
170
  }
@@ -206,11 +217,16 @@ export function validateGeneratedStaleness(projectDir, config = {}) {
206
217
  ? ` (first drift at line ${firstDiff + 1} of section: "${(act[firstDiff] || '').slice(0, 60)}…" vs scanner: "${(exp[firstDiff] || '').slice(0, 60)}…")`
207
218
  : '';
208
219
 
209
- result.warnings.push(
210
- `${basename(doc.path)} → section "${sec.id}" is stale${hint}. Run \`docguard sync --write\` to refresh code-truth sections. ` +
211
- `If this section is intentionally hand-maintained (the scanner mislabeled it), pin it: ` +
212
- `add \`pinned="reason"\` to its \`<!-- docguard:section id=${sec.id} … -->\` marker.`
213
- );
220
+ findings.push(mkFinding({
221
+ code: 'GST002',
222
+ validator: 'generatedStaleness',
223
+ severity: 'warn',
224
+ message: `${basename(doc.path)} → section "${sec.id}" is stale${hint}. Run \`docguard sync --write\` to refresh code-truth sections. ` +
225
+ `If this section is intentionally hand-maintained (the scanner mislabeled it), pin it: ` +
226
+ `add \`pinned="reason"\` to its \`<!-- docguard:section id=${sec.id} … -->\` marker.`,
227
+ location: doc.path,
228
+ suggestion: { kind: 'fix', text: 'Refresh the code-truth section (or pin it if it is intentionally hand-maintained)', command: 'docguard sync --write' },
229
+ }));
214
230
  // v0.14-P3: structured fix so `docguard fix --write` can fix this
215
231
  // mechanically (no AI needed — scanner already produced the right body).
216
232
  result.fixes.push({
@@ -230,5 +246,5 @@ export function validateGeneratedStaleness(projectDir, config = {}) {
230
246
  return { ...result, applicable: false };
231
247
  }
232
248
 
233
- return result;
249
+ return { ...resultFromFindings(findings, { passed: result.passed, total: result.total }), fixes: result.fixes };
234
250
  }
@@ -3,12 +3,18 @@
3
3
  *
4
4
  * Cross-checks package.json version against extension.yml and all .md files.
5
5
  * Flags outdated version strings (e.g., README references v0.7.2 but package.json is 0.8.0).
6
+ *
7
+ * v0.29: migrated to structured findings (MDS001–MDS002). Messages are
8
+ * byte-identical to the legacy strings; the `fixes` array is preserved for
9
+ * the fix applier.
6
10
  */
7
11
 
8
12
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
9
13
  import { resolve, join, relative, extname } from 'node:path';
10
14
  import { loadIgnorePatterns } from '../shared.mjs';
11
15
  import { collectPackageJsons } from '../shared-source.mjs';
16
+ import { walkFiles as sharedWalkFiles } from '../shared-ignore.mjs';
17
+ import { mkFinding, resultFromFindings } from '../findings.mjs';
12
18
 
13
19
  const IGNORE_DIRS = new Set([
14
20
  'node_modules', '.git', '.next', 'dist', 'build', 'coverage',
@@ -22,7 +28,7 @@ const IGNORE_DIRS = new Set([
22
28
  * @returns {{ errors: string[], warnings: string[], passed: number, total: number }}
23
29
  */
24
30
  export function validateMetadataSync(projectDir, config) {
25
- const warnings = [];
31
+ const findings = [];
26
32
  const fixes = [];
27
33
  let passed = 0;
28
34
  let total = 0;
@@ -45,7 +51,8 @@ export function validateMetadataSync(projectDir, config) {
45
51
  if (pkg.version) { currentVersion = pkg.version; currentName = currentName || pkg.name || null; break; }
46
52
  }
47
53
  }
48
- if (!currentVersion) return { errors: [], warnings, passed: 0, total: 0 };
54
+ // Literal legacy shape (no findings/fixes keys) tests deepEqual this object.
55
+ if (!currentVersion) return { errors: [], warnings: [], passed: 0, total: 0 };
49
56
 
50
57
  // Parse into components for smart comparison. `|| 0` guards two-part versions
51
58
  // (e.g. "1.2"): without it vParts[2] is undefined → parseInt → NaN, and every
@@ -65,9 +72,14 @@ export function validateMetadataSync(projectDir, config) {
65
72
  const versionMatch = content.match(/version:\s*["']?(\d+\.\d+\.\d+)["']?/);
66
73
  if (versionMatch) {
67
74
  if (versionMatch[1] !== currentVersion) {
68
- warnings.push(
69
- `${relPath} has version "${versionMatch[1]}" but package.json is "${currentVersion}"`
70
- );
75
+ findings.push(mkFinding({
76
+ code: 'MDS001',
77
+ validator: 'metadataSync',
78
+ severity: 'warn',
79
+ message: `${relPath} has version "${versionMatch[1]}" but package.json is "${currentVersion}"`,
80
+ location: relPath,
81
+ suggestion: { kind: 'fix', text: `Update the version field to ${currentVersion}`, command: 'docguard fix --write' },
82
+ }));
71
83
  fixes.push({ type: 'replace-version', file: relPath, found: versionMatch[1], actual: currentVersion });
72
84
  } else {
73
85
  passed++;
@@ -129,9 +141,14 @@ export function validateMetadataSync(projectDir, config) {
129
141
 
130
142
  if (isOlder && foundVersion !== currentVersion) {
131
143
  total++;
132
- warnings.push(
133
- `${relPath} references "v${foundVersion}" in an actionable context (URL/install/declaration) but current version is "${currentVersion}"`
134
- );
144
+ findings.push(mkFinding({
145
+ code: 'MDS002',
146
+ validator: 'metadataSync',
147
+ severity: 'warn',
148
+ message: `${relPath} references "v${foundVersion}" in an actionable context (URL/install/declaration) but current version is "${currentVersion}"`,
149
+ location: relPath,
150
+ suggestion: { kind: 'fix', text: `Replace the stale ${foundVersion} reference with ${currentVersion}`, command: 'docguard fix --write' },
151
+ }));
135
152
  if (!fixes.some(f => f.file === relPath && f.found === foundVersion)) {
136
153
  fixes.push({ type: 'replace-version', file: relPath, found: foundVersion, actual: currentVersion });
137
154
  }
@@ -143,7 +160,7 @@ export function validateMetadataSync(projectDir, config) {
143
160
  }
144
161
  }
145
162
 
146
- return { errors: [], warnings, passed, total, fixes };
163
+ return { ...resultFromFindings(findings, { passed, total }), fixes };
147
164
  }
148
165
 
149
166
  // ── Helpers ──────────────────────────────────────────────────────────────────
@@ -186,21 +203,7 @@ function findMarkdownFiles(dir) {
186
203
  return mdFiles;
187
204
  }
188
205
 
206
+ // v0.29 consolidation: traversal delegates to the shared canonical walker.
189
207
  function walkFiles(dir, callback) {
190
- if (!existsSync(dir)) return;
191
- let entries;
192
- try { entries = readdirSync(dir); } catch { return; }
193
-
194
- for (const entry of entries) {
195
- if (IGNORE_DIRS.has(entry) || entry.startsWith('.')) continue;
196
- const fullPath = join(dir, entry);
197
- try {
198
- const stat = statSync(fullPath);
199
- if (stat.isDirectory()) {
200
- walkFiles(fullPath, callback);
201
- } else if (stat.isFile()) {
202
- callback(fullPath);
203
- }
204
- } catch { /* skip */ }
205
- }
208
+ sharedWalkFiles(dir, callback, { ignoreDirs: IGNORE_DIRS });
206
209
  }
@@ -8,12 +8,11 @@
8
8
 
9
9
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
10
10
  import { resolve, join, relative } from 'node:path';
11
- import { loadIgnorePatterns, c } from '../shared.mjs';
12
-
13
- const IGNORE_DIRS = new Set([
14
- 'node_modules', '.git', '.next', 'dist', 'build', 'coverage',
15
- '.cache', '__pycache__', '.venv', 'vendor', '.turbo', '.vercel',
16
- ]);
11
+ import { loadIgnorePatterns, resolveDocDirs } from '../shared.mjs';
12
+ // v0.29 consolidation: walker + glob counting live in shared-ignore.mjs (the
13
+ // single implementations) this file previously carried private copies.
14
+ import { walkFiles, countGlobFiles } from '../shared-ignore.mjs';
15
+ import { mkFinding, resultFromFindings } from '../findings.mjs';
17
16
 
18
17
  /**
19
18
  * Validate metrics consistency across documentation.
@@ -22,8 +21,11 @@ const IGNORE_DIRS = new Set([
22
21
  * @param {object} [guardResults] - Results from runGuardInternal (optional)
23
22
  * @returns {{ errors: string[], warnings: string[], passed: number, total: number }}
24
23
  */
24
+ // v0.29: migrated to structured findings (MET001 built-in meta-counts, MET002
25
+ // declared collections). Messages are byte-identical to the legacy strings;
26
+ // the `fixes` array is preserved for the fix applier.
25
27
  export function validateMetricsConsistency(projectDir, config, guardResults) {
26
- const warnings = [];
28
+ const findings = [];
27
29
  const fixes = [];
28
30
  let passed = 0;
29
31
  let total = 0;
@@ -52,18 +54,56 @@ export function validateMetricsConsistency(projectDir, config, guardResults) {
52
54
 
53
55
  // If no actuals to compare, skip
54
56
  if (Object.keys(actuals).length === 0) {
55
- return { errors: [], warnings, passed: 0, total: 0 };
57
+ return resultFromFindings([], { passed: 0, total: 0 });
56
58
  }
57
59
 
58
60
  // ── Scan markdown files for hardcoded numbers ──
59
61
  const isIgnored = loadIgnorePatterns(projectDir);
60
62
  const mdFiles = findMarkdownFiles(projectDir, config);
61
- // Patterns must match standalone number references, not ratio-style "8/8 checks"
63
+ // Patterns must match standalone number references, not ratio-style "8/8 checks".
64
+ // `requireBind`: built-in DocGuard meta-counts (checks/validators) describe a
65
+ // generic noun, so they only fire when the line is bound to "docguard" (Bug #2).
66
+ // `subject`: human phrasing for the warning. `actualSource`: records WHAT the
67
+ // actual count describes so the fix applier can confirm both sides are the same
68
+ // subject before overwriting.
62
69
  const patterns = [
63
- { key: 'checks', regex: /(?<!\d\/)\b(\d{2,})\s+(?:automated\s+)?checks?\b/gi, label: 'checks' },
64
- { key: 'validators', regex: /(?<!\d\/)\b(\d{2,})\s+validators?\b/gi, label: 'validators' },
70
+ { key: 'checks', regex: /(?<!\d\/)\b(\d{2,})\s+(?:automated\s+)?checks?\b/gi, label: 'checks', requireBind: true, subject: "DocGuard's own", actualSource: 'docguard.guard.checks' },
71
+ { key: 'validators', regex: /(?<!\d\/)\b(\d{2,})\s+validators?\b/gi, label: 'validators', requireBind: true, subject: "DocGuard's own", actualSource: 'docguard.guard.validators' },
65
72
  ];
66
73
 
74
+ // v0.29 (field report #6): project-declared collections. `config.collections`
75
+ // maps a documentation noun (e.g. "extractors") to a glob whose matching-file
76
+ // count is the source of truth. This catches the exact class that shipped a
77
+ // wrong "16 extractors" past a green guard — deterministically, in `guard`, with
78
+ // no LLM. A declared collection IS the opt-in binding (the user named this noun),
79
+ // so unlike the built-ins it does NOT require "docguard" on the line. Fail-safe:
80
+ // an unresolved glob (0 matches) never asserts "0", so a misconfigured pattern
81
+ // can't manufacture a false drift. Reserved nouns keep the built-in count.
82
+ const RESERVED = new Set(['checks', 'validators', 'tests']);
83
+ const escapeRegExp = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // noun stems, not globs
84
+ const collections = (config && config.collections && typeof config.collections === 'object') ? config.collections : {};
85
+ for (const [noun, glob] of Object.entries(collections)) {
86
+ const key = String(noun).toLowerCase();
87
+ if (RESERVED.has(key) || typeof glob !== 'string' || !glob.trim()) continue;
88
+ const count = countGlobFiles(projectDir, glob);
89
+ // <= 0 covers BOTH "unresolved/empty glob" (0) and "walk incomplete" (-1,
90
+ // e.g. permission-denied subtree). Either way: never assert a count we
91
+ // can't stand behind — a partial count auto-"fixing" a correct doc number
92
+ // is the tool's worst failure mode.
93
+ if (count <= 0) continue;
94
+ actuals[key] = count;
95
+ const stem = escapeRegExp(String(noun).replace(/s$/i, ''));
96
+ patterns.push({
97
+ key,
98
+ regex: new RegExp(`(?<!\\d\\/)\\b(\\d+)\\s+${stem}s?\\b`, 'gi'),
99
+ label: String(noun),
100
+ requireBind: false,
101
+ isCollection: true,
102
+ glob,
103
+ actualSource: `docguard.collections.${key}`,
104
+ });
105
+ }
106
+
67
107
  // v0.14.1-N1: dedup by (file, label, found) — a file that mentions the
68
108
  // stale number multiple times produces ONE warning, not one per occurrence.
69
109
  // The replace-count applier already uses replace-all semantics, so a single
@@ -82,7 +122,7 @@ export function validateMetricsConsistency(projectDir, config, guardResults) {
82
122
  let content;
83
123
  try { content = readFileSync(mdFile, 'utf-8'); } catch { continue; }
84
124
 
85
- for (const { key, regex, label } of patterns) {
125
+ for (const { key, regex, label, requireBind, subject, actualSource, isCollection, glob } of patterns) {
86
126
  if (actuals[key] === undefined) continue;
87
127
 
88
128
  regex.lastIndex = 0;
@@ -93,11 +133,13 @@ export function validateMetricsConsistency(projectDir, config, guardResults) {
93
133
  // "19" on line 50 are two distinct drifts.
94
134
  const distinctFoundInFile = new Set();
95
135
  while ((match = regex.exec(content)) !== null) {
96
- // Bug #2 (subject-binding): only validate a number BOUND to DocGuard.
97
- // An unbound "N checks" (a proof harness, a CI job, a third-party tool)
98
- // describes a DIFFERENT subject — comparing it to DocGuard's own count
99
- // is a false positive, and auto-fixing it overwrites a correct number.
100
- if (!isDocguardBound(content, match.index)) continue;
136
+ // Bug #2 (subject-binding): for the built-in meta-counts, only validate a
137
+ // number BOUND to DocGuard. An unbound "N checks" (a proof harness, a CI
138
+ // job, a third-party tool) describes a DIFFERENT subject — comparing it to
139
+ // DocGuard's own count is a false positive, and auto-fixing it overwrites a
140
+ // correct number. Project-declared collections (requireBind:false) skip
141
+ // this: naming the noun in `config.collections` IS the explicit binding.
142
+ if (requireBind && !isDocguardBound(content, match.index)) continue;
101
143
  distinctFoundInFile.add(parseInt(match[1], 10));
102
144
  }
103
145
  if (distinctFoundInFile.size === 0) continue;
@@ -108,13 +150,27 @@ export function validateMetricsConsistency(projectDir, config, guardResults) {
108
150
  if (reportedDrift.has(driftKey)) continue;
109
151
  reportedDrift.add(driftKey);
110
152
  total++;
111
- warnings.push(
112
- `${relPath} says "${found} ${label}" but DocGuard's own ${label} count is ${actuals[key]}. Fix with \`docguard fix --write\``
113
- );
153
+ const phrase = isCollection
154
+ ? `the code has ${actuals[key]} (${glob})`
155
+ : `${subject} ${label} count is ${actuals[key]}`;
156
+ findings.push(mkFinding({
157
+ code: isCollection ? 'MET002' : 'MET001',
158
+ validator: 'metricsConsistency',
159
+ severity: 'warn',
160
+ message: `${relPath} says "${found} ${label}" but ${phrase}. Fix with \`docguard fix --write\``,
161
+ location: relPath,
162
+ suggestion: {
163
+ kind: 'fix',
164
+ text: isCollection
165
+ ? `Confirm which side is right, then rewrite the stale count (${found} → ${actuals[key]})`
166
+ : `Rewrite the stale docguard-bound count (${found} → ${actuals[key]})`,
167
+ command: 'docguard fix --write',
168
+ },
169
+ }));
114
170
  // actualSource records WHAT the actual count describes, so the applier
115
171
  // (and a human) can confirm both sides are the same subject before any
116
172
  // overwrite. Without it the fix is refused (fail-closed). See Bug #2.
117
- fixes.push({ type: 'replace-count', file: relPath, label, found, actual: actuals[key], actualSource: `docguard.guard.${key}` });
173
+ fixes.push({ type: 'replace-count', file: relPath, label, found, actual: actuals[key], actualSource });
118
174
  } else {
119
175
  // Matches the actual count — one pass per (file, label), not per occurrence.
120
176
  const passKey = `${relPath}|${label}`;
@@ -127,7 +183,7 @@ export function validateMetricsConsistency(projectDir, config, guardResults) {
127
183
  }
128
184
  }
129
185
 
130
- return { errors: [], warnings, passed, total, fixes };
186
+ return { ...resultFromFindings(findings, { passed, total }), fixes };
131
187
  }
132
188
 
133
189
  // ── Helpers ──────────────────────────────────────────────────────────────────
@@ -208,17 +264,20 @@ function findMarkdownFiles(dir, config = {}) {
208
264
  }
209
265
  } catch { /* unreadable root */ }
210
266
 
211
- // Configured canonical docs (wherever they live), plus the conventional
212
- // doc homes (docs/, docs-canonical/, extensions/) scanned in full
213
- // (recursive). Code/tooling dirs (security/, backend/, src/, …) are NOT doc
214
- // homes and are deliberately excluded.
267
+ // Configured canonical docs (wherever they live), plus every resolved doc
268
+ // home scanned in full (recursive). v0.29 (field report #6, follow-up): the
269
+ // doc-home set is no longer the hardcoded trio; resolveDocDirs auto-detects
270
+ // conventional doc dirs (docs/, documentation/, guides/, …) or honors an
271
+ // explicit config.docs.dirs. NAMED dirs only — code/tooling dirs (security/,
272
+ // backend/, src/, …) and arbitrary subdirs are still NEVER walked (the
273
+ // wu-whatsappinbox false-positive flood the scoping fix removed).
215
274
  const canonical = config && config.requiredFiles && Array.isArray(config.requiredFiles.canonical)
216
275
  ? config.requiredFiles.canonical : [];
217
276
  for (const rel of canonical) {
218
277
  const full = resolve(dir, rel);
219
278
  if (existsSync(full)) { try { if (statSync(full).isFile()) add(full); } catch { /* skip */ } }
220
279
  }
221
- for (const sub of ['docs', 'docs-canonical', 'extensions']) {
280
+ for (const sub of resolveDocDirs(dir, config)) {
222
281
  const searchDir = resolve(dir, sub);
223
282
  if (existsSync(searchDir)) walkFiles(searchDir, add);
224
283
  }
@@ -226,23 +285,6 @@ function findMarkdownFiles(dir, config = {}) {
226
285
  return mdFiles;
227
286
  }
228
287
 
229
- function walkFiles(dir, callback) {
230
- if (!existsSync(dir)) return;
231
- let entries;
232
- try { entries = readdirSync(dir); } catch { return; }
233
-
234
- for (const entry of entries) {
235
- if (IGNORE_DIRS.has(entry) || entry.startsWith('.')) continue;
236
- const fullPath = join(dir, entry);
237
- try {
238
- const stat = statSync(fullPath);
239
- if (stat.isDirectory()) {
240
- walkFiles(fullPath, callback);
241
- } else if (stat.isFile()) {
242
- callback(fullPath);
243
- }
244
- } catch (err) {
245
- console.error(`${c.red}Error reading file or directory: ${err.message}${c.reset}`);
246
- }
247
- }
248
- }
288
+ // Local walker + glob→count helpers were removed in the v0.29 consolidation —
289
+ // `walkFiles` / `countGlobFiles` are imported from ../shared-ignore.mjs, the
290
+ // single canonical implementations.
@@ -12,6 +12,8 @@
12
12
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
13
13
  import { resolve, join, relative, extname, basename } from 'node:path';
14
14
  import { resolveSourceRoots } from '../shared-source.mjs';
15
+ import { walkFiles as sharedWalkFiles } from '../shared-ignore.mjs';
16
+ import { mkFinding, resultFromFindings } from '../findings.mjs';
15
17
 
16
18
  const IGNORE_DIRS = new Set([
17
19
  'node_modules', '.git', '.next', 'dist', 'build', 'coverage',
@@ -77,9 +79,15 @@ const SCHEMA_DETECTORS = [
77
79
 
78
80
  /**
79
81
  * Main validator entry point.
82
+ *
83
+ * v0.29: migrated to structured findings (SCH001–SCH002). Messages are
84
+ * byte-identical to the legacy strings — resultFromFindings derives the
85
+ * errors/warnings arrays from the same findings array.
80
86
  */
81
87
  export function validateSchemaSync(projectDir, config) {
82
- const results = { errors: [], warnings: [], passed: 0, total: 0 };
88
+ const findings = [];
89
+ let passed = 0;
90
+ let total = 0;
83
91
 
84
92
  // Check if DATA-MODEL.md exists
85
93
  const dataModelPath = resolve(projectDir, 'docs-canonical', 'DATA-MODEL.md');
@@ -88,13 +96,18 @@ export function validateSchemaSync(projectDir, config) {
88
96
  // Only warn if we detect schema files
89
97
  const detectedModels = detectAllModels(projectDir, config);
90
98
  if (detectedModels.length > 0) {
91
- results.total++;
92
- results.warnings.push(
93
- `Found ${detectedModels.length} database model(s) (${detectedModels.map(m => m.name).slice(0, 5).join(', ')}${detectedModels.length > 5 ? '...' : ''}) ` +
94
- `but no DATA-MODEL.md exists. Run \`docguard init\` to create one, then document your schema`
95
- );
99
+ total++;
100
+ findings.push(mkFinding({
101
+ code: 'SCH001',
102
+ validator: 'schemaSync',
103
+ severity: 'warn',
104
+ message: `Found ${detectedModels.length} database model(s) (${detectedModels.map(m => m.name).slice(0, 5).join(', ')}${detectedModels.length > 5 ? '...' : ''}) ` +
105
+ `but no DATA-MODEL.md exists. Run \`docguard init\` to create one, then document your schema`,
106
+ location: 'docs-canonical/DATA-MODEL.md',
107
+ suggestion: { kind: 'fix', text: 'Create DATA-MODEL.md, then document the detected models in it', command: 'docguard init' },
108
+ }));
96
109
  }
97
- return results;
110
+ return resultFromFindings(findings, { passed, total });
98
111
  }
99
112
 
100
113
  const dataModelContent = readFileSync(dataModelPath, 'utf-8').toLowerCase();
@@ -104,12 +117,12 @@ export function validateSchemaSync(projectDir, config) {
104
117
 
105
118
  if (detectedModels.length === 0) {
106
119
  // No schema files found — silently pass
107
- return results;
120
+ return resultFromFindings(findings, { passed, total });
108
121
  }
109
122
 
110
123
  // Check each model appears in DATA-MODEL.md
111
124
  for (const model of detectedModels) {
112
- results.total++;
125
+ total++;
113
126
 
114
127
  // Check if model name appears in DATA-MODEL.md (case-insensitive)
115
128
  const modelLower = model.name.toLowerCase();
@@ -120,16 +133,21 @@ export function validateSchemaSync(projectDir, config) {
120
133
  (modelLower.endsWith('s') && dataModelContent.includes(modelLower.slice(0, -1)));
121
134
 
122
135
  if (found) {
123
- results.passed++;
136
+ passed++;
124
137
  } else {
125
- results.warnings.push(
126
- `${model.framework} model "${model.name}" (${model.file}) not documented in DATA-MODEL.md. ` +
127
- `Add it to the Entity Definitions section`
128
- );
138
+ findings.push(mkFinding({
139
+ code: 'SCH002',
140
+ validator: 'schemaSync',
141
+ severity: 'warn',
142
+ message: `${model.framework} model "${model.name}" (${model.file}) not documented in DATA-MODEL.md. ` +
143
+ `Add it to the Entity Definitions section`,
144
+ location: model.file,
145
+ suggestion: { kind: 'fix', text: 'Document the model in the Entity Definitions section of docs-canonical/DATA-MODEL.md' },
146
+ }));
129
147
  }
130
148
  }
131
149
 
132
- return results;
150
+ return resultFromFindings(findings, { passed, total });
133
151
  }
134
152
 
135
153
  // ──── Model Detection ──────────────────────────────────────────────────────
@@ -193,24 +211,11 @@ function findSchemaFiles(projectDir, detector, config = {}) {
193
211
  return files;
194
212
  }
195
213
 
214
+ // v0.29 consolidation: traversal delegates to the shared canonical walker.
196
215
  function scanSchemaDir(dir, filePattern, files) {
197
- let entries;
198
- try { entries = readdirSync(dir); } catch { return; }
199
-
200
- for (const entry of entries) {
201
- if (IGNORE_DIRS.has(entry)) continue;
202
- if (entry.startsWith('.')) continue;
203
-
204
- const full = join(dir, entry);
205
- let stat;
206
- try { stat = statSync(full); } catch { continue; }
207
-
208
- if (stat.isDirectory()) {
209
- scanSchemaDir(full, filePattern, files);
210
- } else if (filePattern.test(entry)) {
211
- files.push(full);
212
- }
213
- }
216
+ sharedWalkFiles(dir, (full) => {
217
+ if (filePattern.test(basename(full))) files.push(full);
218
+ }, { ignoreDirs: IGNORE_DIRS });
214
219
  }
215
220
 
216
221
  /**
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
9
9
  import { resolve, join, extname } from 'node:path';
10
- import { shouldIgnore, relPosix } from '../shared-ignore.mjs';
10
+ import { shouldIgnore, relPosix, walkFiles as sharedWalkFiles } from '../shared-ignore.mjs';
11
11
  import { mkFinding, resultFromFindings, lineSuppresses } from '../findings.mjs';
12
12
 
13
13
  // Each secret pattern maps to a stable finding code (see cli/findings.mjs CODES)
@@ -233,24 +233,11 @@ export function validateSecurity(projectDir, config) {
233
233
  return resultFromFindings(findings, { passed, total });
234
234
  }
235
235
 
236
+ // v0.29 consolidation: traversal delegates to the shared canonical walker.
237
+ // keepDot('.env') is LOAD-BEARING — a secrets validator must scan dotenv files.
236
238
  function walkDir(dir, callback) {
237
- if (!existsSync(dir)) return;
238
-
239
- const entries = readdirSync(dir);
240
- for (const entry of entries) {
241
- if (IGNORE_DIRS.has(entry)) continue;
242
- if (entry.startsWith('.') && entry !== '.env') continue;
243
-
244
- const fullPath = join(dir, entry);
245
- try {
246
- const stat = statSync(fullPath);
247
- if (stat.isDirectory()) {
248
- walkDir(fullPath, callback);
249
- } else if (stat.isFile()) {
250
- callback(fullPath);
251
- }
252
- } catch {
253
- // Skip unreadable files
254
- }
255
- }
239
+ sharedWalkFiles(dir, callback, {
240
+ ignoreDirs: IGNORE_DIRS,
241
+ keepDot: (entry) => entry === '.env',
242
+ });
256
243
  }
@@ -9,6 +9,9 @@
9
9
  *
10
10
  * Re-exports `validateSpecKitIntegration` from the scanner. Importers should
11
11
  * use this path (`validators/spec-kit.mjs`) going forward.
12
+ *
13
+ * v0.29: the validator emits structured findings (SPK001–SPK007); the
14
+ * migration lives in `scanners/speckit.mjs` alongside the validation logic.
12
15
  */
13
16
 
14
17
  export { validateSpecKitIntegration } from '../scanners/speckit.mjs';