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
package/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
  [![PyPI](https://img.shields.io/pypi/v/docguard-cli)](https://pypi.org/project/docguard-cli/)
10
10
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
11
11
  [![Node.js](https://img.shields.io/badge/Node.js-18%2B-green)](https://nodejs.org)
12
- [![Zero Dependencies](https://img.shields.io/badge/Dependencies-0-brightgreen)](package.json)
12
+ [![Runtime deps](https://img.shields.io/badge/runtime_deps-1_(pinned)-green)](package.json)
13
13
  [![Spec Kit Extension](https://img.shields.io/badge/Spec_Kit-Extension-blueviolet)](https://github.com/github/spec-kit)
14
14
 
15
15
  ---
@@ -174,7 +174,7 @@ export function diffEntities(dir, config = {}) {
174
174
  // an entity). scanSchemasDeep covers JS ORMs, SQLAlchemy/Pydantic, Diesel,
175
175
  // Go structs, JPA, Rails, and OpenAPI schemas.
176
176
  const docTools = detectDocTools(dir);
177
- const schemas = scanSchemasDeep(dir, {}, docTools);
177
+ const schemas = scanSchemasDeep(dir, {}, docTools, config);
178
178
  const codeEntities = new Set();
179
179
  for (const e of (schemas.entities || [])) {
180
180
  const n = String(e.name || '').toLowerCase();
@@ -76,13 +76,14 @@ const EXPLAINERS = {
76
76
  },
77
77
  testSpec: {
78
78
  title: 'Test-Spec — declared tests exist',
79
- what: 'Reads TEST-SPEC.md\'s test mapping (rows linking sources to test files) and verifies each referenced test file exists.',
79
+ what: 'Reads TEST-SPEC.md\'s "## Source-to-Test Map" table and verifies every referenced file exists. Parsing is column-HEADER-aware: it locates the source column, the status column, and EVERY test-file column (Unit Test, Integration Test, ) by name, so both the minimal 3-column table and the 4-column table `docguard generate` emits are checked in full — a missing Integration Test is no longer skipped, and a blank cell no longer shifts the columns.',
80
80
  why: 'A spec that claims test coverage for X but the test file is missing is a stale promise.',
81
81
  triggers: [
82
- ['no service-to-test mappings', 'TEST-SPEC.md has no recognized mapping table. Add a table with `| Source | Test file | Status |` columns.'],
82
+ ['no service-to-test mappings', 'TEST-SPEC.md has no recognized mapping table. Add a "## Source-to-Test Map" with column 1 = source, column 2 = test file, last = status. Both `| Source | Test file | Status |` and the generated `| Source File | Unit Test | Integration Test | Status |` are accepted.'],
83
83
  ['referenced test file does not exist', 'A path in TEST-SPEC.md\'s mapping doesn\'t exist. Update the path or remove the row.'],
84
+ ['this project has no automated tests (POC, spike, library)', 'Declare it visibly instead of fighting the validator: add `<!-- docguard:validator testSpec n/a — POC, no automated tests yet -->` to TEST-SPEC.md or AGENTS.md. Test-Spec then renders ➖ [N/A] with your reason (git-tracked), not a warning.'],
84
85
  ],
85
- example: '| `src/auth.ts` | `tests/auth.test.ts` | ✅ |',
86
+ example: '| `src/auth.ts` | `tests/auth.test.ts` | ✅ | (or the generated 4-column shape)',
86
87
  standard: 'ISO/IEC/IEEE 29119-3 (test specification)',
87
88
  },
88
89
  environment: {
@@ -119,14 +120,16 @@ const EXPLAINERS = {
119
120
  standard: 'CDD principle: docs and code commit together',
120
121
  },
121
122
  traceability: {
122
- title: 'Traceability — every FR/SC ID has test coverage',
123
- what: 'Scans specs/ for FR-### and SC-### requirement IDs. Each must appear in a test file as `@req FR-###`.',
124
- why: 'Untraceable requirements drift from implementation.',
123
+ title: 'Traceability — requirement IDs have test coverage + docs link to code',
124
+ what: 'Two linkages under one validator: (1) scans specs/ for FR-###/SC-### (also REQ-###, T-###) requirement IDs, each of which must appear in a test as `@req FR-###`; (2) flags a canonical doc that exists but that no source file references back to — the "unlinked doc" warning.',
125
+ why: 'Untraceable requirements drift from implementation, and a doc no code points to is memory nothing reads.',
125
126
  triggers: [
126
127
  ['has no test coverage', 'Add `// @req FR-012` (or similar) as a comment in the test that verifies the requirement.'],
127
128
  ['orphaned test reference', 'A `@req` comment references an ID that doesn\'t exist in any spec. Update the ID or remove the marker.'],
129
+ ['unlinked doc', 'A canonical doc (e.g. TEST-SPEC.md) exists but no source file references it. Link it from code/tests, or treat it as advisory if the doc is intentionally standalone. This is a doc→source check, distinct from the FR/SC→test check above — they share the Traceability bucket.'],
130
+ ['this project has no formal requirements', 'Declare it visibly: add `<!-- docguard:validator traceability n/a — no formal requirements doc -->` to a canonical doc or AGENTS.md. Renders as ➖ [N/A] with the reason instead of warning on every loose ID.'],
128
131
  ],
129
- example: 'spec.md defines `**FR-012**: ...` and test file has `// @req FR-012` near the test that verifies it',
132
+ example: 'spec.md defines `**FR-012**: ...` and a test has `// @req FR-012`; and TEST-SPEC.md is referenced from a test/source file',
130
133
  standard: 'ISO/IEC/IEEE 29148 (requirements traceability)',
131
134
  },
132
135
  apiSurface: {
@@ -215,8 +218,155 @@ const EXPLAINERS = {
215
218
  example: 'plan.md has Summary, Technical Context, Constitution Check, Project Structure',
216
219
  standard: 'GitHub Spec Kit',
217
220
  },
221
+
222
+ // ── Backfilled in v0.24 (field report, Issue A) ─────────────────────────
223
+ // These validators were registered in guard but had no explain entry, so
224
+ // `docguard explain <key>` returned "not found" — including the very
225
+ // negation-load escape hatch that v0.23.0 shipped (docQuality). The new
226
+ // tests/explain-coverage.test.mjs asserts this table covers every key the
227
+ // guard registry exposes, so the gap can't silently reopen.
228
+ docSections: {
229
+ // Reported by guard under the `structure` severity key, but it's a
230
+ // distinct check (required headings, not file existence) with its own
231
+ // warning + N/A marker — so it gets its own explainer.
232
+ title: 'Doc Sections — each canonical doc has its required headings',
233
+ what: 'For each canonical doc, verifies the required `##` sections exist as real headings: ARCHITECTURE.md (System Overview, Component Map, Tech Stack), DATA-MODEL.md (Entities), SECURITY.md (Authentication, Secrets Management), TEST-SPEC.md (Test Categories, Coverage Rules), ENVIRONMENT.md (Environment Variables, Setup Steps). The DATA-MODEL and ENVIRONMENT requirements relax automatically for CLI/library projects.',
234
+ why: 'A canonical doc that exists but lacks its sections is an empty shell — the section structure is what makes it a reliable memory anchor for humans and agents.',
235
+ triggers: [
236
+ ['missing section', 'Add the named `## Section` heading. If the section is genuinely not applicable (e.g. a CLI with no auth), add the N/A marker — a reason is required: `<!-- docguard:section authentication n/a — CLI tool, no auth layer -->`.'],
237
+ ],
238
+ example: 'SECURITY.md contains both `## Authentication` and `## Secrets Management` as real headings — or carries `<!-- docguard:section authentication n/a — CLI, no auth -->`',
239
+ standard: 'CDD STANDARD (canonical doc section contract)',
240
+ },
241
+ architecture: {
242
+ title: 'Architecture — module imports respect layer boundaries',
243
+ what: 'Builds an import graph across JS/TS files (ES imports, dynamic imports, CommonJS require) and flags (a) imports that cross a forbidden layer boundary declared in `config.layers` or a "Layer Boundaries" table in ARCHITECTURE.md, and (b) circular dependency cycles (length 3–6). N/A unless layers are declared in config or ARCHITECTURE.md.',
244
+ why: 'Layer violations and import cycles are how a clean architecture rots silently. Catching them at doc-time keeps the documented design honest.',
245
+ triggers: [
246
+ ['Circular dependency', 'Break the cycle — extract the shared piece into a module both can import, or invert one of the dependencies.'],
247
+ ['forbidden by ARCHITECTURE.md', 'An import crosses a boundary your ARCHITECTURE.md "Layer Boundaries" table forbids. Remove the import or update the declared boundary.'],
248
+ ['layer imports from forbidden layer', 'Same violation, declared via `config.layers.<layer>.canImport`. Adjust the import or widen the allowed list.'],
249
+ ],
250
+ example: 'ARCHITECTURE.md declares the routes layer may not import from routes, every import respects it, and there are no import cycles',
251
+ standard: 'Layered architecture / Acyclic Dependencies Principle',
252
+ },
253
+ docsDiff: {
254
+ title: 'Docs-Diff — declared tech stack + tests match reality',
255
+ what: 'Two soft-signal diffs: (1) technologies named in ARCHITECTURE.md vs. technologies implied by your dependencies (plus Dockerfile / Terraform detection); (2) test files referenced in TEST-SPEC.md vs. the `*.test.*` / `*.spec.*` files actually on disk. Warnings only — drift is a signal, not a failure. (Env-var drift is handled separately by the Environment validator.)',
256
+ why: 'The tech-stack section and the test list are the doc parts that rot fastest as dependencies and tests come and go.',
257
+ triggers: [
258
+ ['drift:', 'ARCHITECTURE.md or TEST-SPEC.md disagrees with the code. The warning spells out which items are "in code but not documented" vs. "documented but not found in code".'],
259
+ ['in code but not documented', 'Add the technology or test file to the relevant doc.'],
260
+ ['documented but not found in code', 'Remove the stale reference, or restore the file if it was deleted by mistake.'],
261
+ ],
262
+ example: 'ARCHITECTURE.md names exactly the stack the dependencies imply, and every glob in TEST-SPEC.md matches a real test file',
263
+ standard: 'CDD principle: documented surfaces match implemented surfaces',
264
+ },
265
+ metadataSync: {
266
+ title: 'Metadata-Sync — version strings agree with package.json',
267
+ what: 'Takes package.json `version` as the source of truth and flags (a) a different `version:` in extension.yml/yaml, and (b) older same-major version references in docs — but only in actionable contexts (release/download URLs, `@x.y.z` install specs, `version:` declarations). CHANGELOG.md and DRIFT-LOG.md are skipped as historical by definition.',
268
+ why: 'A README install line pinned to an old version, or an extension manifest left behind on release, sends users to the wrong artifact.',
269
+ triggers: [
270
+ ['but package.json is', 'A tracked version string disagrees with package.json. Update it (`docguard fix --write` handles this when marked auto-fixable).'],
271
+ ['in an actionable context', 'A docs install command / URL / declaration references an older same-major version. Bump it to the current version.'],
272
+ ],
273
+ example: 'package.json is 0.23.0, extension.yml says 0.23.0, and every install command / release URL points at 0.23.0',
274
+ standard: 'Semantic Versioning (consistency of published version references)',
275
+ },
276
+ docsCoverage: {
277
+ title: 'Docs-Coverage — documentable artifacts are mentioned somewhere',
278
+ what: 'Collects all doc text (README, AGENTS.md, CLAUDE.md, CONTRIBUTING.md, STANDARD.md, docs-canonical/, docs/, docs-implementation/, extensions/) and checks that root config files, package.json `bin` commands, source directories, and config files the code actually reads each appear in at least one doc. Also checks the README has Installation / Usage / License sections.',
279
+ why: 'A feature, command, or config file that no doc mentions is invisible to new contributors and to AI agents reading the project.',
280
+ triggers: [
281
+ ['exists but is not mentioned in any documentation', 'Document the config file\'s purpose in ARCHITECTURE.md or README.md.'],
282
+ ['but it\'s not mentioned in any documentation', 'A package.json `bin` command is undocumented — mention it in the README.'],
283
+ ['is not referenced in ARCHITECTURE.md', 'A source directory has no mention in ARCHITECTURE.md — add it to the Component Map.'],
284
+ ['is missing a', 'README.md lacks a required section (Installation / Usage / License, per the Standard README spec).'],
285
+ ],
286
+ example: 'Every root config file, package.json bin command, and source directory is named in README.md or ARCHITECTURE.md; the README has Installation, Usage, and License',
287
+ standard: 'Standard README (github.com/RichardLitt/standard-readme)',
288
+ },
289
+ docQuality: {
290
+ title: 'Doc-Quality — prose is clear and verifiable',
291
+ what: 'Runs 8 deterministic prose metrics on each canonical doc + README: passive voice, ambiguous pronouns, atomicity, Flesch readability, Flesch-Kincaid grade, sentence length, negation load, conditional load. Docs under 50 prose words or 3 sentences are skipped as reference material.',
292
+ why: 'Vague, passive, negation-heavy docs are hard for both humans and AI agents to act on. Metrics inspired by IEEE 830 / ISO 29148.',
293
+ triggers: [
294
+ ['High negation load', 'Rephrase in positive terms ("must not fail" → "must succeed"). If the negation is intentional (security/operational docs legitimately use "never"/"must not"), add the per-doc override: `<!-- docguard:quality negation-load off — your reason -->`, or set a custom bar with `<!-- docguard:quality negation-load 0.35 — reason -->`. Project-wide default: `docQuality.negationLoadThreshold` in .docguard.json.'],
295
+ ['High passive voice ratio', 'Use active voice: "the config is read by the loader" → "the loader reads the config".'],
296
+ ['High ambiguous pronoun ratio', 'Replace "it/this/that/they" with the specific noun.'],
297
+ ['Low atomicity', 'Split compound sentences so each states one verifiable fact (IEEE 830 §4.1).'],
298
+ ['Reading level too high', 'Aim for grade 12–16 for technical docs — shorter sentences, simpler words.'],
299
+ ['High conditional load', 'Split tangled conditionals into separate requirements.'],
300
+ ],
301
+ example: 'SECURITY.md written in active voice with negation in ≤20% of sentences — or carrying `<!-- docguard:quality negation-load off — prohibitive language is precise here -->`',
302
+ standard: 'IEEE 830 / ISO/IEC/IEEE 29148 (readable, verifiable requirements)',
303
+ },
304
+ schemaSync: {
305
+ title: 'Schema-Sync — DB models in code are documented in DATA-MODEL.md',
306
+ what: 'Detects schema files for 7 ORMs/frameworks (Prisma, Drizzle, Sequelize, TypeORM, Knex, Django, Rails), extracts the model/table names, and checks each appears (case-insensitive, singular/plural aware) in docs-canonical/DATA-MODEL.md. Migration/utility tables are filtered out. No schema files → passes silently.',
307
+ why: 'An undocumented table is a data model only the code knows — exactly the institutional memory CDD exists to preserve.',
308
+ triggers: [
309
+ ['not documented in DATA-MODEL.md', 'Add the model to DATA-MODEL.md\'s Entity Definitions section.'],
310
+ ['but no DATA-MODEL.md exists', 'Models were found but DATA-MODEL.md is missing. Run `docguard init` to create it, then document the schema.'],
311
+ ],
312
+ example: 'Every `model User` / `model Order` in schema.prisma is named in docs-canonical/DATA-MODEL.md',
313
+ standard: 'CDD principle: the data model is documented, not implied',
314
+ },
218
315
  };
219
316
 
317
+ /**
318
+ * Validator-key → display name, mirroring the names guard prints in its
319
+ * report. Users only ever see these names (e.g. "Doc-Quality", "Doc Sections"),
320
+ * so `docguard explain` must resolve them too — typing what you see should work.
321
+ *
322
+ * This intentionally lists every key the guard registry exposes (guard.mjs).
323
+ * tests/explain-coverage.test.mjs asserts it stays in lock-step with the live
324
+ * registry, so a new validator can't ship without an explain entry + name.
325
+ */
326
+ const DISPLAY_NAMES = {
327
+ structure: 'Structure',
328
+ docSections: 'Doc Sections',
329
+ docsSync: 'Docs-Sync',
330
+ drift: 'Drift-Comments',
331
+ changelog: 'Changelog',
332
+ testSpec: 'Test-Spec',
333
+ environment: 'Environment',
334
+ security: 'Security',
335
+ architecture: 'Architecture',
336
+ freshness: 'Freshness',
337
+ traceability: 'Traceability',
338
+ docsDiff: 'Docs-Diff',
339
+ apiSurface: 'API-Surface',
340
+ metadataSync: 'Metadata-Sync',
341
+ docsCoverage: 'Docs-Coverage',
342
+ docQuality: 'Doc-Quality',
343
+ todoTracking: 'TODO-Tracking',
344
+ schemaSync: 'Schema-Sync',
345
+ specKit: 'Spec-Kit',
346
+ crossReference: 'Cross-Reference',
347
+ generatedStaleness: 'Generated-Staleness',
348
+ surfaceSync: 'Surface-Sync',
349
+ canonicalSync: 'Canonical-Sync',
350
+ metricsConsistency: 'Metrics-Consistency',
351
+ };
352
+
353
+ /** Collapse a key / display name to a comparable form: lowercase, alnum only. */
354
+ const normalizeKey = (s) => String(s).toLowerCase().replace(/[^a-z0-9]/g, '');
355
+
356
+ /**
357
+ * normalized(alias) → canonical key, built once from both the explainer keys
358
+ * and the guard display names. Lets `docQuality`, `doc-quality`, `Doc-Quality`,
359
+ * and `"doc quality"` all resolve to the same entry.
360
+ */
361
+ const ALIAS_INDEX = (() => {
362
+ const idx = {};
363
+ for (const key of Object.keys(EXPLAINERS)) idx[normalizeKey(key)] = key;
364
+ for (const [key, name] of Object.entries(DISPLAY_NAMES)) {
365
+ if (EXPLAINERS[key]) idx[normalizeKey(name)] = key; // name → key (only if explainable)
366
+ }
367
+ return idx;
368
+ })();
369
+
220
370
  /**
221
371
  * Match a warning text fragment against the explainer table. Returns the
222
372
  * matching entry's key + the trigger entry that best matches, or null when
@@ -225,11 +375,10 @@ const EXPLAINERS = {
225
375
  function matchWarning(query) {
226
376
  const q = query.toLowerCase();
227
377
 
228
- // Exact validator-key lookup (e.g. `docguard explain freshness`)
229
- if (EXPLAINERS[query]) return { key: query, trigger: null };
230
- // Also try kebab-case (e.g. `cross-reference` → `crossReference`)
231
- const camelized = query.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
232
- if (EXPLAINERS[camelized]) return { key: camelized, trigger: null };
378
+ // Key / display-name lookup, casing- and separator-insensitive. Covers
379
+ // `freshness`, `cross-reference`, `Doc-Quality`, `"doc sections"`, etc.
380
+ const aliased = ALIAS_INDEX[normalizeKey(query)];
381
+ if (aliased) return { key: aliased, trigger: null };
233
382
 
234
383
  // Search trigger phrases
235
384
  let best = null;
@@ -253,16 +402,19 @@ export function runExplain(projectDir, _config, flags) {
253
402
  const isJson = flags.format === 'json';
254
403
 
255
404
  if (!query) {
405
+ // Exhaustive by construction: iterate the guard display-name map so the
406
+ // list always covers every registered validator (field report, Issue A.3).
407
+ const listed = Object.keys(DISPLAY_NAMES).filter(k => EXPLAINERS[k]);
256
408
  if (isJson) {
257
- console.log(JSON.stringify({ validators: Object.keys(EXPLAINERS) }, null, 2));
409
+ console.log(JSON.stringify({ validators: listed }, null, 2));
258
410
  return;
259
411
  }
260
412
  console.log(`${c.bold}🧭 docguard explain${c.reset} ${c.dim}— usage:${c.reset}`);
261
- console.log(` ${c.cyan}docguard explain <validator-key>${c.reset} e.g. docguard explain freshness`);
413
+ console.log(` ${c.cyan}docguard explain <validator>${c.reset} e.g. docguard explain doc-quality ${c.dim}(key or the name shown in guard)${c.reset}`);
262
414
  console.log(` ${c.cyan}docguard explain "<warning text>"${c.reset} e.g. docguard explain "no service-to-test mappings"`);
263
- console.log(`\n${c.dim}Known validators:${c.reset}`);
264
- for (const [k, e] of Object.entries(EXPLAINERS)) {
265
- console.log(` ${c.cyan}${k.padEnd(22)}${c.reset} ${c.dim}${e.title}${c.reset}`);
415
+ console.log(`\n${c.dim}Known validators (${listed.length}):${c.reset}`);
416
+ for (const k of listed) {
417
+ console.log(` ${c.cyan}${DISPLAY_NAMES[k].padEnd(22)}${c.reset} ${c.dim}${EXPLAINERS[k].title}${c.reset}`);
266
418
  }
267
419
  return;
268
420
  }
@@ -305,4 +457,13 @@ export function runExplain(projectDir, _config, flags) {
305
457
 
306
458
  console.log(`${c.bold}Passing example:${c.reset}\n ${c.dim}${e.example}${c.reset}\n`);
307
459
  console.log(`${c.bold}Standard:${c.reset} ${c.dim}${e.standard}${c.reset}`);
460
+
461
+ // v0.24: surface how to mute/tune from config — there was no in-tool way to
462
+ // discover this, so users reached for severity:"off" (a no-op) instead of
463
+ // the real switch (field report). docSections is reported under the
464
+ // `structure` key in guard, so it disables via that key.
465
+ const cfgKey = match.key === 'docSections' ? 'structure' : match.key;
466
+ console.log(`\n${c.bold}Tune it${c.reset} ${c.dim}(.docguard.json):${c.reset}`);
467
+ console.log(` ${c.cyan}validators.${cfgKey}: false${c.reset} ${c.dim}— disable this validator entirely${c.reset}`);
468
+ console.log(` ${c.cyan}severity.${cfgKey}: "high" | "low"${c.reset} ${c.dim}— change exit-code weight (low = warn-only). Severity never hides the warning from output.${c.reset}`);
308
469
  }
@@ -699,10 +699,25 @@ function outputResults(issues, projectDir, config, flags) {
699
699
  const isPrompt = flags.format === 'prompt';
700
700
 
701
701
  if (issues.length === 0) {
702
+ // v0.24: `fix` only handles issues it can mechanically resolve / prompt for.
703
+ // It used to claim "documentation is complete!" even when `guard` still had
704
+ // advisory warnings (e.g. an unmapped Test-Spec table), so the two commands
705
+ // contradicted each other (field report). Surface guard's advisory count so
706
+ // they agree: 0 fixable ≠ 0 warnings.
707
+ let advisory = 0;
708
+ try {
709
+ const g = runGuardInternal(projectDir, config);
710
+ advisory = (g.validators || []).reduce((n, v) => n + ((v.warnings && v.warnings.length) || 0), 0);
711
+ } catch { /* if guard can't run, fall back to the plain clean message */ }
712
+
702
713
  if (isJson) {
703
- console.log(JSON.stringify({ status: 'clean', issues: [], fixCount: 0 }));
714
+ console.log(JSON.stringify({ status: 'clean', issues: [], fixCount: 0, advisoryWarnings: advisory }));
704
715
  } else if (isPrompt) {
705
- console.log('No CDD issues found. All documentation is complete.');
716
+ console.log(advisory > 0
717
+ ? `No mechanically-fixable issues. ${advisory} advisory warning(s) remain — run \`docguard guard\` to see them.`
718
+ : 'No CDD issues found. All documentation is complete.');
719
+ } else if (advisory > 0) {
720
+ console.log(` ${c.green}${c.bold}✅ No fixable issues here.${c.reset} ${c.dim}guard still reports ${advisory} advisory warning(s) that need authoring, not mechanical fixes — run ${c.reset}${c.cyan}docguard guard${c.dim} to see them.${c.reset}\n`);
706
721
  } else {
707
722
  console.log(` ${c.green}${c.bold}✅ No issues — documentation is complete!${c.reset}\n`);
708
723
  }
@@ -234,13 +234,13 @@ export function runGenerate(projectDir, config, flags) {
234
234
  const scan = scanProject(projectDir);
235
235
 
236
236
  // ── 4. Deep Scan Routes ──
237
- const deepRoutes = scanRoutesDeep(projectDir, stack, docTools);
237
+ const deepRoutes = scanRoutesDeep(projectDir, stack, docTools, { config });
238
238
  if (deepRoutes.length > 0) {
239
239
  console.log(` ${c.bold}Route Scanning:${c.reset} ${deepRoutes.length} endpoints found (source: ${deepRoutes[0]?.source || 'code'})`);
240
240
  }
241
241
 
242
242
  // ── 5. Deep Scan Schemas ──
243
- const deepSchemas = scanSchemasDeep(projectDir, stack, docTools);
243
+ const deepSchemas = scanSchemasDeep(projectDir, stack, docTools, config);
244
244
  if (deepSchemas.entities.length > 0) {
245
245
  console.log(` ${c.bold}Schema Scanning:${c.reset} ${deepSchemas.entities.length} entities, ${deepSchemas.relationships.length} relationships (source: ${deepSchemas.source})`);
246
246
  }
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { c, resolveSeverity } from '../shared.mjs';
11
+ import { loadValidatorSuppressions } from '../validator-markers.mjs';
11
12
  import { detectAgentMode, isSpecKitInitialized } from '../ensure-skills.mjs';
12
13
  import { checkUpgradeStatus } from './upgrade.mjs';
13
14
  import { changedFilesSince, isGitRepo } from '../shared-git.mjs';
@@ -220,10 +221,26 @@ export function runGuardInternal(projectDir, config) {
220
221
  // Metrics-Consistency runs post-loop (needs guard results)
221
222
  ];
222
223
 
224
+ // Inline `<!-- docguard:validator <key> n/a — reason -->` markers let a
225
+ // project declare a whole validator non-applicable, visibly and in-repo
226
+ // (e.g. a POC marking testSpec/traceability N/A). Distinct from the config
227
+ // `validators:{k:false}` switch: a marked validator renders as ➖ [N/A] with
228
+ // its reason, not a silent skip. Resolve once against the full key set.
229
+ const allValidatorKeys = [...new Set(validatorMap.map(v => v.key)), 'canonicalSync', 'metricsConsistency'];
230
+ const { suppressed: naMarkers, unknown: unknownMarkers } = loadValidatorSuppressions(projectDir, allValidatorKeys);
231
+ const naResult = (name, key) => ({
232
+ name, key, status: 'na', quality: null, errors: [], warnings: [], passed: 0, total: 0, durationMs: 0,
233
+ note: naMarkers.get(key) ? `declared N/A: ${naMarkers.get(key)}` : 'declared N/A',
234
+ });
235
+
223
236
  // v0.14-Q2: per-validator timing. Cheap (one `performance.now()` pair per
224
237
  // validator) and the data is what we'd need to optimize anything later.
225
238
  // Exposed via --profile in the public guard.
226
239
  for (const { key, name, fn } of validatorMap) {
240
+ if (naMarkers.has(key)) {
241
+ results.push(naResult(name, key));
242
+ continue;
243
+ }
227
244
  if (validators[key] === false) {
228
245
  results.push({ name, key, status: 'skipped', quality: null, errors: [], warnings: [], passed: 0, total: 0, durationMs: 0 });
229
246
  continue;
@@ -244,7 +261,9 @@ export function runGuardInternal(projectDir, config) {
244
261
  // Needs the live validator results to count "real" validators that ran.
245
262
  // (Pre-canonical-sync ordering — comes before metrics-consistency so the
246
263
  // metrics validator sees a stable surface count.)
247
- if (validators.canonicalSync !== false) {
264
+ if (naMarkers.has('canonicalSync')) {
265
+ results.push(naResult('Canonical-Sync', 'canonicalSync'));
266
+ } else if (validators.canonicalSync !== false) {
248
267
  const start = performance.now();
249
268
  try {
250
269
  const result = validateCanonicalSync(projectDir, config, results);
@@ -257,7 +276,9 @@ export function runGuardInternal(projectDir, config) {
257
276
  }
258
277
 
259
278
  // ── Metrics-Consistency runs AFTER all other validators (needs their results) ──
260
- if (validators.metricsConsistency !== false) {
279
+ if (naMarkers.has('metricsConsistency')) {
280
+ results.push(naResult('Metrics-Consistency', 'metricsConsistency'));
281
+ } else if (validators.metricsConsistency !== false) {
261
282
  const start = performance.now();
262
283
  try {
263
284
  const result = validateMetricsConsistency(projectDir, config, results);
@@ -294,7 +315,13 @@ export function runGuardInternal(projectDir, config) {
294
315
  else effectiveWarnings += wCount;
295
316
  }
296
317
 
297
- const overallStatus = totalErrors > 0 ? 'FAIL' : totalWarnings > 0 ? 'WARN' : 'PASS';
318
+ // The headline status word MUST agree with the exit code, which is
319
+ // severity-aware (effectiveErrors/effectiveWarnings, computed above).
320
+ // Deriving it from RAW counts was a bug: a validator marked severity=high
321
+ // with only warnings printed "WARN" yet exited 1 (FAIL), and one marked
322
+ // severity=low printed "WARN" yet exited 0 (PASS). Use effective counts so
323
+ // what the user reads is what CI does.
324
+ const overallStatus = effectiveErrors > 0 ? 'FAIL' : effectiveWarnings > 0 ? 'WARN' : 'PASS';
298
325
 
299
326
  return {
300
327
  project: config.projectName,
@@ -310,6 +337,11 @@ export function runGuardInternal(projectDir, config) {
310
337
  effectiveErrors,
311
338
  effectiveWarnings,
312
339
  validators: results,
340
+ // Unknown keys in `docguard:validator … n/a` markers — typo protection so
341
+ // a mistyped key doesn't silently fail to suppress. Surfaced by runGuard.
342
+ validatorMarkerWarnings: unknownMarkers.map(
343
+ u => `Unknown validator key "${u.raw}" in a docguard:validator marker — ignored. Valid keys: ${allValidatorKeys.join(', ')}`
344
+ ),
313
345
  timestamp: new Date().toISOString(),
314
346
  };
315
347
  }
@@ -328,10 +360,18 @@ export function runGuardInternal(projectDir, config) {
328
360
  export const CHANGED_ONLY_VALIDATORS = ['docsSync', 'environment', 'apiSurface', 'drift', 'todoTracking'];
329
361
 
330
362
  /**
331
- * Build a validators map that enables only the pre-commit-lite set.
363
+ * Build a validators map that enables the pre-commit-lite set — PLUS any
364
+ * validator the team explicitly escalated to `severity: high`.
332
365
  * Used by `docguard guard --changed-only`.
366
+ *
367
+ * Why the union: `--changed-only` trades coverage for speed, but a
368
+ * `severity: high` override is an explicit "this must always block CI" signal.
369
+ * Silently dropping such a validator here meant a changed-only gate could pass
370
+ * (exit 0) on exactly the drift the team most wanted blocked — e.g. a committed
371
+ * secret when `security` is marked high. So high-severity validators are forced
372
+ * on regardless of the lite set, unless the user also explicitly disabled them.
333
373
  */
334
- function liteValidatorsConfig() {
374
+ export function liteValidatorsConfig(config = {}) {
335
375
  const all = [
336
376
  'structure', 'docsSync', 'drift', 'changelog', 'testSpec', 'environment',
337
377
  'security', 'architecture', 'freshness', 'traceability', 'docsDiff',
@@ -339,8 +379,15 @@ function liteValidatorsConfig() {
339
379
  'schemaSync', 'specKit', 'crossReference', 'generatedStaleness',
340
380
  'canonicalSync', 'metricsConsistency',
341
381
  ];
382
+ const userValidators = (config && config.validators) || {};
342
383
  const out = {};
343
- for (const k of all) out[k] = CHANGED_ONLY_VALIDATORS.includes(k);
384
+ for (const k of all) {
385
+ let enabled = CHANGED_ONLY_VALIDATORS.includes(k);
386
+ if (!enabled && resolveSeverity(config, k) === 'high' && userValidators[k] !== false) {
387
+ enabled = true;
388
+ }
389
+ out[k] = enabled;
390
+ }
344
391
  return out;
345
392
  }
346
393
 
@@ -358,16 +405,24 @@ export function runGuard(projectDir, config, flags) {
358
405
  // scope to this list; others run normally over the whole tree.
359
406
  const ref = flags.since || 'HEAD~1';
360
407
  const changed = isGitRepo(projectDir) ? changedFilesSince(projectDir, ref) : [];
408
+ const liteVals = liteValidatorsConfig(config);
409
+ // Validators that ran beyond the lite set because they're severity=high.
410
+ const escalated = Object.keys(liteVals).filter(
411
+ k => liteVals[k] && !CHANGED_ONLY_VALIDATORS.includes(k)
412
+ );
361
413
  config = {
362
414
  ...config,
363
- validators: liteValidatorsConfig(),
415
+ validators: liteVals,
364
416
  changedFiles: changed,
365
417
  changedSinceRef: ref,
366
418
  };
367
419
  const label = changed.length > 0
368
420
  ? `${changed.length} file(s) changed since ${ref}`
369
421
  : `no changes since ${ref} — running all ${CHANGED_ONLY_VALIDATORS.length} lite validators on full tree`;
370
- console.log(`${c.cyan}⚡ docguard guard --changed-only${c.reset} ${c.dim}(${label})${c.reset}\n`);
422
+ const escalatedNote = escalated.length > 0
423
+ ? ` ${c.yellow}+ ${escalated.length} high-severity validator(s): ${escalated.join(', ')}${c.reset}`
424
+ : '';
425
+ console.log(`${c.cyan}⚡ docguard guard --changed-only${c.reset} ${c.dim}(${label})${c.reset}${escalatedNote}\n`);
371
426
  }
372
427
 
373
428
  const data = runGuardInternal(projectDir, config);
@@ -440,11 +495,23 @@ export function runGuard(projectDir, config, flags) {
440
495
  console.log(`\n${c.bold} ─────────────────────────────────────${c.reset}`);
441
496
 
442
497
  if (data.status === 'PASS') {
443
- console.log(` ${c.green}${c.bold}✅ PASS${c.reset} ${c.green}— All ${data.total} checks passed${c.reset}`);
498
+ // PASS can still carry raw warnings if a validator was demoted to
499
+ // severity=low — surface that honestly rather than claiming a clean sweep.
500
+ if (data.warnings > 0) {
501
+ console.log(` ${c.green}${c.bold}✅ PASS${c.reset} ${c.green}— ${data.passed}/${data.total} passed (${data.warnings} non-blocking warning(s))${c.reset}`);
502
+ } else {
503
+ console.log(` ${c.green}${c.bold}✅ PASS${c.reset} ${c.green}— All ${data.total} checks passed${c.reset}`);
504
+ }
444
505
  } else if (data.status === 'WARN') {
445
- console.log(` ${c.yellow}${c.bold}⚠️ WARN${c.reset} ${c.yellow}— ${data.passed}/${data.total} passed, ${data.warnings} warning(s)${c.reset}`);
506
+ // effective* counts are what drove the verdict; raw warnings are still
507
+ // enumerated per-validator above, so nothing is hidden.
508
+ console.log(` ${c.yellow}${c.bold}⚠️ WARN${c.reset} ${c.yellow}— ${data.passed}/${data.total} passed, ${data.effectiveWarnings} warning(s)${c.reset}`);
446
509
  } else {
447
- console.log(` ${c.red}${c.bold}❌ FAIL${c.reset} ${c.red}— ${data.passed}/${data.total} passed, ${data.errors} error(s), ${data.warnings} warning(s)${c.reset}`);
510
+ // effectiveErrors may include warnings escalated by severity=high, so call
511
+ // them "blocking issue(s)" rather than "error(s)". The severity-override
512
+ // note below spells out any escalation/demotion.
513
+ const warnSuffix = data.effectiveWarnings > 0 ? `, ${data.effectiveWarnings} warning(s)` : '';
514
+ console.log(` ${c.red}${c.bold}❌ FAIL${c.reset} ${c.red}— ${data.passed}/${data.total} passed, ${data.effectiveErrors} blocking issue(s)${warnSuffix}${c.reset}`);
448
515
  }
449
516
 
450
517
  // Next step hint — always point to diagnose when issues exist
@@ -533,6 +600,14 @@ export function runGuard(projectDir, config, flags) {
533
600
  console.log(`\n ${c.yellow}💡${c.reset} ${c.dim}Enhance DocGuard with Spec Kit: ${c.cyan}uv tool install specify-cli --from git+https://github.com/github/spec-kit.git${c.reset}`);
534
601
  }
535
602
 
603
+ // Typo protection for docguard:validator markers — a mistyped key would
604
+ // otherwise silently fail to suppress the validator.
605
+ if (Array.isArray(data.validatorMarkerWarnings) && data.validatorMarkerWarnings.length > 0) {
606
+ for (const w of data.validatorMarkerWarnings) {
607
+ console.log(`\n ${c.yellow}⚠ ${w}${c.reset}`);
608
+ }
609
+ }
610
+
536
611
  // When severity overrides demoted warnings to "low" (or promoted them to
537
612
  // "high"), show a one-line note so the user knows the exit code may not
538
613
  // match what they expected from reading the warning count.
@@ -56,6 +56,7 @@ function spliceManagedBlock(existing, newBody) {
56
56
  }
57
57
  import { resolve } from 'node:path';
58
58
  import { c } from '../shared.mjs';
59
+ import { getHooksDir } from '../shared-git.mjs';
59
60
 
60
61
  const HOOKS = {
61
62
  'pre-commit': {
@@ -216,17 +217,20 @@ export function runHooks(projectDir, config, flags) {
216
217
  console.log(`${c.bold}🪝 DocGuard Hooks — ${config.projectName}${c.reset}`);
217
218
  console.log(`${c.dim} Directory: ${projectDir}${c.reset}\n`);
218
219
 
219
- // Check if .git exists
220
- const gitDir = resolve(projectDir, '.git');
221
- if (!existsSync(gitDir)) {
220
+ // Resolve the real hooks dir via git — NOT `<projectDir>/.git/hooks`, which
221
+ // is wrong inside a linked worktree (where `.git` is a file, not a dir) and
222
+ // ignores a custom core.hooksPath.
223
+ const hooksDir = getHooksDir(projectDir);
224
+ if (!hooksDir) {
222
225
  console.log(` ${c.red}❌ Not a git repository. Run ${c.cyan}git init${c.red} first.${c.reset}\n`);
223
226
  process.exit(1);
224
227
  }
225
228
 
226
- const hooksDir = resolve(gitDir, 'hooks');
227
- if (!existsSync(hooksDir)) {
228
- mkdirSync(hooksDir, { recursive: true });
229
- }
229
+ // Only create the dir when we're actually going to write a hook. Read-only
230
+ // modes (--list, --remove) must not have a filesystem side effect.
231
+ const ensureHooksDir = () => {
232
+ if (!existsSync(hooksDir)) mkdirSync(hooksDir, { recursive: true });
233
+ };
230
234
 
231
235
  // Determine which hooks to install
232
236
  let hookTypes = Object.keys(HOOKS);
@@ -273,6 +277,7 @@ export function runHooks(projectDir, config, flags) {
273
277
  }
274
278
 
275
279
  // Install mode
280
+ ensureHooksDir();
276
281
  let installed = 0;
277
282
  let skipped = 0;
278
283
 
@@ -14,7 +14,7 @@ import { resolve, dirname } from 'node:path';
14
14
  import { fileURLToPath } from 'node:url';
15
15
  import { createInterface } from 'node:readline';
16
16
  import { execSync } from 'node:child_process';
17
- import { c, PROFILES } from '../shared.mjs';
17
+ import { c, PROFILES, CURRENT_SCHEMA_VERSION } from '../shared.mjs';
18
18
  import { ensureSkills, detectAgentMode, detectAIAgent, isSpecKitAvailable, isSpecKitInitialized, getDetectedAgent, safeSpawnSpecify } from '../ensure-skills.mjs';
19
19
 
20
20
  // v0.20: scaffolder names that can be passed via `init --with <name>` and
@@ -287,7 +287,7 @@ export async function runInit(projectDir, config, flags) {
287
287
  // JSON-Schema-aware editor; ignored by DocGuard itself.
288
288
  $schema: 'https://raccioly.github.io/docguard/schemas/docguard-config.schema.json',
289
289
  projectName: config.projectName,
290
- version: '0.5',
290
+ version: CURRENT_SCHEMA_VERSION, // single source of truth (shared.mjs) — never hardcode
291
291
  profile: profileName,
292
292
  projectType: detectedType,
293
293
  projectTypeConfig: ptc,
@@ -373,8 +373,18 @@ poetry.lock
373
373
  const specKitAvailable = isSpecKitAvailable();
374
374
  const specKitInitialized = isSpecKitInitialized(projectDir);
375
375
 
376
- if (flags.noSpecKit) {
377
- console.log(`\n ${c.dim}⏭️ Spec Kit init skipped (--no-spec-kit).${c.reset}`);
376
+ // v0.24 (field report #1): the `starter` profile is "minimal, for side
377
+ // projects" — it skips the heavy Spec Kit framework scaffold (.specify/
378
+ // templates/scripts/memory, ~30 files) by default. DocGuard's own canonical
379
+ // docs and its lightweight agent skills/commands still install (ensureSkills
380
+ // below). Opt back in with --spec-kit. Other profiles are unaffected.
381
+ const starterSkipsSpecKit = profileName === 'starter' && !flags.specKit;
382
+
383
+ if (flags.noSpecKit || starterSkipsSpecKit) {
384
+ const why = flags.noSpecKit
385
+ ? '--no-spec-kit'
386
+ : 'starter profile is minimal — pass --spec-kit to include the framework scaffold';
387
+ console.log(`\n ${c.dim}⏭️ Spec Kit framework scaffold skipped (${why}).${c.reset}`);
378
388
  } else if (specKitAvailable && !specKitInitialized) {
379
389
  console.log(`\n ${c.bold}🌱 Spec Kit Integration${c.reset}`);
380
390
 
@@ -487,8 +497,10 @@ poetry.lock
487
497
  }
488
498
  }
489
499
 
490
- // Auto-install DocGuard skills and commands (spec-kit skills handled by specify init)
491
- ensureSkills(projectDir, flags);
500
+ // Auto-install DocGuard's own skills and commands. Thread the spec-kit skip
501
+ // decision through so ensureSkills doesn't re-trigger the framework scaffold
502
+ // we just declined for the starter profile (or --no-spec-kit).
503
+ ensureSkills(projectDir, { ...flags, noSpecKit: flags.noSpecKit || starterSkipsSpecKit });
492
504
 
493
505
  // v0.20: `docguard init --with agents,hooks,ci,badge,llms,publish` runs
494
506
  // the named scaffolders after init has finished. Each one runs in sequence