docguard-cli 0.23.0 → 0.24.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 (58) hide show
  1. package/README.md +1 -1
  2. package/cli/commands/diff.mjs +1 -1
  3. package/cli/commands/explain.mjs +178 -17
  4. package/cli/commands/fix.mjs +17 -2
  5. package/cli/commands/generate.mjs +2 -2
  6. package/cli/commands/guard.mjs +86 -11
  7. package/cli/commands/hooks.mjs +12 -7
  8. package/cli/commands/init.mjs +18 -6
  9. package/cli/commands/score.mjs +147 -61
  10. package/cli/commands/setup.mjs +2 -2
  11. package/cli/commands/trace.mjs +3 -3
  12. package/cli/commands/upgrade.mjs +61 -13
  13. package/cli/config.mjs +18 -1
  14. package/cli/docguard.mjs +19 -0
  15. package/cli/ensure-skills.mjs +24 -26
  16. package/cli/scanners/api-doc.mjs +17 -3
  17. package/cli/scanners/doc-tools.mjs +32 -15
  18. package/cli/scanners/frontend.mjs +24 -8
  19. package/cli/scanners/js-ast.mjs +432 -0
  20. package/cli/scanners/memory-plan.mjs +1 -1
  21. package/cli/scanners/py-ast.mjs +213 -0
  22. package/cli/scanners/routes.mjs +194 -69
  23. package/cli/scanners/schemas.mjs +97 -51
  24. package/cli/shared-git.mjs +0 -0
  25. package/cli/shared-ignore.mjs +16 -1
  26. package/cli/shared-source.mjs +59 -2
  27. package/cli/shared-trace-patterns.mjs +13 -0
  28. package/cli/shared.mjs +60 -1
  29. package/cli/validator-markers.mjs +91 -0
  30. package/cli/validators/api-surface.mjs +37 -3
  31. package/cli/validators/canonical-sync.mjs +22 -19
  32. package/cli/validators/doc-quality.mjs +2 -42
  33. package/cli/validators/docs-coverage.mjs +13 -0
  34. package/cli/validators/docs-sync.mjs +4 -3
  35. package/cli/validators/drift.mjs +3 -2
  36. package/cli/validators/freshness.mjs +47 -15
  37. package/cli/validators/metadata-sync.mjs +21 -11
  38. package/cli/validators/metrics-consistency.mjs +45 -17
  39. package/cli/validators/security.mjs +13 -5
  40. package/cli/validators/structure.mjs +6 -5
  41. package/cli/validators/surface-sync.mjs +7 -5
  42. package/cli/validators/test-spec.mjs +76 -51
  43. package/cli/validators/todo-tracking.mjs +4 -2
  44. package/cli/validators/traceability.mjs +11 -3
  45. package/cli/writers/sections.mjs +32 -19
  46. package/docs/commands.md +1 -1
  47. package/docs/configuration.md +11 -0
  48. package/docs/faq.md +1 -1
  49. package/extensions/spec-kit-docguard/README.md +1 -1
  50. package/extensions/spec-kit-docguard/extension.yml +2 -2
  51. package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +2 -2
  52. package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +2 -2
  53. package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
  54. package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
  55. package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +2 -1
  56. package/extensions/spec-kit-docguard/templates/github-workflows/docguard-autofix.yml +3 -2
  57. package/extensions/spec-kit-docguard/templates/github-workflows/docguard-guard.yml +2 -2
  58. package/package.json +5 -3
@@ -39,6 +39,39 @@ function safeReadJson(path) {
39
39
  try { return JSON.parse(readFileSync(path, 'utf-8')); } catch { return null; }
40
40
  }
41
41
 
