docguard-cli 0.40.4 → 0.41.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 (57) hide show
  1. package/CHANGELOG.md +3218 -0
  2. package/README.md +25 -16
  3. package/cli/assessment.mjs +94 -0
  4. package/cli/commands/ci.mjs +15 -5
  5. package/cli/commands/diagnose.mjs +20 -13
  6. package/cli/commands/fix.mjs +14 -45
  7. package/cli/commands/guard.mjs +53 -25
  8. package/cli/commands/hooks.mjs +51 -10
  9. package/cli/commands/init.mjs +15 -0
  10. package/cli/commands/reconcile.mjs +10 -3
  11. package/cli/commands/report.mjs +5 -1
  12. package/cli/commands/score.mjs +2 -1
  13. package/cli/commands/upgrade.mjs +4 -1
  14. package/cli/commands/verify.mjs +9 -2
  15. package/cli/commands/watch.mjs +3 -2
  16. package/cli/config.mjs +23 -0
  17. package/cli/evidence/adapters.mjs +14 -0
  18. package/cli/evidence/manifest.mjs +15 -0
  19. package/cli/evidence/python-literal.mjs +304 -0
  20. package/cli/findings.mjs +17 -3
  21. package/cli/scanners/instruction-audit.mjs +88 -11
  22. package/cli/scanners/js-ast.mjs +156 -18
  23. package/cli/scanners/reconciliation.mjs +56 -6
  24. package/cli/scanners/routes.mjs +84 -9
  25. package/cli/scanners/spec-registry.mjs +29 -0
  26. package/cli/shared-git.mjs +98 -0
  27. package/cli/shared-ignore.mjs +1 -1
  28. package/cli/shared.mjs +30 -1
  29. package/cli/validators/api-doc-smells.mjs +2 -2
  30. package/cli/validators/api-surface.mjs +4 -9
  31. package/cli/validators/diff-suspicion.mjs +3 -2
  32. package/cli/validators/docs-sync.mjs +45 -29
  33. package/cli/validators/environment.mjs +64 -6
  34. package/cli/validators/metrics-consistency.mjs +52 -11
  35. package/cli/validators/reference-existence.mjs +4 -2
  36. package/cli/validators/security.mjs +37 -12
  37. package/cli/validators/spec-registry.mjs +10 -7
  38. package/cli/validators/todo-tracking.mjs +31 -11
  39. package/cli/validators/traceability.mjs +29 -4
  40. package/cli/writers/junit.mjs +3 -3
  41. package/cli/writers/sarif.mjs +13 -9
  42. package/docs/configuration.md +12 -1
  43. package/extensions/spec-kit-docguard/README.md +3 -0
  44. package/extensions/spec-kit-docguard/commands/review.md +50 -0
  45. package/extensions/spec-kit-docguard/extension.yml +16 -4
  46. package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +2 -2
  47. package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +2 -2
  48. package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
  49. package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
  50. package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +2 -2
  51. package/extensions/spec-kit-docguard/templates/github-workflows/docguard-autofix.yml +1 -1
  52. package/extensions/spec-kit-docguard/templates/github-workflows/docguard-guard.yml +1 -1
  53. package/package.json +2 -1
  54. package/schemas/docguard-config.schema.json +15 -1
  55. package/schemas/docguard-evidence.schema.json +12 -0
  56. package/templates/ci/github-actions.yml +1 -1
  57. package/templates/evidence-manifest.json +16 -0
package/cli/shared.mjs CHANGED
@@ -2,6 +2,8 @@
2
2
  * Shared constants for DocGuard CLI — colors, profiles, version.
3
3
  * Extracted from docguard.mjs to break circular dependencies.
4
4
  * All commands import from here instead of docguard.mjs.
5
+ * @implements docguard.adoption-workflow-integrity#FR-007
6
+ * @implements docguard.adoption-workflow-integrity#FR-008
5
7
  */
6
8
 
7
9
  /**
@@ -13,7 +15,7 @@
13
15
  * `.docguard.json.version` is BEHIND this constant — pointing users at
14
16
  * `docguard upgrade` to migrate.
15
17
  */
16
- export const CURRENT_SCHEMA_VERSION = '0.5';
18
+ export const CURRENT_SCHEMA_VERSION = '0.6';
17
19
 
