docguard-cli 0.28.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.es.md +102 -0
  2. package/README.md +80 -32
  3. package/README.pt-BR.md +101 -0
  4. package/STANDARD.md +20 -10
  5. package/cli/commands/agents.mjs +149 -0
  6. package/cli/commands/diff.mjs +6 -15
  7. package/cli/commands/generate.mjs +14 -1001
  8. package/cli/commands/guard.mjs +136 -8
  9. package/cli/commands/llms.mjs +67 -5
  10. package/cli/commands/mcp.mjs +263 -0
  11. package/cli/commands/memory.mjs +115 -0
  12. package/cli/commands/score.mjs +76 -12
  13. package/cli/commands/trace.mjs +364 -1
  14. package/cli/commands/verify.mjs +93 -6
  15. package/cli/docguard.mjs +42 -5
  16. package/cli/findings.mjs +511 -0
  17. package/cli/scanners/agent-readability.mjs +202 -0
  18. package/cli/scanners/instruction-audit.mjs +320 -0
  19. package/cli/scanners/semantic-claims.mjs +7 -1
  20. package/cli/scanners/speckit.mjs +443 -28
  21. package/cli/shared-ignore.mjs +148 -16
  22. package/cli/shared.mjs +45 -1
  23. package/cli/validators/api-surface.mjs +113 -26
  24. package/cli/validators/architecture.mjs +66 -43
  25. package/cli/validators/canonical-sync.mjs +59 -28
  26. package/cli/validators/changelog.mjs +41 -17
  27. package/cli/validators/cross-reference.mjs +28 -11
  28. package/cli/validators/doc-quality.mjs +78 -44
  29. package/cli/validators/docs-coverage.mjs +90 -63
  30. package/cli/validators/docs-diff.mjs +63 -64
  31. package/cli/validators/docs-sync.mjs +48 -33
  32. package/cli/validators/drift.mjs +40 -34
  33. package/cli/validators/environment.mjs +67 -27
  34. package/cli/validators/freshness.mjs +12 -5
  35. package/cli/validators/generated-staleness.mjs +26 -10
  36. package/cli/validators/metadata-sync.mjs +28 -25
  37. package/cli/validators/metrics-consistency.mjs +89 -47
  38. package/cli/validators/schema-sync.mjs +37 -32
  39. package/cli/validators/security.mjs +7 -20
  40. package/cli/validators/spec-kit.mjs +3 -0
  41. package/cli/validators/structure.mjs +58 -23
  42. package/cli/validators/surface-sync.mjs +34 -15
  43. package/cli/validators/test-spec.mjs +87 -29
  44. package/cli/validators/todo-tracking.mjs +83 -74
  45. package/cli/validators/traceability.mjs +67 -39
  46. package/cli/writers/doc-generators.mjs +853 -0
  47. package/cli/writers/generate-io.mjs +142 -0
  48. package/cli/writers/sarif.mjs +129 -0
  49. package/commands/docguard.fix.md +56 -53
  50. package/commands/docguard.guard.md +53 -47
  51. package/commands/docguard.review.md +49 -31
  52. package/docs/ai-integration.md +133 -134
  53. package/docs/commands.md +49 -3
  54. package/docs/configuration.md +38 -0
  55. package/docs/faq.md +15 -0
  56. package/extensions/spec-kit-docguard/extension.yml +1 -1
  57. package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +2 -2
  58. package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +2 -2
  59. package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
  60. package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
  61. package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +2 -2
  62. package/package.json +2 -1
  63. package/schemas/docguard-config.schema.json +28 -0
  64. package/templates/ci/gitlab-component.yml +90 -0
  65. package/templates/commands/docguard.fix.md +33 -10
  66. package/templates/commands/docguard.guard.md +40 -26
  67. package/templates/commands/docguard.init.md +23 -11
  68. package/templates/commands/docguard.review.md +25 -8
  69. package/templates/commands/docguard.update.md +14 -4
