docguard-cli 0.26.0 → 0.27.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 (16)"]
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 **16 commands** (the "Daily 5" + 11 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,8 @@ 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
+ | `feedback` | Report likely false positives back to DocGuard — local-first record + a 1-click prefilled, redacted GitHub issue (zero typing) |
277
278
  | `memory` | Per-domain accuracy headline (endpoints / entities / env / tech) |
278
279
  | `memory --diff` | Drill into which specific claims don't match code |
279
280
  | `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
@@ -292,7 +293,7 @@ const EXPLAINERS = {
292
293
  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
294
  triggers: [
294
295
  ['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 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
297
  ['High ambiguous pronoun ratio', 'Replace "it/this/that/they" with the specific noun.'],
297
298
  ['Low atomicity', 'Split compound sentences so each states one verifiable fact (IEEE 830 §4.1).'],
298
299
  ['Reading level too high', 'Aim for grade 12–16 for technical docs — shorter sentences, simpler words.'],
@@ -419,6 +420,27 @@ export function runExplain(projectDir, _config, flags) {
419
420
  return;
420
421
  }
421
422
 
423
+ // v0.27: finding-code lookup — `docguard explain SEC001`. Codes are the stable,
424
+ // LLM-addressable handles that guard prints next to each finding and that
425
+ // inline `// docguard:ignore <CODE>` keys off.
426
+ const codeKey = query.toUpperCase();
427
+ if (CODES[codeKey]) {
428
+ const cd = CODES[codeKey];
429
+ if (isJson) {
430
+ console.log(JSON.stringify({ query, code: codeKey, ...cd }, null, 2));
431
+ return;
432
+ }
433
+ console.log(`${c.bold}🧭 ${codeKey} — ${cd.title}${c.reset}`);
434
+ console.log(`${c.dim} validator: ${cd.validator}${c.reset}\n`);
435
+ console.log(`${c.bold}What it means:${c.reset}\n ${cd.help}\n`);
436
+ if (cd.suppress) {
437
+ console.log(`${c.bold}Suppress inline${c.reset} ${c.dim}(only if it's a confirmed false positive):${c.reset}`);
438
+ console.log(` ${c.cyan}${cd.suppress}${c.reset}\n`);
439
+ }
440
+ 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}`);
441
+ return;
442
+ }
443
+
422
444
  const match = matchWarning(query);
423
445
  if (!match) {
424
446
  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.
@@ -473,14 +506,26 @@ export function runGuard(projectDir, config, flags) {
473
506
  // overall validator status — useful when a validator passes overall
474
507
  // (passed < total) without surfacing the specific failing checks.
475
508
  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}`);
509
+ const showErr = show || v.status === 'fail';
510
+ const showWarn = show || v.status === 'warn';
511
+ // v0.27: render from structured findings when the validator emits them
512
+ // (each issue carries a code, confidence, and a `→ suggestion`); otherwise
513
+ // fall back to the legacy error/warning strings. Identical gating.
514
+ for (const item of renderableItems(v)) {
515
+ if (item.severity === 'error' && !showErr) continue;
516
+ if (item.severity === 'warn' && !showWarn) continue;
517
+ const mark = item.severity === 'error' ? `${c.red}✗` : `${c.yellow}⚠`;
518
+ const codeTag = item.code ? `${c.dim}[${item.code}]${c.reset} ` : '';
519
+ const conf = item.confidence === 'low'
520
+ ? ` ${c.dim}(low confidence — possible false positive)${c.reset}` : '';
521
+ console.log(` ${mark} ${codeTag}${item.message}${c.reset}${conf}`);
522
+ if (item.suggestion) {
523
+ console.log(` ${c.cyan}→${c.reset} ${c.dim}${item.suggestion.text}${c.reset}`);
524
+ if (item.suggestion.command) {
525
+ console.log(` ${c.cyan}${item.suggestion.command}${c.reset}`);
526
+ } else if (item.suggestion.pragma) {
527
+ console.log(` ${c.dim}${item.suggestion.pragma}${c.reset}`);
528
+ }
484
529
  }
485
530
  }
486
531
  // If a validator reports passed < total but has no errors/warnings, surface
@@ -514,14 +559,31 @@ export function runGuard(projectDir, config, flags) {
514
559
  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
560
  }
516
561
 
517
- // Next step hint always point to diagnose when issues exist
562
+ // ── Next stepsevery run ends with a suggested action (v0.27) ──
563
+ // The field-report principle: whenever DocGuard calls out an issue it must
564
+ // suggest what to do next; on a clean run it points at the next workflow step
565
+ // rather than nagging. JSON consumers read this off the `nextStep`/`reportable`
566
+ // contract fields instead of this prose.
567
+ const agentMode = detectAgentMode(projectDir);
568
+ const skill = (name) => (agentMode === 'llm' ? `/docguard.${name}` : `docguard ${name}`);
569
+
518
570
  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