18
20
  /**
19
21
  * Allowed severity values for per-validator `severity` overrides in
@@ -39,6 +41,33 @@ export function resolveSeverity(config, validatorKey) {
39
41
  return 'medium';
40
42
  }
41
43
 
44
+ /**
45
+ * Resolve the enforcement level for one structured finding. Exact finding-code
46
+ * policy wins over validator policy. Without an exact override, intrinsic
47
+ * errors remain blocking and validator severity only reweights warnings.
48
+ */
49
+ export function resolveFindingEnforcement(config, finding, validatorKey) {
50
+ const code = typeof finding?.code === 'string' ? finding.code.toUpperCase() : null;
51
+ const exact = code && config?.findingSeverity?.[code];
52
+ const normalizedExact = typeof exact === 'string' ? exact.toLowerCase() : null;
53
+ if (normalizedExact && SEVERITY_LEVELS.has(normalizedExact)) {
54
+ return {
55
+ level: normalizedExact === 'high' ? 'error' : normalizedExact === 'medium' ? 'warn' : 'info',
56
+ source: 'finding',
57
+ key: code,
58
+ };
59
+ }
60
+ if (finding?.severity === 'error') {
61
+ return { level: 'error', source: 'intrinsic', key: code };
62
+ }
63
+ const validatorSeverity = resolveSeverity(config, validatorKey);
64
+ return {
65
+ level: validatorSeverity === 'high' ? 'error' : validatorSeverity === 'low' ? 'info' : 'warn',
66
+ source: 'validator',
67
+ key: validatorKey,
68
+ };
69
+ }
70
+
42
71
  // ── Canonical section heading matching ─────────────────────────────────────