@@ -1,18 +1,30 @@
1
1
  /**
2
2
  * Test Spec Validator — Checks that tests exist per TEST-SPEC.md coverage rules
3
3
  * Now respects projectTypeConfig (e.g., skip E2E for CLI tools)
4
+ *
5
+ * v0.29: migrated to structured findings (TSP001–TSP007). 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, readdirSync, statSync } from 'node:fs';
7
12
  import { resolve } from 'node:path';
8
13
  import { resolveSourceRoots } from '../shared-source.mjs';
14
+ import { mkFinding, resultFromFindings } from '../findings.mjs';
9
15
 
10
16
  export function validateTestSpec(projectDir, config) {
11
- const results = { name: 'test-spec', errors: [], warnings: [], passed: 0, total: 0 };
17
+ const findings = [];
18
+ let passed = 0;
19
+ let total = 0;
20
+ let note;
12
21
 
13
- const testSpecPath = resolve(projectDir, 'docs-canonical/TEST-SPEC.md');
22
+ const specDoc = 'docs-canonical/TEST-SPEC.md';
23
+ const testSpecPath = resolve(projectDir, specDoc);
14
24
  if (!existsSync(testSpecPath)) {
15
- return results; // Structure validator catches this
25
+ // Structure validator catches this. Keep the exact legacy shape here
26
+ // (no `findings` key) — tests deep-equal this early return.
27
+ return { name: 'test-spec', errors: [], warnings: [], passed: 0, total: 0 };
16
28
  }
17
29
 
18
30
  const content = readFileSync(testSpecPath, 'utf-8');
@@ -80,22 +92,44 @@ export function validateTestSpec(projectDir, config) {
80
92
  // author's CLAIM, not proof — it is NOT counted as a pass. The real pass
81
93
  // comes from the file-existence checks below (code truth, not the glyph).
82
94
  if (status.includes('❌')) {
83
- results.total++;
84
- results.warnings.push(`TEST-SPEC declares ${sourceFile} as ❌ — missing tests`);
95
+ total++;
96
+ findings.push(mkFinding({
97
+ code: 'TSP001',
98
+ validator: 'testSpec',
99
+ severity: 'warn',
100
+ message: `TEST-SPEC declares ${sourceFile} as ❌ — missing tests`,
101
+ location: specDoc,
102
+ suggestion: { kind: 'fix', text: 'Write the missing tests, then update the row status to ✅' },
103
+ }));
85
104
  } else if (status.includes('⚠️')) {
86
- results.total++;
87
- results.warnings.push(`TEST-SPEC declares ${sourceFile} as ⚠️ — partial coverage`);
105
+ total++;
106
+ findings.push(mkFinding({
107
+ code: 'TSP002',
108
+ validator: 'testSpec',
109
+ severity: 'warn',
110
+ message: `TEST-SPEC declares ${sourceFile} as ⚠️ — partial coverage`,
111
+ location: specDoc,
112
+ suggestion: { kind: 'fix', text: 'Extend coverage for this source, then update the row status to ✅' },
113
+ }));
88
114
  }
89
115
 
90
116
  // ── File existence checks ───────────────────────────────────────
91
117
  // Verify source file still exists (catch stale map entries).
92
118
  const cleanSource = sourceFile.replace(/`/g, '').trim();
93
119
  if (cleanSource && cleanSource !== '—' && cleanSource !== 'Source File' && isPathLike(cleanSource)) {
94
- results.total++;
120
+ total++;
95
121
  if (existsSync(resolve(projectDir, cleanSource))) {
96
- results.passed++;
122
+ passed++;
97
123
  } else {
98
- results.warnings.push(`Source-to-Test Map: source file \`${cleanSource}\` not found on disk — stale entry?`);
124
+ findings.push(mkFinding({
125
+ code: 'TSP003',
126
+ validator: 'testSpec',
127
+ severity: 'warn',
128
+ confidence: 'low',
129
+ message: `Source-to-Test Map: source file \`${cleanSource}\` not found on disk — stale entry?`,
130
+ location: specDoc,
131
+ suggestion: { kind: 'review', text: 'Update or remove the stale row if the source file moved or was deleted' },
132
+ }));
99
133
  }
100
134
  }
101
135
 
@@ -104,11 +138,18 @@ export function validateTestSpec(projectDir, config) {
104
138
  for (const ti of testIdxs) {
105
139
  const cleanTest = (cells[ti] || '').replace(/`/g, '').trim();
106
140
  if (isPlaceholder(cleanTest) || !isPathLike(cleanTest)) continue;
107
- results.total++;
141
+ total++;
108
142
  if (existsSync(resolve(projectDir, cleanTest))) {
109
- results.passed++;
143
+ passed++;
110
144
  } else {
111
- results.warnings.push(`Source-to-Test Map: test file \`${cleanTest}\` not found — referenced by ${cleanSource}`);
145
+ findings.push(mkFinding({
146
+ code: 'TSP004',
147
+ validator: 'testSpec',
148
+ severity: 'warn',
149
+ message: `Source-to-Test Map: test file \`${cleanTest}\` not found — referenced by ${cleanSource}`,
150
+ location: specDoc,
151
+ suggestion: { kind: 'fix', text: 'Create the test file, or point the row at the actual test path' },
152
+ }));
112
153
  }
113
154
  }
114
155
  }
@@ -140,10 +181,15 @@ export function validateTestSpec(projectDir, config) {
140
181
  if (num.startsWith('<!--') || num === '#' || journey.startsWith('<!--')) continue;
141
182
 
142
183
  if (status && status.includes('❌')) {
143
- results.total++;
144
- results.warnings.push(
145
- `E2E Journey #${num} (${journey}) — missing test: ${testFile}`
146
- );
184
+ total++;
185
+ findings.push(mkFinding({
186
+ code: 'TSP005',
187
+ validator: 'testSpec',
188
+ severity: 'warn',
189
+ message: `E2E Journey #${num} (${journey}) — missing test: ${testFile}`,
190
+ location: specDoc,
191
+ suggestion: { kind: 'fix', text: 'Implement the journey test, then update the row status to ✅' },
192
+ }));
147
193
  continue;
148
194
  }
149
195
 
@@ -154,14 +200,19 @@ export function validateTestSpec(projectDir, config) {
154
200
  if (testFile && testFile.trim() !== '—' && !testFile.includes('N/A')) {
155
201
  const paths = parseTestPathCell(testFile);
156
202
  if (paths.length > 0) {
157
- results.total++;
203
+ total++;
158
204
  const anyExists = paths.some(p => testEvidenceExists(projectDir, p));
159
205
  if (anyExists) {
160
- results.passed++;
206
+ passed++;
161
207
  } else {
162
- results.warnings.push(
163
- `E2E Journey #${num} (${journey}) marked ✅ but test file not found: ${paths.join(', ')}`
164
- );
208
+ findings.push(mkFinding({
209
+ code: 'TSP006',
210
+ validator: 'testSpec',
211
+ severity: 'warn',
212
+ message: `E2E Journey #${num} (${journey}) marked ✅ but test file not found: ${paths.join(', ')}`,
213
+ location: specDoc,
214
+ suggestion: { kind: 'review', text: 'Fix the test path in the row, or restore the missing test file' },
215
+ }));
165
216
  }
166
217
  }
167
218
  }
@@ -172,7 +223,7 @@ export function validateTestSpec(projectDir, config) {
172
223
  // If TEST-SPEC.md declared no service-to-test mappings, there is nothing to
173
224
  // verify against. Do NOT manufacture a 1/1 pass just because tests exist
174
225
  // somewhere — that rendered a confident green ✅ for a doc that mapped nothing.
175
- if (results.total === 0) {
226
+ if (total === 0) {
176
227
  // 1. Check top-level test dirs
177
228
  const commonTestDirs = ['tests', 'test', '__tests__', 'spec'];
178
229
  const hasTestDir = commonTestDirs.some(d =>
@@ -200,16 +251,23 @@ export function validateTestSpec(projectDir, config) {
200
251
  // file, and the last as status — so both the minimal 3-column shape and
201
252
  // the 4-column table `docguard generate` emits are accepted. Say so, since
202
253
  // the guidance previously contradicted the generated skeleton (field report).
203
- results.note = 'TEST-SPEC.md declares no service-to-test mappings. Add a "## Source-to-Test Map" table — column 1 is the source, column 2 the test file, the last column the status. Both `| Source | Test file | Status |` and the generated `| Source File | Unit Test | Integration Test | Status |` shapes work. Run `docguard explain testSpec` for details.';
254
+ note = 'TEST-SPEC.md declares no service-to-test mappings. Add a "## Source-to-Test Map" table — column 1 is the source, column 2 the test file, the last column the status. Both `| Source | Test file | Status |` and the generated `| Source File | Unit Test | Integration Test | Status |` shapes work. Run `docguard explain testSpec` for details.';
204
255
  } else {
205
- results.warnings.push(
206
- 'No test directory or co-located test files found. ' +
207
- 'Expected: tests/, src/**/__tests__/, or src/**/*.test.* files'
208
- );
256
+ findings.push(mkFinding({
257
+ code: 'TSP007',
258
+ validator: 'testSpec',
259
+ severity: 'warn',
260
+ message: 'No test directory or co-located test files found. ' +
261
+ 'Expected: tests/, src/**/__tests__/, or src/**/*.test.* files',
262
+ location: null,
263
+ suggestion: { kind: 'fix', text: 'Create a tests/ directory or co-located *.test.* files, then map them in TEST-SPEC.md' },
264
+ }));
209
265
  }