- }
571
+ console.log(` ${c.dim}Next: run ${c.cyan}${skill('diagnose')}${c.dim} to get AI fix prompts that resolve the issues above.${c.reset}`);
572
+ } else {
573
+ console.log(` ${c.dim}Next: ${c.cyan}${skill('score')}${c.dim} for your CDD maturity score, or commit with confidence.${c.reset}`);
574
+ }
575
+
576
+ // Low-confidence findings (possible false positives) → offer the local-first
577
+ // feedback path. Broader than secrets: anything DocGuard flagged uncertainly.
578
+ if (Array.isArray(data.reportable) && data.reportable.length > 0) {
579
+ const n = data.reportable.length;
580
+ console.log(` ${c.dim}↪ ${n} finding(s) look uncertain (possible false positives). Review or report: ${c.cyan}${skill('feedback')}${c.reset}`);
581
+ }
582
+
583
+ // Read-only skills nudge (never writes — that's `init`'s job). If the agent
584
+ // has no /docguard.* commands installed yet, say how to get them.
585
+ if (agentMode === 'llm' && !existsSync(resolvePath(projectDir, '.agent', 'skills', 'docguard-guard'))) {
586
+ console.log(` ${c.dim}💡 Install ${c.cyan}/docguard.*${c.dim} commands for your agent: ${c.cyan}docguard init${c.reset}`);
525
587
  }
526
588
 
527
589
  // Badge snippet
@@ -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) ──
package/cli/docguard.mjs CHANGED
@@ -43,6 +43,7 @@ import { runSetup } from './commands/setup.mjs';
43
43
  import { runUpgrade } from './commands/upgrade.mjs';
44
44
  import { runImpact } from './commands/impact.mjs';
45
45
  import { runExplain } from './commands/explain.mjs';
46
+ import { runFeedback } from './commands/feedback.mjs';
46
47
  import { runMemory } from './commands/memory.mjs';
47
48
  import { runDemo } from './commands/demo.mjs';
48
49
  import { runAgent } from './commands/agent.mjs';
@@ -85,7 +86,8 @@ ${c.bold}Tools (situational, but day-to-day useful)${c.reset}
85
86
  ${c.green}fix${c.reset} Generate AI fix instructions for specific docs
86
87
  ${c.green}generate${c.reset} Reverse-engineer canonical docs from existing code (${c.cyan}--plan${c.reset} for AI scan)
87
88
  ${c.green}agent${c.reset} One-shot agent task graph — ordered tasks, pre-filled code-truth, per-task verify (${c.cyan}--format json${c.reset})
88
- ${c.green}explain${c.reset} Explain a validator key or warning text
89
+ ${c.green}explain${c.reset} Explain a validator key, warning text, or finding code (${c.cyan}docguard explain SEC001${c.reset})
90
+ ${c.green}feedback${c.reset} Report likely false positives back to DocGuard (local-first + 1-click prefilled issue)
89
91
  ${c.green}memory${c.reset} Show what DocGuard remembers (${c.cyan}--diff${c.reset} drills into drift)
90
92
  ${c.green}trace${c.reset} Requirements traceability matrix (${c.cyan}--reverse${c.reset} for code→doc map)
91
93
  ${c.green}upgrade${c.reset} Migrate ${c.cyan}.docguard.json${c.reset} schema + CLI (${c.cyan}--apply --pr${c.reset} for team-wide PR)
@@ -279,6 +281,12 @@ const COMMAND_HELP = {
279
281
  flags: [['--diff', 'Drill into drift between memory and code']],
280
282
  examples: ['docguard memory', 'docguard memory --diff'],
281
283
  },
284
+ feedback: {
285
+ summary: 'Report likely false positives back to DocGuard. Collects the low-confidence findings of a guard run, saves a full local record under .docguard/feedback/, and prints a one-click, prefilled, redacted GitHub issue URL (zero typing, no source code or secret values).',
286
+ usage: 'docguard feedback [--format json]',
287
+ flags: [['--format json', 'Machine-readable list of reportable findings + URLs']],
288
+ examples: ['docguard feedback'],
289
+ },
282
290
  };
283
291
 
