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.
Files changed (65) hide show
  1. package/README.es.md +102 -0
  2. package/README.md +64 -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/generate.mjs +14 -1001
  8. package/cli/commands/guard.mjs +136 -8
  9. package/cli/commands/llms.mjs +67 -5
  10. package/cli/commands/mcp.mjs +263 -0
  11. package/cli/commands/memory.mjs +115 -0
  12. package/cli/commands/score.mjs +76 -12
  13. package/cli/docguard.mjs +31 -2
  14. package/cli/findings.mjs +499 -0
  15. package/cli/scanners/agent-readability.mjs +202 -0
  16. package/cli/scanners/semantic-claims.mjs +7 -1
  17. package/cli/scanners/speckit.mjs +98 -28
  18. package/cli/shared-ignore.mjs +148 -16
  19. package/cli/shared.mjs +45 -1
  20. package/cli/validators/api-surface.mjs +113 -26
  21. package/cli/validators/architecture.mjs +66 -43
  22. package/cli/validators/canonical-sync.mjs +59 -28
  23. package/cli/validators/changelog.mjs +41 -17
  24. package/cli/validators/cross-reference.mjs +28 -11
  25. package/cli/validators/doc-quality.mjs +78 -44
  26. package/cli/validators/docs-coverage.mjs +90 -63
  27. package/cli/validators/docs-diff.mjs +63 -64
  28. package/cli/validators/docs-sync.mjs +48 -33
  29. package/cli/validators/drift.mjs +40 -34
  30. package/cli/validators/environment.mjs +67 -27
  31. package/cli/validators/freshness.mjs +12 -5
  32. package/cli/validators/generated-staleness.mjs +26 -10
  33. package/cli/validators/metadata-sync.mjs +28 -25
  34. package/cli/validators/metrics-consistency.mjs +89 -47
  35. package/cli/validators/schema-sync.mjs +37 -32
  36. package/cli/validators/security.mjs +7 -20
  37. package/cli/validators/spec-kit.mjs +3 -0
  38. package/cli/validators/structure.mjs +58 -23
  39. package/cli/validators/surface-sync.mjs +34 -15
  40. package/cli/validators/test-spec.mjs +87 -29
  41. package/cli/validators/todo-tracking.mjs +83 -74
  42. package/cli/validators/traceability.mjs +67 -39
  43. package/cli/writers/doc-generators.mjs +853 -0
  44. package/cli/writers/generate-io.mjs +142 -0
  45. package/cli/writers/sarif.mjs +129 -0
  46. package/commands/docguard.fix.md +56 -53
  47. package/commands/docguard.guard.md +53 -47
  48. package/commands/docguard.review.md +49 -31
  49. package/docs/ai-integration.md +133 -134
  50. package/docs/commands.md +49 -3
  51. package/docs/configuration.md +38 -0
  52. package/docs/faq.md +15 -0
  53. package/extensions/spec-kit-docguard/extension.yml +1 -1
  54. package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +2 -2
  55. package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +2 -2
  56. package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
  57. package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
  58. package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +2 -2
  59. package/package.json +1 -1
  60. package/schemas/docguard-config.schema.json +17 -0
  61. package/templates/commands/docguard.fix.md +33 -10
  62. package/templates/commands/docguard.guard.md +40 -26
  63. package/templates/commands/docguard.init.md +23 -11
  64. package/templates/commands/docguard.review.md +25 -8
  65. package/templates/commands/docguard.update.md +14 -4
@@ -1,10 +1,16 @@
1
1
  /**
2
2
  * Drift Validator — Every // DRIFT: comment must have a DRIFT-LOG.md entry
3
+ *
4
+ * v0.29: migrated to structured findings (DRF001–DRF002). Messages are
5
+ * byte-identical to the legacy strings — resultFromFindings derives the
6
+ * errors/warnings arrays from the same findings, so counts, exit codes, and
7
+ * existing tests are unaffected; guard just renders richer output.
3
8
  */
