docguard-cli 0.23.0 → 0.25.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 +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 +69 -3
  6. package/cli/commands/guard.mjs +86 -11
  7. package/cli/commands/hooks.mjs +12 -7
  8. package/cli/commands/init.mjs +24 -8
  9. package/cli/commands/score.mjs +147 -61
  10. package/cli/commands/setup.mjs +2 -2
  11. package/cli/commands/sync.mjs +6 -0
  12. package/cli/commands/trace.mjs +3 -3
  13. package/cli/commands/upgrade.mjs +61 -13
  14. package/cli/config.mjs +18 -1
  15. package/cli/docguard.mjs +156 -2
  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/project-type.mjs +11 -4
  23. package/cli/scanners/py-ast.mjs +213 -0
  24. package/cli/scanners/routes.mjs +194 -69
  25. package/cli/scanners/schemas.mjs +97 -51
  26. package/cli/shared-git.mjs +0 -0
  27. package/cli/shared-ignore.mjs +23 -2
  28. package/cli/shared-source.mjs +59 -2
  29. package/cli/shared-trace-patterns.mjs +13 -0
  30. package/cli/shared.mjs +92 -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 +2 -42
  35. package/cli/validators/docs-coverage.mjs +13 -0
  36. package/cli/validators/docs-sync.mjs +4 -3
  37. package/cli/validators/drift.mjs +3 -2
  38. package/cli/validators/freshness.mjs +47 -15
  39. package/cli/validators/generated-staleness.mjs +16 -1
  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 +11 -3
  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
@@ -8,11 +8,10 @@
8
8
 
9
9
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
10
10
  import { resolve, join, relative, basename, extname } from 'node:path';
11
-
12
- const IGNORE_DIRS = new Set([
13
- 'node_modules', '.git', '.next', 'dist', 'build', 'coverage',
14
- '.cache', '__pycache__', '.venv', 'vendor', '.turbo',
15
- ]);
11
+ import { extractJsSchemaBodies } from './js-ast.mjs';
12
+ import { extractPythonFiles } from './py-ast.mjs';
13
+ import { readScannable } from '../shared-source.mjs';
14
+ import { DEFAULT_IGNORE_DIRS as IGNORE_DIRS, shouldIgnore, relPosix } from '../shared-ignore.mjs';
16
15
 
17
16
  /**
18
17
  * Deep scan schemas from ORM definitions, validation libraries, and OpenAPI specs.
@@ -21,7 +20,7 @@ const IGNORE_DIRS = new Set([
21
20
  * @param {object} docTools - Detected doc tools (may include OpenAPI)
22
21
  * @returns {object} { entities: [...], relationships: [...], source: string }
23
22
  */