43
72
  /**
44
73
  * Required canonical sections used to be matched by literal substring, so an
@@ -122,7 +122,7 @@ export function validateApiDocSmells(projectDir, config = {}) {
122
122
  confidence: 'low',
123
123
  message: `${f}: "${u.heading.slice(0, 60)}" is documented in name only (${prose} words of explanation) — Lazy API doc.`,
124
124
  location: { file: f, line: u.line },
125
- suggestion: { summary: `Describe what "${u.heading.slice(0, 40)}" does, its params, return, and errors — not just its signature.` },
125
+ suggestion: { kind: 'review', text: `Describe what "${u.heading.slice(0, 40)}" does, its params, return, and errors — not just its signature.` },
126
126
  }));
127
127
  } else if (total >= bloatedMin) {
128
128
  findings.push(mkFinding({
@@ -132,7 +132,7 @@ export function validateApiDocSmells(projectDir, config = {}) {
132
132
  confidence: 'low',
133
133
  message: `${f}: "${u.heading.slice(0, 60)}" is ${total} words for one unit — Bloated API doc; trim to the essential contract.`,
134
134
  location: { file: f, line: u.line },
135
- suggestion: { summary: `Split or trim "${u.heading.slice(0, 40)}" — move examples/edge-cases elsewhere and keep the core contract.` },
135
+ suggestion: { kind: 'review', text: `Split or trim "${u.heading.slice(0, 40)}" — move examples/edge-cases elsewhere and keep the core contract.` },
136
136
  }));
137
137
  }
138
138
  }
@@ -405,9 +405,7 @@ export function validateApiSurface(projectDir, config) {
405
405
  confidence: 'high', // certain contract omission, NOT certain code absence
406
406
  message: `Documented endpoint missing from OpenAPI contract (${source}): ${e.method} ${e.path} (${API_DOC}). ${codeDescription}`,
407
407
  location: API_DOC,
408
- suggestion: e.codeEvidence.status === 'not-found'
409
- ? { kind: 'fix', text: 'Remove the endpoint omitted from the contract and not found by the code scan; verify scanner coverage before applying', command: 'docguard fix --write' }
410
- : { kind: 'review', text: e.codeEvidence.status === 'present'
408
+ suggestion: { kind: 'review', text: e.codeEvidence.status === 'present'
411
409
  ? 'Reconcile the implementation with the intended contract; update OpenAPI if the route is intended. Preserve the documented endpoint during review.'
412
410
  : 'Reconcile the contract and documentation with the intended API. Verify implementation coverage and whether the endpoint was removed before editing documentation.' },
413
411
  }),
@@ -424,12 +422,9 @@ export function validateApiSurface(projectDir, config) {
424
422
  }
425
423
  }
426
424
 
427
- // Only the contract-and-code corroborated subset reaches mechanical writes.
428
- if (confidence === 'spec') {
429
- for (const e of documentedButAbsent) {
430
- fixes.push({ type: 'remove-endpoint', method: e.method, path: e.path, doc: API_DOC });
431
- }
432
- }
425
+ // A route scanner can prove presence, but unsupported syntax means it cannot
426
+ // prove absence. Contract omissions therefore remain review-only and never
427
+ // become mechanical deletion candidates, even when no route was extracted.
433
428
 
434
429
  // Without a spec, a negative scan remains a low-confidence review candidate.
435
430
  if (confidence !== 'spec' && documentedButAbsent.length) {
@@ -185,7 +185,8 @@ export function validateDiffSuspicion(projectDir, config = {}) {
185
185
  message: `${docName} describes ${h.path} (${h.kind} ref), and overlaps old-side diff tokens: ${h.shared.slice(0, 5).join(', ')}${h.shared.length > 5 ? '…' : ''} or removed declaration names (${ref}..HEAD) — possible drift; review whether the documentation is affected.`,
186
186
  location: { file: docName },
187
187
  suggestion: {
188
- summary: `Re-read ${docName} against the current ${h.path}; the old-side tokens (${h.shared.slice(0, 8).join(', ')}) do not establish a semantic contradiction.`,
188
+ kind: 'review',
189
+ text: `Re-read ${docName} against the current ${h.path}; the old-side tokens (${h.shared.slice(0, 8).join(', ')}) do not establish a semantic contradiction.`,
189
190
  },
190
191
  }));
191
192
  }
@@ -197,7 +198,7 @@ export function validateDiffSuspicion(projectDir, config = {}) {
197
198
  confidence: 'low',
198
199
  message: `${docName} references ${hits.length - maxPerDoc} more changed file(s) with old-side token overlap (${ref}..HEAD) — a broad change; review ${docName} as a whole.`,
199
200
  location: { file: docName },
200
- suggestion: { summary: `${docName} looks broadly affected by this change set — review it end-to-end rather than line by line.` },
201
+ suggestion: { kind: 'review', text: `${docName} looks broadly affected by this change set — review it end-to-end rather than line by line.` },
201
202
  }));
202
203
  }
203
204
  }
@@ -12,6 +12,7 @@ import { resolve, join, extname, basename } from 'node:path';
12
12
  import { resolveSourceRoots } from '../shared-source.mjs';
13
13
  import { relPosix, walkFiles as sharedWalkFiles, listCanonicalDocs } from '../shared-ignore.mjs';
14
14
  import { mkFinding, resultFromFindings } from '../findings.mjs';
15
+ import { findAllOpenApiSpecs } from './api-surface.mjs';
15
16
 
16
17
  const IGNORE_DIRS = new Set([
17
18
  'node_modules', '.git', '.next', '.nuxt', 'dist', 'build', 'out',
@@ -46,6 +47,38 @@ function isValidRouteFile(relPath) {
46
47
  return true;
47
48
  }
48
49
 
50
+ /**
51
+ * `src/lib` is a generic utility convention in frontend and full-stack repos,
52
+ * not evidence that every file beneath it is an architectural service. Keep
53
+ * explicit service directories exhaustive; require a service-shaped filename
54
+ * for the ambiguous `src/lib` fallback.
55
+ */
56
+ function isServiceCandidate(file, serviceDir) {
57
+ const normalizedDir = serviceDir.replace(/\\/g, '/').replace(/\/$/, '');
58
+ if (!normalizedDir.endsWith('/src/lib')) return true;
59
+
60
+ const name = basename(file, extname(file));
61
+ return /(?:^|[-_.])services?$/i.test(name) || /Service$/i.test(name);
62
+ }
63
+
64
+ function normalizeApiPath(path) {
65
+ const normalized = String(path || '')
66
+ .trim()
67
+ .toLowerCase()
68
+ .replace(/:[a-z_][a-z0-9_]*/gi, '{}')
69
+ .replace(/\{[^/{}]+\}/g, '{}')
70
+ .replace(/\/+$/g, '');
71
+ return normalized || '/';
72
+ }
73
+
74
+ function routeMatchesOpenApi(routePath, specPaths) {
75
+ const route = normalizeApiPath(routePath);
76
+ return specPaths.some(specPath => {
77
+ const spec = normalizeApiPath(specPath);
78
+ return spec === route || (route !== '/' && spec.endsWith(route));
79
+ });
80
+ }
81
+
49
82
  /**
50
83
  * Expand sub-path patterns (e.g. 'routes', 'src/routes') against the project
51
84
  * root AND every configured source root, returning de-duplicated existing dirs.
@@ -146,6 +179,7 @@ export function validateDocsSync(projectDir, config) {
146
179
 
147
180
  const relPath = relPosix(projectDir, file);
148
181
  if (isTestFile(relPath)) continue;
182
+ if (!isServiceCandidate(file, serviceDir)) continue;
149
183
  // N-1: skip files outside the --changed-only scope.
150
184
  if (!inScope(relPath)) continue;
151
185
 
@@ -169,27 +203,11 @@ export function validateDocsSync(projectDir, config) {
169
203
 
170
204
  // ── Cross-check route files against OpenAPI spec ──
171
205
  // If an OpenAPI spec exists AND route files exist, verify routes have matching paths
172
- const openapiPatterns = [
173
- 'openapi.yaml', 'openapi.yml', 'openapi.json',
174
- 'swagger.yaml', 'swagger.yml', 'swagger.json',
175
- 'api/openapi.yaml', 'api/openapi.yml', 'api/openapi.json',
176
- 'docs/openapi.yaml', 'docs/openapi.yml',
177
- ];
178
-
179
- let openapiContent = '';
180
- let openapiFile = null;
181
- for (const pattern of openapiPatterns) {
182
- const specPath = resolve(projectDir, pattern);
183
- if (existsSync(specPath)) {
184
- try {
185
- openapiContent = readFileSync(specPath, 'utf-8').toLowerCase();
186
- openapiFile = pattern;
187
- } catch { /* ignore */ }
188
- break;
189
- }
190
- }
206
+ const authoritativeSpec = findAllOpenApiSpecs(projectDir, config)[0] || null;
207
+ const openapiPaths = authoritativeSpec?.endpoints.map(endpoint => endpoint.path) || [];
208
+ const openapiFile = authoritativeSpec?.relPath || null;
191
209
 