4
9
 
5
10
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
6
11
  import { resolve, join, extname } from 'node:path';
7
- import { relPosix } from '../shared-ignore.mjs';
12
+ import { relPosix, walkFiles as sharedWalkFiles } from '../shared-ignore.mjs';
13
+ import { mkFinding, resultFromFindings } from '../findings.mjs';
8
14
 
9
15
  const CODE_EXTENSIONS = new Set([
10
16
  '.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx',
@@ -19,7 +25,9 @@ const IGNORE_DIRS = new Set([
19
25
  ]);
20
26
 
21
27
  export function validateDrift(projectDir, config) {
22
- const results = { name: 'drift', errors: [], warnings: [], passed: 0, total: 0 };
28
+ const findings = [];
29
+ let passed = 0;
30
+ let total = 0;
23
31
 
24
32
  // v0.15-P3: when config.changedFiles is set (--changed-only mode), only
25
33
  // visit the listed paths. Drift comments in unchanged files are still in
@@ -64,58 +72,56 @@ export function validateDrift(projectDir, config) {
64
72
 
65
73
  if (driftComments.length === 0) {
66
74
  // No // DRIFT: comments to reconcile — not applicable (NOT a pass).
67
- results.note = 'no // DRIFT: comments in code';
68
- return results;
75
+ return {
76
+ name: 'drift',
77
+ ...resultFromFindings([], { passed: 0, total: 0 }),
78
+ note: 'no // DRIFT: comments in code',
79
+ };
69
80
  }
70
81
 
71
82
  // Read DRIFT-LOG.md
72
83
  const driftLogPath = resolve(projectDir, config.requiredFiles.driftLog);
73
84
  if (!existsSync(driftLogPath)) {
74
- results.total = driftComments.length;
75
85
  for (const dc of driftComments) {
76
- results.errors.push(
77
- `${dc.file}:${dc.line} has DRIFT comment but DRIFT-LOG.md doesn't exist`
78
- );
86
+ findings.push(mkFinding({
87
+ code: 'DRF001',
88
+ validator: 'drift',
89
+ severity: 'error',
90
+ message: `${dc.file}:${dc.line} has DRIFT comment but DRIFT-LOG.md doesn't exist`,
91
+ location: `${dc.file}:${dc.line}`,
92
+ suggestion: { kind: 'fix', text: 'Create the drift log, then record this deviation in it', command: 'docguard init' },
93
+ }));
79
94
  }
80
- return results;
95
+ return { name: 'drift', ...resultFromFindings(findings, { passed: 0, total: driftComments.length }) };
81
96
  }
82
97
 
83
98
  const driftLogContent = readFileSync(driftLogPath, 'utf-8');
84
99
 
85
100
  // Check each drift comment has a matching entry in DRIFT-LOG.md
86
101
  for (const dc of driftComments) {
87
- results.total++;
102
+ total++;
88
103
  // Check if the file is mentioned in DRIFT-LOG.md
89
104
  if (driftLogContent.includes(dc.file)) {
90
- results.passed++;
105
+ passed++;
91
106
  } else {
92
- results.errors.push(
93
- `${dc.file}:${dc.line} — DRIFT comment not logged in DRIFT-LOG.md`
94
- );
107
+ findings.push(mkFinding({
108
+ code: 'DRF002',
109
+ validator: 'drift',
110
+ severity: 'error',
111
+ message: `${dc.file}:${dc.line} — DRIFT comment not logged in DRIFT-LOG.md`,
112
+ location: `${dc.file}:${dc.line}`,
113
+ suggestion: { kind: 'fix', text: 'Add an entry for this file to DRIFT-LOG.md explaining the deviation' },
114
+ }));
95
115
  }
96
116
  }
97
117
 
98
- return results;
118
+ return { name: 'drift', ...resultFromFindings(findings, { passed, total }) };
99
119
  }
100
120
 
121
+ // v0.29 consolidation: traversal delegates to the shared canonical walker;
122
+ // the IGNORE_DIRS set above stays local because its entries are intentional
123
+ // per-validator variance (e.g. 'cli' — DocGuard's own source has DRIFT: in
124
+ // regex patterns).
101
125
  function walkDir(dir, callback) {
102
- if (!existsSync(dir)) return;
103
-
104
- const entries = readdirSync(dir);
105
- for (const entry of entries) {
106
- if (IGNORE_DIRS.has(entry)) continue;
107
- if (entry.startsWith('.')) continue;
108
-
109
- const fullPath = join(dir, entry);
110
- try {
111
- const stat = statSync(fullPath);
112
- if (stat.isDirectory()) {
113
- walkDir(fullPath, callback);
114
- } else if (stat.isFile()) {
115
- callback(fullPath);
116
- }
117
- } catch {
118
- // Skip files we can't read
119
- }
120
- }
126
+ sharedWalkFiles(dir, callback, { ignoreDirs: IGNORE_DIRS });
121
127
  }
@@ -1,19 +1,30 @@
1
1
  /**
2
2
  * Environment Validator — Checks ENVIRONMENT.md docs and .env.example
3
3
  * Now respects projectTypeConfig (e.g., skip env checks for CLI tools)
4
+ *
5
+ * v0.29: migrated to structured findings (ENV001–ENV005). Messages are
6
+ * byte-identical to the legacy strings — resultFromFindings derives the
7
+ * errors/warnings arrays from the same findings, so counts, exit codes, and
8
+ * existing tests are unaffected; guard just renders richer output.
4
9
  */
5
10
 
6
11
  import { existsSync, readFileSync } from 'node:fs';
7
12
  import { resolve } from 'node:path';
8
13
  import { grepEnvUsage } from '../shared-source.mjs';
14
+ import { mkFinding, resultFromFindings } from '../findings.mjs';
9
15
 
10
16
  export function validateEnvironment(projectDir, config) {
11
- const results = { name: 'environment', errors: [], warnings: [], passed: 0, total: 0 };
17
+ const findings = [];
18
+ let passed = 0;
19
+ let total = 0;
12
20
  const ptc = config.projectTypeConfig || {};
13
21
 
14
- const envDocPath = resolve(projectDir, 'docs-canonical/ENVIRONMENT.md');
22
+ const envDoc = 'docs-canonical/ENVIRONMENT.md';
23
+ const envDocPath = resolve(projectDir, envDoc);
15
24
  if (!existsSync(envDocPath)) {
16
- return results; // Structure validator catches missing files
25
+ // Structure validator catches missing files. Keep the exact legacy shape
26
+ // here (no `findings` key) — tests deep-equal this early return.
27
+ return { name: 'environment', errors: [], warnings: [], passed: 0, total: 0 };
17
28
  }
18
29
 
19
30
  const content = readFileSync(envDocPath, 'utf-8');
@@ -21,18 +32,32 @@ export function validateEnvironment(projectDir, config) {
21
32
  // Check for required sections (anchored headings — not substring matches that
22
33
  // could hit a TOC entry or code block).
23
34
  const hasHeading = (re) => re.test(content);
24
- results.total++;
35
+ total++;
25
36
  if (hasHeading(/^#{2,3}\s+(Prerequisites|Setup Steps)\b/m)) {
26
- results.passed++;
37
+ passed++;
27
38
  } else {
28
- results.warnings.push('ENVIRONMENT.md: missing "## Prerequisites" or "## Setup Steps" section');
39
+ findings.push(mkFinding({
40
+ code: 'ENV001',
41
+ validator: 'environment',
42
+ severity: 'warn',
43
+ message: 'ENVIRONMENT.md: missing "## Prerequisites" or "## Setup Steps" section',
44
+ location: envDoc,
45
+ suggestion: { kind: 'fix', text: 'Add a "## Setup Steps" (or "## Prerequisites") section describing how to get the project running' },
46
+ }));
29
47
  }
30
48
 
31
- results.total++;
49
+ total++;
32
50
  if (hasHeading(/^#{2,3}\s+Environment Variables\b/m)) {
33
- results.passed++;
51
+ passed++;
34
52
  } else {
35
- results.warnings.push('ENVIRONMENT.md: missing "## Environment Variables" section');
53
+ findings.push(mkFinding({
54
+ code: 'ENV002',
55
+ validator: 'environment',
56
+ severity: 'warn',
57
+ message: 'ENVIRONMENT.md: missing "## Environment Variables" section',
58
+ location: envDoc,
59
+ suggestion: { kind: 'fix', text: 'Add a "## Environment Variables" section documenting each variable the app reads' },
60
+ }));
36
61
  }
37
62
 
38
63
  // ── Real code-truth check: env vars USED in code but documented nowhere ──
@@ -92,15 +117,20 @@ export function validateEnvironment(projectDir, config) {
92
117
  // vacuous (always passes) and would just inflate the count.
93
118
  if (codeUsed.size > 0) {
94
119
  const usedButUndocumented = [...codeUsed].filter(v => !documented.has(v));
95
- results.total++;
120
+ total++;
96
121
  if (usedButUndocumented.length === 0) {
97
- results.passed++;
122
+ passed++;
98
123
  } else {
99
124
  const shown = usedButUndocumented.slice(0, 10).join(', ');
100
125
  const more = usedButUndocumented.length > 10 ? ` (+${usedButUndocumented.length - 10} more)` : '';
101
- results.warnings.push(
102
- `${usedButUndocumented.length} env var(s) used in code but not documented in ENVIRONMENT.md / .env.example: ${shown}${more}`
103
- );
126
+ findings.push(mkFinding({
127
+ code: 'ENV003',
128
+ validator: 'environment',
129
+ severity: 'warn',
130
+ message: `${usedButUndocumented.length} env var(s) used in code but not documented in ENVIRONMENT.md / .env.example: ${shown}${more}`,
131
+ location: envDoc,
132
+ suggestion: { kind: 'fix', text: 'Document each listed variable in ENVIRONMENT.md, or add it to .env.example' },
133
+ }));
104
134
  }
105
135
  }
106
136
  }
@@ -109,35 +139,45 @@ export function validateEnvironment(projectDir, config) {
109
139
  if (ptc.needsEnvExample !== false && ptc.needsEnvVars !== false) {
110
140
  // Check if .env.example is referenced and exists
111
141
  if (content.includes('.env.example')) {
112
- results.total++;
142
+ total++;
113
143
  if (existsSync(resolve(projectDir, '.env.example'))) {
114
- results.passed++;
144
+ passed++;
115
145
  } else {
116
- results.warnings.push(
117
- 'ENVIRONMENT.md references .env.example but the file does not exist'
118
- );
146
+ findings.push(mkFinding({
147
+ code: 'ENV004',
148
+ validator: 'environment',
149
+ severity: 'warn',
150
+ message: 'ENVIRONMENT.md references .env.example but the file does not exist',
151
+ location: envDoc,
152
+ suggestion: { kind: 'fix', text: 'Create .env.example with placeholder values, or remove the stale reference from ENVIRONMENT.md' },
153
+ }));
119
154
  }
120
155
  }
121
156
 
122
157
  // Check if any .env file exists but no .env.example is provided
123
- results.total++;
158
+ total++;
124
159
  const hasEnvFile = ['.env', '.env.local', '.env.development'].some(f =>
125
160
  existsSync(resolve(projectDir, f))
126
161
  );
127
162
  const hasEnvExample = existsSync(resolve(projectDir, '.env.example'));
128
163
 
129
164
  if (hasEnvFile && !hasEnvExample) {
130
- results.warnings.push(
131
- '.env file exists but no .env.example template — new contributors won\'t know what vars to set'
132
- );
165
+ findings.push(mkFinding({
166
+ code: 'ENV005',
167
+ validator: 'environment',
168
+ severity: 'warn',
169
+ message: '.env file exists but no .env.example template — new contributors won\'t know what vars to set',
170
+ location: '.env.example',
171
+ suggestion: { kind: 'fix', text: 'Create a .env.example template listing every variable with a placeholder value' },
172
+ }));
133
173
  } else {
134
- results.passed++;
174
+ passed++;
135
175
  }
136
176
  } else {
137
177
  // CLI/library project — just verify doc exists and has basic content
138
- results.total++;
139
- results.passed++;
178
+ total++;
179
+ passed++;
140
180
  }
141
181
 
142
- return results;
182
+ return { name: 'environment', ...resultFromFindings(findings, { passed, total }) };
143
183
  }
@@ -28,11 +28,8 @@ try {
28
28
  _sharedGetLastCommitDate = null;
29
29
  }
30
30
 
31
- const IGNORE_DIRS = new Set([
32
- 'node_modules', '.git', '.next', 'dist', 'build',
33
- 'coverage', '.cache', '__pycache__', '.venv', 'vendor',
34
- 'templates', 'configs', 'Research',
35
- ]);
31
+ // (v0.29 cleanup: a dead IGNORE_DIRS set lived here — defined but never
32
+ // referenced. Freshness reads specific configured docs; it never walks.)
36
33
 
37
34
  /**
38
35
  * Read the `<!-- docguard:last-reviewed YYYY-MM-DD -->` header from a doc file.
@@ -245,6 +242,8 @@ export function validateFreshness(dir, config) {
245
242
  // an agent that can stamp a marker but not commit was left guessing.
246
243
  results.push({
247
244
  status: 'warn',
245
+ code: 'FRS001',
246
+ doc: docFile,
248
247
  message: `${docFile} exists but is not yet committed to git — commit it, or add a <!-- docguard:last-reviewed YYYY-MM-DD --> marker (or <!-- docguard:status approved -->).`,
249
248
  });
250
249
  continue;
@@ -266,6 +265,8 @@ export function validateFreshness(dir, config) {
266
265
  if (codeCommitsSince >= WARNING_THRESHOLD_COMMITS) {
267
266
  results.push({
268
267
  status: 'warn',
268
+ code: 'FRS002',
269
+ doc: docFile,
269
270
  message: `${docFile} — ${codeCommitsSince} code commits since last doc update (${docDate.toISOString().split('T')[0]})`,
270
271
  });
271
272
  continue;
@@ -277,6 +278,8 @@ export function validateFreshness(dir, config) {
277
278
  if (daysDiff > STALE_THRESHOLD_DAYS) {
278
279
  results.push({
279
280
  status: 'warn',
281
+ code: 'FRS003',
282
+ doc: docFile,
280
283
  message: `${docFile} — last updated ${daysDiff} days before latest code change`,
281
284
  });
282
285
  continue;
@@ -300,6 +303,8 @@ export function validateFreshness(dir, config) {
300
303
  if (daysDiff > 7) {
301
304
  results.push({
302
305
  status: 'warn',
306
+ code: 'FRS004',
307
+ doc: config.requiredFiles?.changelog || 'CHANGELOG.md',
303
308
  message: `CHANGELOG.md not updated in ${daysDiff} days despite code changes`,
304
309
  });
305
310
  } else {
@@ -336,6 +341,8 @@ export function validateFreshness(dir, config) {
336
341
  if (codeCommitsSince > 3) {
337
342
  results.push({
338
343
  status: 'warn',
344
+ code: 'FRS005',
345
+ doc: config.requiredFiles?.driftLog || 'DRIFT-LOG.md',
339
346
  message: `DRIFT-LOG.md may be stale — ${driftCount} DRIFT comments found in recent commits`,
340
347
  });
341
348
  }
@@ -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
  }