24
- export function scanSchemasDeep(dir, stack, docTools) {
23
+ export function scanSchemasDeep(dir, stack, docTools, config = {}) {
25
24
  // Priority 1: OpenAPI schemas
26
25
  if (docTools?.openapi?.found && docTools.openapi.schemas?.length > 0) {
27
26
  return {
@@ -78,10 +77,22 @@ export function scanSchemasDeep(dir, stack, docTools) {
78
77
  }
79
78
  }
80
79
 
80
+ // Honor .docguardignore / config.ignore: drop entities whose source file the
81
+ // user excluded (e.g. test/fixtures/**), then drop relationships that point at
82
+ // a dropped entity. Filtering the RESULTS (not the walk) keeps the cache and
83
+ // the per-ORM walkers untouched. entity.file is project-relative already.
84
+ const keptEntities = entities.filter(
85
+ e => !e.file || !shouldIgnore(relPosix(dir, resolve(dir, e.file)), config)
86
+ );
87
+ const keptNames = new Set(keptEntities.map(e => e.name));
88
+ const keptRelationships = keptEntities.length === entities.length
89
+ ? relationships
90
+ : relationships.filter(r => keptNames.has(r.from) && keptNames.has(r.to));
91
+
81
92
  return {
82
- entities,
83
- relationships,
84
- source: entities.length > 0 ? entities[0].source : 'none',
93
+ entities: keptEntities,
94
+ relationships: keptRelationships,
95
+ source: keptEntities.length > 0 ? keptEntities[0].source : 'none',
85
96
  };
86
97
  }
87
98
 
@@ -220,26 +231,15 @@ function scanDrizzleSchemas(dir) {
220
231
  const content = readFileSafe(filePath);
221
232
  if (!content || !content.includes('Table(')) return;
222
233
 
223
- let match;
224
- const regex = new RegExp(tablePattern.source, 'g');
225
- while ((match = regex.exec(content)) !== null) {
226
- const varName = match[1];
227
- const tableName = match[2];
228
- const body = match[3];
234
+ // Emit one entity from a (tableName, body) pair. `body` is the balanced
235
+ // inner text of the table's column object.
236
+ const emit = (tableName, body) => {
229
237
  const fields = parseDrizzleColumns(body);
230
-
231
- // Look for references (foreign keys)
232
238
  for (const field of fields) {
233
239
  if (field._ref) {
234
- relationships.push({
235
- from: tableName,
236
- to: field._ref,
237
- type: 'many-to-one',
238
- field: field.name,
239
- });
240
+ relationships.push({ from: tableName, to: field._ref, type: 'many-to-one', field: field.name });
240
241
  }
241
242
  }
242
-
243
243
  entities.push({
244
244
  name: tableName,
245
245
  fields: fields.map(f => ({ ...f, _ref: undefined })),
@@ -247,6 +247,17 @@ function scanDrizzleSchemas(dir) {
247
247
  source: 'drizzle',
248
248
  description: '',
249
249
  });
250
+ };
251
+
252
+ // Full-support tier: AST extraction (nested braces handled). Falls back
253
+ // to the legacy regex only when @babel/parser can't parse the file.
254
+ const ast = extractJsSchemaBodies(content, filePath);
255
+ if (ast) {
256
+ for (const s of ast) if (s.kind === 'drizzle') emit(s.table, s.body);
257
+ } else {
258
+ let match;
259
+ const regex = new RegExp(tablePattern.source, 'g');
260
+ while ((match = regex.exec(content)) !== null) emit(match[2], match[3]);
250
261
  }
251
262
  });
252
263
  }
@@ -327,20 +338,28 @@ function scanZodSchemas(dir) {
327
338
  const content = readFileSafe(filePath);
328
339
  if (!content || !content.includes('z.object')) return;
329
340
 
330
- let match;
331
- const regex = new RegExp(zodPattern.source, 'g');
332
- while ((match = regex.exec(content)) !== null) {
333
- const schemaName = match[1].replace(/Schema$|Validator$/, '');
334
- const body = match[2];
335
- const fields = parseZodFields(body);
336
-
341
+ const emit = (rawName, body) => {
342
+ const schemaName = rawName.replace(/Schema$|Validator$/, '');
337
343
  entities.push({
338
344
  name: schemaName,
339
- fields,
345
+ fields: parseZodFields(body),
340
346
  file: relative(dir, filePath),
341
347
  source: 'zod',
342
348
  description: '',
343
349
  });
350
+ };
351
+
352
+ const ast = extractJsSchemaBodies(content, filePath);
353
+ if (ast) {
354
+ // Keep the legacy naming gate (only *Schema/Validator/Input/Output) so
355
+ // inline z.object() validations aren't treated as data-model entities.
356
+ for (const s of ast) {
357
+ if (s.kind === 'zod' && /(?:Schema|Validator|Input|Output)$/.test(s.name)) emit(s.name, s.body);
358
+ }
359
+ } else {
360
+ let match;
361
+ const regex = new RegExp(zodPattern.source, 'g');
362
+ while ((match = regex.exec(content)) !== null) emit(match[1], match[2]);
344
363
  }
345
364
  });
346
365
  }
@@ -407,25 +426,14 @@ function scanMongooseSchemas(dir) {
407
426
  const content = readFileSafe(filePath);
408
427
  if (!content || !content.includes('Schema(')) return;
409
428
 
410
- let match;
411
- const regex = new RegExp(schemaPattern.source, 'g');
412
- while ((match = regex.exec(content)) !== null) {
413
- const schemaName = match[1].replace(/Schema$/i, '');
414
- const body = match[2];
429
+ const emit = (rawName, body) => {
430
+ const schemaName = rawName.replace(/Schema$/i, '');
415
431
  const fields = parseMongooseFields(body);
416
-
417
- // Check for refs (relationships)
418
432
  for (const field of fields) {
419
433
  if (field._ref) {
420
- relationships.push({
421
- from: schemaName,
422
- to: field._ref,
423
- type: 'many-to-one',
424
- field: field.name,
425
- });
434
+ relationships.push({ from: schemaName, to: field._ref, type: 'many-to-one', field: field.name });
426
435
  }
427
436
  }
428
-
429
437
  entities.push({
430
438
  name: schemaName.charAt(0).toUpperCase() + schemaName.slice(1),
431
439
  fields: fields.map(f => ({ ...f, _ref: undefined })),
@@ -433,6 +441,15 @@ function scanMongooseSchemas(dir) {
433
441
  source: 'mongoose',
434
442
  description: '',
435
443
  });
444
+ };
445
+
446
+ const ast = extractJsSchemaBodies(content, filePath);
447
+ if (ast) {
448
+ for (const s of ast) if (s.kind === 'mongoose') emit(s.name, s.body);
449
+ } else {
450
+ let match;
451
+ const regex = new RegExp(schemaPattern.source, 'g');
452
+ while ((match = regex.exec(content)) !== null) emit(match[1], match[2]);
436
453
  }
437
454
  });
438
455
  }
@@ -504,8 +521,38 @@ function mapMongooseType(type) {
504
521
  function scanPythonModels(dir) {
505
522
  const entities = [];
506
523
  const relationships = [];
507
- walkDir(dir, (filePath) => {
508
- if (!filePath.endsWith('.py')) return;
524
+
525
+ // Collect .py files first so the AST tier parses them in ONE python3
526
+ // subprocess. `null` → Python unavailable / subprocess failed → regex
527
+ // fallback for all; a per-file `ok:false` falls back for that file only.
528
+ // The AST tier gets every field exactly (no body-capture truncation, no
529
+ // miss on multi-base classes) — undercounting fields is what makes the
530
+ // data-model validators falsely pass on a stale DATA-MODEL.md.
531
+ const pyFiles = [];
532
+ walkDir(dir, (filePath) => { if (filePath.endsWith('.py')) pyFiles.push(filePath); });
533
+ const astByFile = extractPythonFiles(pyFiles);
534
+
535
+ for (const filePath of pyFiles) {
536
+ const parsed = astByFile && astByFile[filePath];
537
+ if (parsed && parsed.ok) {
538
+ for (const s of parsed.schemas || []) {
539
+ const fields = (s.fields || []).map(f => ({
540
+ name: f.name, type: f.type || '', required: f.required !== false, description: '',
541
+ }));
542
+ if (fields.length > 0) entities.push({ name: s.name, fields, file: filePath, source: s.kind });
543
+ for (const to of s.rels || []) relationships.push({ from: s.name, to, type: 'related' });
544
+ }
545
+ continue;
546
+ }
547
+ scanPythonModelsRegex(filePath, entities, relationships);
548
+ }
549
+ return { entities, relationships };
550
+ }
551
+
552
+ // Regex (beta) fallback — used per-file when the Python AST tier is unavailable
553
+ // or couldn't parse that file. Identical behavior to the pre-AST scanner.
554
+ function scanPythonModelsRegex(filePath, entities, relationships) {
555
+ {
509
556
  const content = readFileSafe(filePath);
510
557
  if (!content) return;
511
558
  if (!/class\s+\w+\s*\([^)]*(Base|BaseModel|db\.Model|Model|SQLModel)/.test(content)) return;
@@ -549,8 +596,7 @@ function scanPythonModels(dir) {
549
596
  }
550
597
  if (fields.length > 0) entities.push({ name, fields, file: filePath, source: 'pydantic' });
551
598
  }
552
- });
553
- return { entities, relationships };
599
+ }
554
600
  }
555
601
 
556
602
  // ── Rust: Diesel `table! { ... }` ─────────────────────────────────────────────
@@ -687,7 +733,7 @@ function extractOpenAPIRelationships(schemas) {
687
733
  }
688
734
 
689
735
  function readFileSafe(path) {
690
- try { return readFileSync(path, 'utf-8'); } catch { return null; }
736
+ return readScannable(path); // size-capped; skips minified/generated bundles
691
737
  }
692
738
 
693
739
  // v0.15-P2: walkDir is called 8 times across schemas.mjs (Pydantic, Mongoose,
Binary file
@@ -50,7 +50,22 @@ const ALWAYS_REJECT_PATH_RE =
50
50
  * Returns [] if the file is missing or unreadable — never throws.
51
51
  */
52
52
  import { readFileSync, existsSync } from 'node:fs';
53
- import { resolve as resolvePath } from 'node:path';
53
+ import { resolve as resolvePath, relative as relativePath, sep } from 'node:path';
54
+
55
+ /**
56
+ * Project-relative path with POSIX (`/`) separators — the canonical form that
57
+ * every validator should compare against docs, ignore globs, and changed-file
58
+ * sets.
59
+ *
60
+ * Replaces the old `absPath.replace(projectDir + '/', '')` idiom, which failed
61
+ * two ways: on Windows the `/` literal never matched the OS `\` separators, and
62
+ * for a sibling dir sharing a prefix (`/repo` vs `/repo-staging`) the replace
63
+ * was a no-op — both cases left an ABSOLUTE path, silently breaking
64
+ * `content.includes(relPath)`, glob matching, and `--changed-only` scoping.
65
+ */
66
+ export function relPosix(projectDir, absPath) {
67
+ return relativePath(projectDir, absPath).split(sep).join('/');
68
+ }
54
69
 
55
70
  export function loadDocguardIgnore(projectDir) {
56
71
  const p = resolvePath(projectDir, '.docguardignore');
@@ -98,7 +113,13 @@ export function mergeIgnoreFile(projectDir, config) {
98
113
  * @returns {RegExp}
99
114
  */
100
115
  function globToRegex(pattern) {
101
- const escaped = pattern
116
+ // gitignore-style trailing slash ("dir/") means "this directory and everything
117
+ // under it". Strip it so "dir/" matches identically to "dir" — otherwise the
118
+ // escaped pattern keeps the slash and the alternation below can only match a
119
+ // literal "dir//" (double slash), so the pattern silently matches nothing.
120
+ // `|| pattern` guards the degenerate all-slashes case (e.g. "/") from emptying.
121
+ const normalized = pattern.replace(/\/+$/, '') || pattern;
122
+ const escaped = normalized
102
123
  .replace(/\./g, '\\.')
103
124
  .replace(/\*\*/g, '§§') // temp placeholder for **
104
125
  .replace(/\*/g, '[^/]*')
@@ -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',
@@ -124,6 +183,38 @@ export const PROFILES = {
124
183
  freshness: true,
125
184
  },
126
185
  },
186
+ // F3 (field report): non-web-centric profiles. The default doc set assumes an
187
+ // HTTP API + database; for a CLI or library that structure fights the project.
188
+ // These drop the HTTP/DB-shaped requirements. (A bespoke CLI-REFERENCE doc type
189
+ // is a separate, larger follow-up; until then API-REFERENCE doubles as the
190
+ // library's module-API reference.)
191
+ cli: {
192
+ description: 'CLI / command-line tool — no HTTP API or database assumed.',
193
+ requiredFiles: {
194
+ canonical: [
195
+ 'docs-canonical/ARCHITECTURE.md',
196
+ 'docs-canonical/TEST-SPEC.md',
197
+ 'docs-canonical/SECURITY.md',
198
+ 'docs-canonical/ENVIRONMENT.md',
199
+ ],
200
+ agentFile: ['AGENTS.md', 'CLAUDE.md'],
201
+ changelog: 'CHANGELOG.md',
202
+ driftLog: 'DRIFT-LOG.md',
203
+ },
204
+ },
205
+ library: {
206
+ description: 'Library / package — public API matters; no HTTP server or DB assumed.',
207
+ requiredFiles: {
208
+ canonical: [
209
+ 'docs-canonical/ARCHITECTURE.md',
210
+ 'docs-canonical/API-REFERENCE.md',
211
+ 'docs-canonical/TEST-SPEC.md',
212
+ ],
213
+ agentFile: ['AGENTS.md', 'CLAUDE.md'],
214
+ changelog: 'CHANGELOG.md',
215
+ driftLog: 'DRIFT-LOG.md',
216
+ },
217
+ },
127
218
  'enterprise-ai': {
128
219
  description: 'EU AI Act compliance — Annex IV documentation requirements, ALCOA+ alignment, strict freshness. For AI/ML projects under regulatory scrutiny.',
129
220
  requiredFiles: {
@@ -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) ──