docguard-cli 0.35.0 → 0.36.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 (51) hide show
  1. package/README.md +8 -15
  2. package/cli/commands/agent.mjs +27 -6
  3. package/cli/commands/ci.mjs +3 -0
  4. package/cli/commands/diagnose.mjs +8 -2
  5. package/cli/commands/feedback.mjs +83 -89
  6. package/cli/commands/fix.mjs +4 -0
  7. package/cli/commands/generate.mjs +3 -0
  8. package/cli/commands/guard.mjs +37 -20
  9. package/cli/commands/hooks.mjs +61 -40
  10. package/cli/commands/init.mjs +51 -5
  11. package/cli/commands/memory.mjs +29 -15
  12. package/cli/commands/report.mjs +12 -7
  13. package/cli/commands/score.mjs +39 -19
  14. package/cli/commands/sync.mjs +2 -0
  15. package/cli/commands/watch.mjs +113 -70
  16. package/cli/config.mjs +6 -3
  17. package/cli/docguard.mjs +12 -4
  18. package/cli/findings.mjs +13 -13
  19. package/cli/scanners/memory-plan.mjs +279 -134
  20. package/cli/scanners/project-type.mjs +6 -1
  21. package/cli/scanners/semantic-claims.mjs +176 -26
  22. package/cli/shared-diff.mjs +22 -1
  23. package/cli/shared-doc-roles.mjs +59 -0
  24. package/cli/shared-ignore.mjs +15 -2
  25. package/cli/shared-source.mjs +223 -1
  26. package/cli/validator-coverage.mjs +20 -0
  27. package/cli/validators/api-surface.mjs +94 -70
  28. package/cli/validators/architecture.mjs +19 -5
  29. package/cli/validators/diff-suspicion.mjs +45 -9
  30. package/cli/validators/docs-coverage.mjs +6 -5
  31. package/cli/validators/docs-diff.mjs +51 -7
  32. package/cli/validators/environment.mjs +3 -2
  33. package/cli/validators/freshness.mjs +140 -83
  34. package/cli/validators/schema-sync.mjs +3 -2
  35. package/cli/validators/security.mjs +58 -23
  36. package/cli/validators/structure.mjs +3 -1
  37. package/cli/validators/test-spec.mjs +3 -2
  38. package/cli/validators/todo-tracking.mjs +61 -28
  39. package/cli/validators/traceability.mjs +152 -38
  40. package/docs/configuration.md +41 -0
  41. package/extensions/spec-kit-docguard/extension.yml +2 -3
  42. package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +2 -2
  43. package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +2 -2
  44. package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
  45. package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
  46. package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +2 -2
  47. package/extensions/spec-kit-docguard/templates/extensions.yml +1 -2
  48. package/extensions/spec-kit-docguard/templates/github-workflows/docguard-guard.yml +74 -29
  49. package/package.json +1 -1
  50. package/schemas/docguard-config.schema.json +43 -1
  51. package/templates/ci/github-actions.yml +51 -11
@@ -1,14 +1,72 @@
1
1
  /**
2
- * Freshness Validator — Check if documentation is stale relative to code changes.
2
+ * Freshness Validator — Identify documentation review tasks from code history.
3
3
  * Uses git history to compare when docs were last modified vs when code was last changed.
4
4
  *
5
- * This catches the exact issue the user identified: docs say "[ ] planned"
6
- * but the code has already been implemented and committed.
5
+ * This is a repository-wide review heuristic, not proof of semantic drift.
7
6
  */
8
7
 
9
- import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
8
+ import { existsSync, readdirSync, readFileSync, lstatSync } from 'node:fs';
10
9
  import { resolve, join, extname } from 'node:path';