42
+ /**
43
+ * Files DocGuard reads WHOLE and regex/AST-scans. A bundle, minified vendor
44
+ * file, or generated client checked into source is slow to read, hostile to
45
+ * regex, expensive to AST-parse, and is never the project's authored truth.
46
+ * 1.5 MB sits far above any hand-written module yet below typical bundles.
47
+ */
48
+ export const MAX_SCAN_BYTES = 1_500_000;
49
+
50
+ /** True for build artifacts / minified / generated / declaration files. */
51
+ export function isGeneratedPath(p) {
52
+ const b = String(p);
53
+ return /\.min\.[cm]?js$/i.test(b)
54
+ || /\.(bundle|chunk)\.[cm]?jsx?$/i.test(b)
55
+ || /[.-]generated\.[a-z0-9]+$/i.test(b)
56
+ || /\.d\.ts$/i.test(b);
57
+ }
58
+
59
+ /**
60
+ * Read a source file for scanning, or return null when it should be skipped:
61
+ * unreadable, a generated/minified artifact, or larger than `maxBytes`. This is
62
+ * the single guard that keeps every scanner from choking on a checked-in
63
+ * bundle. Skipping is logged by callers that care (most just see "no match").
64
+ */
65
+ export function readScannable(absPath, { maxBytes = MAX_SCAN_BYTES } = {}) {
66
+ try {
67
+ if (isGeneratedPath(absPath)) return null;
68
+ if (statSync(absPath).size > maxBytes) return null;
69
+ return readFileSync(absPath, 'utf-8');
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
42
75
  /**
43
76
  * Expand a workspace glob (e.g. "packages/*") into concrete directories
44
77
  * that contain a package.json. Only the trailing single-level "/*" glob is
@@ -236,8 +269,8 @@ export function grepEnvUsage(projectDir, config = {}) {
236
269
  if (!CODE_EXTENSIONS.has(extname(filePath))) return;
237
270
  const rel = relative(projectDir, filePath);
238
271
  if (shouldIgnore(rel, config)) return;
239
- let content;
240
- try { content = readFileSync(filePath, 'utf-8'); } catch { return; }
272
+ const content = readScannable(filePath);
273
+ if (content === null) return; // unreadable, generated, or too large to scan
241
274
  if (!content.includes('env')) return;
242
275
  // patterns[2] is the import.meta.env one — its matches are Vite-injected
243
276
  // when the name is an intrinsic, and must not be reported as user env vars.
@@ -250,6 +283,30 @@ export function grepEnvUsage(projectDir, config = {}) {
250
283
  names.add(m[1]);
251
284
  }
252
285
  }
286
+
287
+ // v0.24: env vars are increasingly declared in a validation schema
288
+ // (Zod / envalid / convict) and read via a typed `config` object instead of
289
+ // `process.env.X` — so the direct-access patterns above miss them and every
290
+ // documented var looked "missing from code" (field report). Only harvest
291
+ // when the file actually validates process.env through such a schema.
292
+ const validatesEnv =
293
+ /(?:safeParse|parse)\s*\(\s*process\.env\b/.test(content) || // zod: schema.parse(process.env)
294
+ /\bcleanEnv\s*\(\s*process\.env\b/.test(content) || // envalid
295
+ /\bconvict\s*\(/.test(content); // convict
296
+ if (validatesEnv) {
297
+ let km;
298
+ // Zod / envalid: the schema KEYS are the env var names. Data schemas use
299
+ // camelCase keys, so requiring UPPER_SNAKE keeps this env-specific.
300
+ const keyRe = /^\s*['"]?([A-Z][A-Z0-9_]*[A-Z0-9])['"]?\s*:/gm;
301
+ while ((km = keyRe.exec(content)) !== null) {
302
+ if (km[1].length >= 3 && !VITE_INTRINSICS.has(km[1])) names.add(km[1]);
303
+ }
304
+ // convict: the env var name is the `env:` property value, not the key.
305
+ const convictRe = /\benv\s*:\s*['"]([A-Z][A-Z0-9_]*[A-Z0-9])['"]/g;
306
+ while ((km = convictRe.exec(content)) !== null) {
307
+ if (km[1].length >= 3) names.add(km[1]);
308
+ }
309
+ }
253
310
  };
254
311
 
255
312
  const walk = (dir) => {
@@ -12,6 +12,19 @@
12
12
  * pattern (app/api, pages/api) preserved from the v0.22.0 #195 fix.
13
13
  */
14
14
 
15
+ /**
16
+ * A markdown file is documentation, never the source that *implements* a
17
+ * canonical doc — so it must not count as a doc→code match. Without this,
18
+ * SECURITY.md's "Auth modules" glob (which includes `guard`) matched
19
+ * `commands/docguard.guard.md` and listed DocGuard's own command docs as the
20
+ * project's auth modules (field report). Real config-file matches (.env,
21
+ * Dockerfile, pyproject.toml, .gitignore) are unaffected — none are `.md`.
22
+ * Used by both `docguard trace` and the guard-time Traceability validator.
23
+ */
24
+ export function isTraceableSource(relPath) {
25
+ return !relPath.endsWith('.md');
26
+ }
27
+
15
28
  export const TEST_PATTERNS = [
16
29
  // JS/TS
17
30
  /\.test\.[jt]sx?$/, /\.spec\.[jt]sx?$/, /\.test\.(mjs|cjs)$/,
package/cli/shared.mjs CHANGED
@@ -39,6 +39,65 @@ export function resolveSeverity(config, validatorKey) {
39
39
  return 'medium';
40
40
  }
41
41
 
42
+ // ── Canonical section heading matching ─────────────────────────────────────
43
+ /**
44
+ * Required canonical sections used to be matched by literal substring, so an
45
+ * arc42/C4 doc with "## 5.4 Layer boundaries" or "## Building Block View"
46
+ * scored as if the section were absent — the validator made well-structured
47
+ * docs look WORSE than the skeleton (field report). These synonyms + section-
48
+ * number tolerance let equivalent headings count. Synonyms only ever ADD
49
+ * matches; the literal canonical heading always still passes.
50
+ *
51
+ * Keyed by the normalized canonical heading (lowercase, alphanumerics + spaces).
52
+ */
53
+ export const SECTION_SYNONYMS = {
54
+ 'system overview': ['system context', 'system summary', 'introduction and goals', 'context and scope', 'overview and goals'],
55
+ 'component map': ['components', 'component overview', 'building block view', 'building blocks', 'containers', 'module overview'],
56
+ 'tech stack': ['technology stack', 'technologies', 'technical stack'],
57
+ 'entities': ['entity definitions', 'data model', 'domain model', 'data entities'],
58
+ 'authentication': ['auth', 'authn', 'authentication and authorization', 'identity'],
59
+ 'secrets management': ['secrets', 'secret management', 'secrets handling', 'credentials', 'credential management'],
60
+ 'test categories': ['test types', 'categories of tests', 'test strategy', 'testing strategy', 'test approach'],
61
+ 'coverage rules': ['coverage', 'coverage targets', 'coverage goals', 'coverage requirements', 'coverage policy'],
62
+ 'environment variables': ['env vars', 'environment', 'configuration', 'config variables'],
63
+ 'setup steps': ['setup', 'getting started', 'installation', 'setup instructions', 'local setup', 'quick start'],
64
+ 'layer boundaries': ['layers', 'layering', 'module boundaries', 'layer boundary', 'boundaries'],
65
+ 'external dependencies': ['dependencies', 'external systems', 'third party dependencies', 'integrations', 'external interfaces'],
66
+ 'revision history': ['changelog', 'change history', 'history', 'revisions', 'document history', 'change log'],
67
+ };
68
+
69
+ /** Normalize a markdown heading: drop #, leading arc42-style numbers, punctuation. */
70
+ function normalizeHeadingText(line) {
71
+ return line
72
+ .replace(/^#{1,6}\s*/, '') // strip leading #s
73
+ .replace(/^\d+(?:\.\d+)*\.?\s+/, '') // strip leading "5.4 " / "3. " section numbers
74
+ .toLowerCase()
75
+ .replace(/[^a-z0-9]+/g, ' ') // non-alphanumeric → single space
76
+ .trim();
77
+ }
78
+
79
+ /**
80
+ * True if `content` has an H2–H6 heading equivalent to `canonicalHeading` —
81
+ * the exact text, a known synonym, or the same text behind an arc42-style
82
+ * section number (e.g. "## 5.4 Layer boundaries" satisfies "## Layer Boundaries").
83
+ *
84
+ * @param {string} content - markdown file contents
85
+ * @param {string} canonicalHeading - e.g. '## Component Map' or 'Component Map'
86
+ */
87
+ export function docHasSection(content, canonicalHeading) {
88
+ const key = normalizeHeadingText(canonicalHeading);
89
+ if (!key) return false;
90
+ const accepted = [key, ...(SECTION_SYNONYMS[key] || [])];
91
+ const headings = content.match(/^#{2,6}\s+.+$/gm) || [];
92
+ for (const line of headings) {
93
+ const norm = normalizeHeadingText(line);
94
+ for (const phrase of accepted) {
95
+ if (norm.includes(phrase)) return true;
96
+ }
97
+ }
98
+ return false;
99
+ }
100
+
42
101
  /**
43
102
  * Parse a dotted-decimal version string into a tuple of integers for
44
103
  * comparison. Tolerates extra suffixes (e.g. `0.4-beta` → [0, 4]).
@@ -85,7 +144,7 @@ export const c = {
85
144
  // ── Compliance Profiles ───────────────────────────────────────────────────
86
145
  export const PROFILES = {
87
146
  starter: {
88
- description: 'Minimal CDD — just architecture + changelog. For side projects and prototypes.',
147
+ description: 'Minimal CDD — architecture + changelog, no Spec Kit framework scaffold (pass --spec-kit to add it). For side projects and prototypes.',
89
148
  requiredFiles: {
90
149
  canonical: [
91
150
  'docs-canonical/ARCHITECTURE.md',
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Inline whole-validator N/A markers — "declare intentional non-applicability,
3
+ * visibly."
4
+ *
5
+ * A project can mute an entire validator from inside its docs, with the
6
+ * rationale right next to the declaration and tracked in git:
7
+ *
8
+ * <!-- docguard:validator testSpec n/a — POC, no automated tests yet -->
9
+ * <!-- docguard:validator traceability n/a — no formal requirements doc -->
10
+ *
11
+ * This is the validator-level sibling of the section-level
12
+ * `<!-- docguard:section <id> n/a — reason -->`. Unlike the config switch
13
+ * (`validators: { testSpec: false }`), which renders as a silent "disabled",
14
+ * a marked validator renders as a visible `➖ [N/A] (declared N/A: reason)` —
15
+ * honest non-applicability, not an invisible skip or a fake green check.
16
+ *
17
+ * Markers are read from the project's primary docs (canonical docs + the root
18
+ * agent/readme files) so the rationale lives where humans and agents read.
19
+ *
20
+ * Zero NPM dependencies — pure Node.js built-ins.
21
+ */
22
+
23
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
24
+ import { resolve, join } from 'node:path';
25
+
26
+ // `<!-- docguard:validator <key> n/a [— reason] -->`
27
+ // Separator before the reason may be —, :, or one-or-more hyphens. Reason
28
+ // is optional. Case-insensitive on the keyword and "n/a".
29
+ const MARKER_RE = /<!--\s*docguard:validator\s+([A-Za-z0-9_-]+)\s+n\/a\b\s*(?:[—:\-]+\s*([^>]*?))?\s*-->/gi;
30
+
31
+ /** Files where a validator marker is honored — the docs humans actually read. */
32
+ function markerSourceFiles(projectDir) {
33
+ const files = [];
34
+ const canonicalDir = resolve(projectDir, 'docs-canonical');
35
+ if (existsSync(canonicalDir)) {
36
+ try {
37
+ for (const f of readdirSync(canonicalDir)) {
38
+ if (f.toLowerCase().endsWith('.md')) files.push(join(canonicalDir, f));
39
+ }
40
+ } catch { /* ignore */ }
41
+ }
42
+ for (const root of ['AGENTS.md', 'README.md', 'CLAUDE.md']) {
43
+ const p = resolve(projectDir, root);
44
+ if (existsSync(p)) files.push(p);
45
+ }
46
+ return files;
47
+ }
48
+
49
+ /** Normalize a key for tolerant matching: `Test-Spec`/`test_spec` → `testspec`. */
50
+ function norm(key) {
51
+ return String(key).toLowerCase().replace(/[^a-z0-9]/g, '');
52
+ }
53
+
54
+ /**
55
+ * Scan the project's primary docs for `docguard:validator <key> n/a` markers.
56
+ *
57
+ * @param {string} projectDir
58
+ * @param {Iterable<string>} validKeys - the canonical validator keys (camelCase)
59
+ * @returns {{ suppressed: Map<string,string>, unknown: Array<{raw:string, file:string}> }}
60
+ * `suppressed` maps a canonical validator key → reason ('' if none given).
61
+ * `unknown` lists markers whose key didn't resolve (typo protection).
62
+ */
63
+ export function loadValidatorSuppressions(projectDir, validKeys) {
64
+ const canonicalByNorm = new Map();
65
+ for (const k of validKeys) canonicalByNorm.set(norm(k), k);
66
+
67
+ const suppressed = new Map();
68
+ const unknown = [];
69
+
70
+ for (const file of markerSourceFiles(projectDir)) {
71
+ let content;
72
+ try { content = readFileSync(file, 'utf-8'); } catch { continue; }
73
+ if (!content.includes('docguard:validator')) continue;
74
+
75
+ MARKER_RE.lastIndex = 0;
76
+ let m;
77
+ while ((m = MARKER_RE.exec(content)) !== null) {
78
+ const rawKey = m[1];
79
+ const reason = (m[2] || '').trim();
80
+ const canonical = canonicalByNorm.get(norm(rawKey));
81
+ if (!canonical) {
82
+ unknown.push({ raw: rawKey, file });
83
+ continue;
84
+ }
85
+ // First marker wins; keep its reason. Re-declaring is harmless.
86
+ if (!suppressed.has(canonical)) suppressed.set(canonical, reason);
87
+ }
88
+ }
89
+
90
+ return { suppressed, unknown };
91
+ }
@@ -29,6 +29,7 @@ import { detectOpenAPI } from '../scanners/doc-tools.mjs';
29
29
  import { scanRoutesDeep } from '../scanners/routes.mjs';
30
30
  import { parseApiReferenceDoc, compareEndpoints, endpointKey } from '../scanners/api-doc.mjs';
31
31
  import { collectPackageJsons, getWorkspaceDirs } from '../shared-source.mjs';
32
+ import { relPosix } from '../shared-ignore.mjs';
32
33
 
33
34
  const MAX_REPORTED = 15;
34
35
  const API_DOC = 'docs-canonical/API-REFERENCE.md';
@@ -86,15 +87,35 @@ export function findAllOpenApiSpecs(projectDir, config) {
86
87
  seenAbs.add(absPath);
87
88
  specs.push({
88
89
  absPath,
89
- relPath: absPath.startsWith(resolve(projectDir))
90
- ? absPath.slice(resolve(projectDir).length + 1)
91
- : absPath,
90
+ relPath: relPosix(projectDir, absPath),
92
91
  endpoints: oa.endpoints.filter(e => e && e.method && e.path),
93
92
  });
94
93
  }
95
94
  return specs;
96
95
  }
97
96
 
97
+ /**
98
+ * OpenAPI specs that exist and declare a `paths:` section but parsed to ZERO
99
+ * endpoints — i.e. DocGuard's minimal YAML/JSON parser couldn't extract them
100
+ * (an unsupported feature: `$ref`, anchors, folded scalars). These are silently
101
+ * skipped by findAllOpenApiSpecs (good — code scanning takes over), but the
102
+ * parse failure must be SURFACED so a broken spec doesn't masquerade as a
103
+ * clean "no API surface" pass. Returns relative spec paths.
104
+ */
105
+ export function findUnparseableSpecs(projectDir, config) {
106
+ const out = [];
107
+ const seen = new Set();
108
+ for (const dir of orderedSpecDirs(projectDir, config)) {
109
+ const oa = detectOpenAPI(dir);
110
+ if (!oa.found || !oa.parseIncomplete) continue;
111
+ const abs = resolve(dir, oa.path);
112
+ if (seen.has(abs)) continue;
113
+ seen.add(abs);
114
+ out.push(relPosix(projectDir, abs));
115
+ }
116
+ return out;
117
+ }
118
+
98
119
  /**
99
120
  * Detect divergence between multiple canonical OpenAPI specs.
100
121
  * @returns {null | { specs, divergent: string[], authoritative: string }}
@@ -208,6 +229,19 @@ export function validateApiSurface(projectDir, config) {
208
229
  }
209
230
  }
210
231
 
232
+ // ── Honest-failure: an OpenAPI spec we couldn't parse ──
233
+ // A spec that declares paths but yielded zero endpoints means our parser
234
+ // choked on it. We fall back to code scanning (below), but the parse failure
235
+ // is surfaced here rather than silently producing a clean "no surface" pass.
236
+ for (const specPath of findUnparseableSpecs(projectDir, config)) {
237
+ warnings.push(
238
+ `OpenAPI spec ${specPath} declares paths but DocGuard parsed 0 endpoints from it ` +
239
+ `(likely an unsupported YAML feature — $ref, anchors, or folded scalars). ` +
240
+ `Falling back to code scanning; the spec's own endpoint list is unavailable. ` +
241
+ `Validate it with a full OpenAPI linter.`
242
+ );
243
+ }
244
+
211
245
  const drift = computeApiSurfaceDrift(projectDir, config);
212
246
 
213
247
  // ── Multi-spec divergence (independent of the API-REFERENCE doc) ──
@@ -124,37 +124,40 @@ export function validateCanonicalSync(projectDir, config, guardResults) {
124
124
  actualValidatorNames = guardResults.map(r => r.name).filter(Boolean);
125
125
  }
126
126
 
127
- // ── Read README ─────────────────────────────────────────────────────
128
- const readmePath = resolve(projectDir, 'README.md');
129
- if (!existsSync(readmePath)) {
130
- result.warnings.push('canonical-sync: README.md not foundcannot check surface claims');
131
- result.total = 1;
132
- return result;
127
+ // ── Read surface docs (README.md + AGENTS.md) ──────────────────────
128
+ // Both carry "N commands / N validators" surface claims. Scanning only the
129
+ // README is why AGENTS.md's counts ("Commands (15 total)", "24 validators")
130
+ // drifted unchecked for releases close that gap by checking both.
131
+ const surfaceFiles = ['README.md', 'AGENTS.md'];
132
+ let readme = '';
133
+ let readAny = false;
134
+ for (const f of surfaceFiles) {
135
+ const p = resolve(projectDir, f);
136
+ if (!existsSync(p)) continue;
137
+ try { readme += readFileSync(p, 'utf-8') + '\n'; readAny = true; } catch { /* skip unreadable */ }
133
138
  }
134
-
135
- let readme;
136
- try {
137
- readme = readFileSync(readmePath, 'utf-8');
138
- } catch {
139
- result.warnings.push('canonical-sync: README.md unreadable');
139
+ if (!readAny) {
140
+ result.warnings.push('canonical-sync: no README.md or AGENTS.md found — cannot check surface claims');
140
141
  result.total = 1;
141
142
  return result;
142
143
  }
143
144
 
144
145
  // ── Check 1: "ships N commands" ─────────────────────────────────────
146
+ // Check ALL claims (matchAll), not just the first: with README + AGENTS.md
147
+ // concatenated, a correct claim in one file must not mask a stale claim in
148
+ // the other (the same first-match-masking trap the secret scanner had).
145
149
  result.total++;
146
- const shipsCommandsRe = /ships\s+\*{0,2}(\d+)\s+commands?\*{0,2}/i;
147
- const m1 = readme.match(shipsCommandsRe);
148
- if (m1) {
149
- const claimed = Number(m1[1]);
150
- if (claimed === actualCommandCount) {
150
+ const cmdMatches = [...readme.matchAll(/ships\s+\*{0,2}(\d+)\s+commands?\*{0,2}/gi)];
151
+ if (cmdMatches.length > 0) {
152
+ const wrong = [...new Set(cmdMatches.map(m => Number(m[1])).filter(n => n !== actualCommandCount))];
153
+ if (wrong.length === 0) {
151
154
  result.passed++;
152
155
  } else {
153
156
  const detail = actualUserFacingCount !== actualCommandFileCount
154
157
  ? `${actualCommandCount} user-facing commands in --help (${actualCommandFileCount} files including deprecation aliases)`
155
158
  : `${actualCommandCount} command file(s)`;
156
159
  result.warnings.push(
157
- `README.md claims "ships ${claimed} commands" but the real count is ${detail}. Update the README.`
160
+ `A surface doc (README.md/AGENTS.md) claims ${wrong.map(n => `"ships ${n} commands"`).join(' / ')} but the real count is ${detail}. Update it.`
158
161
  );
159
162
  }
160
163
  } else {
@@ -177,7 +180,7 @@ export function validateCanonicalSync(projectDir, config, guardResults) {
177
180
  } else {
178
181
  const uniqueWrong = [...new Set(wrongClaims)];
179
182
  result.warnings.push(
180
- `README.md claims ${uniqueWrong.map(n => `"${n} validators"`).join(' / ')} but guard reports ${actualValidatorCount}. Update the README.`
183
+ `A surface doc (README.md/AGENTS.md) claims ${uniqueWrong.map(n => `"${n} validators"`).join(' / ')} but guard reports ${actualValidatorCount}. Update it.`
181
184
  );
182
185
  }
183
186
  } else {
@@ -16,14 +16,12 @@
16
16
  * cells as "long sentences"), this version extracts ONLY actual prose
17
17
  * paragraphs. Docs that are mostly tables/code skip readability scoring.
18
18
  *
19
- * Optional: If `understanding` CLI is installed, runs a full 31-metric deep scan.
20
- *
21
- * Zero NPM runtime dependencies — pure Node.js built-ins only.
19
+ * Zero NPM runtime dependencies, and zero process execution pure Node.js
20
+ * built-ins reading files only.
22
21
  */
23
22
 
24
23
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
25
24
  import { resolve, join, extname } from 'node:path';
26
- import { execSync, execFileSync } from 'node:child_process';
27
25
 
28
26
  // ──── Metric Thresholds ────
29
27
  // These define "good" vs "warning" boundaries for each metric.
@@ -441,40 +439,6 @@ function getGradeLabel(grade) {
441
439
  return 'graduate+';
442
440
  }
443
441
 
444
- // ──── Understanding CLI Integration ────
445
-
446
- /**
447
- * Check if the `understanding` CLI is available on the system.
448
- */
449
- function findUnderstandingCli() {
450
- try {
451
- const cmd = process.platform === 'win32' ? 'where understanding' : 'which understanding';
452
- const result = execSync(`${cmd} 2>/dev/null`, {
453
- encoding: 'utf-8',
454
- timeout: 3000,
455
- }).trim();
456
- return result || null;
457
- } catch {
458
- return null;
459
- }
460
- }
461
-
462
- /**
463
- * Run the `understanding` CLI on a file and parse results.
464
- */
465
- function runUnderstandingDeepScan(filePath) {
466
- try {
467
- const result = execFileSync('understanding', ['analyze', filePath, '--enhanced', '--json'], {
468
- encoding: 'utf-8',
469
- timeout: 10000,
470
- stdio: ['pipe', 'pipe', 'ignore'],
471
- });
472
- return JSON.parse(result);
473
- } catch {
474
- return null;
475
- }
476
- }
477
-
478
442
  // ──── Main Validator ────
479
443
 
480
444
  /**
@@ -587,10 +551,6 @@ export function validateDocQuality(projectDir, config) {
587
551
  return results;
588
552
  }
589
553
 
590
- // Check for optional understanding CLI
591
- const understandingCli = findUnderstandingCli();
592
- const useDeepScan = config.docQuality?.deepScan !== false && understandingCli;
593
-
594
554
  for (const doc of docs) {
595
555
  if (!existsSync(doc.path)) continue;
596
556
 
@@ -35,8 +35,20 @@ const COMMON_DOTFILES = new Set([
35
35
  '.env', '.env.local', '.env.development', '.env.production',
36
36
  '.vscode', '.idea', '.github', '.husky',
37
37
  '.babelrc', '.browserslistrc', '.stylelintrc',
38
+ '.dockerignore', '.python-version', '.tool-versions', '.ruby-version',
39
+ '.gitkeep', '.keep',
38
40
  ]);
39
41
 
42
+ // Generated tool artifacts (caches, coverage data, lock-data) that land at the
43
+ // repo root but are NOT configuration a human authors or documents. Treating
44
+ // them as "undocumented config files" is a false positive (field test:
45
+ // quick-recon-tool flagged pytest's `.coverage` SQLite data file). Matched by
46
+ // exact name OR prefix (`.coverage.<host>.<pid>` is coverage.py's parallel form).
47
+ const GENERATED_DOTFILE_PREFIXES = ['.coverage', '.eslintcache', '.stylelintcache', '.tsbuildinfo'];
48
+ function isGeneratedArtifact(name) {
49
+ return GENERATED_DOTFILE_PREFIXES.some(p => name === p || name.startsWith(p + '.'));
50
+ }
51
+
40
52
  /**
41
53
  * Validate that code artifacts are referenced in documentation.
42
54
  * @param {string} projectDir - Project root directory
@@ -125,6 +137,7 @@ function checkConfigFiles(projectDir, allDocContent, config = {}) {
125
137
 
126
138
  if (!isDotFile && !isProjectConfig) continue;
127
139
  if (COMMON_DOTFILES.has(entry)) continue;
140
+ if (isGeneratedArtifact(entry)) continue;
128
141
  if (entry === 'tsconfig.json' || entry === 'package-lock.json') continue;
129
142
 
130
143
  // Skip directories — this check is for configuration FILES, not dirs.
@@ -5,6 +5,7 @@
5
5
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
6
6
  import { resolve, join, extname, basename } from 'node:path';
7
7
  import { resolveSourceRoots } from '../shared-source.mjs';
8
+ import { relPosix } from '../shared-ignore.mjs';
8
9
 
9
10
  const IGNORE_DIRS = new Set([
10
11
  'node_modules', '.git', '.next', '.nuxt', 'dist', 'build', 'out',
@@ -103,7 +104,7 @@ export function validateDocsSync(projectDir, config) {
103
104
  const ext = extname(file);
104
105
  if (!['.ts', '.tsx', '.js', '.jsx', '.mjs', '.py', '.java', '.go'].includes(ext)) continue;
105
106
 
106
- const relPath = file.replace(projectDir + '/', '');
107
+ const relPath = relPosix(projectDir, file);
107
108
  if (isTestFile(relPath)) continue;
108
109
  if (!isValidRouteFile(relPath)) continue;
109
110
  // N-1: skip files outside the --changed-only scope.
@@ -129,7 +130,7 @@ export function validateDocsSync(projectDir, config) {
129
130
  const ext = extname(file);
130
131
  if (!['.ts', '.tsx', '.js', '.jsx', '.mjs', '.py', '.java', '.go'].includes(ext)) continue;
131
132
 
132
- const relPath = file.replace(projectDir + '/', '');
133
+ const relPath = relPosix(projectDir, file);
133
134
  if (isTestFile(relPath)) continue;
134
135
  // N-1: skip files outside the --changed-only scope.
135
136
  if (!inScope(relPath)) continue;
@@ -175,7 +176,7 @@ export function validateDocsSync(projectDir, config) {
175
176
  const ext = extname(file);
176
177
  if (!['.ts', '.tsx', '.js', '.jsx', '.mjs'].includes(ext)) continue;
177
178
 
178
- const relPathForFilter = file.replace(projectDir + '/', '');
179
+ const relPathForFilter = relPosix(projectDir, file);
179
180
  if (isTestFile(relPathForFilter)) continue;
180
181
  if (!isValidRouteFile(relPathForFilter)) continue;
181
182
 
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
6
6
  import { resolve, join, extname } from 'node:path';
7
+ import { relPosix } from '../shared-ignore.mjs';
7
8
 
8
9
  const CODE_EXTENSIONS = new Set([
9
10
  '.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx',
@@ -31,7 +32,7 @@ export function validateDrift(projectDir, config) {
31
32
  // string fixtures (e.g. `'// DRIFT: a-drift\n'`). Reading the test as
32
33
  // source would treat the string as a real drift comment. Skip test
33
34
  // files unless the user opts in — same pattern TODO-Tracking uses.
34
- const rel = filePath.replace(projectDir + '/', '');
35
+ const rel = relPosix(projectDir, filePath);
35
36
  const includeTests = config?.drift?.includeTestFiles === true;
36
37
  if (!includeTests && /(^|\/)(__tests__|tests?|spec)\/|\.(test|spec)\.[^.]+$/.test(rel)) {
37
38
  return;
@@ -44,7 +45,7 @@ export function validateDrift(projectDir, config) {
44
45
  const match = line.match(/(?:\/\/|#|\/\*|\-\-)\s*DRIFT:\s*(.+)/i);
45
46
  if (match) {
46
47
  driftComments.push({
47
- file: filePath.replace(projectDir + '/', ''),
48
+ file: relPosix(projectDir, filePath),
48
49
  line: i + 1,
49
50
  comment: match[1].trim(),
50
51
  });