284
292
  function printCommandHelp(command) {
@@ -507,6 +515,9 @@ async function main() {
507
515
  const READ_ONLY_COMMANDS = new Set([
508
516
  'guard', 'audit', 'score', 'diff', 'impact',
509
517
  'diagnose', 'trace', 'explain', 'memory', 'demo', 'agent',
518
+ // feedback only writes its own .docguard/feedback/ — it must NOT scaffold
519
+ // skills or touch source, so it's gated out of ensureSkills like the rest.
520
+ 'feedback',
510
521
  ]);
511
522
 
512
523
  // Silent auto-check: install skills/commands if missing. Skip entirely in
@@ -646,6 +657,12 @@ async function main() {
646
657
  case 'explain':
647
658
  runExplain(projectDir, config, flags);
648
659
  break;
660
+ case 'feedback':
661
+ // v0.27 (field report #3 / LLM feedback loop): collect low-confidence
662
+ // findings (likely false positives) → local record + 1-click prefilled,
663
+ // redacted, capped GitHub issue URL. Opt-in; nothing filed automatically.
664
+ runFeedback(projectDir, config, flags);
665
+ break;
649
666
  case 'memory':
650
667
  runMemory(projectDir, config, flags);
651
668
  break;
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Findings — the structured, LLM-addressable result unit (v0.27).
3
+ *
4
+ * Background (LLM field report #3): DocGuard's whole job is to tell an agent
5
+ * what to do NEXT. A free-text `errors`/`warnings` string can't carry a stable
6
+ * code (for `explain <CODE>` + inline suppression), a confidence (the signal
7
+ * the false-positive feedback loop runs on), or a machine-readable suggested
8
+ * action. A Finding carries all three.
9
+ *
10
+ * The migration is INCREMENTAL and BACKWARD-COMPATIBLE. A validator that opts in
11
+ * builds `Finding[]` and returns `resultFromFindings(...)`, which still emits the
12
+ * exact `{ errors, warnings, passed, total }` shape every existing consumer
13
+ * (guard counts + exit code, diagnose, score, ci, `--format json`) already reads
14
+ * — PLUS a `findings` array that guard renders richly (each issue gets its
15
+ * `→ suggestion`). Validators that haven't migrated keep returning their
16
+ * hand-built results and render exactly as before. Nothing regresses.
17
+ *
18
+ * Zero npm dependencies — pure Node.js built-ins.
19
+ *
20
+ * @typedef {Object} Suggestion
21
+ * @property {'fix'|'suppress'|'review'|'report'} kind
22
+ * @property {string} text One concise line: what to do next.
23
+ * @property {string} [command] Optional CLI/skill command to run.
24
+ * @property {string} [pragma] Optional inline suppression snippet.
25
+ *
26
+ * @typedef {Object} Finding
27
+ * @property {string} code Stable code, e.g. 'SEC001' (see CODES).
28
+ * @property {string} validator Owning validator key.
29
+ * @property {'error'|'warn'} severity
30
+ * @property {'high'|'low'} confidence 'low' = candidate false positive.
31
+ * @property {string} message Concise, NO ansi colour.
32
+ * @property {string|null} location 'path:line' or 'path'.
33
+ * @property {Suggestion|null} suggestion
34
+ * @property {boolean} reportable Surface in `docguard feedback`.
35
+ * @property {string|null} redactedContext Safe-to-share context for a report.
36
+ */
37
+
38
+ /**
39
+ * Stable finding-code registry. `docguard explain <CODE>` reads this, and
40
+ * inline `// docguard:ignore <CODE>` keys off it. Keep codes append-only — a
41
+ * published code is a public surface we don't renumber.
42
+ */
43
+ export const CODES = {
44
+ SEC001: {
45
+ validator: 'security',
46
+ title: 'Hardcoded password',
47
+ help: 'A `password`/`passwd`/`pwd` assignment with a quoted literal value (8+ chars). If the value is natural-language UI copy or a validation message — not a credential — this is a false positive: DocGuard now flags those low-confidence, but you can suppress inline.',
48
+ suppress: '// docguard:ignore SEC001 — UI copy, not a credential',
49
+ },
50
+ SEC002: {
51
+ validator: 'security',
52
+ title: 'Hardcoded API key',
53
+ help: 'An `api_key`/`apikey` assignment with a quoted literal value (16+ chars). Move it to an environment variable and read it via `process.env`.',
54
+ suppress: '// docguard:ignore SEC002 — sample value in fixture',
55
+ },
56
+ SEC003: {
57
+ validator: 'security',
58
+ title: 'Hardcoded secret key',
59
+ help: 'A `secret_key`/`secretkey` assignment with a quoted literal value (16+ chars). Move it to an environment variable.',
60
+ suppress: '// docguard:ignore SEC003 — reason',
61
+ },
62
+ SEC004: {
63
+ validator: 'security',
64
+ title: 'Hardcoded access token',
65
+ help: 'An `access_token`/`accesstoken` assignment with a quoted literal value (16+ chars). Move it to an environment variable.',
66
+ suppress: '// docguard:ignore SEC004 — reason',
67
+ },
68
+ SEC005: {
69
+ validator: 'security',
70
+ title: 'AWS Access Key ID',
71
+ help: 'A string matching the AWS Access Key ID format (AKIA…). Rotate it immediately if real, and move credentials to the AWS credential chain / environment.',
72
+ suppress: '// docguard:ignore SEC005 — documented example key',
73
+ },
74
+ SEC006: {
75
+ validator: 'security',
76
+ title: 'API secret key (Stripe/OpenAI pattern)',
77
+ help: 'A string matching a live/test secret-key format (sk-…, sk_live_…). Rotate it if real and move it to an environment variable.',
78
+ suppress: '// docguard:ignore SEC006 — reason',
79
+ },
80
+ SEC010: {
81
+ validator: 'security',
82
+ title: '.env not in .gitignore',
83
+ help: 'No `.env` entry was found in .gitignore, so a local `.env` could be committed. Add `.env` (and `.env.local`) to .gitignore.',
84
+ suppress: null,
85
+ },
86
+ SEC011: {
87
+ validator: 'security',
88
+ title: 'No source files scanned for secrets',
89
+ help: 'The secret scan matched zero source files — usually a too-broad ignore config or a wrong sourceRoot. A scan that checks nothing is a dangerous false ✅.',
90
+ suppress: null,
91
+ },
92
+ };
93
+
94
+ /**
95
+ * Build a Finding with sane defaults. `reportable` defaults to true for
96
+ * low-confidence findings — low confidence IS the feedback signal.
97
+ *
98
+ * @param {Partial<Finding>} f
99
+ * @returns {Finding}
100
+ */
101
+ export function mkFinding(f) {
102
+ const severity = f.severity === 'error' ? 'error' : 'warn';
103
+ const confidence = f.confidence === 'low' ? 'low' : 'high';
104
+ return {
105
+ code: f.code || null,
106
+ validator: f.validator || null,
107
+ severity,
108
+ confidence,
109
+ message: f.message || '',
110
+ location: f.location || null,
111
+ suggestion: f.suggestion || null,
112
+ reportable: f.reportable === true || confidence === 'low',
113
+ redactedContext: f.redactedContext || null,
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Derive the legacy `{ errors, warnings, passed, total }` result from a list of
119
+ * findings, keeping `findings` attached for the rich renderer. ONE source of
120
+ * truth — the strings guard counts and the findings guard renders can never
121
+ * disagree because they're computed from the same array.
122
+ *
123
+ * @param {Finding[]} findings
124
+ * @param {{passed?:number, total?:number, applicable?:boolean}} [opts]
125
+ */
126
+ export function resultFromFindings(findings, opts = {}) {
127
+ const errors = [];
128
+ const warnings = [];
129
+ for (const f of findings) {
130
+ if (f.severity === 'error') errors.push(f.message);
131
+ else warnings.push(f.message);
132
+ }
133
+ const res = {
134
+ errors,
135
+ warnings,
136
+ passed: opts.passed || 0,
137
+ total: opts.total != null ? opts.total : 0,
138
+ findings,
139
+ };
140
+ if (opts.applicable !== undefined) res.applicable = opts.applicable;
141
+ return res;
142
+ }
143
+
144
+ /**
145
+ * Does an inline `docguard:ignore` pragma in `text` suppress finding `code`?
146
+ *
147
+ * Accepted forms (mirrors the ergonomics of eslint-disable / ruff `# noqa`):
148
+ * docguard:ignore → suppresses ANY code on the line
149
+ * docguard:ignore SEC001 → suppresses exactly SEC001
150
+ * docguard:ignore SEC001,DQ002 → comma list
151
+ * docguard:ignore SEC* → prefix wildcard
152
+ * docguard:ignore all → suppresses any code
153
+ * docguard:ignore-secret → convenience alias for any SEC* code
154
+ *
155
+ * @param {string} text
156
+ * @param {string} code
157
+ * @returns {boolean}
158
+ */
159
+ export function suppressesCode(text, code) {
160
+ if (!text || !code) return false;
161
+ const m = text.match(/docguard:ignore(-secret)?\b[ \t]*([A-Za-z0-9_,*-]+)?/i);
162
+ if (!m) return false;
163
+ if (m[1]) return /^SEC/i.test(code); // ignore-secret alias
164
+ const arg = (m[2] || '').trim();
165
+ if (!arg) return true; // bare ignore → any code
166
+ return arg.split(',').map((s) => s.trim()).some((tok) => {
167
+ if (!tok) return false;
168
+ if (tok.toLowerCase() === 'all') return true;
169
+ if (tok.endsWith('*')) return code.toUpperCase().startsWith(tok.slice(0, -1).toUpperCase());
170
+ return tok.toUpperCase() === code.toUpperCase();
171
+ });
172
+ }
173
+
174
+ /**
175
+ * Source-line suppression: an ignore pragma counts if it's on the flagged line
176
+ * OR the line directly above it (so a comment can sit above the offending
177
+ * statement, the common style for non-trailing-comment languages).
178
+ */
179
+ export function lineSuppresses(code, line, prevLine = '') {
180
+ return suppressesCode(line, code) || suppressesCode(prevLine, code);
181
+ }
182
+
183
+ /**
184
+ * Flatten a one-line, colour-free rendering of a suggestion — used by JSON
185
+ * consumers, diagnose, and the feedback body. Guard does its own coloured
186
+ * rendering and does not use this.
187
+ */
188
+ export function suggestionLine(s) {
189
+ if (!s) return '';
190
+ let out = s.text || '';
191
+ if (s.command) out += ` → ${s.command}`;
192
+ else if (s.pragma) out += ` → ${s.pragma}`;
193
+ return out;
194
+ }
@@ -295,6 +295,27 @@ function classifyChars(content, ext) {
295
295
  * including bracket access.
296
296
  * @returns {Set<string>} variable names referenced in code
297
297
  */
298
+ /**
299
+ * v0.27 (field report #7): env vars injected by the test runner / CI / cloud
300
+ * SDK are READ in code (e.g. `if (process.env.VITEST)` as a test guard) but no
301
+ * application documents them as config — flagging them "undocumented" is a
302
+ * false positive. This is the env equivalent of the SYSTEM allowlist already
303
+ * applied on the docs side in environment.mjs.
304
+ *
305
+ * Deliberately conservative — NODE_ENV is intentionally NOT here: this project
306
+ * already decided NODE_ENV is legitimate app config (see environment.mjs).
307
+ */
308
+ const RUNNER_ENV_VARS = new Set([
309
+ 'VITEST', 'CI', 'JEST_WORKER_ID', 'AWS_SESSION_TOKEN', 'AWS_EXECUTION_ENV',
310
+ ]);
311
+ const RUNNER_ENV_PREFIXES = ['GITHUB_', 'RUNNER_', 'VITEST_', 'JEST_', 'CIRCLE_', 'GITLAB_CI'];
312
+
313
+ /** True when `name` is a runner/CI/SDK-injected var, not product config. */
314
+ export function isRunnerEnvVar(name) {
315
+ if (RUNNER_ENV_VARS.has(name)) return true;
316
+ return RUNNER_ENV_PREFIXES.some((p) => name.startsWith(p));
317
+ }
318
+
298
319
  export function grepEnvUsage(projectDir, config = {}) {
299
320
  const names = new Set();
300
321
  const roots = resolveSourceRoots(projectDir, config);
@@ -347,6 +368,7 @@ export function grepEnvUsage(projectDir, config = {}) {
347
368
  while ((m = rx.exec(content)) !== null) {
348
369
  if (kind[m.index] !== 0) continue; // keyword inside a string/comment → a mention, not a read
349
370
  if (isViteSource && VITE_INTRINSICS.has(m[1])) continue;
371
+ if (isRunnerEnvVar(m[1])) continue; // v0.27 (#7): runner/CI/SDK var, not product config
350
372
  names.add(m[1]);
351
373
  }
352
374
  }
@@ -366,12 +388,12 @@ export function grepEnvUsage(projectDir, config = {}) {
366
388
  // camelCase keys, so requiring UPPER_SNAKE keeps this env-specific.
367
389
  const keyRe = /^\s*['"]?([A-Z][A-Z0-9_]*[A-Z0-9])['"]?\s*:/gm;
368
390
  while ((km = keyRe.exec(content)) !== null) {
369
- if (km[1].length >= 3 && !VITE_INTRINSICS.has(km[1])) names.add(km[1]);
391
+ if (km[1].length >= 3 && !VITE_INTRINSICS.has(km[1]) && !isRunnerEnvVar(km[1])) names.add(km[1]);
370
392
  }
371
393
  // convict: the env var name is the `env:` property value, not the key.
372
394
  const convictRe = /\benv\s*:\s*['"]([A-Z][A-Z0-9_]*[A-Z0-9])['"]/g;
373
395
  while ((km = convictRe.exec(content)) !== null) {
374
- if (km[1].length >= 3) names.add(km[1]);
396
+ if (km[1].length >= 3 && !isRunnerEnvVar(km[1])) names.add(km[1]);
375
397
  }
376
398
  }
377
399
  };
@@ -533,7 +533,13 @@ function analyzeDocument(doc) {
533
533
  conditionalLoad: conditional.ratio,
534
534
  },
535
535
  details: { passive, ambiguous, atomicity, negation, conditional },
536
- overrides: { negationLoad: parseQualityOverride(content, 'negation-load') },
536
+ overrides: {
537
+ negationLoad: parseQualityOverride(content, 'negation-load'),
538
+ // v0.27 (#9): parity with negation-load. Sequence/flow docs (MESSAGE-FLOWS,
539
+ // INTEGRATIONS) are legitimately passive; let them opt out per-doc instead
540
+ // of warning unconditionally.
541
+ passiveVoice: parseQualityOverride(content, 'passive-voice'),
542
+ },
537
543
  };
538
544
  }
539
545
 
@@ -561,12 +567,17 @@ export function validateDocQuality(projectDir, config) {
561
567
 
562
568
  // ── Check 1: Passive Voice ──
563
569
  results.total++;
564
- if (m.passiveVoiceRatio <= THRESHOLDS.passiveVoiceRatio.warn) {
570
+ const passiveOv = analysis.overrides?.passiveVoice;
571
+ const passiveThreshold = passiveOv?.threshold
572
+ ?? config.docQuality?.passiveVoiceThreshold
573
+ ?? THRESHOLDS.passiveVoiceRatio.warn;
574
+ if (passiveOv?.off || m.passiveVoiceRatio <= passiveThreshold) {
565
575
  results.passed++;
566
576
  } else {
567
577
  results.warnings.push(
568
578
  `${doc.name}: High passive voice ratio (${(m.passiveVoiceRatio * 100).toFixed(0)}% of sentences). ` +
569
- `Use active voice for clarity. Found ${analysis.details.passive.count}/${analysis.details.passive.total} passive sentences`
579
+ `Use active voice for clarity. Found ${analysis.details.passive.count}/${analysis.details.passive.total} passive sentences. ` +
580
+ `If the passive voice is intentional (sequence/flow doc), add: <!-- docguard:quality passive-voice off — your reason -->`
570
581
  );
571
582
  }
572
583
 
@@ -8,6 +8,18 @@
8
8
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
9
9
  import { resolve, join, extname } from 'node:path';
10
10
  import { shouldIgnore, relPosix } from '../shared-ignore.mjs';
11
+ import { mkFinding, resultFromFindings, lineSuppresses } from '../findings.mjs';
12
+
13
+ // Each secret pattern maps to a stable finding code (see cli/findings.mjs CODES)
14
+ // so it is `explain`-able and inline-suppressible (`// docguard:ignore SEC00x`).
15
+ const LABEL_TO_CODE = {
16
+ 'hardcoded password': 'SEC001',
17
+ 'hardcoded API key': 'SEC002',
18
+ 'hardcoded secret key': 'SEC003',
19
+ 'hardcoded access token': 'SEC004',
20
+ 'AWS Access Key ID': 'SEC005',
21
+ 'API secret key (Stripe/OpenAI pattern)': 'SEC006',
22
+ };
11
23
 
12
24
  const CODE_EXTENSIONS = new Set([
13
25
  '.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx',
@@ -54,11 +66,46 @@ function isSafePlaceholder(line, matchStr) {
54
66
  return SAFE_PATTERNS.some(p => p.test(line));
55
67
  }
56
68
 
57
- export function validateSecurity(projectDir, config) {
58
- const results = { name: 'security', errors: [], warnings: [], passed: 0, total: 0 };
69
+ /**
70
+ * v0.27 (field report #1): a password-style key whose VALUE is natural
71
+ * language — an error message, validation copy, UI string — is almost never a
72
+ * credential. e.g. a "New password must differ from recent passwords"
73
+ * validation message assigned to such a key.
74
+ *
75
+ * We don't drop these (a real secret that happens to read like prose must still
76
+ * surface — false-green is the failure mode this tool exists to prevent); we
77
+ * downgrade them to a LOW-CONFIDENCE warning the agent can suppress inline,
78
+ * instead of a blocking error. Heuristic per the field report: ≥3 words, OR
79
+ * ≥2 internal spaces, OR ends in sentence punctuation.
80
+ *
81
+ * @param {string} value - the literal inside the quotes
82
+ */
83
+ function looksLikeProse(value) {
84
+ if (!value) return false;
85
+ const v = value.trim();
86
+ const words = v.split(/\s+/).filter(Boolean);
87
+ // Multi-word natural language (validation messages, UI copy, sentences).
88
+ if (words.length >= 3) return true;
89
+ // A 2-word sentence fragment ending in terminal punctuation — but NOT a
90
+ // single token like "SuperSecretPassword!" (strong passwords end in !/? too,
91
+ // so terminal punctuation ALONE must never reclassify a one-word value).
92
+ if (words.length >= 2 && /[.!?]$/.test(v)) return true;
93
+ return false;
94
+ }
95
+
96
+ /** Pull the first quoted literal out of a matched secret expression. */
97
+ function quotedValue(matchStr) {
98
+ const m = matchStr.match(/['"]([^'"]*)['"]/);
99
+ return m ? m[1] : '';
100
+ }
59
101
 
102
+ export function validateSecurity(projectDir, config) {
103
+ /** @type {import('../findings.mjs').Finding[]} */
60
104
  const findings = [];
105
+ let passed = 0;
106
+ let total = 0;
61
107
  let scanned = 0;
108
+ let realSecretCount = 0;
62
109
 
63
110
  walkDir(projectDir, (filePath) => {
64
111
  const ext = extname(filePath);
@@ -89,25 +136,58 @@ export function validateSecurity(projectDir, config) {
89
136
  // Lazily initialize lines only when a match is found
90
137
  if (!lines) lines = content.split('\n');
91
138
 
92
- // Find the line containing this match for context-aware filtering
93
- const matchPos = match.index;
94
- let charCount = 0;
95
- let matchLine = '';
96
- for (const line of lines) {
97
- charCount += line.length + 1; // +1 for newline
98
- if (charCount > matchPos) {
99
- matchLine = line;
100
- break;
101
- }
102
- }
139
+ // 1-based line number + the line above (for inline-pragma suppression).
140
+ const lineNo = content.slice(0, match.index).split('\n').length;
141
+ const matchLine = lines[lineNo - 1] || '';
142
+ const prevLine = lines[lineNo - 2] || '';
103
143
 
104
144
  // Skip known-safe placeholder/example values, but keep scanning for a
105
145
  // real one further down the file.
106
146
  if (isSafePlaceholder(matchLine, match[0])) continue;
107
147
 
108
- findings.push({ file: relPath, label, match: match[0].substring(0, 30) + '...' });
148
+ const code = LABEL_TO_CODE[label];
149
+
150
+ // v0.27 (#8): honour an inline `// docguard:ignore SEC00x` pragma on the
151
+ // line or the line above — per-line suppression instead of blinding the
152
+ // whole file via `securityIgnore`.
153
+ if (code && lineSuppresses(code, matchLine, prevLine)) break;
154
+
155
+ const location = `${relPath}:${lineNo}`;
156
+ const value = quotedValue(match[0]);
157
+ const isProse = looksLikeProse(value);
158
+
159
+ if (isProse) {
160
+ // v0.27 (#1): natural-language value → low-confidence warning, not a
161
+ // blocking error. Still surfaced (no false-green), still suppressible,
162
+ // and now reportable via `docguard feedback`.
163
+ findings.push(mkFinding({
164
+ code, validator: 'security', severity: 'warn', confidence: 'low',
165
+ message: `${location}: possible ${label} — but the value reads like natural-language text (likely UI copy / a validation message, not a credential)`,
166
+ location,
167
+ suggestion: {
168
+ kind: 'suppress',
169
+ text: 'If this is UI copy or a message and not a real secret, suppress it inline.',
170
+ pragma: `// docguard:ignore ${code} — UI copy, not a credential`,
171
+ },
172
+ reportable: true,
173
+ redactedContext: `${label} pattern fired on a value that is natural-language text (~${value.trim().split(/\s+/).filter(Boolean).length} words). Literal omitted.`,
174
+ }));
175
+ } else {
176
+ realSecretCount++;
177
+ findings.push(mkFinding({
178
+ code, validator: 'security', severity: 'error', confidence: 'high',
179
+ message: `${location}: possible ${label} found`,
180
+ location,
181
+ suggestion: {
182
+ kind: 'fix',
183
+ text: 'Move the secret to an environment variable and read it via process.env / the platform secret store. Never commit credentials.',
184
+ command: code ? `docguard explain ${code}` : undefined,
185
+ pragma: code ? `// docguard:ignore ${code} — reason (only if a confirmed false positive)` : undefined,
186
+ },
187
+ }));
188
+ }
109
189
  // One finding per (file, label) is enough — the reported message is
110
- // identical for repeats and we've already proven a real secret exists.
190
+ // identical for repeats and we've already proven a match exists.
111
191
  break;
112
192
  }
113
193
  }
@@ -116,35 +196,41 @@ export function validateSecurity(projectDir, config) {
116
196
  // Only count the secret scan as a passed check if we actually scanned files.
117
197
  // An empty scan that reports "no secrets" is a dangerous false ✅ — surface it.
118
198
  if (scanned > 0) {
119
- results.total++;
120
- if (findings.length === 0) {
121
- results.passed++;
122
- } else {
123
- for (const f of findings) {
124
- results.errors.push(`${f.file}: possible ${f.label} found`);
125
- }
126
- }
199
+ total++;
200
+ // Low-confidence (prose) findings do not fail the check — only real secrets do.
201
+ if (realSecretCount === 0) passed++;
127
202
  } else {
128
- results.warnings.push(
129
- 'No source files were scanned for secrets — check config.sourceRoot / ignore patterns'
130
- );
203
+ findings.push(mkFinding({
204
+ code: 'SEC011', validator: 'security', severity: 'warn', confidence: 'high',
205
+ message: 'No source files were scanned for secrets — check config.sourceRoot / ignore patterns',
206
+ suggestion: { kind: 'review', text: 'Verify config.sourceRoot and ignore patterns actually include your source tree.' },
207
+ }));
131
208
  }
132
209
 
133
210
  // Check .gitignore includes .env
134
- results.total++;
211
+ total++;
135
212
  const gitignorePath = resolve(projectDir, '.gitignore');
136
213
  if (existsSync(gitignorePath)) {
137
214
  const gitignore = readFileSync(gitignorePath, 'utf-8');
138
215
  if (gitignore.includes('.env') || gitignore.includes('.env.local')) {
139
- results.passed++;
216
+ passed++;
140
217
  } else {
141
- results.warnings.push('.gitignore does not include .env — secrets may be committed');
218
+ findings.push(mkFinding({
219
+ code: 'SEC010', validator: 'security', severity: 'warn', confidence: 'high',
220
+ message: '.gitignore does not include .env — secrets may be committed',
221
+ location: '.gitignore',
222
+ suggestion: { kind: 'fix', text: 'Add `.env` and `.env.local` to .gitignore.' },
223
+ }));
142
224
  }
143
225
  } else {
144
- results.warnings.push('No .gitignore found — secrets may be committed');
226
+ findings.push(mkFinding({
227
+ code: 'SEC010', validator: 'security', severity: 'warn', confidence: 'high',
228
+ message: 'No .gitignore found — secrets may be committed',
229
+ suggestion: { kind: 'fix', text: 'Create a .gitignore that excludes `.env` and `.env.local`.' },
230
+ }));
145
231
  }
146
232
 
147
- return results;
233
+ return resultFromFindings(findings, { passed, total });
148
234
  }
149
235
 
150
236
  function walkDir(dir, callback) {
@@ -262,6 +262,10 @@ function loadTrackingDocs(projectDir, config) {
262
262
  const trackingFiles = [
263
263
  'ROADMAP.md', 'CURRENT-STATE.md', 'TODO.md', 'BACKLOG.md',
264
264
  'docs-canonical/ARCHITECTURE.md', 'CHANGELOG.md',
265
+ // v0.27 (field report #6): many projects keep the roadmap/backlog under
266
+ // docs-canonical/ — a TODO tracked there was wrongly read as "untracked".
267
+ 'docs-canonical/ROADMAP.md', 'docs-canonical/CURRENT-STATE.md',
268
+ 'docs-canonical/BACKLOG.md', 'docs-canonical/TODO.md',
265
269
  ...(config.todoTracking?.trackingFiles || []),
266
270
  ];
267
271
 
@@ -3,7 +3,7 @@ schema_version: "1.0"
3
3
  extension:
4
4
  id: "docguard"
5
5
  name: "DocGuard — CDD Enforcement"
6
- version: "0.25.1"
6
+ version: "0.27.0"
7
7
  description: "Canonical-Driven Development enforcement as a true spec-kit extension. LLM-first design with automated validators, 4 AI behavior skills, spec-kit skill chaining, and workflow hooks. One pinned runtime dependency (@babel/parser); pure Node.js otherwise."
8
8
  author: "Ricardo Accioly"
9
9
  repository: "https://github.com/raccioly/docguard"
@@ -6,10 +6,10 @@ description: AI-driven documentation repair with structured research workflow, t
6
6
  compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
7
7
  metadata:
8
8
  author: docguard
9
- version: 0.25.0
9
+ version: 0.27.0
10
10
  source: extensions/spec-kit-docguard/skills/docguard-fix
11
11
  ---
12
- <!-- docguard:version: 0.25.0 -->
12
+ <!-- docguard:version: 0.27.0 -->
13
13
 
14
14
  # DocGuard Fix Skill
15
15
 
@@ -7,10 +7,10 @@ description: Run DocGuard guard validation against Canonical-Driven Development
7
7
  compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
8
8
  metadata:
9
9
  author: docguard
10
- version: 0.25.0
10
+ version: 0.27.0
11
11
  source: extensions/spec-kit-docguard/skills/docguard-guard
12
12
  ---
13
- <!-- docguard:version: 0.25.0 -->
13
+ <!-- docguard:version: 0.27.0 -->
14
14
 
15
15
  # DocGuard Guard Skill
16
16
 
@@ -6,10 +6,10 @@ description: Cross-document consistency analysis and quality assessment. Perform
6
6
  compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
7
7
  metadata:
8
8
  author: docguard
9
- version: 0.25.0
9
+ version: 0.27.0
10
10
  source: extensions/spec-kit-docguard/skills/docguard-review
11
11
  ---
12
- <!-- docguard:version: 0.25.0 -->
12
+ <!-- docguard:version: 0.27.0 -->
13
13
 
14
14
  # DocGuard Review Skill
15
15
 
@@ -6,10 +6,10 @@ description: CDD maturity assessment with category-aware improvement roadmap. Ru
6
6
  compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
7
7
  metadata:
8
8
  author: docguard
9
- version: 0.25.0
9
+ version: 0.27.0
10
10
  source: extensions/spec-kit-docguard/skills/docguard-score
11
11
  ---
12
- <!-- docguard:version: 0.25.0 -->
12
+ <!-- docguard:version: 0.27.0 -->
13
13
 
14
14
  # DocGuard Score Skill
15
15
 
@@ -4,10 +4,10 @@ description: Keep canonical documentation ALWAYS UP TO DATE. Refreshes code-trut
4
4
  compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
5
5
  metadata:
6
6
  author: docguard
7
- version: 0.25.0
7
+ version: 0.27.0
8
8
  source: extensions/spec-kit-docguard/skills/docguard-sync
9
9
  ---
10
- <!-- docguard:version: 0.25.0 -->
10
+ <!-- docguard:version: 0.27.0 -->
11
11
 
12
12
  # DocGuard Sync Skill
13
13
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "docguard-cli",
3
- "version": "0.26.0",
3
+ "version": "0.27.0",
4
4
  "description": "The enforcement tool for Canonical-Driven Development (CDD). Audit, generate, and guard your project documentation.",
5
5
  "type": "module",
6
6
  "bin": {