11
- import { execSync, execFileSync } from 'node:child_process';
10
+ import { execFileSync } from 'node:child_process';
11
+ import { buildIgnoreFilter, loadDocguardIgnore, DEFAULT_IGNORE_DIRS, relPosix } from '../shared-ignore.mjs';
12
+
13
+ // Keep aligned with shared-source's supported languages, including module variants.
14
+ const CODE_EXTS = ['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.mts', '.cts',
15
+ '.py', '.java', '.go', '.rs', '.rb', '.php'];
16
+ const CODE_PATHS = CODE_EXTS.map(ext => `*${ext}`);
17
+
18
+ function pathFilter(dir, config) {
19
+ const ignored = buildIgnoreFilter([...(config.ignore || []), ...loadDocguardIgnore(dir)]);
20
+ return path => path === '..' || path.startsWith('../') ||
21
+ path.split('/').some(part => part === '.local' || DEFAULT_IGNORE_DIRS.has(part)) || ignored(path);
22
+ }
23
+
24
+ function safeExistingPath(dir, abs) {
25
+ try {
26
+ let current = dir;
27
+ for (const part of relPosix(dir, abs).split('/')) {
28
+ current = join(current, part);
29
+ if (lstatSync(current).isSymbolicLink()) return false;
30
+ }
31
+ return true;
32
+ } catch { return false; }
33
+ }
34
+
35
+ // Unlike a stat-based walk, this never follows a symlink into private/outside data.
36
+ function collectDocs(dir, config, ignored) {
37
+ const files = new Set();
38
+ function add(path, recurse = false) {
39
+ if (typeof path !== 'string') return;
40
+ const abs = resolve(dir, path);
41
+ const rel = relPosix(dir, abs);
42
+ if (ignored(rel)) return;
43
+ try {
44
+ if (!safeExistingPath(dir, abs)) return;
45
+ const stat = lstatSync(abs);
46
+ if (stat.isFile()) files.add(rel);
47
+ else if (recurse && stat.isDirectory()) {
48
+ for (const entry of readdirSync(abs, { withFileTypes: true })) {
49
+ if (entry.name.startsWith('.')) continue;
50
+ if (entry.isDirectory() || extname(entry.name).toLowerCase() === '.md') add(join(rel, entry.name), true);
51
+ }
52
+ }
53
+ } catch { /* Missing/unreadable docs are handled by structural validators. */ }
54
+ }
55
+ add('docs-canonical', true);
56
+ // Additional homes are opt-in: inferred doc directories are not review policy.
57
+ for (const path of Array.isArray(config.docs?.dirs) ? config.docs.dirs : []) add(path, true);
58
+ for (const file of config.requiredFiles?.canonical || []) add(file, true);
59
+ const agents = config.requiredFiles?.agentFile || ['AGENTS.md', 'CLAUDE.md'];
60
+ for (const file of Array.isArray(agents) ? agents : [agents]) add(file);
61
+ for (const [file, spec] of Object.entries(config.documentTypes || {})) {
62
+ if (spec?.category === 'canonical') add(file, true);
63
+ }
64
+ add('ROADMAP.md');
65
+ // Changelog and deviation logs have their own review signals below.
66
+ const tracking = [config.requiredFiles?.changelog || 'CHANGELOG.md', config.requiredFiles?.driftLog || 'DRIFT-LOG.md']
67
+ .map(path => relPosix(dir, resolve(dir, path)));
68
+ return [...files].filter(file => !tracking.includes(file)).sort();
69
+ }
12
70
 
13
71
  // B-5 fix (v0.13.1): use a defensive import. If `shared-git.mjs` is missing
14
72
  // or unloadable in the end-user install (whatever the root cause — partial
@@ -28,9 +86,6 @@ try {
28
86
  _sharedGetLastCommitDate = null;
29
87
  }
30
88
 
31
- // (v0.29 cleanup: a dead IGNORE_DIRS set lived here — defined but never
32
- // referenced. Freshness reads specific configured docs; it never walks.)
33
-
34
89
  /**
35
90
  * Read the `<!-- docguard:last-reviewed YYYY-MM-DD -->` header from a doc file.
36
91
  * Returns the parsed Date when present, null otherwise (file missing, header
@@ -45,13 +100,13 @@ export function readLastReviewedDate(absPath) {
45
100
  const m = content.match(/<!--\s*docguard:last-reviewed\s+(\d{4}-\d{2}-\d{2})\s*-->/);
46
101
  if (!m) return null;
47
102
  const d = new Date(m[1] + 'T00:00:00Z');
48
- if (isNaN(d.getTime())) return null;
103
+ if (isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== m[1]) return null;
49
104
  // Reject future-dated headers. A typo'd or copy-pasted future date (e.g.
50
105
  // 2030-01-01) would otherwise make a genuinely stale doc look "fresh"
51
106
  // forever — its age goes negative and "commits since" rounds to zero. A
52
107
  // review can't legitimately have happened in the future, so we ignore the
53
- // header and fall back to the real git date. (1-day grace for timezones.)
54
- if (d.getTime() > Date.now() + 24 * 60 * 60 * 1000) return null;
108
+ // header and fall back to the real git date (UTC calendar days).
109
+ if (d.getTime() > Date.now()) return null;
55
110
  return d;
56
111
  } catch {
57
112
  return null;
@@ -60,15 +115,26 @@ export function readLastReviewedDate(absPath) {
60
115
 
61
116
  /**
62
117
  * Read the `<!-- docguard:status <value> -->` marker (draft | review | approved
63
- * | living). Returns the lowercased value, or null. Used by the uncommitted-doc
118
+ * | living | active | deprecated | historical | superseded). Returns the lowercased value, or null. Used by the uncommitted-doc
64
119
  * check (Bug #6): a doc the agent generated this session and marked `approved`
65
120
  * has an explicit currency signal even before it's committed.
66
121
  */
