docguard-cli 0.25.1 → 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.
@@ -19,6 +19,8 @@ import { scanSchemasDeep } from './schemas.mjs';
19
19
  import { scanFrontend } from './frontend.mjs';
20
20
  import { grepEnvUsage } from '../shared-source.mjs';
21
21
  import { detectIntegrations } from './integrations.mjs';
22
+ import { PROFILES } from '../shared.mjs';
23
+ import { scanComponents, scanTestInventory } from './inventory.mjs';
22
24
 
23
25
  const md = {
24
26
  table(headers, rows) {
@@ -209,6 +211,9 @@ function _buildMemoryPlanUncached(projectDir, config = {}) {
209
211
  framework: null, stateLib: null, dataLib: null };
210
212
  const envVars = [...grepEnvUsage(projectDir, config)].sort();
211
213
  const integrations = detectIntegrations(projectDir, config);
214
+ // Pre-filled code-truth (field report §5): real source modules + test inventory.
215
+ const modules = scanComponents(projectDir, config);
216
+ const tests = scanTestInventory(projectDir, config);
212
217
 
213
218
  const surface = {
214
219
  profile,
@@ -224,8 +229,35 @@ function _buildMemoryPlanUncached(projectDir, config = {}) {
224
229
  apiCalls: fe.apiCalls,
225
230
  i18n: fe.i18n,
226
231
  frontend: { framework: fe.framework, stateLib: fe.stateLib, dataLib: fe.dataLib },
232
+ modules, // top-level source modules → ARCHITECTURE Component Map (pre-filled)
233
+ tests, // { files:[{file,cases}], totalCases, totalFiles } → TEST-SPEC inventory
227
234
  };
228
235
 
236
+ // ── Profile gate (Bug #5) ──
237
+ // generate must respect the active COMPLIANCE profile, not just surface
238
+ // counts. For non-web profiles (cli/library) we only emit an optional
239
+ // web/UI/DB-shaped canonical doc when the profile explicitly requires it — so
240
+ // a CLI never gets API-REFERENCE/INTEGRATIONS just because the surface scan
241
+ // tripped on a stray HTTP call or SDK string. Other profiles
242
+ // (standard/enterprise/…) stay surface-driven.
243
+ const profileName = config.profile || 'standard';
244
+ const allowedCanonical = new Set(PROFILES[profileName]?.requiredFiles?.canonical || []);
245
+ const constrainedProfile = profileName === 'cli' || profileName === 'library';
246
+ const profileAllows = (docPath) => !constrainedProfile || allowedCanonical.has(docPath);
247
+
248
+ // Anti-false-green: when the profile suppresses a doc the surface WOULD have
249
+ // produced, say so — a web app mislabeled with the wrong --profile is still
250
+ // recoverable instead of silently under-documented.
251
+ const notes = [];
252
+ if (constrainedProfile) {
253
+ if (surface.endpoints.length > 0 && !allowedCanonical.has('docs-canonical/API-REFERENCE.md')) {
254
+ notes.push(`Detected ${surface.endpoints.length} endpoint(s) but the '${profileName}' profile omits API-REFERENCE.md — not generated. If this project genuinely exposes an HTTP API, re-run with --profile standard.`);
255
+ }
256
+ if (surface.integrations.length > 0 && !allowedCanonical.has('docs-canonical/INTEGRATIONS.md')) {
257
+ notes.push(`Detected ${surface.integrations.length} integration(s) but the '${profileName}' profile omits INTEGRATIONS.md — not generated.`);
258
+ }
259
+ }
260
+
229
261
  // ── Compose documents + sections (language/kind-aware) ──
230
262
  const docs = [];
231
263
  const agentTasks = [];
@@ -248,9 +280,24 @@ function _buildMemoryPlanUncached(projectDir, config = {}) {
248
280
  sections.push(addTask('docs-canonical/ARCHITECTURE.md', 'overview',
249
281
  'Write a 2-3 sentence System Overview: what this project does and who uses it.',
250
282
  { languages: profile.languages, frameworks: profile.frameworks, kind: profile.kind }));
251
- sections.push(addTask('docs-canonical/ARCHITECTURE.md', 'components',
252
- 'Describe the major components/modules and their responsibilities, using the real directories below.',
253
- { ecosystems: profile.ecosystems.map(e => ({ dir: e.dir, language: e.language, framework: e.framework })) }));
283
+
284
+ // Component Map PRE-FILLED from the real source layout (field report §5):
285
+ // the agent gets the module list for free and only annotates responsibilities.
286
+ if (surface.modules.length > 0) {
287
+ sections.push({
288
+ id: 'component-map',
289
+ source: 'code',
290
+ body: md.table(['Module', 'Kind', 'Responsibility'],
291
+ surface.modules.map(m => [`\`${m.path}\``, m.kind, '<!-- one-line responsibility -->'])),
292
+ });
293
+ sections.push(addTask('docs-canonical/ARCHITECTURE.md', 'components',
294
+ 'Fill in a one-line responsibility for each module in the Component Map above (replace each `<!-- one-line responsibility -->`). Group related modules into layers if it aids understanding.',
295
+ { modules: surface.modules.map(m => m.path) }));
296
+ } else {
297
+ sections.push(addTask('docs-canonical/ARCHITECTURE.md', 'components',
298
+ 'Describe the major components/modules and their responsibilities, using the real directories below.',
299
+ { ecosystems: profile.ecosystems.map(e => ({ dir: e.dir, language: e.language, framework: e.framework })) }));
300
+ }
254
301
 
255
302
  // Frontend modules (stores/hooks/contexts) — code-truth section when present.
256
303
  const feCounts = surface.stores.length + surface.hooks.length + surface.contexts.length;
@@ -269,8 +316,31 @@ function _buildMemoryPlanUncached(projectDir, config = {}) {
269
316
  docs.push({ path: 'docs-canonical/ARCHITECTURE.md', sections });
270
317
  }
271
318
 
272
- // API-REFERENCEonly if there's an API surface.
273
- if (surface.endpoints.length > 0) {
319
+ // TEST-SPECalways (a required canonical doc in every profile). The test
320
+ // INVENTORY is pre-filled from the real test files (field report §5); the
321
+ // agent writes only the coverage rules + the service→test mapping.
322
+ {
323
+ const ti = surface.tests;
324
+ const sections = [];
325
+ if (ti.totalFiles > 0) {
326
+ const header = `**${ti.totalFiles} test file(s)${ti.totalCases > 0 ? `, ${ti.totalCases} test case(s)` : ''}**`;
327
+ const rows = ti.files.map(t => [`\`${t.file}\``, t.cases > 0 ? String(t.cases) : '—']);
328
+ sections.push({
329
+ id: 'test-inventory',
330
+ source: 'code',
331
+ body: `${header}\n\n${md.table(['Test file', 'Cases'], rows)}`,
332
+ });
333
+ }
334
+ sections.push(addTask('docs-canonical/TEST-SPEC.md', 'coverage',
335
+ ti.totalFiles > 0
336
+ ? 'Document the test categories (unit / integration / e2e), the coverage rules, and the service→test mapping. The detected test files + case counts are listed above.'
337
+ : 'No test files were detected. Document the intended test strategy: categories, coverage targets, and where tests will live.',
338
+ { totalFiles: ti.totalFiles, totalCases: ti.totalCases }));
339
+ docs.push({ path: 'docs-canonical/TEST-SPEC.md', sections });
340
+ }
341
+
342
+ // API-REFERENCE — only if there's an API surface AND the profile allows it.
343
+ if (surface.endpoints.length > 0 && profileAllows('docs-canonical/API-REFERENCE.md')) {
274
344
  const rows = surface.endpoints.map(e => [`\`${e.method}\``, `\`${e.path}\``, e.auth ? '🔒' : '🔓']);
275
345
  const sections = [{
276
346
  id: 'endpoints',
@@ -283,8 +353,8 @@ function _buildMemoryPlanUncached(projectDir, config = {}) {
283
353
  docs.push({ path: 'docs-canonical/API-REFERENCE.md', sections });
284
354
  }
285
355
 
286
- // DATA-MODEL — only if entities detected.
287
- if (surface.entities.length > 0) {
356
+ // DATA-MODEL — only if entities detected AND the profile allows it.
357
+ if (surface.entities.length > 0 && profileAllows('docs-canonical/DATA-MODEL.md')) {
288
358
  const rows = surface.entities.map(e => [`\`${e.name}\``, String((e.fields || []).length)]);
289
359
  const sections = [{
290
360
  id: 'entities',
@@ -297,8 +367,8 @@ function _buildMemoryPlanUncached(projectDir, config = {}) {
297
367
  docs.push({ path: 'docs-canonical/DATA-MODEL.md', sections });
298
368
  }
299
369
 
300
- // SCREENS — only for web frontends with screens.
301
- if (surface.screens.length > 0) {
370
+ // SCREENS — only for web frontends with screens AND if the profile allows it.
371
+ if (surface.screens.length > 0 && profileAllows('docs-canonical/SCREENS.md')) {
302
372
  const rows = surface.screens.map(s => [`\`${s.path}\``, s.component || '—']);
303
373
  const sections = [{
304
374
  id: 'screens',
@@ -311,8 +381,8 @@ function _buildMemoryPlanUncached(projectDir, config = {}) {
311
381
  docs.push({ path: 'docs-canonical/SCREENS.md', sections });
312
382
  }
313
383
 
314
- // INTEGRATIONS — external services / SDKs detected from deps.
315
- if (surface.integrations.length > 0) {
384
+ // INTEGRATIONS — external services / SDKs detected from deps (profile-gated).
385
+ if (surface.integrations.length > 0 && profileAllows('docs-canonical/INTEGRATIONS.md')) {
316
386
  const rows = surface.integrations.map(i => [i.category, `**${i.name}**`, i.evidence.slice(0, 3).join(', ')]);
317
387
  const sections = [{
318
388
  id: 'integrations',
@@ -325,8 +395,8 @@ function _buildMemoryPlanUncached(projectDir, config = {}) {
325
395
  docs.push({ path: 'docs-canonical/INTEGRATIONS.md', sections });
326
396
  }
327
397
 
328
- // FEATURES — derived from screens + endpoints when there's a UI surface.
329
- if (surface.screens.length > 0) {
398
+ // FEATURES — derived from screens + endpoints when there's a UI surface (profile-gated).
399
+ if (surface.screens.length > 0 && profileAllows('docs-canonical/FEATURES.md')) {
330
400
  const groups = {};
331
401
  for (const s of surface.screens) {
332
402
  const seg = (s.path.split('/').filter(Boolean)[0] || 'root');
@@ -395,5 +465,5 @@ function _buildMemoryPlanUncached(projectDir, config = {}) {
395
465
  ],
396
466
  });
397
467
 
398
- return { profile, surface, docs, agentTasks };
468
+ return { profile, surface, docs, agentTasks, notes };
399
469
  }
@@ -15,7 +15,7 @@
15
15
 
16
16
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
17
17
  import { resolve, join, relative, dirname, basename } from 'node:path';
18
- import { shouldIgnore, relPosix } from '../shared-ignore.mjs';
18
+ import { shouldIgnore, relPosix, isNonProductDir } from '../shared-ignore.mjs';
19
19
 
20
20
  const IGNORE_DIRS = new Set([
21
21
  'node_modules', '.git', '.next', 'dist', 'build', 'coverage', 'target',
@@ -53,9 +53,12 @@ function findManifests(projectDir, maxDepth = 4, config = {}) {
53
53
  for (const e of entries) {
54
54
  if (e.isDirectory()) {
55
55
  if (IGNORE_DIRS.has(e.name) || e.name.startsWith('.')) continue;
56
- // Honor config.ignore / .docguardignore: a user who excludes tests/ or
57
- // base-research/ must not have those dirs' manifests (e.g. a fixture
58
- // package.json declaring express) misclassify the project's stack.
56
+ // v0.26 (Bug #1): skip non-product dirs (tests/fixtures/examples/…) by
57
+ // DEFAULT. A fixture `package.json` declaring express or a `py_app`
58
+ // requirements.txt with flask must never set the PROJECT's stack/kind.
59
+ // This is the first-run fix — it needs no `.docguardignore`.
60
+ if (isNonProductDir(e.name, config)) continue;
61
+ // Also honor explicit config.ignore / .docguardignore patterns.
59
62
  if (shouldIgnore(relPosix(root, join(dir, e.name)), config)) continue;
60
63
  walk(join(dir, e.name), depth + 1);
61
64
  } else if (e.isFile()) {
@@ -315,3 +318,56 @@ export function detectProjectProfile(projectDir, config = {}) {
315
318
  kind: primary?.kind || 'unknown',
316
319
  };
317
320
  }
321
+
322
+ /**
323
+ * Find a `name = "..."` entry inside a TOML `[section]` (e.g. `[project]`,
324
+ * `[package]`, `[tool.poetry]`). Header match is exact on the bracket content.
325
+ */
326
+ function tomlSectionName(content, section) {
327
+ if (!content) return null;
328
+ let inSection = false;
329
+ for (const line of content.split(/\r?\n/)) {
330
+ const header = line.match(/^\s*\[([^\]]+)\]/);
331
+ if (header) { inSection = header[1].trim() === section; continue; }
332
+ if (inSection) {
333
+ const m = line.match(/^\s*name\s*=\s*['"]([^'"]+)['"]/);
334
+ if (m) return m[1].trim();
335
+ }
336
+ }
337
+ return null;
338
+ }
339
+
340
+ /**
341
+ * Resolve the project's declared NAME from its ROOT manifest, falling back to
342
+ * the directory basename.
343
+ *
344
+ * Fixes Bug #4: inside a git worktree the directory is an auto-generated slug
345
+ * (e.g. `compassionate-chaplygin-c91f47`), but the real name lives in
346
+ * `pyproject.toml [project].name` / `package.json` name / `Cargo.toml [package]
347
+ * name` / `composer.json` name / `go.mod` module. Reads only root manifests —
348
+ * cheap, no tree walk — so it's safe to call at config-load time.
349
+ */
350
+ export function detectProjectName(projectDir) {
351
+ const root = resolve(projectDir);
352
+
353
+ const pkg = readJson(join(root, 'package.json'));
354
+ if (pkg && typeof pkg.name === 'string' && pkg.name.trim()) return pkg.name.trim();
355
+
356
+ const py = tomlSectionName(readSafe(join(root, 'pyproject.toml')), 'project')
357
+ || tomlSectionName(readSafe(join(root, 'pyproject.toml')), 'tool.poetry');
358
+ if (py) return py;
359
+
360
+ const cargo = tomlSectionName(readSafe(join(root, 'Cargo.toml')), 'package');
361
+ if (cargo) return cargo;
362
+
363
+ const composer = readJson(join(root, 'composer.json'));
364
+ if (composer && typeof composer.name === 'string' && composer.name.trim()) {
365
+ const n = composer.name.trim();
366
+ return n.includes('/') ? n.slice(n.lastIndexOf('/') + 1) : n; // vendor/name → name
367
+ }
368
+
369
+ const goMod = readSafe(join(root, 'go.mod')).match(/^\s*module\s+(\S+)/m);
370
+ if (goMod) return basename(goMod[1].replace(/\/+$/, ''));
371
+
372
+ return basename(root);
373
+ }
@@ -9,7 +9,7 @@
9
9
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
10
10
  import { resolve, join, relative, basename, extname, dirname } from 'node:path';
11
11
  import { resolveSourceRoots, readScannable } from '../shared-source.mjs';
12
- import { DEFAULT_IGNORE_DIRS as IGNORE_DIRS, shouldIgnore, relPosix } from '../shared-ignore.mjs';
12
+ import { DEFAULT_IGNORE_DIRS as IGNORE_DIRS, shouldIgnore, relPosix, isNonProductPath } from '../shared-ignore.mjs';
13
13
  import { extractJsRouteCalls, extractJsRouteObjects, extractJsMountsAndImports } from './js-ast.mjs';
14
14
  import { extractPythonFiles } from './py-ast.mjs';
15
15
 
@@ -78,16 +78,22 @@ export function scanRoutesDeep(dir, stack, docTools, opts = {}) {
78
78
  routes.push(...scanFastAPIRoutes(dir));
79
79
  }
80
80
 
81
- // Deduplicate by method+path, and honor .docguardignore / config.ignore so a
82
- // fixtures dir with fake routes doesn't pollute the API surface. Filtering the
83
- // RESULTS (route.file → project-relative) keeps the per-framework walkers as-is.
81
+ // Deduplicate by method+path, and drop routes that live in non-product dirs
82
+ // (tests/fixtures/examples) so a fixtures dir with fake routes doesn't pollute
83
+ // the API surface. Filtering the RESULTS (route.file → project-relative) keeps
84
+ // the per-framework walkers as-is. v0.26 (Bug #1): isNonProductPath applies by
85
+ // DEFAULT (no .docguardignore needed); shouldIgnore honors explicit config.
84
86
  const cfg = opts.config || {};
85
87
  const seen = new Set();
86
88
  return routes.filter(r => {
87
89
  const key = `${r.method}:${r.path}`;
88
90
  if (seen.has(key)) return false;
89
91
  seen.add(key);
90
- if (r.file && shouldIgnore(relPosix(dir, resolve(dir, r.file)), cfg)) return false;
92
+ if (r.file) {
93
+ const rel = relPosix(dir, resolve(dir, r.file));
94
+ if (isNonProductPath(rel, cfg)) return false;
95
+ if (shouldIgnore(rel, cfg)) return false;
96
+ }
91
97
  return true;
92
98
  });
93
99
  }
@@ -40,6 +40,46 @@ export const DEFAULT_IGNORE_DIRS = new Set([
40
40
  const ALWAYS_REJECT_PATH_RE =
41
41
  /(?:^|[/\\])(?:node_modules|\.claude[/\\]worktrees|\.git[/\\]worktrees|\.jj)(?:[/\\]|$)/;
42
42
 
43
+ /**
44
+ * Directory names that hold NON-PRODUCT code — test fixtures, sample apps,
45
+ * example projects, mocks. Excluded from SURFACE DETECTION (framework / route /
46
+ * integration / env-var inference) BY DEFAULT, with no `.docguardignore`
47
+ * required.
48
+ *
49
+ * Why this exists (v0.26, field report Bug #1): a tool's own test fixtures —
50
+ * e.g. a deliberately-vulnerable Express sample under `tests/fixtures/` — were
51
+ * being read as the PRODUCT's architecture, so a pure-Python CLI got documented
52
+ * as an Express/Flask web app. Honoring `config.ignore` (added v0.25) wasn't
53
+ * enough: the realistic first run has no `.docguardignore` yet.
54
+ *
55
+ * SCOPE: detection/generate scanners ONLY — deliberately NOT guard's structural
56
+ * validators. A user's real `examples/` dir still counts toward docs coverage.
57
+ * Anti-false-green: when a surface signal appears ONLY under these dirs, callers
58
+ * SHOULD surface a low-confidence "confirm these are fixtures" note rather than
59
+ * silently drop it. Override via `config.detection.includeNonProduct = true`.
60
+ */
61
+ export const DEFAULT_DETECTION_IGNORE_DIRS = new Set([
62
+ 'fixtures', '__fixtures__', 'test-fixtures', 'testfixtures', 'testdata',
63
+ 'test', 'tests', '__tests__', 'spec', 'specs', '__mocks__', 'mocks',
64
+ 'examples', 'example', 'sample', 'samples',
65
+ ]);
66
+
67
+ /** True if `dirName` is a non-product dir detection should skip by default. */
68
+ export function isNonProductDir(dirName, config = {}) {
69
+ if (config?.detection?.includeNonProduct) return false;
70
+ return DEFAULT_DETECTION_IGNORE_DIRS.has(dirName);
71
+ }
72
+
73
+ /**
74
+ * True if ANY path segment of `relPath` (POSIX, project-relative) is a
75
+ * non-product detection dir — for filtering file-level detection results.
76
+ */
77
+ export function isNonProductPath(relPath, config = {}) {
78
+ if (config?.detection?.includeNonProduct) return false;
79
+ if (!relPath) return false;
80
+ return relPath.split('/').some(seg => DEFAULT_DETECTION_IGNORE_DIRS.has(seg));
81
+ }
82
+
43
83
  /**
44
84
  * Read `.docguardignore` from a project directory and return its patterns.
45
85
  *
@@ -15,7 +15,7 @@
15
15
 
16
16
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
17
17
  import { resolve, join, dirname, relative, extname } from 'node:path';
18
- import { shouldIgnore } from './shared-ignore.mjs';
18
+ import { shouldIgnore, isNonProductDir, isNonProductPath } from './shared-ignore.mjs';
19
19
 
20
20
  const IGNORE_DIRS = new Set([
21
21
  'node_modules', '.git', '.next', '.nuxt', 'dist', 'build', 'out',
@@ -232,12 +232,90 @@ export function detectDocker(projectDir, config = {}) {
232
232
  return false;
233
233
  }
234
234
 
235
+ const HASH_COMMENT_EXTS = new Set(['.py', '.rb', '.php', '.sh']);
236
+ const SLASH_COMMENT_EXTS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.go', '.rs', '.java', '.php', '.kt', '.scala']);
237
+
238
+ /**
239
+ * Classify every character of `content` as code (0), string-literal (1), or
240
+ * comment (2) with a single-pass, dependency-free lexer. Used by env detection
241
+ * (Bug #7) so a variable is counted only when actually READ in code — not when
242
+ * merely mentioned inside a string literal (e.g. a detection signature like
243
+ * `r"os.environ.get('JWT_SECRET')"`) or a comment. Handles ' " ` quotes,
244
+ * Python triple-quotes, `#` and `//` line comments, and `/_ _/` block comments.
245
+ * Best-effort: on an unterminated single-line string it bails at the newline so
246
+ * it never swallows the rest of the file (errs toward marking code, so a real
247
+ * read is never dropped).
248
+ */
249
+ function classifyChars(content, ext) {
250
+ const n = content.length;
251
+ const kind = new Uint8Array(n); // 0 = code, 1 = string, 2 = comment
252
+ const hashC = HASH_COMMENT_EXTS.has(ext);
253
+ const slashC = SLASH_COMMENT_EXTS.has(ext);
254
+ const triple = ext === '.py';
255
+ let i = 0;
256
+ while (i < n) {
257
+ const ch = content[i];
258
+ if (hashC && ch === '#') { while (i < n && content[i] !== '\n') kind[i++] = 2; continue; }
259
+ if (slashC && ch === '/' && content[i + 1] === '/') { while (i < n && content[i] !== '\n') kind[i++] = 2; continue; }
260
+ if (slashC && ch === '/' && content[i + 1] === '*') {
261
+ kind[i++] = 2; if (i < n) kind[i++] = 2;
262
+ while (i < n && !(content[i] === '*' && content[i + 1] === '/')) kind[i++] = 2;
263
+ if (i < n) { kind[i++] = 2; if (i < n) kind[i++] = 2; }
264
+ continue;
265
+ }
266
+ if (triple && (ch === '"' || ch === "'") && content[i + 1] === ch && content[i + 2] === ch) {
267
+ const q = ch;
268
+ kind[i++] = 1; kind[i++] = 1; kind[i++] = 1;
269
+ while (i < n && !(content[i] === q && content[i + 1] === q && content[i + 2] === q)) {
270
+ if (content[i] === '\\') { kind[i++] = 1; if (i < n) kind[i++] = 1; continue; }
271
+ kind[i++] = 1;
272
+ }
273
+ if (i < n) { kind[i++] = 1; if (i < n) kind[i++] = 1; if (i < n) kind[i++] = 1; }
274
+ continue;
275
+ }
276
+ if (ch === '"' || ch === "'" || ch === '`') {
277
+ const q = ch;
278
+ kind[i++] = 1; // opening quote
279
+ while (i < n && content[i] !== q) {
280
+ if (content[i] === '\\') { kind[i++] = 1; if (i < n) kind[i++] = 1; continue; }
281
+ if (content[i] === '\n' && q !== '`') break; // unterminated single-line string — bail
282
+ kind[i++] = 1;
283
+ }
284
+ if (i < n && content[i] === q) kind[i++] = 1; // closing quote
285
+ continue;
286
+ }
287
+ kind[i++] = 0;
288
+ }
289
+ return kind;
290
+ }
291
+
235
292
  /**
236
293
  * Grep source files under the resolved source roots for environment variable
237
294
  * usage in both the Node (process dot env) and Vite (import meta env) styles,
238
295
  * including bracket access.
239
296
  * @returns {Set<string>} variable names referenced in code
240
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
+
241
319
  export function grepEnvUsage(projectDir, config = {}) {
242
320
  const names = new Set();
243
321
  const roots = resolveSourceRoots(projectDir, config);
@@ -269,9 +347,18 @@ export function grepEnvUsage(projectDir, config = {}) {
269
347
  if (!CODE_EXTENSIONS.has(extname(filePath))) return;
270
348
  const rel = relative(projectDir, filePath);
271
349
  if (shouldIgnore(rel, config)) return;
350
+ // v0.26 (Bug #7): a token that appears only in a test/fixture file is not a
351
+ // product env read. Skip non-product paths by default (no .docguardignore).
352
+ if (isNonProductPath(rel.replace(/\\/g, '/'), config)) return;
272
353
  const content = readScannable(filePath);
273
354
  if (content === null) return; // unreadable, generated, or too large to scan
274
355
  if (!content.includes('env')) return;
356
+ // v0.26 (Bug #7): classify chars so we count env vars actually READ in code,
357
+ // not ones MENTIONED inside a string literal (a detection signature like
358
+ // `r"os.environ.get('JWT_SECRET')"`) or a comment. We test the position of
359
+ // the access KEYWORD (process/os/import) — for a real read the keyword is
360
+ // code while only the argument 'X' is a string, so the name is still caught.
361
+ const kind = classifyChars(content, extname(filePath));
275
362
  // patterns[2] is the import.meta.env one — its matches are Vite-injected
276
363
  // when the name is an intrinsic, and must not be reported as user env vars.
277
364
  for (let i = 0; i < patterns.length; i++) {
@@ -279,7 +366,9 @@ export function grepEnvUsage(projectDir, config = {}) {
279
366
  const rx = new RegExp(patterns[i].source, 'g');
280
367
  const isViteSource = i === 2;
281
368
  while ((m = rx.exec(content)) !== null) {
369
+ if (kind[m.index] !== 0) continue; // keyword inside a string/comment → a mention, not a read
282
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
283
372
  names.add(m[1]);
284
373
  }
285
374
  }
@@ -299,12 +388,12 @@ export function grepEnvUsage(projectDir, config = {}) {
299
388
  // camelCase keys, so requiring UPPER_SNAKE keeps this env-specific.
300
389
  const keyRe = /^\s*['"]?([A-Z][A-Z0-9_]*[A-Z0-9])['"]?\s*:/gm;
301
390
  while ((km = keyRe.exec(content)) !== null) {
302
- 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]);
303
392
  }
304
393
  // convict: the env var name is the `env:` property value, not the key.
305
394
  const convictRe = /\benv\s*:\s*['"]([A-Z][A-Z0-9_]*[A-Z0-9])['"]/g;
306
395
  while ((km = convictRe.exec(content)) !== null) {
307
- if (km[1].length >= 3) names.add(km[1]);
396
+ if (km[1].length >= 3 && !isRunnerEnvVar(km[1])) names.add(km[1]);
308
397
  }
309
398
  }
310
399
  };
@@ -314,6 +403,7 @@ export function grepEnvUsage(projectDir, config = {}) {
314
403
  try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
315
404
  for (const e of entries) {
316
405
  if (IGNORE_DIRS.has(e.name) || e.name.startsWith('.')) continue;
406
+ if (e.isDirectory() && isNonProductDir(e.name, config)) continue; // v0.26: skip test/fixture dirs in env detection
317
407
  const full = join(dir, e.name);
318
408
  if (e.isDirectory()) walk(full);
319
409
  else if (e.isFile()) visit(full);
@@ -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
 
@@ -61,6 +61,22 @@ export function readLastReviewedDate(absPath) {
61
61
  }
62
62
  }
63
63
 
64
+ /**
65
+ * Read the `<!-- docguard:status <value> -->` marker (draft | review | approved
66
+ * | living). Returns the lowercased value, or null. Used by the uncommitted-doc
67
+ * check (Bug #6): a doc the agent generated this session and marked `approved`
68
+ * has an explicit currency signal even before it's committed.
69
+ */
70
+ function readDocStatus(absPath) {
71
+ try {
72
+ const content = readFileSync(absPath, 'utf-8');
73
+ const m = content.match(/<!--\s*docguard:status\s+([a-z]+)\s*-->/i);
74
+ return m ? m[1].toLowerCase() : null;
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+
64
80
  /**
65
81
  * Get the last git commit date for a file.
66
82
  * Returns null if the file isn't tracked or git isn't available.
@@ -213,10 +229,23 @@ export function validateFreshness(dir, config) {
213
229
  const reviewedDate = readLastReviewedDate(docPath);
214
230
  const docDate = reviewedDate || getLastGitDate(docFile, dir);
215
231
  if (!docDate) {
216
- // File exists but isn't tracked in git yet
232
+ // File exists but has no freshness signal (not in git, no last-reviewed).
233
+ // Bug #6: an agent that generated the doc THIS session and stamped it
234
+ // `<!-- docguard:status approved -->` has signaled it's intentionally
235
+ // current. In the generate-then-fill flow the human hasn't committed yet,
236
+ // so the "uncommitted" warning is noise — suppress it for approved docs.
237
+ if (readDocStatus(docPath) === 'approved') {
238
+ results.push({
239
+ status: 'pass',
240
+ message: `${docFile} is marked approved (not yet committed — fine mid-session)`,
241
+ });
242
+ continue;
243
+ }
244
+ // State BOTH satisfiers — the warning used to mention only committing, so
245
+ // an agent that can stamp a marker but not commit was left guessing.
217
246
  results.push({
218
247
  status: 'warn',
219
- message: `${docFile} exists but is not yet committed to git`,
248
+ message: `${docFile} exists but is not yet committed to git — commit it, or add a <!-- docguard:last-reviewed YYYY-MM-DD --> marker (or <!-- docguard:status approved -->).`,
220
249
  });
221
250
  continue;
222
251
  }
@@ -93,6 +93,11 @@ export function validateMetricsConsistency(projectDir, config, guardResults) {
93
93
  // "19" on line 50 are two distinct drifts.
94
94
  const distinctFoundInFile = new Set();
95
95
  while ((match = regex.exec(content)) !== null) {
96
+ // Bug #2 (subject-binding): only validate a number BOUND to DocGuard.
97
+ // An unbound "N checks" (a proof harness, a CI job, a third-party tool)
98
+ // describes a DIFFERENT subject — comparing it to DocGuard's own count
99
+ // is a false positive, and auto-fixing it overwrites a correct number.
100
+ if (!isDocguardBound(content, match.index)) continue;
96
101
  distinctFoundInFile.add(parseInt(match[1], 10));
97
102
  }
98
103
  if (distinctFoundInFile.size === 0) continue;
@@ -104,9 +109,12 @@ export function validateMetricsConsistency(projectDir, config, guardResults) {
104
109
  reportedDrift.add(driftKey);
105
110
  total++;
106
111
  warnings.push(
107
- `${relPath} says "${found} ${label}" but actual count is ${actuals[key]}. Fix with \`docguard fix --write\``
112
+ `${relPath} says "${found} ${label}" but DocGuard's own ${label} count is ${actuals[key]}. Fix with \`docguard fix --write\``
108
113
  );
109
- fixes.push({ type: 'replace-count', file: relPath, label, found, actual: actuals[key] });
114
+ // actualSource records WHAT the actual count describes, so the applier
115
+ // (and a human) can confirm both sides are the same subject before any
116
+ // overwrite. Without it the fix is refused (fail-closed). See Bug #2.
117
+ fixes.push({ type: 'replace-count', file: relPath, label, found, actual: actuals[key], actualSource: `docguard.guard.${key}` });
110
118
  } else {
111
119
  // Matches the actual count — one pass per (file, label), not per occurrence.
112
120
  const passKey = `${relPath}|${label}`;
@@ -124,6 +132,22 @@ export function validateMetricsConsistency(projectDir, config, guardResults) {
124
132
 
125
133
  // ── Helpers ──────────────────────────────────────────────────────────────────
126
134
 
135
+ /**
136
+ * Bug #2 — subject binding. A "N checks/validators" claim is DocGuard's to
137
+ * govern ONLY if it's bound to DocGuard: the line containing the number must
138
+ * reference "docguard" (case-insensitive), which also covers an explicit
139
+ * `<!-- docguard:metric ... -->` marker on that line. Numbers describing
140
+ * anything else (a proof harness, a CI pipeline, a competitor's tool) are out
141
+ * of scope — validating them is a false positive and auto-fixing them corrupts
142
+ * a correct number with DocGuard's unrelated count.
143
+ */
144
+ function isDocguardBound(content, index) {
145
+ const lineStart = content.lastIndexOf('\n', index) + 1;
146
+ let lineEnd = content.indexOf('\n', index);
147
+ if (lineEnd === -1) lineEnd = content.length;
148
+ return /docguard/i.test(content.slice(lineStart, lineEnd));
149
+ }
150
+
127
151
  function findTestFiles(dir) {
128
152
  const tests = [];
129
153
  const testDirs = ['tests', 'test', '__tests__', 'spec', 'e2e'];