210
266
  }
211
267
 
212
- return results;
268
+ const res = { name: 'test-spec', ...resultFromFindings(findings, { passed, total }) };
269
+ if (note) res.note = note;
270
+ return res;
213
271
  }
214
272
 
215
273
  /**
@@ -17,7 +17,8 @@
17
17
 
18
18
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
19
19
  import { resolve, join, relative, extname } from 'node:path';
20
- import { shouldIgnore } from '../shared-ignore.mjs';
20
+ import { shouldIgnore, walkFiles as sharedWalkFiles } from '../shared-ignore.mjs';
21
+ import { mkFinding, resultFromFindings } from '../findings.mjs';
21
22
 
22
23
  const IGNORE_DIRS = new Set([
23
24
  'node_modules', '.git', '.next', 'dist', 'build', 'coverage',
@@ -77,25 +78,34 @@ const SKIP_REASON_PATTERN = /\/\/\s*(REASON|SKIP|TODO|FIXME|NOTE|WHY)\s*:/i;
77
78
 
78
79
  /**
79
80
  * Main validator — checks for untracked TODOs and unexplained test skips.
81
+ *
82
+ * v0.29: migrated to structured findings (TDO001–TDO003). Messages are
83
+ * byte-identical to the legacy strings — resultFromFindings derives the
84
+ * errors/warnings arrays from the same findings array.
80
85
  */
