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
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
  }
@@ -40,10 +40,40 @@ function backupFile(filePath) {
40
40
  * Call this instead of raw writeFileSync when generating docs.
41
41
  */
42
42
  function safeWrite(filePath, content) {
43
+ mkdirSync(dirname(filePath), { recursive: true });
43
44
  backupFile(filePath);
44
45
  writeFileSync(filePath, content, 'utf-8');
45
46
  }
46
47
 
48
+ /**
49
+ * B7 (field report): after generate emits canonical docs, register them in
50
+ * `.docguard.json` requiredFiles.canonical so `guard` doesn't immediately flag
51
+ * the generator's OWN output as an "orphaned" doc ("exists but not in your
52
+ * requiredFiles"). Only ADDS (never removes/deletes), only docs-canonical/*.md
53
+ * that actually exist on disk, and only when a config file already exists (init
54
+ * owns config creation). Idempotent — a second run with nothing new is a no-op.
55
+ * @returns {number} count of paths newly registered.
56
+ */
57
+ function registerGeneratedCanonicalDocs(projectDir, candidatePaths) {
58
+ const cfgPath = resolve(projectDir, '.docguard.json');
59
+ if (!existsSync(cfgPath)) return 0;
60
+ let cfg;
61
+ try { cfg = JSON.parse(readFileSync(cfgPath, 'utf-8')); } catch { return 0; }
62
+ const canon = [...new Set(candidatePaths)].filter(p =>
63
+ p.startsWith('docs-canonical/') && p.endsWith('.md') && existsSync(resolve(projectDir, p))
64
+ );
65
+ if (canon.length === 0) return 0;
66
+ if (!cfg.requiredFiles || typeof cfg.requiredFiles !== 'object') cfg.requiredFiles = {};
67
+ const existing = Array.isArray(cfg.requiredFiles.canonical) ? cfg.requiredFiles.canonical : [];
68
+ const seen = new Set(existing);
69
+ let added = 0;
70
+ for (const p of canon) if (!seen.has(p)) { existing.push(p); seen.add(p); added++; }
71
+ if (added === 0) return 0;
72
+ cfg.requiredFiles.canonical = existing;
73
+ try { writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + '\n', 'utf-8'); } catch { return 0; }
74
+ return added;
75
+ }
76
+
47
77
  const CODE_EXTENSIONS = new Set([
48
78
  '.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx',
49
79
  '.py', '.java', '.go', '.rs', '.rb', '.php', '.cs',
@@ -113,6 +143,19 @@ function appendStandardsCitation(content, docName) {
113
143
  return content.trimEnd() + '\n' + footer;
114
144
  }
115
145
 
146
+ /**
147
+ * F1 (field report): web-shaped surface (HTTP endpoints, SDK deps, routes)
148
+ * auto-extracted from a cli/library/unknown-kind project is often pattern-
149
+ * matches in the project's OWN source (e.g. a scanner/linter whose code mentions
150
+ * express, boto3, jwt as detection strings), not real usage. We do NOT suppress
151
+ * it — that could hide a real surface (a false-green) — we flag it 'low'
152
+ * confidence so the surface is verified before being documented. Web kinds
153
+ * (webapp/api/service) stay 'normal'.
154
+ */
155
+ function surfaceConfidence(kind) {
156
+ return ['webapp', 'api', 'service'].includes(kind) ? 'normal' : 'low';
157
+ }
158
+
116
159
  /**
117
160
  * `docguard generate --plan` — AI-powered Generate.
118
161
  * Builds the code-truth skeleton (marked sections) and emits the agent task
@@ -139,6 +182,7 @@ export function runGeneratePlan(projectDir, config, flags) {
139
182
  screens: plan.surface.screens.length,
140
183
  components: plan.surface.components.length,
141
184
  envVars: plan.surface.envVars.length,
185
+ confidence: surfaceConfidence(plan.profile.kind),
142
186
  },
143
187
  docs: plan.docs.map(d => ({
144
188
  path: d.path,
@@ -169,11 +213,18 @@ export function runGeneratePlan(projectDir, config, flags) {
169
213
  : `> **AI task:** ${sec.task}\n<!-- docguard:pending agent writes this section -->`;
170
214
  content = upsertSection(content, sec.id, body, { source: sec.source }).content;
171
215
  }
172
- writeFileSync(full, content, 'utf-8');
216
+ // Route through safeWrite: creates the parent dir (docs-implementation/ may
217
+ // not exist yet — was an ENOENT crash) and snapshots a .bak before writing.
218
+ safeWrite(full, content);
173
219
  wrote++;
174
220
  }
221
+ // B7: register the scaffolded canonical docs so guard doesn't flag them.
222
+ const registered = registerGeneratedCanonicalDocs(projectDir, plan.docs.map(d => d.path));
175
223
  console.log(`${c.bold}🔮 DocGuard Generate --plan --write — ${config.projectName}${c.reset}`);
176
224
  console.log(` ${c.green}✅ Scaffolded ${wrote} doc(s)${c.reset} with code-truth sections + ${plan.agentTasks.length} agent task(s).`);
225
+ if (registered > 0) {
226
+ console.log(` ${c.dim}Registered ${registered} canonical doc(s) in .docguard.json requiredFiles.${c.reset}`);
227
+ }
177
228
  console.log(` ${c.dim}Now run your AI agent (/docguard.fix) to write the prose sections, then ${c.cyan}docguard guard${c.dim}.${c.reset}\n`);
178
229
  return;
179
230
  }
@@ -182,6 +233,10 @@ export function runGeneratePlan(projectDir, config, flags) {
182
233
  console.log(`${c.bold}🔮 DocGuard Generate Plan — ${config.projectName}${c.reset}`);
183
234
  console.log(`${c.dim} ${plan.profile.polyglot ? 'Polyglot' : 'Single-language'}: ${plan.profile.languages.join(', ')} | frameworks: ${plan.profile.frameworks.join(', ') || '—'} | kind: ${plan.profile.kind}${c.reset}\n`);
184
235
  console.log(` ${c.bold}Code-truth surface:${c.reset} ${plan.surface.endpoints.length} endpoints · ${plan.surface.entities.length} entities · ${plan.surface.screens.length} screens · ${plan.surface.components.length} components · ${plan.surface.envVars.length} env vars\n`);
236
+ const webSurface = plan.surface.endpoints.length + plan.surface.entities.length + plan.surface.screens.length + plan.surface.components.length;
237
+ if (surfaceConfidence(plan.profile.kind) === 'low' && webSurface > 0) {
238
+ console.log(` ${c.yellow}⚠️ Low-confidence surface:${c.reset} ${c.dim}this looks like a ${plan.profile.kind} (not a web app), so the HTTP/SDK/route surface above may be pattern-matches in your OWN source — not real usage. Verify before documenting; pin any corrected code section with ${c.cyan}pinned="reason"${c.dim}.${c.reset}\n`);
239
+ }
185
240
  console.log(` ${c.bold}Documents to build (${plan.docs.length}):${c.reset}`);
186
241
  for (const d of plan.docs) {
187
242
  const code = d.sections.filter(s => s.source === 'code').length;
@@ -234,13 +289,13 @@ export function runGenerate(projectDir, config, flags) {
234
289
  const scan = scanProject(projectDir);
235
290
 
236
291
  // ── 4. Deep Scan Routes ──
237
- const deepRoutes = scanRoutesDeep(projectDir, stack, docTools);
292
+ const deepRoutes = scanRoutesDeep(projectDir, stack, docTools, { config });
238
293
  if (deepRoutes.length > 0) {
239
294
  console.log(` ${c.bold}Route Scanning:${c.reset} ${deepRoutes.length} endpoints found (source: ${deepRoutes[0]?.source || 'code'})`);
240
295
  }
241
296
 
242
297
  // ── 5. Deep Scan Schemas ──
243
- const deepSchemas = scanSchemasDeep(projectDir, stack, docTools);
298
+ const deepSchemas = scanSchemasDeep(projectDir, stack, docTools, config);
244
299
  if (deepSchemas.entities.length > 0) {
245
300
  console.log(` ${c.bold}Schema Scanning:${c.reset} ${deepSchemas.entities.length} entities, ${deepSchemas.relationships.length} relationships (source: ${deepSchemas.source})`);
246
301
  }
@@ -301,8 +356,19 @@ export function runGenerate(projectDir, config, flags) {
301
356
  created += rootResults.created;
302
357
  skipped += rootResults.skipped;
303
358
 
359
+ // B7: keep guard coherent — register the canonical docs we emitted so the
360
+ // traceability validator doesn't flag the generator's own output.
361
+ const registered = registerGeneratedCanonicalDocs(projectDir, [
362
+ 'docs-canonical/ARCHITECTURE.md', 'docs-canonical/API-REFERENCE.md',
363
+ 'docs-canonical/DATA-MODEL.md', 'docs-canonical/ENVIRONMENT.md',
364
+ 'docs-canonical/TEST-SPEC.md', 'docs-canonical/SECURITY.md',
365
+ ]);
366
+
304
367
  console.log(`\n${c.bold} ─────────────────────────────────────${c.reset}`);
305
368
  console.log(` ${c.green}Generated: ${created}${c.reset} Skipped: ${skipped} (already exist)`);
369
+ if (registered > 0) {
370
+ console.log(` ${c.dim}Registered ${registered} canonical doc(s) in .docguard.json requiredFiles.${c.reset}`);
371
+ }
306
372
  if (docTools._detected.length > 0) {
307
373
  console.log(` ${c.dim}Leveraged: ${docTools._detected.join(', ')} (existing tools detected)${c.reset}`);
308
374
  }
@@ -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.