67
122
  function readDocStatus(absPath) {
68
123
  try {
69
124
  const content = readFileSync(absPath, 'utf-8');
70
- const m = content.match(/<!--\s*docguard:status\s+([a-z]+)\s*-->/i);
71
- return m ? m[1].toLowerCase() : null;
125
+ let fence = null;
126
+ for (const line of content.split('\n')) {
127
+ const marker = line.match(/^\s{0,3}(`{3,}|~{3,})/);
128
+ if (fence) {
129
+ if (marker && marker[1][0] === fence[0] && marker[1].length >= fence.length
130
+ && line.slice(marker[0].length).trim() === '') fence = null;
131
+ continue;
132
+ }
133
+ if (marker) { fence = marker[1]; continue; }
134
+ const m = line.match(/^\s*<!--\s*docguard:status\s+([a-z]+)\s*-->\s*$/i);
135
+ if (m) return m[1].toLowerCase();
136
+ }
137
+ return null;
72
138
  } catch {
73
139
  return null;
74
140
  }
@@ -105,23 +171,21 @@ function getLastGitDate(filePath, dir) {
105
171
  }
106
172
 
107
173
  /**
108
- * Get the count of commits that touched code files since a given date.
174
+ * Read committed source changes once, including additions and deletions.
109
175
  */
110
- function getCodeCommitsSince(date, dir) {
111
- try {
112
- const isoDate = date.toISOString();
113
- // execFileSync (argv array) + count in JS — no shell `| wc -l` pipe, which
114
- // isn't portable (Windows) and made the count depend on an external binary.
115
- const out = execFileSync(
116
- 'git',
117
- ['log', `--since=${isoDate}`, '--oneline', '--diff-filter=M', '--',
118
- '*.js', '*.mjs', '*.ts', '*.tsx', '*.py', '*.java', '*.go'],
119
- { cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
120
- ).trim();
121
- return out ? out.split('\n').filter(Boolean).length : 0;
122
- } catch {
123
- return 0;
124
- }
176
+ function getCodeHistory(dir, ignored) {
177
+ // One query per validation, independent of document count/review dates.
178
+ // NUL-delimited names also handle spaces/newlines without shell parsing.
179
+ const out = execFileSync('git',
180
+ ['log', '--format=%x1e%H%x00%aI%x00%cI', '--name-only', '-z', '--no-renames', '--',
181
+ ...CODE_PATHS, ':(exclude).local/**', ':(exclude)**/.local/**'],
182
+ { cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], maxBuffer: 16 * 1024 * 1024 });
183
+ return out.split('\x1e').slice(1).map(record => {
184
+ const [hash, authorDate, commitDate, ...names] = record.split('\0');
185
+ const paths = names.map(name => name.replace(/^\n/, '')).filter(name =>
186
+ name && CODE_EXTS.includes(extname(name)) && !ignored(name));
187
+ return { hash, date: new Date(authorDate), since: new Date(commitDate), paths };
188
+ }).filter(commit => commit.paths.length);
125
189
  }
126
190
 
127
191
  /**
@@ -129,7 +193,7 @@ function getCodeCommitsSince(date, dir) {
129
193
  */
130
194
  function isGitRepo(dir) {
131
195
  try {
132
- execSync('git rev-parse --is-inside-work-tree', {
196
+ execFileSync('git', ['rev-parse', '--is-inside-work-tree'], {
133
197
  cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe']
134
198
  });
135
199
  return true;
@@ -143,7 +207,7 @@ function isGitRepo(dir) {
143
207
  */
144
208
  function getTotalCommits(dir) {
145
209
  try {
146
- return parseInt(execSync('git rev-list --count HEAD', {
210
+ return parseInt(execFileSync('git', ['rev-list', '--count', 'HEAD'], {
147
211
  cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe']
148
212
  }).trim()) || 0;
149
213
  } catch {
@@ -152,22 +216,8 @@ function getTotalCommits(dir) {
152
216
  }
153
217
 
154
218
  /**
155
- * Get the last N commits touching code files (not docs).
219
+ * Evaluate review signals against repository-wide code history.
156
220
  */
157
- function getRecentCodeCommits(dir, count = 5) {
158
- try {
159
- const out = execFileSync(
160
- 'git',
161
- ['log', `-${count}`, '--format=%h %aI %s', '--',
162
- '*.js', '*.mjs', '*.ts', '*.tsx', '*.py', '*.java'],
163
- { cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
164
- ).trim();
165
- return out ? out.split('\n') : [];
166
- } catch {
167
- return [];
168
- }
169
- }
170
-
171
221
  export function validateFreshness(dir, config) {
172
222
  const results = [];
173
223
 
@@ -189,32 +239,32 @@ export function validateFreshness(dir, config) {
189
239
  }
190
240
 
191
241
  // ── 1. Check each canonical doc's last update vs latest code commit ──
192
- const docFiles = [
193
- 'docs-canonical/ARCHITECTURE.md',
194
- 'docs-canonical/DATA-MODEL.md',
195
- 'docs-canonical/SECURITY.md',
196
- 'docs-canonical/TEST-SPEC.md',
197
- 'docs-canonical/ENVIRONMENT.md',
198
- 'ROADMAP.md',
199
- 'AGENTS.md',
200
- ];
201
-
202
- // Get the most recent code commit date
203
- const recentCodeCommits = getRecentCodeCommits(dir, 1);
204
- let latestCodeDate = null;
205
- if (recentCodeCommits.length > 0) {
206
- const parts = recentCodeCommits[0].split(' ');
207
- if (parts.length >= 2) {
208
- latestCodeDate = new Date(parts[1]);
209
- }
242
+ const ignored = pathFilter(dir, config);
243
+ const docFiles = collectDocs(dir, config, ignored);
244
+ let history;
245
+ try { history = getCodeHistory(dir, ignored); } catch {
246
+ return [{ status: 'skip', message: 'Code history unavailable — freshness check skipped' }];
210
247
  }
248
+ const latestCodeDate = history[0]?.date || null;
249
+ const counts = new Map();
250
+ const getCodeCommitsSince = date => {
251
+ const key = date.toISOString();
252
+ if (!counts.has(key)) counts.set(key, history.filter(commit => commit.since >= date).length);
253
+ return counts.get(key);
254
+ };
211
255
 
212
- const STALE_THRESHOLD_DAYS = 30; // Docs older than 30 days vs latest code = stale
213
- const WARNING_THRESHOLD_COMMITS = 10; // More than 10 code commits since last doc update = stale
256
+ const REVIEW_THRESHOLD_DAYS = 30; // Repository-wide trigger, not proof of drift
257
+ const WARNING_THRESHOLD_COMMITS = 10; // Repository-wide review trigger
214
258
 
215
259
  for (const docFile of docFiles) {
216
260
  const docPath = resolve(dir, docFile);
217
261
  if (!existsSync(docPath)) continue;
262
+ const docStatus = readDocStatus(docPath);
263
+ if (['historical', 'superseded', 'deprecated'].includes(docStatus)) {
264
+ results.push({ status: 'skip', doc: docFile,
265
+ message: `${docFile} is marked ${docStatus} — currentness review not applicable; historical accuracy is not verified` });
266
+ continue;
267
+ }
218
268
 
219
269
  // Prefer the explicit `<!-- docguard:last-reviewed YYYY-MM-DD -->` header
220
270
  // over the git commit date. A reviewer who reads a doc and stamps the
@@ -231,10 +281,10 @@ export function validateFreshness(dir, config) {
231
281
  // `<!-- docguard:status approved -->` has signaled it's intentionally
232
282
  // current. In the generate-then-fill flow the human hasn't committed yet,
233
283
  // so the "uncommitted" warning is noise — suppress it for approved docs.
234
- if (readDocStatus(docPath) === 'approved') {
284
+ if (docStatus === 'approved') {
235
285
  results.push({
236
286
  status: 'pass',
237
- message: `${docFile} is marked approved (not yet committed fine mid-session)`,
287
+ message: `${docFile} is marked approved (not yet committed; author signal, not semantic verification)`,
238
288
  });
239
289
  continue;
240
290
  }
@@ -244,7 +294,7 @@ export function validateFreshness(dir, config) {
244
294
  status: 'warn',
245
295
  code: 'FRS001',
246
296
  doc: docFile,
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 -->).`,
297
+ message: `${docFile} exists but is not yet committed to git — review due: no dated review signal. After reviewing intent and implementation, commit it or add a <!-- docguard:last-reviewed YYYY-MM-DD --> marker.`,
248
298
  });
249
299
  continue;
250
300
  }
@@ -260,14 +310,14 @@ export function validateFreshness(dir, config) {
260
310
  const sinceDate = reviewedDate
261
311
  ? new Date(reviewedDate.getTime() + 24 * 60 * 60 * 1000 - 1000)
262
312
  : docDate;
263
- const codeCommitsSince = getCodeCommitsSince(sinceDate, dir);
313
+ const codeCommitsSince = getCodeCommitsSince(sinceDate);
264
314
 
265
315
  if (codeCommitsSince >= WARNING_THRESHOLD_COMMITS) {
266
316
  results.push({
267
317
  status: 'warn',
268
318
  code: 'FRS002',
269
319
  doc: docFile,
270
- message: `${docFile} — ${codeCommitsSince} code commits since last doc update (${docDate.toISOString().split('T')[0]})`,
320
+ message: `${docFile} — review due: ${codeCommitsSince} code commits since last doc update/review (${docDate.toISOString().split('T')[0]}); repository-wide heuristic, not evidence this document is stale`,
271
321
  });
272
322
  continue;
273
323
  }
@@ -275,12 +325,12 @@ export function validateFreshness(dir, config) {
275
325
  // Check age vs latest code commit
276
326
  if (latestCodeDate) {
277
327
  const daysDiff = Math.floor((latestCodeDate - docDate) / (1000 * 60 * 60 * 24));
278
- if (daysDiff > STALE_THRESHOLD_DAYS) {
328
+ if (daysDiff > REVIEW_THRESHOLD_DAYS) {
279
329
  results.push({
280
330
  status: 'warn',
281
331
  code: 'FRS003',
282
332
  doc: docFile,
283
- message: `${docFile} — last updated ${daysDiff} days before latest code change`,
333
+ message: `${docFile} — review due: last updated ${daysDiff} days before latest code change; repository-wide heuristic, not evidence this document is stale`,
284
334
  });
285
335
  continue;
286
336
  }
@@ -288,13 +338,14 @@ export function validateFreshness(dir, config) {
288
338
 
289
339
  results.push({
290
340
  status: 'pass',
291
- message: `${docFile} is fresh`,
341
+ message: `${docFile} — no review due by the repository-wide history heuristic; not proof the document is fresh (not semantic verification)`,
292
342
  });
293
343
  }
294
344
 
295
345
  // ── 2. Check CHANGELOG.md was updated in the last 5 code commits ──
296
346
  const changelogPath = resolve(dir, config.requiredFiles?.changelog || 'CHANGELOG.md');
297
- if (existsSync(changelogPath)) {
347
+ if (!ignored(relPosix(dir, changelogPath)) && safeExistingPath(dir, changelogPath)
348
+ && !['historical', 'superseded', 'deprecated'].includes(readDocStatus(changelogPath))) {
298
349
  const changelogDate =
299
350
  readLastReviewedDate(changelogPath) ||
300
351
  getLastGitDate(config.requiredFiles?.changelog || 'CHANGELOG.md', dir);
@@ -305,12 +356,12 @@ export function validateFreshness(dir, config) {
305
356
  status: 'warn',
306
357
  code: 'FRS004',
307
358
  doc: config.requiredFiles?.changelog || 'CHANGELOG.md',
308
- message: `CHANGELOG.md not updated in ${daysDiff} days despite code changes`,
359
+ message: `${config.requiredFiles?.changelog || 'CHANGELOG.md'} review due: last updated ${daysDiff} days before latest code change; verify whether release notes are needed`,
309
360
  });
310
361
  } else {
311
362
  results.push({
312
363
  status: 'pass',
313
- message: 'CHANGELOG.md is up to date',
364
+ message: `${config.requiredFiles?.changelog || 'CHANGELOG.md'} no review due by the history heuristic (not semantic verification)`,
314
365
  });
315
366
  }
316
367
  }
@@ -318,7 +369,8 @@ export function validateFreshness(dir, config) {
318
369
 
319
370
  // ── 3. Check DRIFT-LOG.md was updated if there are DRIFT comments ──
320
371
  const driftPath = resolve(dir, config.requiredFiles?.driftLog || 'DRIFT-LOG.md');
321
- if (existsSync(driftPath)) {
372
+ if (history.length && !ignored(relPosix(dir, driftPath)) && safeExistingPath(dir, driftPath)
373
+ && !['historical', 'superseded', 'deprecated'].includes(readDocStatus(driftPath))) {
322
374
  const driftDate = getLastGitDate(config.requiredFiles?.driftLog || 'DRIFT-LOG.md', dir);
323
375
  // Check for recent DRIFT comments ADDED to code. The old approach piped
324
376
  // `git log --all -p | grep -c DRIFT:`, which counted DRIFT: on removed
@@ -329,7 +381,8 @@ export function validateFreshness(dir, config) {
329
381
  try {
330
382
  const diff = execFileSync(
331
383
  'git',
332
- ['log', '-5', '-p', '--', '*.js', '*.mjs', '*.ts', '*.tsx', '*.py'],
384
+ ['log', '-5', '-p', '--', ...[...new Set(history.slice(0, 5).flatMap(commit => commit.paths))]
385
+ .map(path => `:(literal)${path}`)],
333
386
  { cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
334
387
  );
335
388
  const driftCount = diff
@@ -337,18 +390,22 @@ export function validateFreshness(dir, config) {
337
390
  .filter(l => /^\+(?!\+\+)/.test(l) && l.includes('DRIFT:'))
338
391
  .length;
339
392
  if (driftCount > 0 && driftDate) {
340
- const codeCommitsSince = getCodeCommitsSince(driftDate, dir);
393
+ const codeCommitsSince = getCodeCommitsSince(driftDate);
341
394
  if (codeCommitsSince > 3) {
342
395
  results.push({
343
396
  status: 'warn',
344
397
  code: 'FRS005',
345
398
  doc: config.requiredFiles?.driftLog || 'DRIFT-LOG.md',
346
- message: `DRIFT-LOG.md may be stale ${driftCount} DRIFT comments found in recent commits`,
399
+ message: `${config.requiredFiles?.driftLog || 'DRIFT-LOG.md'} review due: ${driftCount} added DRIFT comment lines found in recent commits; verify whether deviations are already recorded`,
347
400
  });
348
401
  }
349
402
  }
350
403
  } catch { /* skip */ }
351
404
  }
352
405
 
353
- return results;
406
+ return results.map(result => result.status === 'warn' ? {
407
+ ...result,
408
+ confidence: 'low',
409
+ suggestion: { kind: 'review', text: 'Review this history signal against the document’s purpose and intended behavior. Confirm whether documentation or code needs a change; record a review date only after reviewing. Preserve intentional historical content.' },
410
+ } : result);
354
411
  }
@@ -1,3 +1,4 @@
1
+ import { docRolePath, resolveDocRole } from '../shared-doc-roles.mjs';
1
2
  /**
2
3
  * Schema Sync Validator — Ensures database schemas are documented in DATA-MODEL.md
3
4
  *
@@ -90,7 +91,7 @@ export function validateSchemaSync(projectDir, config) {
90
91
  let total = 0;
91
92
 
92
93
  // Check if DATA-MODEL.md exists
93
- const dataModelPath = resolve(projectDir, 'docs-canonical', 'DATA-MODEL.md');
94
+ const dataModelPath = resolveDocRole(projectDir, config, 'dataModel');
94
95
  if (!existsSync(dataModelPath)) {
95
96
  // No DATA-MODEL.md — nothing to sync against
96
97
  // Only warn if we detect schema files
@@ -103,7 +104,7 @@ export function validateSchemaSync(projectDir, config) {
103
104
  severity: 'warn',
104
105
  message: `Found ${detectedModels.length} database model(s) (${detectedModels.map(m => m.name).slice(0, 5).join(', ')}${detectedModels.length > 5 ? '...' : ''}) ` +
105
106
  `but no DATA-MODEL.md exists. Run \`docguard init\` to create one, then document your schema`,
106
- location: 'docs-canonical/DATA-MODEL.md',
107
+ location: docRolePath(config, 'dataModel'),
107
108
  suggestion: { kind: 'fix', text: 'Create DATA-MODEL.md, then document the detected models in it', command: 'docguard init' },
108
109
  }));
109
110
  }
@@ -9,6 +9,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
9
9
  import { resolve, join, extname } from 'node:path';
10
10
  import { shouldIgnore, relPosix, walkFiles as sharedWalkFiles } from '../shared-ignore.mjs';
11
11
  import { mkFinding, resultFromFindings, lineSuppresses } from '../findings.mjs';
12
+ import { parseJsTs, walk } from '../scanners/js-ast.mjs';
12
13
 
13
14
  // Each secret pattern maps to a stable finding code (see cli/findings.mjs CODES)
14
15
  // so it is `explain`-able and inline-suppressible (`// docguard:ignore SEC00x`).
@@ -42,28 +43,19 @@ const SECRET_PATTERNS = [
42
43
  { pattern: /(?:sk-|sk_live_|sk_test_)[a-zA-Z0-9]{20,}/g, label: 'API secret key (Stripe/OpenAI pattern)' },
43
44
  ];
44
45
 
45
- // Known-safe placeholder/example values that should never be flagged
46
- const SAFE_PATTERNS = [
47
- /EXAMPLE/i, // AWS docs example keys contain "EXAMPLE"
48
- /placeholder\s*=\s*["']/i, // HTML placeholder attributes
49
- /example\s*:/i, // OpenAPI example: blocks
50
- /['"]password123['"]/, // Common test fixture value
51
- /\/\/\s*example/i, // Code comments with "example"
52
- /<!--.*-->/, // HTML comments
53
- ];
54
-
55
46
  /**
56
- * Check if a match line is a known-safe placeholder/example.
57
- * @param {string} line - The full source line containing the match
58
- * @param {string} matchStr - The matched string
59
- * @returns {boolean} - true if this is a safe/placeholder value
47
+ * Placeholder exemptions belong to the matched value, never sibling fields
48
+ * or trailing comments. Recognizable provider keys use only the exact public
49
+ * AWS example exception, even when the surrounding text says "example".
60
50
  */
61
- function isSafePlaceholder(line, matchStr) {
62
- // Check if the matched string itself contains "EXAMPLE"
63
- if (/EXAMPLE/i.test(matchStr)) return true;
64
-
65
- // Check if the source line matches any safe pattern
66
- return SAFE_PATTERNS.some(p => p.test(line));
51
+ function isSafePlaceholder(line, matchStr, label) {
52
+ if (label === 'AWS Access Key ID') return matchStr === 'AKIAIOSFODNN7EXAMPLE';
53
+ if (label === 'API secret key (Stripe/OpenAI pattern)') return false;
54
+ const value = quotedValue(matchStr);
55
+ if (/EXAMPLE/i.test(value) || value === 'password123') return true;
56
+ // Retain documentation-only line comments; executable code followed by an
57
+ // example comment is not documentation-only and must still be checked.
58
+ return /^\s*\/\/\s*example\b/i.test(line);
67
59
  }
68
60
 
69
61
  /**
@@ -99,6 +91,42 @@ function quotedValue(matchStr) {
99
91
  return m ? m[1] : '';
100
92
  }
101
93
 
94
+ /**
95
+ * Only explicit synthetic password vocabulary inside a mock-call expectation
96
+ * qualifies. Test paths, assertion context, or a private-looking value alone
97
+ * are insufficient. Provider key signatures remain independently scanned.
98
+ * Unknown syntax/parser failure supplies no exemptions.
99
+ */
100
+ function fixturePasswordRanges(content, filename) {
101
+ if (!/(?:^|\/)__tests?__\/|\.(?:test|spec)\.[cm]?[jt]sx?$/.test(filename)) return [];
102
+ const { ast, ok } = parseJsTs(content, filename);
103
+ if (!ok || ast.errors?.length) return [];
104
+ const ranges = [];
105
+ walk(ast, node => {
106
+ if (node.type !== 'CallExpression') return;
107
+ const callee = node.callee;
108
+ if (callee.type !== 'MemberExpression' || callee.computed ||
109
+ !/^(?:toHaveBeenCalledWith|toHaveBeenLastCalledWith|toHaveBeenNthCalledWith)$/.test(callee.property.name)) return;
110
+ const expectation = callee.object;
111
+ if (expectation.type !== 'CallExpression' || expectation.callee.type !== 'Identifier' ||
112
+ expectation.callee.name !== 'expect') return;
113
+ // Inspect direct object arguments only; executing a nested callback or
114
+ // helper inside an assertion does not make its credentials fixture data.
115
+ for (const arg of node.arguments) {
116
+ if (arg.type !== 'ObjectExpression') continue;
117
+ for (const prop of arg.properties) {
118
+ if (prop.type !== 'ObjectProperty' || prop.computed ||
119
+ !/^(?:password|passwd|pwd)$/i.test(prop.key.name || prop.key.value || '') ||
120
+ prop.value.type !== 'StringLiteral') continue;
121
+ if (/^(?:test|mock|dummy|fixture)[_-]?(?:password|passwd|pwd)[0-9!@#$%_*.-]*$/i.test(prop.value.value)) {
122
+ ranges.push([prop.start, prop.end]);
123
+ }
124
+ }
125
+ }
126
+ });
127
+ return ranges;
128
+ }
129
+
102
130
  export function validateSecurity(projectDir, config) {
103
131
  /** @type {import('../findings.mjs').Finding[]} */
104
132
  const findings = [];
@@ -124,6 +152,7 @@ export function validateSecurity(projectDir, config) {
124
152
  scanned++;
125
153
  const content = readFileSync(filePath, 'utf-8');
126
154
  let lines = null;
155
+ let fixtureRanges = null;
127
156
 
128
157
  for (const { pattern, label } of SECRET_PATTERNS) {
129
158
  pattern.lastIndex = 0;
@@ -143,14 +172,19 @@ export function validateSecurity(projectDir, config) {
143
172
 
144
173
  // Skip known-safe placeholder/example values, but keep scanning for a
145
174
  // real one further down the file.
146
- if (isSafePlaceholder(matchLine, match[0])) continue;
175
+ if (isSafePlaceholder(matchLine, match[0], label)) continue;
147
176
 
148
177
  const code = LABEL_TO_CODE[label];
178
+ if (code === 'SEC001') {
179
+ fixtureRanges ??= fixturePasswordRanges(content, relPath);
180
+ if (fixtureRanges.some(([start, end]) => match.index >= start &&
181
+ match.index + match[0].length <= end)) continue;
182
+ }
149
183
 
150
184
  // v0.27 (#8): honour an inline `// docguard:ignore SEC00x` pragma on the
151
185
  // line or the line above — per-line suppression instead of blinding the
152
186
  // whole file via `securityIgnore`.
153
- if (code && lineSuppresses(code, matchLine, prevLine)) break;
187
+ if (code && lineSuppresses(code, matchLine, prevLine)) continue;
154
188
 
155
189
  const location = `${relPath}:${lineNo}`;
156
190
  const value = quotedValue(match[0]);
@@ -186,7 +220,8 @@ export function validateSecurity(projectDir, config) {
186
220
  },
187
221
  }));
188
222
  }
189
- // One finding per (file, label) is enough — the reported message is
223
+ if (isProse) continue;
224
+ // One blocking finding per (file, label) is enough — the reported message is
190
225
  // identical for repeats and we've already proven a match exists.
191
226
  break;
192
227
  }
@@ -1,3 +1,4 @@
1
+ import { remapDocPath } from '../shared-doc-roles.mjs';
1
2
  /**
2
3
  * Structure Validator — Checks that all required CDD files exist
3
4
  *
@@ -105,7 +106,8 @@ export function validateDocSections(projectDir, config) {
105
106
  : ['## Setup Steps'], // Always need setup steps, env vars optional
106
107
  };
107
108
 
108
- for (const [file, sections] of Object.entries(requiredSections)) {
109
+ for (const [defaultFile, sections] of Object.entries(requiredSections)) {
110
+ const file = remapDocPath(config, defaultFile);
109
111
  const fullPath = resolve(projectDir, file);
110
112
  if (!existsSync(fullPath)) continue;
111
113
 
@@ -1,3 +1,4 @@
1
+ import { docRolePath, resolveDocRole } from '../shared-doc-roles.mjs';
1
2
  /**
2
3
  * Test Spec Validator — Checks that tests exist per TEST-SPEC.md coverage rules
3
4
  * Now respects projectTypeConfig (e.g., skip E2E for CLI tools)
@@ -19,8 +20,8 @@ export function validateTestSpec(projectDir, config) {
19
20
  let total = 0;
20
21
  let note;
21
22
 
22
- const specDoc = 'docs-canonical/TEST-SPEC.md';
23
- const testSpecPath = resolve(projectDir, specDoc);
23
+ const specDoc = docRolePath(config, 'testSpec');
24
+ const testSpecPath = resolveDocRole(projectDir, config, 'testSpec');
24
25
  if (!existsSync(testSpecPath)) {
25
26
  // Structure validator catches this. Keep the exact legacy shape here
26
27
  // (no `findings` key) — tests deep-equal this early return.