81
86
  export function validateTodoTracking(projectDir, config) {
82
- const results = { errors: [], warnings: [], passed: 0, total: 0 };
87
+ const findings = [];
88
+ let passed = 0;
89
+ let total = 0;
83
90
 
84
91
  // ── Part 1: Skipped Tests ──
85
92
  const skipResults = checkSkippedTests(projectDir, config);
86
- results.errors.push(...skipResults.errors);
87
- results.warnings.push(...skipResults.warnings);
88
- results.passed += skipResults.passed;
89
- results.total += skipResults.total;
93
+ findings.push(...skipResults.findings);
94
+ passed += skipResults.passed;
95
+ total += skipResults.total;
90
96
 
91
97
  // ── Part 2: Untracked Annotations ──
92
98
  const todoResults = checkUntrackedTodos(projectDir, config);
93
- results.errors.push(...todoResults.errors);
94
- results.warnings.push(...todoResults.warnings);
95
- results.passed += todoResults.passed;
96
- results.total += todoResults.total;
97
-
98
- return results;
99
+ findings.push(...todoResults.findings);
100
+ passed += todoResults.passed;
101
+ total += todoResults.total;
102
+
103
+ const res = resultFromFindings(findings, { passed, total });
104
+ // Back-compat: on a clean run the legacy result carried no extra keys (the
105
+ // empty-project test deep-equals the whole object), and an empty findings
106
+ // array has nothing to render — so omit the key when there are no findings.
107
+ if (findings.length === 0) delete res.findings;
108
+ return res;
99
109
  }
100
110
 
101
111
  // ──── Skipped Tests ────────────────────────────────────────────────────────
