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.
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Semantic claim extractor (LLM field report #5).
3
+ *
4
+ * The highest-value class of doc bug is SEMANTIC: a documented number/enum/limit
5
+ * that no longer matches the code — DLP retention "30 days" vs code 730, a status
6
+ * enum "PENDING/IDLE" vs "WAITING", "100/min" vs "500 req/s", "29+ roles" vs 44,
7
+ * "4 GSIs" vs 6. Regex/AST can't judge these (the doc value and the code value
8
+ * are both just numbers), so they slip through every deterministic validator.
9
+ *
10
+ * DocGuard is zero-dependency and does NOT call an LLM itself. So this is an
11
+ * EXTRACTOR: it surfaces the verifiable claims — value, unit, doc:line, section,
12
+ * and the nearest cited code path — as a structured task list. The agent running
13
+ * `docguard verify --semantic` does the actual comparison against the code. This
14
+ * mirrors the `docguard agent` task-graph: deterministic discovery, LLM judgment.
15
+ *
16
+ * Precision over recall: a number is only a claim when it carries a recognized
17
+ * unit (days/ms/req-s/GSIs/roles/…); an enum only when it's a list of 2+
18
+ * UPPER_SNAKE tokens in a status/state/enum context. Bare version strings, dates,
19
+ * and prose numbers are ignored.
20
+ *
21
+ * Zero npm dependencies — pure Node.js built-ins.
22
+ */
23
+
24
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
25
+ import { resolve, join } from 'node:path';
26
+
27
+ // Numbers are only claims when adjacent to a recognized unit.
28
+ const NUMBER_PATTERNS = [
29
+ { kind: 'duration', re: /\b(\d+(?:\.\d+)?)\s*(milliseconds?|ms|seconds?|secs?|minutes?|mins?|hours?|hrs?|days?|weeks?|months?|years?)\b/gi },
30
+ { kind: 'rate', re: /\b(\d+)\s*(?:\/|\bper\b|\breq(?:uests?)?\s*\/?)\s*(s|sec|seconds?|min|minutes?|hours?|h)\b/gi },
31
+ { kind: 'count', re: /\b(\d+)\s*\+?\s*(GSIs?|LSIs?|indexes|indices|roles?|permissions?|scopes?|tables?|queues?|topics?|buckets?|endpoints?|routes?|validators?|columns?|fields?|shards?|partitions?|replicas?|retries|workers?|threads?|connections?)\b/gi },
32
+ ];
33
+
34
+ // A list of 2+ UPPER_SNAKE tokens separated by / , | or "or" — an enum claim,
35
+ // but only when the line or its heading reads like a status/state/enum context.
36
+ const ENUM_LIST_RE = /\b[A-Z][A-Z0-9_]{2,}(?:\s*(?:\/|,|\||\bor\b)\s*[A-Z][A-Z0-9_]{2,}){1,}\b/g;
37
+ const ENUM_CONTEXT_RE = /\b(status|state|enum|values?|one of|phase|stage|transitions?)\b/i;
38
+
39
+ // A code path mentioned in or near the claim — the agent's starting point.
40
+ const CITED_CODE_RE = /`?([\w./-]+\.(?:ts|tsx|js|mjs|cjs|jsx|py|go|rs|java|kt|rb|php|sql|yaml|yml|json))`?(?::(\d+))?/;
41
+
42
+ const MAX_CLAIMS = 80;
43
+
44
+ /** Canonical docs + the root docs where limits/counts commonly live. */
45
+ function claimSourceDocs(projectDir) {
46
+ const docs = [];
47
+ const canonical = resolve(projectDir, 'docs-canonical');
48
+ if (existsSync(canonical)) {
49
+ try {
50
+ for (const f of readdirSync(canonical)) {
51
+ if (f.toLowerCase().endsWith('.md')) docs.push(`docs-canonical/${f}`);
52
+ }
53
+ } catch { /* ignore */ }
54
+ }
55
+ for (const root of ['README.md', 'AGENTS.md']) {
56
+ if (existsSync(resolve(projectDir, root))) docs.push(root);
57
+ }
58
+ return docs;
59
+ }
60
+
61
+ /** True if a line is inside a fenced code block (toggled by the caller). */
62
+ function findCitedCode(lines, idx) {
63
+ // Search the claim line first, then the immediately adjacent lines. A tight
64
+ // window avoids cross-attributing a path from an unrelated nearby claim (e.g.
65
+ // a rate limit grabbing the retention doc's cited file three lines up).
66
+ for (let d = 0; d <= 1; d++) {
67
+ for (const j of d === 0 ? [idx] : [idx - d, idx + d]) {
68
+ if (j < 0 || j >= lines.length) continue;
69
+ const m = CITED_CODE_RE.exec(lines[j]);
70
+ if (m) return m[2] ? `${m[1]}:${m[2]}` : m[1];
71
+ }
72
+ }
73
+ return null;
74
+ }
75
+
76
+ /**
77
+ * Extract semantic claims from a project's canonical docs.
78
+ * @returns {Array<{ doc, line, section, kind, subkind, value, unit, text, citedCode }>}
79
+ */
80
+ export function extractSemanticClaims(projectDir, config = {}) {
81
+ const claims = [];
82
+ const seen = new Set();
83
+
84
+ for (const doc of claimSourceDocs(projectDir)) {
85
+ let content;
86
+ try { content = readFileSync(resolve(projectDir, doc), 'utf-8'); } catch { continue; }
87
+ const lines = content.split('\n');
88
+ let section = '';
89
+ let inFence = false;
90
+
91
+ for (let i = 0; i < lines.length; i++) {
92
+ const line = lines[i];
93
+ if (/^\s*```/.test(line)) { inFence = !inFence; continue; }
94
+ if (inFence) continue; // numbers in code samples are examples, not claims
95
+ const h = line.match(/^#{1,6}\s+(.*)$/);
96
+ if (h) { section = h[1].trim(); continue; }
97
+
98
+ const lineNo = i + 1;
99
+ const push = (claim) => {
100
+ const key = `${doc}:${lineNo}:${claim.kind}:${claim.value}:${claim.unit || ''}`;
101
+ if (seen.has(key)) return;
102
+ seen.add(key);
103
+ claims.push({ doc, line: lineNo, section, citedCode: findCitedCode(lines, i), text: line.trim().slice(0, 200), ...claim });
104
+ };
105
+
106
+ for (const { kind, re } of NUMBER_PATTERNS) {
107
+ re.lastIndex = 0;
108
+ let m;
109
+ while ((m = re.exec(line)) !== null) {
110
+ push({ kind: 'number', subkind: kind, value: m[1], unit: m[2].toLowerCase() });
111
+ }
112
+ }
113
+
114
+ if (ENUM_CONTEXT_RE.test(line) || ENUM_CONTEXT_RE.test(section)) {
115
+ ENUM_LIST_RE.lastIndex = 0;
116
+ let m;
117
+ while ((m = ENUM_LIST_RE.exec(line)) !== null) {
118
+ // Skip all-caps acronym runs joined by slash that are really one token.
119
+ const values = m[0].split(/\s*(?:\/|,|\||\bor\b)\s*/).filter(Boolean);
120
+ if (values.length >= 2) push({ kind: 'enum', subkind: 'enum-list', value: values.join('/'), unit: null });
121
+ }
122
+ }
123
+
124
+ if (claims.length >= MAX_CLAIMS) return claims;
125
+ }
126
+ }
127
+ return claims;
128
+ }
129
+
130
+ /**
131
+ * Turn extracted claims into agent-executable verification tasks (one per claim).
132
+ * Pure — reused by the command and any task-graph consumer.
133
+ */
134
+ export function buildSemanticVerifyTasks(claims) {
135
+ return claims.map((c, i) => {
136
+ const where = c.citedCode ? ` Start at the cited code: ${c.citedCode}.` : ' No code path is cited nearby — grep the codebase for the relevant constant/config.';
137
+ const what = c.kind === 'enum'
138
+ ? `the enum/status set "${c.value}"`
139
+ : `the ${c.subkind} value ${c.value}${c.unit ? ` ${c.unit}` : ''}`;
140
+ return {
141
+ id: `verify.semantic.${i + 1}`,
142
+ doc: c.doc,
143
+ line: c.line,
144
+ section: c.section,
145
+ kind: c.kind,
146
+ value: c.value,
147
+ unit: c.unit,
148
+ citedCode: c.citedCode,
149
+ claim: c.text,
150
+ instruction: `Verify ${what} documented in ${c.doc}:${c.line}${c.section ? ` (section "${c.section}")` : ''} against the code.${where} If the code disagrees, the doc (or the code) is wrong — report the mismatch with both values.`,
151
+ confidence: 'requires-human',
152
+ };
153
+ });
154
+ }
@@ -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
  };
@@ -208,10 +208,56 @@ export function computeApiSurfaceDrift(projectDir, config) {
208
208
  };
209
209
  }
210
210
 
211
+ /**
212
+ * v0.28 (field report #4): diff the OpenAPI spec against the routes actually
213
+ * REGISTERED in code. When a spec exists, resolveApiSurface treats it as ground
214
+ * truth and the API-REFERENCE doc reconciles against it — so a spec that declares
215
+ * a phantom endpoint (no Express/Fastify route registers it) passes doc-vs-spec
216
+ * clean while the spec itself is wrong. This catches that.
217
+ *
218
+ * Conservative on purpose: only runs when code routes are actually scannable.
219
+ * If the scanner finds zero routes (unsupported framework, dynamically-registered
220
+ * routes), we can't tell "no route" from "scanner blind", so we skip rather than
221
+ * flag every spec endpoint as phantom. Reuses compareEndpoints so path-param /
222
+ * mount-prefix normalization matches the rest of the validator.
223
+ *
224
+ * @returns {{ applicable:boolean, specPath:string|null, routeCount:number,
225
+ * matched:object[], specDeclaredNoRoute:object[], reason?:string }}
226
+ */
227
+ export function computeSpecVsRouteDrift(projectDir, config) {
228
+ const specs = findAllOpenApiSpecs(projectDir, config);
229
+ if (specs.length === 0) {
230
+ return { applicable: false, specPath: null, routeCount: 0, matched: [], specDeclaredNoRoute: [], reason: 'no openapi spec' };
231
+ }
232
+ const spec = specs[0]; // authoritative (sourceRoot first, root last)
233
+ const framework = detectFramework(projectDir, config);
234
+ const routes = scanRoutesDeep(projectDir, { framework }, { openapi: { found: false } }, { config });
235
+ if (routes.length === 0) {
236
+ return { applicable: false, specPath: spec.relPath, routeCount: 0, matched: [], specDeclaredNoRoute: [], reason: 'no routes scannable' };
237
+ }
238
+ // documentedButAbsent = in the SPEC (first arg) but absent from the ROUTES
239
+ // (second arg) = spec-declares-but-no-route.
240
+ const cmp = compareEndpoints(
241
+ spec.endpoints.map(e => ({ method: e.method, path: e.path })),
242
+ routes.map(r => ({ method: r.method, path: r.path }))
243
+ );
244
+ return {
245
+ applicable: true,
246
+ specPath: spec.relPath,
247
+ routeCount: routes.length,
248
+ matched: cmp.matched,
249
+ specDeclaredNoRoute: cmp.documentedButAbsent,
250
+ };
251
+ }
252
+
211
253
  export function validateApiSurface(projectDir, config) {
212
254
  const errors = [];
213
255
  const warnings = [];
214
256
  const fixes = [];
257
+ const trim = (arr) => {
258
+ const shown = arr.slice(0, MAX_REPORTED);
259
+ return { shown, extra: arr.length - shown.length };
260
+ };
215
261
 
216
262
  // v0.14-P2: when --changed-only scoping is active and NONE of the changed
217
263
  // files look like route/spec/controller files, this validator has nothing
@@ -256,19 +302,39 @@ export function validateApiSurface(projectDir, config) {
256
302
  );
257
303
  }
258
304
 
305
+ // ── #4: spec declares an endpoint with no registered route ──
306
+ // Independent of the API-REFERENCE doc — it checks the spec against code, so it
307
+ // runs even when no doc exists. Conservative (only when routes are scannable).
308
+ const specRoute = computeSpecVsRouteDrift(projectDir, config);
309
+ let specRouteTotal = 0;
310
+ let specRoutePassed = 0;
311
+ if (specRoute.applicable) {
312
+ specRouteTotal = specRoute.matched.length + specRoute.specDeclaredNoRoute.length;
313
+ specRoutePassed = specRoute.matched.length;
314
+ if (specRoute.specDeclaredNoRoute.length) {
315
+ const { shown, extra } = trim(specRoute.specDeclaredNoRoute);
316
+ for (const e of shown) {
317
+ warnings.push(
318
+ `OpenAPI spec (${specRoute.specPath}) declares ${e.method} ${e.path} but no route registers it in code — ` +
319
+ `the spec may be wrong, and the API-REFERENCE doc reconciles clean against it, hiding the gap.`
320
+ );
321
+ }
322
+ if (extra > 0) warnings.push(`…and ${extra} more spec-declared endpoint(s) with no registered route`);
323
+ }
324
+ }
325
+
259
326
  if (!drift.applicable) {
260
- // Nothing to validate against the API-REFERENCE doc.
261
- return { errors, warnings, passed: 0, total: 0, fixes, authoritativeSpec: drift.source };
327
+ // Nothing to validate against the API-REFERENCE doc — but the spec-vs-route
328
+ // check above may still have produced findings.
329
+ return {
330
+ errors, warnings, passed: specRoutePassed, total: specRouteTotal, fixes,
331
+ authoritativeSpec: drift.source || specRoute.specPath,
332
+ };
262
333
  }
263
334
 
264
335
  const { documentedButAbsent, presentButUndocumented, matched, confidence, source } = drift;
265
- const total = matched.length + documentedButAbsent.length + presentButUndocumented.length;
266
- const passed = matched.length;
267
-
268
- const trim = (arr) => {
269
- const shown = arr.slice(0, MAX_REPORTED);
270
- return { shown, extra: arr.length - shown.length };
271
- };
336
+ const total = matched.length + documentedButAbsent.length + presentButUndocumented.length + specRouteTotal;
337
+ const passed = matched.length + specRoutePassed;
272
338
 
273
339
  // documented-but-absent → deterministic remove-endpoint fixes
274
340
  if (documentedButAbsent.length) {
@@ -97,11 +97,11 @@ function validateConfigLayers(projectDir, config, layers, results) {
97
97
  const relPath = relative(projectDir, file);
98
98
  const imports = extractImports(content);
99
99
 
100
- for (const imp of imports) {
101
- if (!imp.startsWith('.') && !imp.startsWith('/')) continue;
100
+ for (const { spec } of imports) {
101
+ if (!spec.startsWith('.') && !spec.startsWith('/')) continue;
102
102
 
103
103
  for (const forbiddenDir of layer.forbidden) {
104
- if (imp.includes(forbiddenDir) || imp.includes(`/${forbiddenDir}/`)) {
104
+ if (spec.includes(forbiddenDir) || spec.includes(`/${forbiddenDir}/`)) {
105
105
  results.total++;
106
106
  results.errors.push(
107
107
  `${relPath}: ${layer.name} layer imports from forbidden layer (${forbiddenDir})`
@@ -135,14 +135,19 @@ function buildImportGraph(projectDir, config) {
135
135
 
136
136
  const resolvedImports = [];
137
137
  for (const imp of imports) {
138
- if (!imp.startsWith('.') && !imp.startsWith('/')) continue;
138
+ if (!imp.spec.startsWith('.') && !imp.spec.startsWith('/')) continue;
139
139
 
140
140
  // Resolve relative imports
141
141
  const fromDir = dirname(file);
142
- const resolved = resolveImport(fromDir, imp, projectDir);
142
+ const resolved = resolveImport(fromDir, imp.spec, projectDir);
143
143
  if (resolved) {
144
- resolvedImports.push(resolved);
145
- graph.edges.push({ from: relPath, to: resolved });
144
+ graph.edges.push({ from: relPath, to: resolved, dynamic: imp.dynamic });
145
+ // v0.28 (field report #2): a dynamic `await import()` does NOT create a
146
+ // load-time edge — it's the canonical way to BREAK an import cycle. So
147
+ // it's excluded from the cycle-detection adjacency (fileMap) while still
148
+ // recorded in graph.edges for layer-boundary checks (an import is still
149
+ // an import for layering).
150
+ if (!imp.dynamic) resolvedImports.push(resolved);
146
151
  }
147
152
  }
148
153
 
@@ -153,26 +158,33 @@ function buildImportGraph(projectDir, config) {
153
158
  return graph;
154
159
  }
155
160
 
161
+ /**
162
+ * Extract a file's imports as `{ spec, dynamic }`. `dynamic:true` marks a
163
+ * runtime `import('…')` — which does NOT create a load-time dependency edge and
164
+ * is the canonical way to break an import cycle (field report #2). ES `import …
165
+ * from` and CommonJS `require()` are load-time (static).
166
+ */
156
167
  function extractImports(content) {
157
168
  const imports = [];
158
169
 
159
- // ES module imports
170
+ // ES module imports (static, load-time). `import\s+` requires whitespace after
171
+ // `import`, so it never matches a dynamic `import(` call.
160
172
  const esImportRegex = /import\s+(?:.*?\s+from\s+)?['"]([^'"]+)['"]/g;
161
173
  let match;
162
174
  while ((match = esImportRegex.exec(content)) !== null) {
163
- imports.push(match[1]);
175
+ imports.push({ spec: match[1], dynamic: false });
164
176
  }
165
177
 
166
- // Dynamic imports
178
+ // Dynamic imports (runtime — NOT a load-time cycle edge)
167
179
  const dynamicRegex = /import\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
168
180
  while ((match = dynamicRegex.exec(content)) !== null) {
169
- imports.push(match[1]);
181
+ imports.push({ spec: match[1], dynamic: true });
170
182
  }
171
183
 
172
- // CommonJS require
184
+ // CommonJS require (static, load-time)
173
185
  const requireRegex = /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
174
186
  while ((match = requireRegex.exec(content)) !== null) {
175
- imports.push(match[1]);
187
+ imports.push({ spec: match[1], dynamic: false });
176
188
  }
177
189
 
178
190
  return imports;
@@ -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.28.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.28.0
10
10
  source: extensions/spec-kit-docguard/skills/docguard-fix
11
11
  ---
12
- <!-- docguard:version: 0.25.0 -->
12
+ <!-- docguard:version: 0.28.0 -->
13
13
 
14
14
  # DocGuard Fix Skill
15
15