docguard-cli 0.26.0 → 0.28.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.
package/README.md CHANGED
@@ -63,7 +63,7 @@ DocGuard is an official [GitHub Spec Kit](https://github.com/github/spec-kit) co
63
63
 
64
64
  ```mermaid
65
65
  graph TD
66
- CLI["CLI Entry<br/>docguard.mjs"] --> Commands["Commands (14)"]
66
+ CLI["CLI Entry<br/>docguard.mjs"] --> Commands["Commands (17)"]
67
67
  Commands --> guard["guard"]
68
68
  Commands --> generate["generate"]
69
69
  Commands --> score["score"]
@@ -250,7 +250,7 @@ This installs DocGuard's slash commands (`/docguard.init`, `/docguard.guard`, `/
250
250
 
251
251
  ## Usage
252
252
 
253
- DocGuard ships **14 commands** (the "Daily 5" + 9 situational tools, including the zero-install `demo`). Six additional one-shot scaffolders are accessed via `docguard init --with <name>`. Eight v0.19 commands continue to work as deprecation aliases through v0.20.x — see [MIGRATION-v0.20.md](docs-implementation/MIGRATION-v0.20.md).
253
+ DocGuard ships **17 commands** (the "Daily 5" + 12 situational tools, including the zero-install `demo`). Six additional one-shot scaffolders are accessed via `docguard init --with <name>`. Eight v0.19 commands continue to work as deprecation aliases through v0.20.x — see [MIGRATION-v0.20.md](docs-implementation/MIGRATION-v0.20.md).
254
254
 
255
255
  **The Daily 5** — what you'll reach for 95% of the time:
256
256
 
@@ -273,7 +273,9 @@ DocGuard ships **14 commands** (the "Daily 5" + 9 situational tools, including t
273
273
  | `fix --history` | Audit log of every mechanical fix applied (from `.docguard/fixed.json`) |
274
274
  | `generate` | Reverse-engineer docs from existing codebase (`--plan` for AI scan) |
275
275
  | `agent` | One-shot agent task graph — ordered, pre-filled code-truth, per-task verify (`--format json`) |
276
- | `explain <warning>` | Paste any warning — get the validator's docstring + fix path |
276
+ | `explain <warning\|CODE>` | Paste any warning — or a finding code like `SEC001` — to get the validator's docstring, fix path, and how to suppress |
277
+ | `verify --semantic` | Extract documented numbers/limits/enums (retention days, rate limits, GSI/role counts, status enums) as a task list for an agent to check against code — the semantic-drift class regex/AST can't see |
278
+ | `feedback` | Report likely false positives back to DocGuard — local-first record + a 1-click prefilled, redacted GitHub issue (zero typing) |
277
279
  | `memory` | Per-domain accuracy headline (endpoints / entities / env / tech) |
278
280
  | `memory --diff` | Drill into which specific claims don't match code |
279
281
  | `score --diff` | Drill into which checks pulled each category down |
@@ -17,6 +17,7 @@
17
17
  */
18
18
 
19
19
  import { c } from '../shared.mjs';
20
+ import { CODES } from '../findings.mjs';
20
21
 
21
22
  /**
22
23
  * Validator-key → human-readable explainer. Keyed by the same key DocGuard
@@ -110,13 +111,14 @@ const EXPLAINERS = {
110
111
  },
111
112
  freshness: {
112
113
  title: 'Freshness — docs updated alongside code',
113
- what: 'For each canonical doc, counts code commits since the doc\'s last commit. >10 commits = stale.',
114
+ what: 'For each canonical doc it picks a "last updated" date by PRECEDENCE, then counts code commits since (>10 = stale): (1) an explicit `<!-- docguard:last-reviewed YYYY-MM-DD -->` marker — a human review signal git cannot see, so it WINS; (2) `<!-- docguard:status approved -->`; (3) the git commit date; (4) for an uncommitted file with no marker, it asks you to commit OR add a marker. CHANGELOG.md follows the SAME precedence — a marker satisfies it before any commit, which matters in a pre-commit edit/review loop. `docguard init` now stamps a `last-reviewed` marker into every canonical doc so freshness is marker-based and consistent from day one.',
114
115
  why: 'Docs drift silently. This validator surfaces the drift before it becomes invisible.',
115
116
  triggers: [
116
- ['code commits since last doc update', 'Run `docguard sync --write` to refresh code-truth sections, then review the prose for accuracy.'],
117
+ ['code commits since last doc update', 'Run `docguard sync --write` to refresh code-truth sections, then review the prose and update (or add) the `<!-- docguard:last-reviewed YYYY-MM-DD -->` marker.'],
118
+ ['not yet committed to git', 'A canonical doc has no git history and no marker. Commit it, or add `<!-- docguard:last-reviewed YYYY-MM-DD -->` (or `<!-- docguard:status approved -->`) so freshness is satisfiable before the commit.'],
117
119
  ['DRIFT-LOG.md may be stale', 'DRIFT comments in code outpaced log entries. Add the entries.'],
118
120
  ],
119
- example: 'ARCHITECTURE.md last committed within 10 code commits',
121
+ example: 'ARCHITECTURE.md carries `<!-- docguard:last-reviewed 2026-06-19 -->` (or was committed within 10 code commits)',
120
122
  standard: 'CDD principle: docs and code commit together',
121
123
  },
122
124
  traceability: {
@@ -133,12 +135,13 @@ const EXPLAINERS = {
133
135
  standard: 'ISO/IEC/IEEE 29148 (requirements traceability)',
134
136
  },
135
137
  apiSurface: {
136
- title: 'API-Surface — endpoints in code match API-REFERENCE.md',
137
- what: 'Compares routes scanned from code (Express, Next, FastAPI, Spring, etc.) against endpoints listed in API-REFERENCE.md and OpenAPI specs.',
138
- why: 'Documented but missing endpoints are dead links. Endpoints in code that aren\'t documented are invisible.',
138
+ title: 'API-Surface — endpoints in code match API-REFERENCE.md (and the spec matches the routes)',
139
+ what: 'Compares routes scanned from code (Express, Next, FastAPI, Spring, etc.) against endpoints listed in API-REFERENCE.md and OpenAPI specs. When an OpenAPI spec exists it is the authoritative surface — so it ALSO diffs the spec against the actually-registered code routes, catching a spec that declares a phantom endpoint (the doc reconciles clean against a wrong spec otherwise). That spec-vs-route check is conservative: it only runs when code routes are actually scannable.',
140
+ why: 'Documented but missing endpoints are dead links. Endpoints in code that aren\'t documented are invisible. And a spec nobody implements is a lie the doc check can\'t see.',
139
141
  triggers: [
140
142
  ['documented but absent', 'API-REFERENCE.md lists an endpoint that scanRoutes() can\'t find. Remove or fix the doc; `fix --write` removes when marked.'],
141
143
  ['present but undocumented', 'A route exists in code but API-REFERENCE.md doesn\'t list it. Add it.'],
144
+ ['declares', 'The OpenAPI spec declares an endpoint that no Express/Fastify/etc. route registers in code — i.e. "declares METHOD /path but no route registers it". Either implement the route or remove the phantom endpoint from the spec (the API-REFERENCE doc reconciles clean against the spec, so this is the only check that catches it).'],
142
145
  ],
143
146
  example: 'GET /api/users in src/routes/users.ts AND in API-REFERENCE.md\'s Endpoints table',
144
147
  standard: 'OpenAPI 3.1',
@@ -292,7 +295,7 @@ const EXPLAINERS = {
292
295
  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
296
  triggers: [
294
297
  ['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".'],
298
+ ['High passive voice ratio', 'Use active voice: "the config is read by the loader" → "the loader reads the config". If the doc is legitimately passive (a sequence/flow doc), add the per-doc override: `<!-- docguard:quality passive-voice off — your reason -->`, or set a custom bar with `<!-- docguard:quality passive-voice 0.4 — reason -->`. Project-wide default: `docQuality.passiveVoiceThreshold` in .docguard.json.'],
296
299
  ['High ambiguous pronoun ratio', 'Replace "it/this/that/they" with the specific noun.'],
297
300
  ['Low atomicity', 'Split compound sentences so each states one verifiable fact (IEEE 830 §4.1).'],
298
301
  ['Reading level too high', 'Aim for grade 12–16 for technical docs — shorter sentences, simpler words.'],
@@ -419,6 +422,27 @@ export function runExplain(projectDir, _config, flags) {
419
422
  return;
420
423
  }
421
424
 
425
+ // v0.27: finding-code lookup — `docguard explain SEC001`. Codes are the stable,
426
+ // LLM-addressable handles that guard prints next to each finding and that
427
+ // inline `// docguard:ignore <CODE>` keys off.
428
+ const codeKey = query.toUpperCase();
429
+ if (CODES[codeKey]) {
430
+ const cd = CODES[codeKey];
431
+ if (isJson) {
432
+ console.log(JSON.stringify({ query, code: codeKey, ...cd }, null, 2));
433
+ return;
434
+ }
435
+ console.log(`${c.bold}🧭 ${codeKey} — ${cd.title}${c.reset}`);
436
+ console.log(`${c.dim} validator: ${cd.validator}${c.reset}\n`);
437
+ console.log(`${c.bold}What it means:${c.reset}\n ${cd.help}\n`);
438
+ if (cd.suppress) {
439
+ console.log(`${c.bold}Suppress inline${c.reset} ${c.dim}(only if it's a confirmed false positive):${c.reset}`);
440
+ console.log(` ${c.cyan}${cd.suppress}${c.reset}\n`);
441
+ }
442
+ console.log(`${c.bold}Got it wrong?${c.reset} ${c.dim}Send a redacted report so a future release stops flagging it: ${c.cyan}docguard feedback${c.reset}`);
443
+ return;
444
+ }
445
+
422
446
  const match = matchWarning(query);
423
447
  if (!match) {
424
448
  if (isJson) {
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Feedback Command — close the loop with the tool's maintainers (v0.27).
3
+ *
4
+ * DocGuard is a tool for LLMs: when it gets something wrong, the cheapest way to
5
+ * make the NEXT release better is to capture that signal. This command turns the
6
+ * low-confidence findings of a guard run (candidate false positives — and any
7
+ * other finding DocGuard itself flagged as uncertain) into:
8
+ *
9
+ * 1. a LOCAL-FIRST record under .docguard/feedback/<code>-<id>.json (full,
10
+ * reviewable, never sent anywhere automatically), and
11
+ * 2. a one-click, PREFILLED GitHub issue URL that needs zero typing.
12
+ *
13
+ * Hard constraints (learned the hard way — see commit 3b600fd, where an
14
+ * oversized prefilled URL overflowed GitHub's ~8 KB limit and silently failed):
15
+ * - The URL is CAPPED well under the limit; bulk lives in the local file.
16
+ * - It is REDACTED: no source code, no secret values — only a basename, a line
17
+ * number, and the safe `redactedContext` the validator built.
18
+ * - It is OPT-IN: nothing is filed automatically; the human clicks (or not).
19
+ *
20
+ * Not read-only in the strict sense — it writes its own .docguard/feedback/ —
21
+ * but it never scaffolds skills and never touches the user's source tree.
22
+ *
23
+ * Zero npm dependencies — pure Node.js built-ins.
24
+ */
25
+
26
+ import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
27
+ import { resolve, dirname, basename } from 'node:path';
28
+ import { fileURLToPath } from 'node:url';
29
+ import { c } from '../shared.mjs';
30
+ import { runGuardInternal } from './guard.mjs';
31
+
32
+ const _PKG = JSON.parse(
33
+ readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json'), 'utf-8')
34
+ );
35
+ const CLI_VERSION = _PKG.version;
36
+ const ISSUES_BASE = (_PKG.bugs && _PKG.bugs.url) || 'https://github.com/raccioly/docguard/issues';
37
+
38
+ // Keep the prefilled URL comfortably under GitHub's ~8 KB request-URL limit.
39
+ const URL_CAP = 1800;
40
+
41
+ /** Deterministic short id from a string — avoids Date.now()/Math.random(). */
42
+ function shortId(str) {
43
+ let h = 5381;
44
+ for (let i = 0; i < str.length; i++) h = ((h << 5) + h + str.charCodeAt(i)) >>> 0;
45
+ return h.toString(36).slice(0, 6);
46
+ }
47
+
48
+ /** Redact a finding location for the SHARED url: basename + line only. */
49
+ function safeLocation(location) {
50
+ if (!location) return '(unknown)';
51
+ const [path, line] = String(location).split(/:(?=\d+$)/);
52
+ return line ? `${basename(path)}:${line}` : basename(path);
53
+ }
54
+
55
+ /**
56
+ * Build a prefilled, capped issue URL. Drops optional body lines (longest-value
57
+ * first) until under the cap; title + code + location always survive.
58
+ */
59
+ function buildIssueUrl(finding) {
60
+ const code = finding.code || 'FINDING';
61
+ const validator = finding.validator || 'unknown';
62
+ const shortMsg = (finding.message || '').replace(/\s+/g, ' ').slice(0, 70);
63
+ const title = `[feedback] ${code} (${validator}): ${shortMsg}`;
64
+
65
+ // Optional lines are ordered most→least droppable.
66
+ const required = [
67
+ `DocGuard v${CLI_VERSION} flagged this and it may be a false positive (or other feedback).`,
68
+ '',
69
+ `- Code: ${code}`,
70
+ `- Validator: ${validator}`,
71
+ `- Location: ${safeLocation(finding.location)}`,
72
+ `- Confidence: ${finding.confidence}`,
73
+ ];
74
+ const optional = [];
75
+ if (finding.redactedContext) optional.push(`- Context: ${finding.redactedContext}`);
76
+ if (finding.suggestion && finding.suggestion.text) optional.push(`- Suggestion shown: ${finding.suggestion.text}`);
77
+ const footer = ['', 'Generated by `docguard feedback` — no source code or secret values are included.'];
78
+
79
+ const compose = (opt) => `${ISSUES_BASE}/new?labels=${encodeURIComponent('docguard-feedback')}` +
80
+ `&title=${encodeURIComponent(title)}` +
81
+ `&body=${encodeURIComponent([...required, ...opt, ...footer].join('\n'))}`;
82
+
83
+ let opt = [...optional];
84
+ let url = compose(opt);
85
+ while (url.length > URL_CAP && opt.length > 0) {
86
+ opt = opt.slice(0, -1);
87
+ url = compose(opt);
88
+ }
89
+ if (url.length > URL_CAP) {
90
+ // Even the required body is too long (pathological) — collapse to a stub.
91
+ url = `${ISSUES_BASE}/new?labels=${encodeURIComponent('docguard-feedback')}` +
92
+ `&title=${encodeURIComponent(title)}` +
93
+ `&body=${encodeURIComponent(`DocGuard v${CLI_VERSION} — ${code} (${validator}). Full details saved locally; please attach.`)}`;
94
+ }
95
+ return { url, title };
96
+ }
97
+
98
+ export function runFeedback(projectDir, config, flags) {
99
+ const data = runGuardInternal(projectDir, config);
100
+ const reportable = (data.findings || []).filter((f) => f.reportable);
101
+ const isJson = flags.format === 'json';
102
+
103
+ if (reportable.length === 0) {
104
+ if (isJson) {
105
+ console.log(JSON.stringify({ reportable: [], message: 'no uncertain findings' }, null, 2));
106
+ return;
107
+ }
108
+ console.log(`${c.bold}📮 DocGuard Feedback${c.reset}`);
109
+ console.log(`${c.green}✅ Nothing to report — DocGuard is confident about everything it flagged.${c.reset}`);
110
+ console.log(`${c.dim} (Feedback collects low-confidence findings, i.e. likely false positives.)${c.reset}\n`);
111
+ return;
112
+ }
113
+
114
+ // Capture each reportable finding locally (full, reviewable) + build its URL.
115
+ const feedbackDir = resolve(projectDir, '.docguard', 'feedback');
116
+ const items = reportable.map((f) => {
117
+ const id = shortId(`${f.code}|${f.location || f.message}`);
118
+ const { url, title } = buildIssueUrl(f);
119
+ const fileName = `${(f.code || 'finding').toLowerCase()}-${id}.json`;
120
+ const filePath = resolve(feedbackDir, fileName);
121
+ return { finding: f, id, url, title, fileName, filePath };
122
+ });
123
+
124
+ if (isJson) {
125
+ console.log(JSON.stringify({
126
+ version: CLI_VERSION,
127
+ reportable: items.map((it) => ({ code: it.finding.code, location: it.finding.location, url: it.url, file: `.docguard/feedback/${it.fileName}` })),
128
+ }, null, 2));
129
+ // Still write the local records so the JSON path is not a dead end.
130
+ }
131
+
132
+ let wrote = 0;
133
+ for (const it of items) {
134
+ try {
135
+ if (!existsSync(feedbackDir)) mkdirSync(feedbackDir, { recursive: true });
136
+ writeFileSync(it.filePath, JSON.stringify({
137
+ capturedBy: `docguard feedback (v${CLI_VERSION})`,
138
+ finding: it.finding,
139
+ issueUrl: it.url,
140
+ }, null, 2) + '\n', 'utf-8');
141
+ wrote++;
142
+ } catch { /* best-effort local capture */ }
143
+ }
144
+
145
+ if (isJson) return;
146
+
147
+ console.log(`${c.bold}📮 DocGuard Feedback${c.reset}`);
148
+ console.log(`${c.dim} ${items.length} uncertain finding(s) — likely false positives. Saved locally to ${c.cyan}.docguard/feedback/${c.reset}\n`);
149
+
150
+ for (const it of items) {
151
+ const f = it.finding;
152
+ console.log(` ${c.yellow}[${f.code}]${c.reset} ${f.message}`);
153
+ if (f.suggestion && f.suggestion.pragma) {
154
+ console.log(` ${c.dim}Suppress locally instead: ${f.suggestion.pragma}${c.reset}`);
155
+ }
156
+ console.log(` ${c.dim}Report (1 click, prefilled, redacted):${c.reset}`);
157
+ console.log(` ${c.cyan}${it.url}${c.reset}`);
158
+ console.log(` ${c.dim}Local copy: .docguard/feedback/${it.fileName}${c.reset}\n`);
159
+ }
160
+
161
+ console.log(`${c.dim}These reports help DocGuard stop flagging the same false positive in a future release.${c.reset}`);
162
+ console.log(`${c.dim}Nothing is sent automatically — open a link only if you want to.${c.reset}\n`);
163
+ }
@@ -180,6 +180,27 @@ export function classifyResult(result) {
180
180
  return { status, quality };
181
181
  }
182
182
 
183
+ /**
184
+ * v0.27: the list of issues to render for a validator. Prefers structured
185
+ * findings (code + confidence + suggestion) when present; otherwise maps the
186
+ * legacy error/warning strings into the same shape so the renderer is uniform.
187
+ */
188
+ function renderableItems(v) {
189
+ if (Array.isArray(v.findings) && v.findings.length > 0) {
190
+ return v.findings.map((f) => ({
191
+ severity: f.severity,
192
+ message: f.message,
193
+ code: f.code,
194
+ confidence: f.confidence,
195
+ suggestion: f.suggestion,
196
+ }));
197
+ }
198
+ return [
199
+ ...(v.errors || []).map((m) => ({ severity: 'error', message: m })),
200
+ ...(v.warnings || []).map((m) => ({ severity: 'warn', message: m })),
201
+ ];
202
+ }
203
+
183
204
  export function runGuardInternal(projectDir, config) {
184
205
  const validators = config.validators || {};
185
206
  const results = [];
@@ -323,6 +344,15 @@ export function runGuardInternal(projectDir, config) {
323
344
  // what the user reads is what CI does.
324
345
  const overallStatus = effectiveErrors > 0 ? 'FAIL' : effectiveWarnings > 0 ? 'WARN' : 'PASS';
325
346
 
347
+ // v0.27: stable, LLM-addressable contract. `findings` is the flattened,
348
+ // structured view (those validators that emit it); `reportable` are the
349
+ // low-confidence ones the feedback loop offers to report; `nextStep` is the
350
+ // single machine hint so an agent in a hook never has to parse prose.
351
+ const allFindings = activeResults.flatMap((r) => (Array.isArray(r.findings) ? r.findings : []));
352
+ const reportable = allFindings.filter((f) => f.reportable);
353
+ const nextStep =
354
+ overallStatus === 'PASS' ? null : 'docguard diagnose';
355
+
326
356
  return {
327
357
  project: config.projectName,
328
358
  profile: config.profile || 'standard',
@@ -331,6 +361,9 @@ export function runGuardInternal(projectDir, config) {
331
361
  total: totalChecks,
332
362
  errors: totalErrors,
333
363
  warnings: totalWarnings,
364
+ findings: allFindings,
365
+ reportable,
366
+ nextStep,
334
367
  // v0.5: severity-aware counts for exit-code logic. The display still uses
335
368
  // the raw counts above so users see every warning, but CI only fails on
336
369
  // things they've marked as high-severity.
@@ -429,12 +462,17 @@ export function runGuard(projectDir, config, flags) {
429
462
 
430
463
  // ── JSON output ──
431
464
  if (flags.format === 'json') {
432
- console.log(JSON.stringify(data, null, 2));
433
465
  // Use severity-aware effective counts for exit code; raw counts stay in the JSON
434
466
  // for display tools that want to show the full picture.
435
- if (data.effectiveErrors > 0) process.exit(1);
436
- if (data.effectiveWarnings > 0) process.exit(2);
437
- process.exit(0);
467
+ const code = data.effectiveErrors > 0 ? 1 : data.effectiveWarnings > 0 ? 2 : 0;
468
+ // v0.28: set exitCode + return instead of process.exit(). A large JSON
469
+ // payload (>~8 KB) written to a PIPE flushes asynchronously; an immediate
470
+ // process.exit() truncates it mid-string, so a CI consumer parsing stdout
471
+ // gets "Unterminated string in JSON" on exactly the big reports that matter.
472
+ // Returning lets Node drain stdout and exit naturally with process.exitCode.
473
+ process.exitCode = code;
474
+ process.stdout.write(JSON.stringify(data, null, 2) + '\n');
475
+ return;
438
476
  }
439
477
 
440
478
  // ── Text output ──
@@ -473,14 +511,26 @@ export function runGuard(projectDir, config, flags) {
473
511
  // overall validator status — useful when a validator passes overall
474
512
  // (passed < total) without surfacing the specific failing checks.
475
513
  const show = flags.verbose || flags.showFailing;
476
- if (show || v.status === 'fail') {
477
- for (const err of v.errors) {
478
- console.log(` ${c.red}✗ ${err}${c.reset}`);
479
- }
480
- }
481
- if (show || v.status === 'warn') {
482
- for (const warn of v.warnings) {
483
- console.log(` ${c.yellow}⚠ ${warn}${c.reset}`);
514
+ const showErr = show || v.status === 'fail';
515
+ const showWarn = show || v.status === 'warn';
516
+ // v0.27: render from structured findings when the validator emits them
517
+ // (each issue carries a code, confidence, and a `→ suggestion`); otherwise
518
+ // fall back to the legacy error/warning strings. Identical gating.
519
+ for (const item of renderableItems(v)) {
520
+ if (item.severity === 'error' && !showErr) continue;
521
+ if (item.severity === 'warn' && !showWarn) continue;
522
+ const mark = item.severity === 'error' ? `${c.red}✗` : `${c.yellow}⚠`;
523
+ const codeTag = item.code ? `${c.dim}[${item.code}]${c.reset} ` : '';
524
+ const conf = item.confidence === 'low'
525
+ ? ` ${c.dim}(low confidence — possible false positive)${c.reset}` : '';
526
+ console.log(` ${mark} ${codeTag}${item.message}${c.reset}${conf}`);
527
+ if (item.suggestion) {
528
+ console.log(` ${c.cyan}→${c.reset} ${c.dim}${item.suggestion.text}${c.reset}`);
529
+ if (item.suggestion.command) {
530
+ console.log(` ${c.cyan}${item.suggestion.command}${c.reset}`);
531
+ } else if (item.suggestion.pragma) {
532
+ console.log(` ${c.dim}${item.suggestion.pragma}${c.reset}`);
533
+ }
484
534
  }
485
535
  }
486
536
  // If a validator reports passed < total but has no errors/warnings, surface
@@ -514,14 +564,31 @@ export function runGuard(projectDir, config, flags) {
514
564
  console.log(` ${c.red}${c.bold}❌ FAIL${c.reset} ${c.red}— ${data.passed}/${data.total} passed, ${data.effectiveErrors} blocking issue(s)${warnSuffix}${c.reset}`);
515
565
  }
516
566
 
517
- // Next step hint always point to diagnose when issues exist
567
+ // ── Next stepsevery run ends with a suggested action (v0.27) ──
568
+ // The field-report principle: whenever DocGuard calls out an issue it must
569
+ // suggest what to do next; on a clean run it points at the next workflow step
570
+ // rather than nagging. JSON consumers read this off the `nextStep`/`reportable`
571
+ // contract fields instead of this prose.
572
+ const agentMode = detectAgentMode(projectDir);
573
+ const skill = (name) => (agentMode === 'llm' ? `/docguard.${name}` : `docguard ${name}`);
574
+
518
575
  if (data.status !== 'PASS') {
519
- const agentMode = detectAgentMode(projectDir);
520
- if (agentMode === 'llm') {
521
- console.log(` ${c.dim}Use ${c.cyan}/docguard.diagnose${c.dim} to get AI fix prompts.${c.reset}`);
522
- } else {
523
- console.log(` ${c.dim}Run ${c.cyan}docguard diagnose${c.dim} to get AI fix prompts.${c.reset}`);
524
- }
576
+ console.log(` ${c.dim}Next: run ${c.cyan}${skill('diagnose')}${c.dim} to get AI fix prompts that resolve the issues above.${c.reset}`);
577
+ } else {
578
+ console.log(` ${c.dim}Next: ${c.cyan}${skill('score')}${c.dim} for your CDD maturity score, or commit with confidence.${c.reset}`);
579
+ }
580
+
581
+ // Low-confidence findings (possible false positives) → offer the local-first
582
+ // feedback path. Broader than secrets: anything DocGuard flagged uncertainly.
583
+ if (Array.isArray(data.reportable) && data.reportable.length > 0) {
584
+ const n = data.reportable.length;
585
+ console.log(` ${c.dim}↪ ${n} finding(s) look uncertain (possible false positives). Review or report: ${c.cyan}${skill('feedback')}${c.reset}`);
586
+ }
587
+
588
+ // Read-only skills nudge (never writes — that's `init`'s job). If the agent
589
+ // has no /docguard.* commands installed yet, say how to get them.
590
+ if (agentMode === 'llm' && !existsSync(resolvePath(projectDir, '.agent', 'skills', 'docguard-guard'))) {
591
+ console.log(` ${c.dim}💡 Install ${c.cyan}/docguard.*${c.dim} commands for your agent: ${c.cyan}docguard init${c.reset}`);
525
592
  }
526
593
 
527
594
  // Badge snippet
@@ -645,7 +712,8 @@ export function runGuard(projectDir, config, flags) {
645
712
  }
646
713
 
647
714
  // v0.5: severity-aware exit codes (see runGuardInternal for the rollup).
648
- if (data.effectiveErrors > 0) process.exit(1);
649
- if (data.effectiveWarnings > 0) process.exit(2);
650
- process.exit(0);
715
+ // v0.28: exitCode + return (not process.exit) so the buffered text output
716
+ // flushes to a pipe before the process exits — same truncation fix as the
717
+ // JSON path above.
718
+ process.exitCode = data.effectiveErrors > 0 ? 1 : data.effectiveWarnings > 0 ? 2 : 0;
651
719
  }
@@ -78,6 +78,20 @@ const __filename = fileURLToPath(import.meta.url);
78
78
  const __dirname = dirname(__filename);
79
79
  const TEMPLATES_DIR = resolve(__dirname, '../../templates');
80
80
 
81
+ /**
82
+ * v0.28 (field report #11): inject a `<!-- docguard:last-reviewed DATE -->`
83
+ * marker right after the first H1, so a canonical doc has a freshness signal the
84
+ * Freshness validator reads directly (not git mtime). No-op if one is present.
85
+ */
86
+ function stampLastReviewed(content, date) {
87
+ const marker = `<!-- docguard:last-reviewed ${date} -->`;
88
+ const lines = content.split('\n');
89
+ const h1 = lines.findIndex(l => /^#\s/.test(l));
90
+ if (h1 === -1) return `${marker}\n\n${content}`;
91
+ lines.splice(h1 + 1, 0, '', marker);
92
+ return lines.join('\n');
93
+ }
94
+
81
95
  // ── Readline helper ──────────────────────────────────────────────────────
82
96
 
83
97
  function askQuestion(prompt) {
@@ -263,7 +277,15 @@ export async function runInit(projectDir, config, flags) {
263
277
  if (existsSync(templatePath)) {
264
278
  const content = readFileSync(templatePath, 'utf-8');
265
279
  const today = new Date().toISOString().split('T')[0];
266
- const processed = content.replace(/YYYY-MM-DD/g, today);
280
+ let processed = content.replace(/YYYY-MM-DD/g, today);
281
+ // v0.28 (field report #11): every canonical doc must ship with a freshness
282
+ // marker so the Freshness validator is marker-based (consistent across docs,
283
+ // and satisfiable in a pre-commit review loop) rather than silently falling
284
+ // back to git mtime. Templates now all carry one; this is the belt-and-
285
+ // suspenders guarantee for any future template that forgets.
286
+ if (mapping.dest.startsWith('docs-canonical/') && !/docguard:last-reviewed/.test(processed)) {
287
+ processed = stampLastReviewed(processed, today);
288
+ }
267
289
  writeFileSync(destPath, processed, 'utf-8');
268
290
  created.push(mapping.dest);
269
291
  console.log(` ${c.green}✅${c.reset} Created: ${c.cyan}${mapping.dest}${c.reset}`);
@@ -10,6 +10,70 @@ import { c, docHasSection } from '../shared.mjs';
10
10
  import { validateSecurity } from '../validators/security.mjs';
11
11
  import { runGuardInternal } from './guard.mjs';
12
12
 
13
+ /**
14
+ * Detect whether the project configures a test runner (the "Check 3" of the
15
+ * testing score). Extracted as an exported seam so it's unit-testable without
16
+ * the full score pipeline.
17
+ *
18
+ * Recognises, in order: standalone config files; pytest config inside
19
+ * pyproject.toml / tox.ini; node:test via projectTypeConfig or scripts.test;
20
+ * a `scripts.test` that invokes a known runner; Vitest configured INSIDE
21
+ * vite.config.* (field report #3 — `vitest/config` import or a `test:` block);
22
+ * and runner configs in common workspace subdirs.
23
+ *
24
+ * @param {string} dir
25
+ * @param {object} config
26
+ * @returns {boolean}
27
+ */
28
+ export function detectTestRunner(dir, config = {}) {
29
+ const testConfigFiles = ['jest.config.js', 'jest.config.ts', 'vitest.config.ts', 'vitest.config.js', 'pytest.ini', 'setup.cfg', '.mocharc.yml'];
30
+ if (testConfigFiles.some((f) => existsSync(resolve(dir, f)))) return true;
31
+
32
+ // Python: pytest config usually lives inside pyproject.toml ([tool.pytest.ini_options])
33
+ // or tox.ini ([pytest]) — not a standalone file.
34
+ for (const [file, marker] of [['pyproject.toml', /\[tool\.pytest/], ['tox.ini', /\[pytest\]/]]) {
35
+ const p = resolve(dir, file);
36
+ if (!existsSync(p)) continue;
37
+ try { if (marker.test(readFileSync(p, 'utf-8'))) return true; } catch { /* skip */ }
38
+ }
39
+
40
+ // node:test has no config file — recognize it via projectTypeConfig or package.json.
41
+ const ptc = config.projectTypeConfig || {};
42
+ if (ptc.testFramework === 'node:test') return true;
43
+ const pkgPath = resolve(dir, 'package.json');
44
+ if (existsSync(pkgPath)) {
45
+ try {
46
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
47
+ const testScript = pkg.scripts?.test || '';
48
+ if (testScript.includes('node --test') || testScript.includes('node:test')) return true;
49
+ // v0.27 (field report #3): a `scripts.test` that runs a known runner IS a
50
+ // configured test runner, even without a standalone config file.
51
+ if (/\b(vitest|jest|mocha|ava|playwright|cypress|pytest)\b/.test(testScript)) return true;
52
+ } catch { /* skip */ }
53
+ }
54
+
55
+ // v0.27 (field report #3): Vitest configured INSIDE vite.config.* rather than a
56
+ // standalone vitest.config (`vitest/config` import + a `test:` block).
57
+ for (const f of ['vite.config.ts', 'vite.config.js', 'vite.config.mts', 'vite.config.mjs']) {
58
+ const p = resolve(dir, f);
59
+ if (!existsSync(p)) continue;
60
+ try {
61
+ const src = readFileSync(p, 'utf-8');
62
+ if (/vitest\/config/.test(src) || /^\s*test\s*:/m.test(src)) return true;
63
+ } catch { /* skip */ }
64
+ }
65
+
66
+ // Workspace subdirs: a runner config one level down still configures the project.
67
+ const subConfigs = ['vitest.config.ts', 'vitest.config.js', 'jest.config.ts', 'jest.config.js', 'vite.config.ts'];
68
+ for (const sub of ['backend', 'frontend', 'server', 'client', 'app', 'web', 'api']) {
69
+ for (const f of subConfigs) {
70
+ if (existsSync(resolve(dir, sub, f))) return true;
71
+ }
72
+ }
73
+
74
+ return false;
75
+ }
76
+
13
77
  /**
14
78
  * v0.18-P3: map score categories to the validator keys that contribute.
15
79
  * One category can roll up multiple validators (e.g. "environment" pulls
@@ -585,38 +649,7 @@ function calcTestingScore(dir, config) {
585
649
  else failures.push({ issue: 'TEST-SPEC.md missing', fixCmd: 'docguard fix --doc test-spec' });
586
650
 
587
651
  // ── Check 3: Test config or built-in runner (15 pts) ──
588
- const testConfigFiles = ['jest.config.js', 'jest.config.ts', 'vitest.config.ts', 'vitest.config.js', 'pytest.ini', 'setup.cfg', '.mocharc.yml'];
589
- let hasTestRunner = testConfigFiles.some(f => existsSync(resolve(dir, f)));
590
-
591
- // Python: pytest config usually lives inside pyproject.toml ([tool.pytest.ini_options])
592
- // or tox.ini ([pytest]) — not a standalone file. Detect those too, so a uv/pytest
593
- // project isn't told to "add a test runner" it already configured (field report, Issue B).
594
- if (!hasTestRunner) {
595
- for (const [file, marker] of [['pyproject.toml', /\[tool\.pytest/], ['tox.ini', /\[pytest\]/]]) {
596
- const p = resolve(dir, file);
597
- if (!existsSync(p)) continue;
598
- try { if (marker.test(readFileSync(p, 'utf-8'))) { hasTestRunner = true; break; } } catch { /* skip */ }
599
- }
600
- }
601
-
602
- // node:test has no config file — recognize it via projectTypeConfig or package.json.
603
- if (!hasTestRunner) {
604
- const ptc = config.projectTypeConfig || {};
605
- if (ptc.testFramework === 'node:test') {
606
- hasTestRunner = true;
607
- } else {
608
- const pkgPath = resolve(dir, 'package.json');
609
- if (existsSync(pkgPath)) {
610
- try {
611
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
612
- const testScript = pkg.scripts?.test || '';
613
- if (testScript.includes('node --test') || testScript.includes('node:test')) hasTestRunner = true;
614
- } catch { /* skip */ }
615
- }
616
- }
617
- }
618
-
619
- if (hasTestRunner) score += 15;
652
+ if (detectTestRunner(dir, config)) score += 15;
620
653
  else failures.push({ issue: 'no test runner config detected (jest/vitest/pytest/node:test)' });
621
654
 
622
655
  // ── Check 4: CI test step (15 pts) ──