@@ -104,15 +114,14 @@ export function validateTodoTracking(projectDir, config) {
104
114
  * Scan test files for skip/todo patterns without adjacent explanation comments.
105
115
  */
106
116
  function checkSkippedTests(projectDir, config) {
107
- const errors = [];
108
- const warnings = [];
117
+ const findings = [];
109
118
  let passed = 0;
110
119
  let total = 0;
111
120
 
112
121
  const testFiles = [];
113
122
  findTestFiles(projectDir, projectDir, testFiles, config);
114
123
 
115
- if (testFiles.length === 0) return { errors, warnings, passed, total };
124
+ if (testFiles.length === 0) return { findings, passed, total };
116
125
 
117
126
  // Check: "Project has test files" → pass
118
127
  total++;
@@ -157,10 +166,19 @@ function checkSkippedTests(projectDir, config) {
157
166
  skippedWithReason++;
158
167
  } else {
159
168
  skippedWithoutReason++;
160
- warnings.push(
161
- `Skipped test without explanation at ${relPath}:${i + 1}. ` +
162
- `Add a // REASON: comment explaining why the test is skipped`
163
- );
169
+ findings.push(mkFinding({
170
+ code: 'TDO001',
171
+ validator: 'todoTracking',
172
+ severity: 'warn',
173
+ message: `Skipped test without explanation at ${relPath}:${i + 1}. ` +
174
+ `Add a // REASON: comment explaining why the test is skipped`,
175
+ location: `${relPath}:${i + 1}`,
176
+ suggestion: {
177
+ kind: 'fix',
178
+ text: 'Add a // REASON: comment on or up to 3 lines above the skip explaining why',
179
+ pragma: '// REASON: <why this test is skipped>',
180
+ },
181
+ }));
164
182
  }
165
183
  }
166
184
  }
@@ -173,7 +191,7 @@ function checkSkippedTests(projectDir, config) {
173
191
  }
174
192
  }
175
193
 
176
- return { errors, warnings, passed, total };
194
+ return { findings, passed, total };
177
195
  }
178
196
 
179
197
  // ──── Untracked Annotations ────────────────────────────────────────────────
@@ -183,8 +201,7 @@ function checkSkippedTests(projectDir, config) {
183
201
  * in tracking documentation.
184
202
  */
185
203
  function checkUntrackedTodos(projectDir, config) {
186
- const errors = [];
187
- const warnings = [];
204
+ const findings = [];
188
205
  let passed = 0;
189
206
  let total = 0;
190
207
 
@@ -196,7 +213,7 @@ function checkUntrackedTodos(projectDir, config) {
196
213
  // No TODOs found — that's clean code
197
214
  total++;
198
215
  passed++;
199
- return { errors, warnings, passed, total };
216
+ return { findings, passed, total };
200
217
  }
201
218
 
202
219
  // Check if TODOs are tracked in documentation
@@ -235,23 +252,41 @@ function checkUntrackedTodos(projectDir, config) {
235
252
  untrackedCount++;
236
253
  // Only report first 5 to avoid noise
237
254
  if (untrackedCount <= 5) {
238
- warnings.push(
239
- `Untracked ${todo.keyword} at ${todo.file}:${todo.line}: "${todo.text.substring(0, 60)}". ` +
240
- `Add to ROADMAP.md, CURRENT-STATE.md, or a GitHub issue`
241
- );
255
+ findings.push(mkFinding({
256
+ code: 'TDO002',
257
+ validator: 'todoTracking',
258
+ severity: 'warn',
259
+ message: `Untracked ${todo.keyword} at ${todo.file}:${todo.line}: "${todo.text.substring(0, 60)}". ` +
260
+ `Add to ROADMAP.md, CURRENT-STATE.md, or a GitHub issue`,
261
+ location: `${todo.file}:${todo.line}`,
262
+ suggestion: {
263
+ kind: 'fix',
264
+ text: 'Track it in ROADMAP.md or CURRENT-STATE.md (or resolve it) — or exclude the path via todoIgnore in .docguard.json',
265
+ },
266
+ }));
242
267
  }
243
268
  }
244
269
  }
245
270
 
246
271
  if (untrackedCount > 5) {
247
- warnings.push(`...and ${untrackedCount - 5} more untracked TODO/FIXME items`);
272
+ findings.push(mkFinding({
273
+ code: 'TDO003',
274
+ validator: 'todoTracking',
275
+ severity: 'warn',
276
+ message: `...and ${untrackedCount - 5} more untracked TODO/FIXME items`,
277
+ location: null,
278
+ suggestion: {
279
+ kind: 'suppress',
280
+ text: 'Address the items above and re-run guard to surface the rest — or exclude noisy paths via todoIgnore in .docguard.json',
281
+ },
282
+ }));
248
283
  }
249
284
 
250
285
  if (untrackedCount === 0) {
251
286
  passed++;
252
287
  }
253
288
 
254
- return { errors, warnings, passed, total };
289
+ return { findings, passed, total };
255
290
  }
256
291
 
