docguard-cli 0.22.1 → 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 (61) hide show
  1. package/README.md +4 -4
  2. package/cli/commands/demo.mjs +1 -1
  3. package/cli/commands/diff.mjs +19 -8
  4. package/cli/commands/explain.mjs +178 -17
  5. package/cli/commands/fix.mjs +17 -2
  6. package/cli/commands/generate.mjs +2 -2
  7. package/cli/commands/guard.mjs +86 -11
  8. package/cli/commands/hooks.mjs +12 -7
  9. package/cli/commands/init.mjs +18 -6
  10. package/cli/commands/score.mjs +147 -61
  11. package/cli/commands/setup.mjs +2 -2
  12. package/cli/commands/trace.mjs +3 -101
  13. package/cli/commands/upgrade.mjs +61 -13
  14. package/cli/config.mjs +245 -0
  15. package/cli/docguard.mjs +21 -217
  16. package/cli/ensure-skills.mjs +24 -26
  17. package/cli/scanners/api-doc.mjs +17 -3
  18. package/cli/scanners/doc-tools.mjs +32 -15
  19. package/cli/scanners/frontend.mjs +24 -8
  20. package/cli/scanners/js-ast.mjs +432 -0
  21. package/cli/scanners/memory-plan.mjs +1 -1
  22. package/cli/scanners/py-ast.mjs +213 -0
  23. package/cli/scanners/routes.mjs +194 -69
  24. package/cli/scanners/schemas.mjs +97 -51
  25. package/cli/scanners/speckit.mjs +14 -0
  26. package/cli/shared-git.mjs +0 -0
  27. package/cli/shared-ignore.mjs +16 -1
  28. package/cli/shared-source.mjs +59 -2
  29. package/cli/shared-trace-patterns.mjs +118 -0
  30. package/cli/shared.mjs +60 -1
  31. package/cli/validator-markers.mjs +91 -0
  32. package/cli/validators/api-surface.mjs +37 -3
  33. package/cli/validators/canonical-sync.mjs +22 -19
  34. package/cli/validators/doc-quality.mjs +27 -44
  35. package/cli/validators/docs-coverage.mjs +13 -0
  36. package/cli/validators/docs-diff.mjs +16 -6
  37. package/cli/validators/docs-sync.mjs +4 -3
  38. package/cli/validators/drift.mjs +3 -2
  39. package/cli/validators/freshness.mjs +47 -15
  40. package/cli/validators/metadata-sync.mjs +21 -11
  41. package/cli/validators/metrics-consistency.mjs +45 -17
  42. package/cli/validators/security.mjs +13 -5
  43. package/cli/validators/structure.mjs +6 -5
  44. package/cli/validators/surface-sync.mjs +7 -5
  45. package/cli/validators/test-spec.mjs +76 -51
  46. package/cli/validators/todo-tracking.mjs +4 -2
  47. package/cli/validators/traceability.mjs +12 -54
  48. package/cli/writers/sections.mjs +32 -19
  49. package/docs/commands.md +1 -1
  50. package/docs/configuration.md +11 -0
  51. package/docs/faq.md +1 -1
  52. package/extensions/spec-kit-docguard/README.md +1 -1
  53. package/extensions/spec-kit-docguard/extension.yml +2 -2
  54. package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +2 -2
  55. package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +2 -2
  56. package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
  57. package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
  58. package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +2 -1
  59. package/extensions/spec-kit-docguard/templates/github-workflows/docguard-autofix.yml +3 -2
  60. package/extensions/spec-kit-docguard/templates/github-workflows/docguard-guard.yml +2 -2
  61. 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) => {
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Shared trace patterns — the single source of truth for doc→code traceability,
3
+ * used by BOTH the `docguard trace` command (cli/commands/trace.mjs) and the
4
+ * guard-time Traceability validator (cli/validators/traceability.mjs).
5
+ *
6
+ * Previously each file had its own copy: trace.mjs was multilingual (v0.16-P2)
7
+ * while the validator stayed JS/TS-only, so the README's "language-aware trace
8
+ * mapping" claim was false for the `guard` path. Sharing here makes the claim
9
+ * true and prevents the two from drifting again (field-report Issue 3).
10
+ *
11
+ * The API-REFERENCE entry additionally carries the explicit Next.js App Router
12
+ * pattern (app/api, pages/api) preserved from the v0.22.0 #195 fix.
13
+ */
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
+
28
+ export const TEST_PATTERNS = [
29
+ // JS/TS
30
+ /\.test\.[jt]sx?$/, /\.spec\.[jt]sx?$/, /\.test\.(mjs|cjs)$/,
31
+ // Python — pytest conventions
32
+ /(^|\/)test_[^/]+\.py$/, /[^/]+_test\.py$/, /(^|\/)tests?\/[^/]+\.py$/,
33
+ // Go
34
+ /_test\.go$/,
35
+ // Java/Kotlin — JUnit/TestNG conventions
36
+ /(?:Test|Tests|Spec|IT)\.(?:java|kt)$/,
37
+ // Rust — tests live in tests/ or as #[cfg(test)] modules; pattern below covers integration tests
38
+ /(^|\/)tests\/[^/]+\.rs$/,
39
+ // Ruby/RSpec
40
+ /_spec\.rb$/, /_test\.rb$/,
41
+ // PHP/PHPUnit
42
+ /Test\.php$/, /(^|\/)tests?\/[^/]+\.php$/,
43
+ ];
44
+
45
+ export const TRACE_MAP = {
46
+ 'ARCHITECTURE.md': {
47
+ standard: 'arc42 / C4 Model',
48
+ sourcePatterns: [
49
+ // Entry points: JS (index/main/app/server.[jt]sx?), Python (__main__.py, main.py, app.py, cli.py),
50
+ // Go (main.go, cmd/), Rust (main.rs, lib.rs), Java (Application.java, Main.java)
51
+ { label: 'Entry points', glob: /(?:^|\/)(?:index|main|app|server|cli|__main__|Application|Main)\.(?:[jt]sx?|mjs|cjs|py|go|rs|java|kt|rb)$|(?:^|\/)cmd\// },
52
+ // Config files: JS (package.json/tsconfig/next.config/vite.config), Python (pyproject.toml/setup.py/setup.cfg),
53
+ // Rust (Cargo.toml), Go (go.mod), Java/Kotlin (pom.xml/build.gradle), Ruby (Gemfile), PHP (composer.json)
54
+ { label: 'Config files', glob: /(?:^|\/)(?:package\.json|tsconfig|next\.config|vite\.config|pyproject\.toml|setup\.(?:py|cfg)|Cargo\.toml|go\.mod|pom\.xml|build\.gradle|Gemfile|composer\.json)/ },
55
+ // Route handlers + module dirs
56
+ { label: 'Route handlers / modules', glob: /(?:^|\/)(?:routes?|api|pages|app|controllers?|handlers?|views?|services?)\// },
57
+ ],
58
+ },
59
+ 'DATA-MODEL.md': {
60
+ standard: 'C4 Component / ER (Chen)',
61
+ sourcePatterns: [
62
+ // Schema/model files: JS (schema/model/entity/migration/prisma), Python (models.py/schema.py/Pydantic/SQLAlchemy),
63
+ // Go (models/), Rust (struct definitions in models/), Java (entities/)
64
+ { label: 'Schema definitions', glob: /(?:schema|model|entity|migration|prisma)/i },
65
+ // Type definitions: JS types.ts, Python types.py, Rust types.rs
66
+ { label: 'Type definitions', glob: /(?:^|\/)types?\.(?:[jt]sx?|mjs|py|rs|go|java|kt)$/ },
67
+ // ORM/database libs (any language)
68
+ { label: 'Database configs', glob: /(?:drizzle|knex|sequelize|typeorm|sqlalchemy|alembic|django|diesel|sqlx|gorm|hibernate|active.?record)/i },
69
+ ],
70
+ },
71
+ 'TEST-SPEC.md': {
72
+ standard: 'ISO/IEC/IEEE 29119-3',
73
+ sourcePatterns: [
74
+ // Test files in any ecosystem (mirrors TEST_PATTERNS above)
75
+ { label: 'Test files', glob: /\.(?:test|spec)\.(?:mjs|cjs|[jt]sx?)$|(?:^|\/)test_[^/]+\.py$|[^/]+_test\.py$|_test\.go$|(?:Test|Spec|IT)\.(?:java|kt)$|(?:^|\/)tests?\/[^/]+\.(?:rs|py|rb|php)$|_(?:spec|test)\.rb$|Test\.php$/ },
76
+ // Test runner configs: JS (jest/vitest/playwright/cypress), Python (pytest.ini/tox.ini), Rust (Cargo.toml has [[test]]),
77
+ // Java (pom.xml/build.gradle), Go (no config file typically)
78
+ { label: 'Test config', glob: /(?:jest|vitest|playwright|cypress|pytest|tox|phpunit)\.config|(?:^|\/)pytest\.ini$|(?:^|\/)tox\.ini$|(?:^|\/)phpunit\.xml$/ },
79
+ { label: 'E2E / integration tests', glob: /(?:^|\/)(?:e2e|integration|tests?\/integration)\// },
80
+ ],
81
+ },
82
+ 'SECURITY.md': {
83
+ standard: 'OWASP ASVS v4.0',
84
+ sourcePatterns: [
85
+ // Auth modules — semantic, language-agnostic
86
+ { label: 'Auth modules', glob: /(?:auth|login|session|jwt|oauth|middleware|guard|csrf|cors|permissions?|policy)/i },
87
+ // Secret configs — .env family + secrets.* / keyring patterns
88
+ { label: 'Secret configs', glob: /\.env(?:\.|$)|(?:^|\/)secrets?\.(?:py|js|ts|yaml|yml|json)$|keyring/i },
89
+ // Gitignore + ignore files
90
+ { label: 'Ignore files', glob: /^\.(?:git|docker|npm)ignore$/ },
91
+ ],
92
+ },
93
+ 'ENVIRONMENT.md': {
94
+ standard: '12-Factor App',
95
+ sourcePatterns: [
96
+ // .env family across all ecosystems
97
+ { label: 'Env files', glob: /\.env(?:\.|$)|(?:^|\/)\.envrc$/ },
98
+ // Containerization
99
+ { label: 'Container configs', glob: /(?:^|\/)(?:Dockerfile|docker-compose|\.dockerignore|Containerfile)/ },
100
+ // Python venv / requirements / lock files
101
+ { label: 'Python env', glob: /(?:^|\/)(?:requirements[^/]*\.txt|Pipfile|poetry\.lock|uv\.lock|pyproject\.toml)$/ },
102
+ // CI/CD configs
103
+ { label: 'CI/CD configs', glob: /(?:^|\/)\.(?:github|gitlab-ci|circleci|drone|gitea)/ },
104
+ ],
105
+ },
106
+ 'API-REFERENCE.md': {
107
+ standard: 'OpenAPI 3.1',
108
+ sourcePatterns: [
109
+ // Route handlers + Python views/urls + Java/Spring controllers
110
+ { label: 'Route handlers', glob: /(?:^|\/)(?:routes?|controllers?|handlers?|views?|urls?\.py)/ },
111
+ { label: 'Next.js API routes', glob: /(^|\/)(app|pages)\/api\// },
112
+ // OpenAPI / API specs
113
+ { label: 'API spec', glob: /(?:openapi|swagger|asyncapi)\.(?:json|ya?ml)/ },
114
+ // Middleware / decorators
115
+ { label: 'API middleware', glob: /(?:^|\/)middleware\/|decorators?\.py$/ },
116
+ ],
117
+ },
118
+ };
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
  /**
@@ -516,6 +480,21 @@ function getCanonicalDocs(projectDir) {
516
480
  * paragraphs are scored. Documents that are mostly tables/code/reference
517
481
  * material are skipped for readability (they'd score 0/100 unfairly).
518
482
  */
483
+ /**
484
+ * Parse a per-doc quality-rule override marker, e.g.
485
+ * <!-- docguard:quality negation-load off — security doc, prohibitive language is precise -->
486
+ * <!-- docguard:quality negation-load 0.35 — operational doc -->
487
+ * Returns { off: true } | { threshold: <number> } | null. A required reason
488
+ * after the value is encouraged (and self-documenting) but not enforced here.
489
+ */
490
+ function parseQualityOverride(content, rule) {
491
+ const re = new RegExp('<!--\\s*docguard:quality\\s+' + rule + '\\s+(off|\\d*\\.?\\d+)\\b', 'i');
492
+ const m = content.match(re);
493
+ if (!m) return null;
494
+ const v = m[1].toLowerCase();
495
+ return v === 'off' ? { off: true } : { threshold: parseFloat(v) };
496
+ }
497
+
519
498
  function analyzeDocument(doc) {
520
499
  const content = readFileSync(doc.path, 'utf-8');
521
500
  const proseText = extractProse(content);
@@ -554,6 +533,7 @@ function analyzeDocument(doc) {
554
533
  conditionalLoad: conditional.ratio,
555
534
  },
556
535
  details: { passive, ambiguous, atomicity, negation, conditional },
536
+ overrides: { negationLoad: parseQualityOverride(content, 'negation-load') },
557
537
  };
558
538
  }
559
539
 
@@ -571,10 +551,6 @@ export function validateDocQuality(projectDir, config) {
571
551
  return results;
572
552
  }
573
553
 
574
- // Check for optional understanding CLI
575
- const understandingCli = findUnderstandingCli();
576
- const useDeepScan = config.docQuality?.deepScan !== false && understandingCli;
577
-
578
554
  for (const doc of docs) {
579
555
  if (!existsSync(doc.path)) continue;
580
556
 
@@ -650,13 +626,20 @@ export function validateDocQuality(projectDir, config) {
650
626
  }
651
627
 
652
628
  // ── Check 7: Negation Load ──
629
+ // Per-doc override (security/operational docs legitimately use "never",
630
+ // "must not", "cannot") and a project-wide config threshold both honored.
653
631
  results.total++;
654
- if (m.negationLoad <= THRESHOLDS.negationLoad.warn) {
632
+ const negOv = analysis.overrides?.negationLoad;
633
+ const negThreshold = negOv?.threshold
634
+ ?? config.docQuality?.negationLoadThreshold
635
+ ?? THRESHOLDS.negationLoad.warn;
636
+ if (negOv?.off || m.negationLoad <= negThreshold) {
655
637
  results.passed++;
656
638
  } else {
657
639
  results.warnings.push(
658
640
  `${doc.name}: High negation load (${(m.negationLoad * 100).toFixed(0)}% of sentences use negation). ` +
659
- `Rephrase in positive terms: "must not fail" → "must succeed" (IEEE 830 §4.3)`
641
+ `Rephrase in positive terms: "must not fail" → "must succeed" (IEEE 830 §4.3). ` +
642
+ `If the negation is intentional, add: <!-- docguard:quality negation-load off — your reason -->`
660
643
  );
661
644
  }
662
645
 
@@ -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.