192
- if (openapiContent && openapiFile) {
210
+ if (openapiPaths.length > 0 && openapiFile) {
193
211
  // Check that route files have corresponding paths in OpenAPI spec (monorepo-aware)
194
212
  for (const routeDir of expandDirs(projectDir, config, ['src/routes', 'src/app/api', 'routes', 'app/api'])) {
195
213
  const files = getFilesRecursive(routeDir);
@@ -222,12 +240,11 @@ export function validateDocsSync(projectDir, config) {
222
240
  let matched = false;
223
241
 
224
242
  if (actualRoutes.length > 0) {
225
- // Check if ANY of the actual route paths appear in the OpenAPI spec
226
- matched = actualRoutes.some(route => {
227
- // Normalize: /api/conversations/:id /api/conversations
228
- const basePath = route.replace(/\/:[^/]+/g, '').replace(/\/{[^}]+}/g, '');
229
- return openapiContent.includes(basePath) || openapiContent.includes(route);
230
- });
243
+ // Express :param and OpenAPI {param} are equivalent. Compare whole
244
+ // path segments so `/users` cannot accidentally match `/superusers`.
245
+ // A route-local path may omit an application mount prefix, so an
246
+ // exact segment suffix is also accepted.
247
+ matched = actualRoutes.some(route => routeMatchesOpenApi(route, openapiPaths));
231
248
  } else {
232
249
  // Strategy 2 (fallback): Strip common suffixes and check filename
233
250
  // userRoutes.ts → 'user', conversationRoutes.ts → 'conversation'
@@ -238,9 +255,8 @@ export function validateDocsSync(projectDir, config) {
238
255
  .replace(/router$/i, '');
239
256
 
240
257
  if (cleanName.length > 0) {
241
- matched = openapiContent.includes(`/${cleanName}`) ||
242
- openapiContent.includes(`"${cleanName}"`) ||
243
- openapiContent.includes(`'${cleanName}'`);
258
+ matched = openapiPaths.some(path =>
259
+ normalizeApiPath(path).split('/').some(segment => segment === cleanName));
244
260
  }
245
261
  }
246
262
 
@@ -14,11 +14,71 @@ import { docRolePath, resolveDocRole } from '../shared-doc-roles.mjs';
14
14
  * existing tests are unaffected; guard just renders richer output.
15
15
  */
16
16
 
17
- import { existsSync, readFileSync } from 'node:fs';
18
- import { resolve } from 'node:path';
19
- import { grepEnvUsage } from '../shared-source.mjs';
17
+ import { existsSync, lstatSync, readFileSync } from 'node:fs';
18
+ import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
19
+ import { grepEnvUsage, resolveSourceRoots } from '../shared-source.mjs';
20
+ import { shouldIgnore } from '../shared-ignore.mjs';
20
21
  import { mkFinding, resultFromFindings } from '../findings.mjs';
21
22
 
23
+ const ENV_TEMPLATE_NAMES = ['.env.example', '.env.template'];
24
+
25
+ function isWithin(projectRoot, path) {
26
+ const rel = relative(projectRoot, path);
27
+ return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
28
+ }
29
+
30
+ /**
31
+ * Discover repository-owned env templates at the root and package boundaries.
32
+ * Source roots may point inside a package (for example `backend/src`), so every
33
+ * ancestor up to the repository root is considered. `resolveSourceRoots()` also
34
+ * contributes declared npm/pnpm workspace packages. Candidates are de-duplicated
35
+ * before reading and rejected when ignored, outside the repository, or reached
36
+ * through a symlink.
37
+ */
38
+ function discoverEnvTemplates(projectDir, config) {
39
+ const projectRoot = resolve(projectDir);
40
+ const candidateDirs = new Set([projectRoot]);
41
+
42
+ for (const sourceRoot of resolveSourceRoots(projectRoot, config)) {
43
+ let current = resolve(sourceRoot);
44
+ while (isWithin(projectRoot, current)) {
45
+ candidateDirs.add(current);
46
+ if (current === projectRoot) break;
47
+ const parent = dirname(current);
48
+ if (parent === current) break;
49
+ current = parent;
50
+ }
51
+ }
52
+
53
+ const templates = new Set();
54
+ for (const dir of candidateDirs) {
55
+ for (const name of ENV_TEMPLATE_NAMES) {
56
+ const abs = resolve(dir, name);
57
+ const rel = relative(projectRoot, abs);
58
+ if (!rel || !isWithin(projectRoot, abs)) continue;
59
+ const relPosix = rel.split(sep).join('/');
60
+ if (shouldIgnore(relPosix, config)) continue;
61
+
62
+ let current = projectRoot;
63
+ let safe = true;
64
+ for (const segment of rel.split(sep)) {
65
+ current = resolve(current, segment);
66
+ try {
67
+ const stat = lstatSync(current);
68
+ if (stat.isSymbolicLink()) { safe = false; break; }
69
+ if (current === abs && !stat.isFile()) { safe = false; break; }
70
+ } catch {
71
+ safe = false;
72
+ break;
73
+ }
74
+ }
75
+ if (safe) templates.add(abs);
76
+ }
77
+ }
78
+
79
+ return [...templates].sort();
80
+ }
81
+
22
82
  export function validateEnvironment(projectDir, config) {
23
83
  const findings = [];
24
84
  let passed = 0;
@@ -109,9 +169,7 @@ export function validateEnvironment(projectDir, config) {
109
169
  if (SYSTEM.has(m[1])) continue;
110
170
  documented.add(m[1]);
111
171
  }
112
- for (const envFile of ['.env.example', '.env.template']) {
113
- const p = resolve(projectDir, envFile);
114
- if (!existsSync(p)) continue;
172
+ for (const p of discoverEnvTemplates(projectDir, config)) {
115
173
  const re = /^([A-Z][A-Z0-9_]*[A-Z0-9])\s*=/gm;
116
174
  const ex = readFileSync(p, 'utf-8');
117
175
  let em;
@@ -131,7 +131,7 @@ export function validateMetricsConsistency(projectDir, config, guardResults) {
131
131
  // then emit ONE warning per distinct value. A file that says "20" on
132
132
  // line 5 and "20" on line 50 is the same drift; "20" on line 5 and
133
133
  // "19" on line 50 are two distinct drifts.
134
- const distinctFoundInFile = new Set();
134
+ const occurrencesByValue = new Map();
135
135
  while ((match = regex.exec(content)) !== null) {
136
136
  // Bug #2 (subject-binding): for the built-in meta-counts, only validate a
137
137
  // number BOUND to DocGuard. An unbound "N checks" (a proof harness, a CI
@@ -140,11 +140,17 @@ export function validateMetricsConsistency(projectDir, config, guardResults) {
140
140
  // correct number. Project-declared collections (requireBind:false) skip
141
141
  // this: naming the noun in `config.collections` IS the explicit binding.
142
142
  if (requireBind && !isDocguardBound(content, match.index)) continue;
143
- distinctFoundInFile.add(parseInt(match[1], 10));
143
+ const found = parseInt(match[1], 10);
144
+ const occurrences = occurrencesByValue.get(found) || { current: 0, historical: 0 };
145
+ occurrences[isHistoricalMetricContext(content, match.index) ? 'historical' : 'current']++;
146
+ occurrencesByValue.set(found, occurrences);
144
147
  }
145
- if (distinctFoundInFile.size === 0) continue;
148
+ if (occurrencesByValue.size === 0) continue;
146
149
 
147
- for (const found of distinctFoundInFile) {
150
+ for (const [found, occurrences] of occurrencesByValue) {
151
+ // Historical transitions are evidence about an earlier release, not an
152
+ // assertion of the current count. Rewriting them would falsify history.
153
+ if (occurrences.current === 0) continue;
148
154
  if (found > 0 && found !== actuals[key]) {
149
155
  const driftKey = `${relPath}|${label}|${found}`;
150
156
  if (reportedDrift.has(driftKey)) continue;
@@ -153,24 +159,33 @@ export function validateMetricsConsistency(projectDir, config, guardResults) {
153
159
  const phrase = isCollection
154
160
  ? `the code has ${actuals[key]} (${glob})`
155
161
  : `${subject} ${label} count is ${actuals[key]}`;
162
+ const safeToRewrite = occurrences.historical === 0;
156
163
  findings.push(mkFinding({
157
164
  code: isCollection ? 'MET002' : 'MET001',
158
165
  validator: 'metricsConsistency',
159
166
  severity: 'warn',
160
- message: `${relPath} says "${found} ${label}" but ${phrase}. Fix with \`docguard fix --write\``,
167
+ confidence: safeToRewrite ? 'high' : 'low',
168
+ reportable: !safeToRewrite,
169
+ message: `${relPath} says "${found} ${label}" but ${phrase}. ${safeToRewrite
170
+ ? 'Fix with `docguard fix --write`'
171
+ : 'Review manually because the same count also appears in historical context'}`,
161
172
  location: relPath,
162
173
  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',
174
+ kind: safeToRewrite ? 'fix' : 'review',
175
+ text: safeToRewrite
176
+ ? (isCollection
177
+ ? `Confirm which side is right, then rewrite the stale count (${found} → ${actuals[key]})`
178
+ : `Rewrite the stale docguard-bound count (${found} → ${actuals[key]})`)
179
+ : `Review the current assertion without changing historical ${found} ${label} statements`,
180
+ ...(safeToRewrite ? { command: 'docguard fix --write' } : {}),
168
181
  },
169
182
  }));
170
183
  // actualSource records WHAT the actual count describes, so the applier
171
184
  // (and a human) can confirm both sides are the same subject before any
172
185
  // overwrite. Without it the fix is refused (fail-closed). See Bug #2.
173
- fixes.push({ type: 'replace-count', file: relPath, label, found, actual: actuals[key], actualSource });
186
+ if (safeToRewrite) {
187
+ fixes.push({ type: 'replace-count', file: relPath, label, found, actual: actuals[key], actualSource });
188
+ }
174
189
  } else {
175
190
  // Matches the actual count — one pass per (file, label), not per occurrence.
176
191
  const passKey = `${relPath}|${label}`;
@@ -204,6 +219,32 @@ function isDocguardBound(content, index) {
204
219
  return /docguard/i.test(content.slice(lineStart, lineEnd));
205
220
  }
206
221
 
222
+ /**
223
+ * Return true when a metric occurrence describes history rather than current
224
+ * state. The classifier deliberately recognizes only explicit historical
225
+ * evidence: transition arrows/from-to wording, historical metadata, and
226
+ * release-history headings. Ambiguous prose remains visible, but a count that
227
+ * appears in both contexts is never handed to the mechanical replace-all fix.
228
+ */
229
+ function isHistoricalMetricContext(content, index) {
230
+ const lineStart = content.lastIndexOf('\n', index) + 1;
231
+ let lineEnd = content.indexOf('\n', index);
232
+ if (lineEnd === -1) lineEnd = content.length;
233
+ const line = content.slice(lineStart, lineEnd);
234
+
235
+ if (/\d[\d,]*\s*(?:→|->|=>)\s*\d[\d,]*/.test(line)) return true;
236
+ if (/\b(?:increased|grew|rose|decreased|dropped|fell|expanded|reduced|changed|moved|went|bumped|upgraded)\s+from\s+\d[\d,]*\s+to\s+\d[\d,]*/i.test(line)) return true;
237
+ if (/\b(?:previously|formerly|historically|back then|at launch|in (?:release|version|v)\s*\d|during (?:the )?(?:release|upgrade|migration))\b/i.test(line)) return true;
238
+
239
+ const before = content.slice(0, lineStart);
240
+ const headings = [...before.matchAll(/^#{1,6}\s+(.+)$/gm)];
241
+ const heading = headings.at(-1)?.[1] || '';
242
+ if (/\b(?:change\s*log|release (?:notes|history)|version history|migration history|what changed)\b/i.test(heading)) return true;
243
+
244
+ const prefix = content.slice(0, Math.min(content.length, 4096));
245
+ return /<!--\s*docguard:status\s+(?:historical|superseded|deprecated|archived)\s*-->/i.test(prefix);
246
+ }
247
+
207
248
  function findTestFiles(dir) {
208
249
  const tests = [];
209
250
  const testDirs = ['tests', 'test', '__tests__', 'spec', 'e2e'];
@@ -252,7 +252,8 @@ export function validateReferenceExistence(projectDir, config = {}) {
252
252
  message: `${doc.name} references \`${sym}\`, which existed in the code when the doc was last updated but has ZERO matches at HEAD — likely renamed or removed.`,
253
253
  location: { file: doc.name },
254
254
  suggestion: {
255
- summary: `Update or remove the \`${sym}\` reference in ${doc.name} (or suppress if it is a still-relevant user-facing name).`,
255
+ kind: 'review',
256
+ text: `Update or remove the \`${sym}\` reference in ${doc.name} (or suppress if it is a still-relevant user-facing name).`,
256
257
  },
257
258
  }));
258
259
  }
@@ -287,7 +288,8 @@ export function validateReferenceExistence(projectDir, config = {}) {
287
288
  message,
288
289
  location: { file: first.file, line: first.line },
289
290
  suggestion: {
290
- summary: known.size > 0
291
+ kind: 'review',
292
+ text: known.size > 0
291
293
  ? `Fix the number or write the missing ADR entry (or suppress with // docguard:ignore REF002 on the citation line).`
292
294
  : `Create an ADR doc (docguard init writes templates/ADR.md) or suppress with // docguard:ignore REF002.`,
293
295
  },
@@ -98,16 +98,21 @@ function quotedValue(matchStr) {
98
98
  * are insufficient. Provider key signatures remain independently scanned.
99
99
  * Unknown syntax/parser failure supplies no exemptions.
100
100
  */
101
- function fixturePasswordRanges(content, filename) {
101
+ function fixturePasswordEvidence(content, filename) {
102
102
  if (!/(?:^|\/)__tests?__\/|\.(?:test|spec)\.[cm]?[jt]sx?$/.test(filename)) return [];
103
103
  const { ast, ok } = parseJsTs(content, filename);
104
104
  if (!ok || ast.errors?.length) return [];
105
- const ranges = [];
105
+ const evidence = [];
106
+ const literalCounts = new Map();
107
+ walk(ast, node => {
108
+ if (node.type !== 'StringLiteral') return;
109
+ literalCounts.set(node.value, (literalCounts.get(node.value) || 0) + 1);
110
+ });
106
111
  walk(ast, node => {
107
112
  if (node.type !== 'CallExpression') return;
108
113
  const callee = node.callee;
109
114
  if (callee.type !== 'MemberExpression' || callee.computed ||
110
- !/^(?:toHaveBeenCalledWith|toHaveBeenLastCalledWith|toHaveBeenNthCalledWith)$/.test(callee.property.name)) return;
115
+ !/^(?:toHaveBeenCalledWith|toHaveBeenLastCalledWith|toHaveBeenNthCalledWith|toMatchObject|toEqual|toStrictEqual)$/.test(callee.property.name)) return;
111
116
  const expectation = callee.object;
112
117
  if (expectation.type !== 'CallExpression' || expectation.callee.type !== 'Identifier' ||
113
118
  expectation.callee.name !== 'expect') return;
@@ -119,13 +124,17 @@ function fixturePasswordRanges(content, filename) {
119
124
  if (prop.type !== 'ObjectProperty' || prop.computed ||
120
125
  !/^(?:password|passwd|pwd)$/i.test(prop.key.name || prop.key.value || '') ||
121
126
  prop.value.type !== 'StringLiteral') continue;
122
- if (/^(?:test|mock|dummy|fixture)[_-]?(?:password|passwd|pwd)[0-9!@#$%_*.-]*$/i.test(prop.value.value)) {
123
- ranges.push([prop.start, prop.end]);
124
- }
127
+ const explicitSynthetic = /^(?:test|mock|dummy|fixture)[_-]?(?:password|passwd|pwd)[0-9!@#$%_*.-]*$/i.test(prop.value.value);
128
+ const repeatedFixtureValue = (literalCounts.get(prop.value.value) || 0) > 1;
129
+ if (explicitSynthetic || repeatedFixtureValue) evidence.push({
130
+ start: prop.start,
131
+ end: prop.end,
132
+ classification: explicitSynthetic ? 'safe' : 'probable',
133
+ });
125
134
  }
126
135
  }
127
136
  });
128
- return ranges;
137
+ return evidence;
129
138
  }
130
139
 
131
140
  export function validateSecurity(projectDir, config) {
@@ -153,7 +162,7 @@ export function validateSecurity(projectDir, config) {
153
162
  scanned++;
154
163
  const content = readFileSync(filePath, 'utf-8');
155
164
  let lines = null;
156
- let fixtureRanges = null;
165
+ let passwordFixtureEvidence = null;
157
166
 
158
167
  for (const { pattern, label } of SECRET_PATTERNS) {
159
168
  pattern.lastIndex = 0;
@@ -176,10 +185,27 @@ export function validateSecurity(projectDir, config) {
176
185
  if (isSafePlaceholder(matchLine, match[0], label)) continue;
177
186
 
178
187
  const code = LABEL_TO_CODE[label];
188
+ const location = `${relPath}:${lineNo}`;
179
189
  if (code === 'SEC001') {
180
- fixtureRanges ??= fixturePasswordRanges(content, relPath);
181
- if (fixtureRanges.some(([start, end]) => match.index >= start &&
182
- match.index + match[0].length <= end)) continue;
190
+ passwordFixtureEvidence ??= fixturePasswordEvidence(content, relPath);
191
+ const fixture = passwordFixtureEvidence.find(({ start, end }) => match.index >= start &&
192
+ match.index + match[0].length <= end);
193
+ if (fixture?.classification === 'safe') continue;
194
+ if (fixture?.classification === 'probable') {
195
+ findings.push(mkFinding({
196
+ code, validator: 'security', severity: 'warn', confidence: 'low',
197
+ message: `${location}: possible ${label}, but the same redacted value is used as test input and expected output`,
198
+ location,
199
+ suggestion: {
200
+ kind: 'review',
201
+ text: 'Confirm this repeated test value is synthetic. Use a clearly named fixture password or suppress this line with a reason.',
202
+ pragma: `// docguard:ignore ${code} — repeated synthetic test fixture`,
203
+ },
204
+ reportable: true,
205
+ redactedContext: `${label} pattern fired inside a test assertion and the same literal appears elsewhere in the test. Literal omitted.`,
206
+ }));
207
+ continue;
208
+ }
183
209
  }
184
210
 
185
211
  // v0.27 (#8): honour an inline `// docguard:ignore SEC00x` pragma on the
@@ -187,7 +213,6 @@ export function validateSecurity(projectDir, config) {
187
213
  // whole file via `securityIgnore`.
188
214
  if (code && lineSuppresses(code, matchLine, prevLine)) continue;
189
215
 
190
- const location = `${relPath}:${lineNo}`;
191
216
  const value = quotedValue(match[0]);
192
217
  const isProse = looksLikeProse(value);
193
218
 
@@ -13,13 +13,16 @@ export function validateSpecRegistry(projectDir, config = {}) {
13
13
  confidence: 'high',
14
14
  message: issue.message,
15
15
  location: issue.path,
16
- suggestion: {
17
- kind: 'review',
18
- text: issue.code === 'SPR002'
19
- ? 'Assign a unique immutable Spec ID in the authoritative spec metadata.'
20
- : 'Resolve the lifecycle or registry integrity conflict, then refresh the registry.',
21
- command: 'docguard specs --write',
22
- },
16
+ suggestion: issue.code === 'SPR002'
17
+ ? {
18
+ kind: 'review',
19
+ text: 'Add a unique immutable Spec ID to the authoritative spec using the `**Spec ID**: <unique-id>` or `<!-- docguard:spec-id <unique-id> -->` form; then run `docguard specs --write`.',
20
+ }
21
+ : {
22
+ kind: 'review',
23
+ text: 'Resolve the lifecycle or registry integrity conflict, then refresh the registry.',
24
+ command: 'docguard specs --write',
25
+ },
23
26
  }));
24
27
  if (!projection.current && projection.issues.length === 0) {
25
28
  findings.push(mkFinding({
@@ -74,21 +74,38 @@ const SKIP_PATTERNS = [
74
74
  /\bit\.todo\s*\(/,
75
75
  ];
76
76
 
77
- // Reasons must contain text and belong to this call, not a neighboring test.
78
- const SKIP_REASON_PATTERN = /(?:REASON|SKIP|TODO|FIXME|NOTE|WHY)\s*:\s*\S/i;
77
+ function hasReasonText(value) {
78
+ return /(?:REASON|SKIP|TODO|FIXME|NOTE|WHY)\s*:[\s\S]*\S/i.test(value);
79
+ }
79
80
 
80
81
  function hasAdjacentReason(content, call, comments) {
81
- return comments.some(comment => {
82
- if (!SKIP_REASON_PATTERN.test(comment.value)) return false;
82
+ const ordered = [...comments].sort((a, b) => a.start - b.start);
83
+ return ordered.some((comment, index) => {
83
84
  if (comment.end <= call.start) {
84
85
  // A trailing comment belongs to the preceding statement.
85
86
  const lineStart = content.lastIndexOf('\n', comment.start - 1) + 1;
86
87
  if (!/^\s*$/.test(content.slice(lineStart, comment.start))) return false;
87
88
  const gap = content.slice(comment.end, call.start);
88
- return call.loc.start.line - comment.loc.end.line <= 3 && /^\s*$/.test(gap);
89
+ if (call.loc.start.line - comment.loc.end.line !== 1 || !/^\s*$/.test(gap)) return false;
90
+
91
+ // Babel represents adjacent // lines as separate comments. Walk backward
92
+ // across only contiguous, full-line comments so `// REASON:` can introduce
93
+ // a multiline explanation without borrowing a disconnected annotation.
94
+ let first = index;
95
+ while (first > 0) {
96
+ const previous = ordered[first - 1];
97
+ const current = ordered[first];
98
+ if (previous.type !== 'CommentLine' || current.type !== 'CommentLine') break;
99
+ if (current.loc.start.line - previous.loc.end.line !== 1) break;
100
+ const previousLineStart = content.lastIndexOf('\n', previous.start - 1) + 1;
101
+ if (!/^\s*$/.test(content.slice(previousLineStart, previous.start))) break;
102
+ if (!/^\s*$/.test(content.slice(previous.end, current.start))) break;
103
+ first--;
104
+ }
105
+ return hasReasonText(ordered.slice(first, index + 1).map(item => item.value).join('\n'));
89
106
  }
90
107
  if (comment.start >= call.end && comment.loc.start.line === call.loc.end.line) {
91
- return /^[\s;]*$/.test(content.slice(call.end, comment.start));
108
+ return hasReasonText(comment.value) && /^[\s;]*$/.test(content.slice(call.end, comment.start));
92
109
  }
93
110
  return false;
94
111
  });
@@ -123,10 +140,13 @@ function fallbackSkippedCalls(content) {
123
140
  const calls = [];
124
141
  for (let i = 0; i < codeLines.length; i++) {
125
142
  if (!SKIP_PATTERNS.some(p => p.test(codeLines[i]))) continue;
126
- // Only a directly preceding comment is unambiguous without a parser.
127
- const previous = lines[i - 1] || '';
128
- calls.push({ line: i + 1, hasReason: /^\s*\/\//.test(previous) &&
129
- SKIP_REASON_PATTERN.test(previous) });
143
+ // Only a contiguous block of directly preceding line comments is
144
+ // unambiguous without a parser. Blank lines and code sever ownership.
145
+ const commentBlock = [];
146
+ for (let cursor = i - 1; cursor >= 0 && /^\s*\/\//.test(lines[cursor] || ''); cursor--) {
147
+ commentBlock.unshift(lines[cursor].replace(/^\s*\/\//, ''));
148
+ }
149
+ calls.push({ line: i + 1, hasReason: hasReasonText(commentBlock.join('\n')) });
130
150
  }
131
151
  return calls;
132
152
  }
@@ -251,7 +271,7 @@ function checkSkippedTests(projectDir, config) {
251
271
  location: `${relPath}:${line}`,
252
272
  suggestion: {
253
273
  kind: 'fix',
254
- text: 'Add a // REASON: comment on or up to 3 lines above the skip explaining why',
274
+ text: 'Add a // REASON: comment immediately above the skip explaining why',
255
275
  pragma: '// REASON: <why this test is skipped>',
256
276
  },
257
277
  }));