257
292
  /**
@@ -285,34 +320,23 @@ function loadTrackingDocs(projectDir, config) {
285
320
 
286
321
  // ──── File Scanners ────────────────────────────────────────────────────────
287
322
 
323
+ // v0.29 consolidation: traversal delegates to the shared canonical walker;
324
+ // test-file pattern matching and config-ignore filtering stay per-file here.
288
325
  function findTestFiles(rootDir, dir, files, config) {
289
- let entries;
290
- try { entries = readdirSync(dir); } catch { return; }
291
-
292
- for (const entry of entries) {
293
- if (IGNORE_DIRS.has(entry)) continue;
294
- if (entry.startsWith('.')) continue;
295
-
296
- const full = join(dir, entry);
297
- let stat;
298
- try { stat = statSync(full); } catch { continue; }
299
-
300
- if (stat.isDirectory()) {
301
- findTestFiles(rootDir, full, files, config);
302
- } else {
303
- const ext = extname(entry).toLowerCase();
304
- if (!TEST_EXTENSIONS.has(ext)) continue;
305
-
306
- // Match test file patterns
307
- if (/\.(test|spec)\.(mjs|cjs|[jt]sx?)$/.test(entry) ||
308
- /__(tests|test)__/.test(relative(rootDir, full))) {
309
- const relPath = relative(rootDir, full);
310
- // Apply config ignore patterns (todoIgnore + global ignore)
311
- if (config && shouldIgnore(relPath, config, 'todoIgnore')) continue;
312
- files.push(relPath);
313
- }
326
+ sharedWalkFiles(dir, (full) => {
327
+ const entry = full.slice(full.lastIndexOf('/') + 1);
328
+ const ext = extname(entry).toLowerCase();
329
+ if (!TEST_EXTENSIONS.has(ext)) return;
330
+
331
+ // Match test file patterns
332
+ if (/\.(test|spec)\.(mjs|cjs|[jt]sx?)$/.test(entry) ||
333
+ /__(tests|test)__/.test(relative(rootDir, full))) {
334
+ const relPath = relative(rootDir, full);
335
+ // Apply config ignore patterns (todoIgnore + global ignore)
336
+ if (config && shouldIgnore(relPath, config, 'todoIgnore')) return;
337
+ files.push(relPath);
314
338
  }
315
- }
339
+ }, { ignoreDirs: IGNORE_DIRS });
316
340
  }
317
341
 
318
342
  // Test-file path patterns — TODO scanning skips these by default to avoid
@@ -349,25 +373,10 @@ function findTodos(rootDir, dir, todos, config) {
349
373
  return;
350
374
  }
351
375
 
352
- let entries;
353
- try { entries = readdirSync(dir); } catch { return; }
354
-
355
- const includeTests = config?.todoTracking?.includeTestFiles === true;
356
-
357
- for (const entry of entries) {
358
- if (IGNORE_DIRS.has(entry)) continue;
359
- if (entry.startsWith('.')) continue;
360
-
361
- const full = join(dir, entry);
362
- let stat;
363
- try { stat = statSync(full); } catch { continue; }
364
-
365
- if (stat.isDirectory()) {
366
- findTodos(rootDir, full, todos, config);
367
- } else {
368
- _scanTodoFile(rootDir, full, todos, config);
369
- }
370
- }
376
+ // v0.29 consolidation: traversal delegates to the shared canonical walker.
377
+ sharedWalkFiles(dir, (full) => _scanTodoFile(rootDir, full, todos, config), {
378
+ ignoreDirs: IGNORE_DIRS,
379
+ });
371
380
  }
372
381
 
373
382
  /**
@@ -16,6 +16,8 @@
16
16
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
17
17
  import { resolve, join, relative, basename, extname } from 'node:path';
18
18
  import { TRACE_MAP, TEST_PATTERNS, isTraceableSource } from '../shared-trace-patterns.mjs';
19
+ import { walkFiles as sharedWalkFiles } from '../shared-ignore.mjs';
20
+ import { mkFinding, resultFromFindings } from '../findings.mjs';
19
21
 
20
22
  const IGNORE_DIRS = new Set([
21
23
  'node_modules', '.git', '.next', 'dist', 'build', 'coverage',
@@ -51,18 +53,21 @@ const DEFAULT_REQ_PATTERNS = [
51
53
  * Validate traceability — ensures canonical docs have corresponding source artifacts,
52
54
  * and requirement IDs trace through to test files.
53
55
  * Respects config.requiredFiles.canonical — only checks docs the user requires.
56
+ *
57
+ * v0.29: migrated to structured findings (TRC001–TRC005). Messages are
58
+ * byte-identical to the legacy strings — resultFromFindings derives the
59
+ * errors/warnings arrays from the same findings array.
54
60
  * @returns {{ errors: string[], warnings: string[], passed: number, total: number }}
55
61
  */
56
62
  export function validateTraceability(projectDir, config) {
57
- const errors = [];
58
- const warnings = [];
63
+ const findings = [];
59
64
  let passed = 0;
60
65
  let total = 0;
61
66
 
62
67
  const docsDir = resolve(projectDir, 'docs-canonical');
63
68
  if (!existsSync(docsDir)) {
64
69
  // No docs-canonical dir at all — structure validator handles this
65
- return { errors, warnings, passed: 0, total: 0 };
70
+ return resultFromFindings([], { passed: 0, total: 0 });
66
71
  }
67
72
 
68
73
  // Build set of required doc basenames from config
@@ -93,7 +98,14 @@ export function validateTraceability(projectDir, config) {
93
98
  const docExists = existsSync(docPath);
94
99
 
95
100
  if (!docExists) {
96
- warnings.push(`${docName} — required but missing, no traceability possible`);
101
+ findings.push(mkFinding({
102
+ code: 'TRC001',
103
+ validator: 'traceability',
104
+ severity: 'warn',
105
+ message: `${docName} — required but missing, no traceability possible`,
106
+ location: `docs-canonical/${docName}`,
107
+ suggestion: { kind: 'fix', text: 'Create the required doc from the professional template', command: 'docguard init' },
108
+ }));
97
109
  continue;
98
110
  }
99
111
 
@@ -121,7 +133,18 @@ export function validateTraceability(projectDir, config) {
121
133
  if (hasSource) {
122
134
  passed++;
123
135
  } else {
124
- warnings.push(`${docName} — exists but no matching source code found (unlinked doc)`);
136
+ findings.push(mkFinding({
137
+ code: 'TRC002',
138
+ validator: 'traceability',
139
+ severity: 'warn',
140
+ message: `${docName} — exists but no matching source code found (unlinked doc)`,
141
+ location: `docs-canonical/${docName}`,
142
+ suggestion: {
143
+ kind: 'fix',
144
+ text: 'Link a source file explicitly with a header annotation if the code lives in a non-standard location',
145
+ pragma: `// @doc ${docName}`,
146
+ },
147
+ }));
125
148
  }
126
149
  }
127
150
 
@@ -130,19 +153,25 @@ export function validateTraceability(projectDir, config) {
130
153
  const existingDocs = readdirSync(docsDir).filter(f => f.endsWith('.md'));
131
154
  for (const docFile of existingDocs) {
132
155
  if (!requiredDocs.has(docFile) && TRACE_MAP[docFile]) {
133
- warnings.push(`${docFile} — file exists in docs-canonical/ but is not in your requiredFiles config. Consider deleting it or adding it to .docguard.json requiredFiles.canonical`);
156
+ findings.push(mkFinding({
157
+ code: 'TRC003',
158
+ validator: 'traceability',
159
+ severity: 'warn',
160
+ message: `${docFile} — file exists in docs-canonical/ but is not in your requiredFiles config. Consider deleting it or adding it to .docguard.json requiredFiles.canonical`,
161
+ location: `docs-canonical/${docFile}`,
162
+ suggestion: { kind: 'review', text: 'Delete the doc, or add it to requiredFiles.canonical in .docguard.json so it gets validated' },
163
+ }));
134
164
  }
135
165
  }
136
166
  } catch { /* ignore */ }
137
167
 
138
168
  // ── Part 2: Requirement ID Traceability (V-Model) ──
139
169
  const reqResult = validateRequirementTraceability(projectDir, config, projectFiles);
140
- errors.push(...reqResult.errors);
141
- warnings.push(...reqResult.warnings);
170
+ findings.push(...reqResult.findings);
142
171
  passed += reqResult.passed;
143
172
  total += reqResult.total;
144
173
 
145
- return { errors, warnings, passed, total };
174
+ return resultFromFindings(findings, { passed, total });
146
175
  }
147
176
 
148
177
  // ──── Requirement ID Traceability ────────────────────────────────────────────
@@ -156,8 +185,7 @@ export function validateTraceability(projectDir, config) {
156
185
  * - Reports untraced requirements and orphaned test refs
157
186
  */
158
187
  function validateRequirementTraceability(projectDir, config, projectFiles) {
159
- const errors = [];
160
- const warnings = [];
188
+ const findings = [];
161
189
  let passed = 0;
162
190
  let total = 0;
163
191
 
@@ -172,7 +200,7 @@ function validateRequirementTraceability(projectDir, config, projectFiles) {
172
200
 
173
201
  // If no requirement IDs found, silently pass — this project doesn't use them
174
202
  if (reqIds.size === 0) {
175
- return { errors, warnings, passed, total };
203
+ return { findings, passed, total };
176
204
  }
177
205
 
178
206
  // ── Step 2: Scan test files for requirement ID references ──
@@ -186,10 +214,15 @@ function validateRequirementTraceability(projectDir, config, projectFiles) {
186
214
  if (testRefs.has(reqId)) {
187
215
  passed++;
188
216
  } else {
189
- warnings.push(
190
- `Requirement ${reqId} (${location.file}:${location.line}) has no test coverage. ` +
191
- `Add @req ${reqId} comment to the test that verifies this requirement`
192
- );
217
+ findings.push(mkFinding({
218
+ code: 'TRC004',
219
+ validator: 'traceability',
220
+ severity: 'warn',
221
+ message: `Requirement ${reqId} (${location.file}:${location.line}) has no test coverage. ` +
222
+ `Add @req ${reqId} comment to the test that verifies this requirement`,
223
+ location: `${location.file}:${location.line}`,
224
+ suggestion: { kind: 'fix', text: `Add an @req ${reqId} comment to the test that verifies this requirement` },
225
+ }));
193
226
  }
194
227
  }
195
228
 
@@ -197,14 +230,19 @@ function validateRequirementTraceability(projectDir, config, projectFiles) {
197
230
  for (const [reqId, refs] of testRefs) {
198
231
  if (!reqIds.has(reqId)) {
199
232
  total++;
200
- warnings.push(
201
- `Test references ${reqId} (${refs[0].file}:${refs[0].line}) but no requirement ` +
202
- `with this ID exists in documentation. Remove the reference or add the requirement to docs`
203
- );
233
+ findings.push(mkFinding({
234
+ code: 'TRC005',
235
+ validator: 'traceability',
236
+ severity: 'warn',
237
+ message: `Test references ${reqId} (${refs[0].file}:${refs[0].line}) but no requirement ` +
238
+ `with this ID exists in documentation. Remove the reference or add the requirement to docs`,
239
+ location: `${refs[0].file}:${refs[0].line}`,
240
+ suggestion: { kind: 'review', text: 'Remove the stale reference, or add the requirement to the documentation' },
241
+ }));
204
242
  }
205
243
  }
206
244
 
207
- return { errors, warnings, passed, total };
245
+ return { findings, passed, total };
208
246
  }
209
247
 
210
248
  function collectRequirementIds(projectDir, config, patterns) {
@@ -377,23 +415,13 @@ function scanDocAnnotations(projectFiles, projectDir) {
377
415
  return map;
378
416
  }
379
417
 
418
+ // v0.29 consolidation: traversal delegates to the shared canonical walker.
419
+ // keepDot preserves the traceability-relevant dot entries (.env, .env.example,
420
+ // .gitignore, .github/) that a doc may legitimately reference.
380
421
  function scanDir(rootDir, dir, files) {
381
- let entries;
382
- try { entries = readdirSync(dir); } catch { return; }
383
-
384
- for (const entry of entries) {
385
- if (IGNORE_DIRS.has(entry)) continue;
386
- if (entry.startsWith('.') && entry !== '.env' && entry !== '.env.example'
387
- && entry !== '.gitignore' && !entry.startsWith('.github')) continue;
388
-
389
- const full = join(dir, entry);
390
- let stat;
391
- try { stat = statSync(full); } catch { continue; }
392
-
393
- if (stat.isDirectory()) {
394
- scanDir(rootDir, full, files);
395
- } else {
396
- files.push(relative(rootDir, full));
397
- }
398
- }
422
+ sharedWalkFiles(dir, (full) => files.push(relative(rootDir, full)), {
423
+ ignoreDirs: IGNORE_DIRS,
424
+ keepDot: (entry) => entry === '.env' || entry === '.env.example'
425
+ || entry === '.gitignore' || entry.startsWith('.github'),
426
+ });
399
427
  }