docguard-cli 0.25.0 → 0.26.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
@@ -128,6 +128,8 @@ See [CHANGELOG.md](CHANGELOG.md) for the full history.
128
128
 
129
129
  ## ⚡ Quick Start
130
130
 
131
+ > **Package naming:** this repo is `raccioly/docguard`; the published package is **`docguard-cli`** on both [npm](https://www.npmjs.com/package/docguard-cli) and [PyPI](https://pypi.org/project/docguard-cli/); the installed command is `docguard`. Same project — the `-cli` suffix is just the registry name. The package runs **no install scripts**, so `npm i -g docguard-cli --ignore-scripts` is equivalent.
132
+
131
133
  ### Node.js (npm)
132
134
 
133
135
  ```bash
@@ -270,6 +272,7 @@ DocGuard ships **14 commands** (the "Daily 5" + 9 situational tools, including t
270
272
  | `fix --write` | Apply deterministic fixes (no AI — version bumps, counts, anchors, sections) |
271
273
  | `fix --history` | Audit log of every mechanical fix applied (from `.docguard/fixed.json`) |
272
274
  | `generate` | Reverse-engineer docs from existing codebase (`--plan` for AI scan) |
275
+ | `agent` | One-shot agent task graph — ordered, pre-filled code-truth, per-task verify (`--format json`) |
273
276
  | `explain <warning>` | Paste any warning — get the validator's docstring + fix path |
274
277
  | `memory` | Per-domain accuracy headline (endpoints / entities / env / tech) |
275
278
  | `memory --diff` | Drill into which specific claims don't match code |
@@ -0,0 +1,135 @@
1
+ /**
2
+ * `docguard agent` — the one-shot agent task graph.
3
+ *
4
+ * Field report §2: an LLM told "run docguard and fix the docs" had to drive ~10
5
+ * manual round-trips (guard → init → config → generate → hand-write 7 docs →
6
+ * guard → fix FPs → …). This command collapses that into a SINGLE ordered,
7
+ * dependency-aware, self-contained task stream an agent executes without extra
8
+ * discovery:
9
+ * - code-truth tasks ship PRE-FILLED content (insert as-is; only `<!-- … -->`
10
+ * placeholders need values),
11
+ * - human-judgment tasks carry the instruction + grounding facts,
12
+ * - every task has an acceptance/verify command so the agent self-checks in
13
+ * isolation instead of re-running the whole suite and diffing,
14
+ * - phases impose order: config → canonical-docs → verify,
15
+ * - confidence is propagated (code-truth = high; prose = requires-human), and
16
+ * anything the profile suppressed surfaces as a note, never a committed guess.
17
+ *
18
+ * Read-only: it emits the plan, it never writes. A compact human summary by
19
+ * default; `--format json` emits the full machine task graph (the agent-
20
+ * executable artifact) — mirroring `generate --plan` / `generate --plan
21
+ * --format json`.
22
+ */
23
+
24
+ import { buildMemoryPlan } from '../scanners/memory-plan.mjs';
25
+ import { c } from '../shared.mjs';
26
+
27
+ const PHASES = ['config', 'canonical-docs', 'verify'];
28
+
29
+ function docSlug(path) {
30
+ return path.replace(/^docs-(?:canonical|implementation)\//, '').replace(/\.md$/, '').toLowerCase();
31
+ }
32
+
33
+ /**
34
+ * Transform a memory plan into the ordered agent task graph. Pure — no I/O — so
35
+ * it is unit-testable and reused by both the command and `generate`.
36
+ */
37
+ export function buildAgentTaskGraph(projectDir, config, plan) {
38
+ const profileName = config.profile || 'standard';
39
+ const tasks = [];
40
+
41
+ // Phase 1 — config: make the project model explicit BEFORE any doc is written.
42
+ // (The agent learned this ordering by trial in the field report; encode it.)
43
+ tasks.push({
44
+ id: 'config.setup',
45
+ phase: 'config',
46
+ file: '.docguard.json',
47
+ kind: 'human-judgment',
48
+ instruction: `Ensure .docguard.json exists and is correct: run \`docguard init --profile ${profileName}\` if it is absent. Confirm projectName is "${config.projectName}" and profile is "${profileName}".`,
49
+ grounding: { projectName: config.projectName, profile: profileName, kind: plan.profile.kind, languages: plan.profile.languages },
50
+ prefilled: null,
51
+ acceptance: { verify: 'docguard guard --format json', expect: '.docguard.json present and the config validator passes' },
52
+ confidence: 'high',
53
+ });
54
+
55
+ // Phase 2 — canonical-docs: one task per section. Section order from the plan
56
+ // already puts code-truth before prose within each doc, so insert-then-write.
57
+ for (const doc of plan.docs) {
58
+ const slug = docSlug(doc.path);
59
+ for (const sec of doc.sections) {
60
+ const isCode = sec.source === 'code';
61
+ tasks.push({
62
+ id: `${slug}.${sec.id}`,
63
+ phase: 'canonical-docs',
64
+ file: doc.path,
65
+ section: sec.id,
66
+ kind: isCode ? 'code-truth' : 'human-judgment',
67
+ prefilled: isCode ? sec.body : null,
68
+ instruction: isCode
69
+ ? `Insert the pre-filled "${sec.id}" content into ${doc.path} verbatim — it is extracted from your code. Only fill any \`<!-- … -->\` placeholders.`
70
+ : sec.task,
71
+ grounding: sec.grounding || null,
72
+ acceptance: { verify: 'docguard guard --format json', expect: `no missing/stale finding for ${doc.path}` },
73
+ confidence: isCode ? 'high' : 'requires-human',
74
+ });
75
+ }
76
+ }
77
+
78
+ // Phase 3 — verify: the agent's own gate. Self-check, don't assume.
79
+ tasks.push({
80
+ id: 'verify.guard',
81
+ phase: 'verify',
82
+ file: null,
83
+ kind: 'verify',
84
+ instruction: 'Run `docguard guard --format json`. Resolve every error and warning, then re-run until clean. Run `docguard score` to confirm the maturity grade.',
85
+ prefilled: null,
86
+ grounding: null,
87
+ acceptance: { verify: 'docguard guard --format json', expect: '0 errors' },
88
+ confidence: 'high',
89
+ });
90
+
91
+ return {
92
+ project: config.projectName,
93
+ profile: { name: profileName, kind: plan.profile.kind, languages: plan.profile.languages, frameworks: plan.profile.frameworks },
94
+ order: PHASES,
95
+ counts: {
96
+ tasks: tasks.length,
97
+ codeTruth: tasks.filter(t => t.kind === 'code-truth').length,
98
+ humanJudgment: tasks.filter(t => t.kind === 'human-judgment').length,
99
+ },
100
+ tasks,
101
+ notes: plan.notes || [],
102
+ };
103
+ }
104
+
105
+ export function runAgent(projectDir, config, flags) {
106
+ // Allow `--profile <name>` to preview a profile's plan without having to run
107
+ // `init` first (the field-report agent had no config yet on its first call).
108
+ const cfg = flags.profile ? { ...config, profile: flags.profile } : config;
109
+ const plan = buildMemoryPlan(projectDir, cfg);
110
+ const graph = buildAgentTaskGraph(projectDir, cfg, plan);
111
+
112
+ if (flags.format === 'json') {
113
+ // The agent-executable artifact.
114
+ console.log(JSON.stringify({ ...graph, timestamp: new Date().toISOString() }, null, 2));
115
+ return;
116
+ }
117
+
118
+ // Default: a compact human summary of the same graph.
119
+ {
120
+ console.log(`${c.bold}🤖 DocGuard Agent Task Graph — ${graph.project}${c.reset}`);
121
+ console.log(`${c.dim} profile: ${graph.profile.name} · kind: ${graph.profile.kind} · ${graph.counts.tasks} tasks (${graph.counts.codeTruth} code-truth, ${graph.counts.humanJudgment} human)${c.reset}\n`);
122
+ for (const phase of graph.order) {
123
+ const inPhase = graph.tasks.filter(t => t.phase === phase);
124
+ if (!inPhase.length) continue;
125
+ console.log(` ${c.bold}▸ ${phase}${c.reset}`);
126
+ for (const t of inPhase) {
127
+ const tag = t.kind === 'code-truth' ? `${c.green}[code]${c.reset} `
128
+ : t.kind === 'verify' ? `${c.cyan}[verify]${c.reset}` : `${c.yellow}[human]${c.reset}`;
129
+ console.log(` ${tag} ${c.bold}${t.id}${c.reset}${t.file ? ` ${c.dim}→ ${t.file}${c.reset}` : ''}`);
130
+ }
131
+ }
132
+ for (const note of graph.notes) console.log(` ${c.yellow}ℹ️ ${note}${c.reset}`);
133
+ console.log(`\n ${c.dim}Run with --format json for the full machine-readable task stream.${c.reset}`);
134
+ }
135
+ }
@@ -164,6 +164,8 @@ function surfaceConfidence(kind) {
164
164
  * inserted as agent-task placeholders), respecting human prose via markers.
165
165
  */
166
166
  export function runGeneratePlan(projectDir, config, flags) {
167
+ // `--profile <name>` previews a profile's doc set without needing `init` first.
168
+ if (flags.profile) config = { ...config, profile: flags.profile };
167
169
  const plan = buildMemoryPlan(projectDir, config);
168
170
 
169
171
  if (flags.format === 'json') {
@@ -181,6 +183,8 @@ export function runGeneratePlan(projectDir, config, flags) {
181
183
  entities: plan.surface.entities.length,
182
184
  screens: plan.surface.screens.length,
183
185
  components: plan.surface.components.length,
186
+ modules: plan.surface.modules.length,
187
+ tests: { files: plan.surface.tests.totalFiles, cases: plan.surface.tests.totalCases },
184
188
  envVars: plan.surface.envVars.length,
185
189
  confidence: surfaceConfidence(plan.profile.kind),
186
190
  },
@@ -191,6 +195,7 @@ export function runGeneratePlan(projectDir, config, flags) {
191
195
  : { id: s.id, source: 'human', task: s.task, grounding: s.grounding }),
192
196
  })),
193
197
  agentTasks: plan.agentTasks,
198
+ notes: plan.notes || [],
194
199
  timestamp: new Date().toISOString(),
195
200
  }, null, 2));
196
201
  return;
@@ -225,6 +230,9 @@ export function runGeneratePlan(projectDir, config, flags) {
225
230
  if (registered > 0) {
226
231
  console.log(` ${c.dim}Registered ${registered} canonical doc(s) in .docguard.json requiredFiles.${c.reset}`);
227
232
  }
233
+ for (const note of plan.notes || []) {
234
+ console.log(` ${c.yellow}ℹ️ ${note}${c.reset}`);
235
+ }
228
236
  console.log(` ${c.dim}Now run your AI agent (/docguard.fix) to write the prose sections, then ${c.cyan}docguard guard${c.dim}.${c.reset}\n`);
229
237
  return;
230
238
  }
@@ -232,7 +240,7 @@ export function runGeneratePlan(projectDir, config, flags) {
232
240
  // Text summary.
233
241
  console.log(`${c.bold}🔮 DocGuard Generate Plan — ${config.projectName}${c.reset}`);
234
242
  console.log(`${c.dim} ${plan.profile.polyglot ? 'Polyglot' : 'Single-language'}: ${plan.profile.languages.join(', ')} | frameworks: ${plan.profile.frameworks.join(', ') || '—'} | kind: ${plan.profile.kind}${c.reset}\n`);
235
- console.log(` ${c.bold}Code-truth surface:${c.reset} ${plan.surface.endpoints.length} endpoints · ${plan.surface.entities.length} entities · ${plan.surface.screens.length} screens · ${plan.surface.components.length} components · ${plan.surface.envVars.length} env vars\n`);
243
+ console.log(` ${c.bold}Code-truth surface:${c.reset} ${plan.surface.modules.length} modules · ${plan.surface.tests.totalFiles} test files (${plan.surface.tests.totalCases} cases) · ${plan.surface.endpoints.length} endpoints · ${plan.surface.entities.length} entities · ${plan.surface.screens.length} screens · ${plan.surface.envVars.length} env vars\n`);
236
244
  const webSurface = plan.surface.endpoints.length + plan.surface.entities.length + plan.surface.screens.length + plan.surface.components.length;
237
245
  if (surfaceConfidence(plan.profile.kind) === 'low' && webSurface > 0) {
238
246
  console.log(` ${c.yellow}⚠️ Low-confidence surface:${c.reset} ${c.dim}this looks like a ${plan.profile.kind} (not a web app), so the HTTP/SDK/route surface above may be pattern-matches in your OWN source — not real usage. Verify before documenting; pin any corrected code section with ${c.cyan}pinned="reason"${c.dim}.${c.reset}\n`);
@@ -243,6 +251,9 @@ export function runGeneratePlan(projectDir, config, flags) {
243
251
  const prose = d.sections.filter(s => s.source === 'human').length;
244
252
  console.log(` ${c.cyan}${d.path}${c.reset} ${c.dim}(${code} code section(s), ${prose} agent task(s))${c.reset}`);
245
253
  }
254
+ for (const note of plan.notes || []) {
255
+ console.log(` ${c.yellow}ℹ️ ${note}${c.reset}`);
256
+ }
246
257
  console.log(`\n ${c.bold}🤖 Agent tasks (${plan.agentTasks.length}):${c.reset} ${c.dim}prose the AI must write, grounded in scanned facts.${c.reset}`);
247
258
  for (const t of plan.agentTasks) {
248
259
  console.log(` ${c.dim}• [${t.doc} → ${t.sectionId}] ${t.instruction}${c.reset}`);
package/cli/config.mjs CHANGED
@@ -12,11 +12,16 @@ import { existsSync, readFileSync } from 'node:fs';
12
12
  import { resolve, basename } from 'node:path';
13
13
  import { c, PROFILES, SEVERITY_LEVELS } from './shared.mjs';
14
14
  import { mergeIgnoreFile } from './shared-ignore.mjs';
15
+ import { detectProjectName } from './scanners/project-type.mjs';
15
16
 
16
17
  export function loadConfig(projectDir) {
17
18
  const configPath = resolve(projectDir, '.docguard.json');
18
19
  const defaults = {
19
- projectName: basename(projectDir),
20
+ // v0.26 (Bug #4): read the declared name from the root manifest
21
+ // (pyproject/package.json/Cargo/composer/go.mod) before falling back to the
22
+ // dir basename — otherwise a git-worktree slug becomes the project name.
23
+ // An explicit `projectName` in .docguard.json still wins via deepMerge.
24
+ projectName: detectProjectName(projectDir),
20
25
  // Legacy/unversioned fallback ONLY — the value a config is ASSUMED to be
21
26
  // when its file has no `version` field. NOT the current schema version
22
27
  // (that's CURRENT_SCHEMA_VERSION in shared.mjs, written by `init`). Kept low
package/cli/docguard.mjs CHANGED
@@ -45,6 +45,7 @@ import { runImpact } from './commands/impact.mjs';
45
45
  import { runExplain } from './commands/explain.mjs';
46
46
  import { runMemory } from './commands/memory.mjs';
47
47
  import { runDemo } from './commands/demo.mjs';
48
+ import { runAgent } from './commands/agent.mjs';
48
49
  import { ensureSkills } from './ensure-skills.mjs';
49
50
 
50
51
  // ── Shared constants (imported to break circular dependencies) ──────────
@@ -83,6 +84,7 @@ ${c.bold}Tools (situational, but day-to-day useful)${c.reset}
83
84
  ${c.green}diagnose${c.reset} AI orchestrator — guard → emit fix prompts in one command
84
85
  ${c.green}fix${c.reset} Generate AI fix instructions for specific docs
85
86
  ${c.green}generate${c.reset} Reverse-engineer canonical docs from existing code (${c.cyan}--plan${c.reset} for AI scan)
87
+ ${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})
86
88
  ${c.green}explain${c.reset} Explain a validator key or warning text
87
89
  ${c.green}memory${c.reset} Show what DocGuard remembers (${c.cyan}--diff${c.reset} drills into drift)
88
90
  ${c.green}trace${c.reset} Requirements traceability matrix (${c.cyan}--reverse${c.reset} for code→doc map)
@@ -192,6 +194,15 @@ const COMMAND_HELP = {
192
194
  ],
193
195
  examples: ['docguard generate', 'docguard generate --plan', 'docguard generate --plan --write', 'docguard generate --plan --format json'],
194
196
  },
197
+ agent: {
198
+ summary: 'One-shot agent task graph: ordered, dependency-aware, with pre-filled code-truth + per-task verify.',
199
+ usage: 'docguard agent [--profile <name>] [--format json]',
200
+ flags: [
201
+ ['--format json', 'Machine-readable task graph (the agent-executable artifact)'],
202
+ ['--profile <name>', 'Preview a profile (cli/library/standard/…) without running init first'],
203
+ ],
204
+ examples: ['docguard agent', 'docguard agent --format json', 'docguard agent --profile cli --format json'],
205
+ },
195
206
  guard: {
196
207
  summary: 'Validate code against canonical docs (all validators).',
197
208
  usage: 'docguard guard [--format json] [--changed-only] [--fail-on-warning]',
@@ -474,16 +485,39 @@ async function main() {
474
485
  // .agent/.specify writes, which were a surprising side effect of a bare
475
486
  // `generate --plan` (and were already suppressed for `--plan --write`).
476
487
  const jsonMode = flags.format === 'json';
477
- const headless = jsonMode || flags.write || flags.checkOnly || flags.changedOnly || flags.quiet || flags.plan;
488
+ // `agent` emits a machine task graph (JSON by default) it must be banner-
489
+ // free and side-effect-free like the other read-only commands.
490
+ const headless = jsonMode || flags.write || flags.checkOnly || flags.changedOnly || flags.quiet || flags.plan || command === 'agent';
478
491
 
479
492
  if (!headless) printBanner();
480
493
 
481
494
  const config = loadConfig(projectDir);
482
495
 
496
+ // Commands that only READ and REPORT — they must never mutate the working
497
+ // tree. Scaffolding (ensureSkills → .agent/.specify, spawning `specify`)
498
+ // belongs to setup/init/generate and the `init --with` family, where the
499
+ // user is establishing or expanding their setup, not auditing it.
500
+ //
501
+ // v0.26 (field report Bug #3): a bare `docguard guard` used to run
502
+ // ensureSkills → auto-init Spec Kit → spawn `specify` and write ~9 files into
503
+ // the tree BEFORE printing results. Surprising for a *validate* command, and
504
+ // fatal for a read-only CI audit or a clean-tree precondition check. These
505
+ // commands are now exempt regardless of flags. (`audit` is the guard alias;
506
+ // `diff`/`impact` only read; `demo` runs against a throwaway fixture.)
507
+ const READ_ONLY_COMMANDS = new Set([
508
+ 'guard', 'audit', 'score', 'diff', 'impact',
509
+ 'diagnose', 'trace', 'explain', 'memory', 'demo', 'agent',
510
+ ]);
511
+
483
512
  // Silent auto-check: install skills/commands if missing. Skip entirely in
484
- // headless modes where the user wants deterministic, parseable output and
485
- // doesn't expect side effects on their AI-agent skill directories.
486
- if (command !== 'setup' && command !== 'init' && !headless) {
513
+ // headless modes (deterministic, parseable output; no side effects expected)
514
+ // and for read-only commands (see above).
515
+ if (
516
+ command !== 'setup' &&
517
+ command !== 'init' &&
518
+ !READ_ONLY_COMMANDS.has(command) &&
519
+ !headless
520
+ ) {
487
521
  ensureSkills(projectDir, flags);
488
522
  }
489
523
 
@@ -566,6 +600,11 @@ async function main() {
566
600
  case 'generate':
567
601
  runGenerate(projectDir, config, flags);
568
602
  break;
603
+ case 'agent':
604
+ // v0.26 (field report §2): one-shot, dependency-ordered task graph with
605
+ // pre-filled code-truth + per-task verify. Read-only; JSON by default.
606
+ runAgent(projectDir, config, flags);
607
+ break;
569
608
  case 'hooks':
570
609
  await runInit(projectDir, config, { ...flags, with: ['hooks'], skipPrompts: true });
571
610
  break;
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Inventory scanners — code-truth the generator PRE-FILLS instead of leaving
3
+ * empty placeholders.
4
+ *
5
+ * Field report §2/§5: `generate` markets "reverse-engineer docs from code" but
6
+ * shipped empty templates, so the agent hand-greps the structure that was right
7
+ * there in the code. These two extractors populate the `source:"code"` sections
8
+ * so the agent is left with only the genuine prose (the *why*):
9
+ * - scanComponents: top-level source modules → ARCHITECTURE Component Map
10
+ * - scanTestInventory: test files + per-file case counts → TEST-SPEC inventory
11
+ *
12
+ * Language-agnostic, best-effort, zero NPM deps.
13
+ */
14
+
15
+ import { existsSync, readdirSync } from 'node:fs';
16
+ import { resolve, join, relative, extname } from 'node:path';
17
+ import { resolveSourceRoots, readScannable } from '../shared-source.mjs';
18
+ import { DEFAULT_IGNORE_DIRS as IGNORE_DIRS, isNonProductDir } from '../shared-ignore.mjs';
19
+
20
+ const CODE_EXT = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.py', '.go', '.rs', '.rb', '.php', '.java', '.kt']);
21
+ const toPosix = (p) => p.split(/[\\/]/).join('/');
22
+
23
+ // ── Component map ─────────────────────────────────────────────────────────────
24
+
25
+ // Descend through single-package wrappers (src/ → src/<pkg>/) so we list the
26
+ // REAL modules, not just the wrapper folder. A wrapper is a dir whose only
27
+ // non-ignored child is another dir (e.g. a Python `src/<pkg>/` layout).
28
+ function componentRoot(root, config) {
29
+ let cur = root;
30
+ for (let depth = 0; depth < 3; depth++) {
31
+ let entries;
32
+ try { entries = readdirSync(cur, { withFileTypes: true }); } catch { break; }
33
+ const dirs = entries.filter(e => e.isDirectory() && !e.name.startsWith('.')
34
+ && !IGNORE_DIRS.has(e.name) && !isNonProductDir(e.name, config));
35
+ const codeFiles = entries.filter(e => e.isFile() && CODE_EXT.has(extname(e.name))
36
+ && !/^(index|main|mod|lib|__init__|__main__)\./.test(e.name));
37
+ if (dirs.length === 1 && codeFiles.length === 0) { cur = join(cur, dirs[0].name); continue; }
38
+ break;
39
+ }
40
+ return cur;
41
+ }
42
+
43
+ /**
44
+ * Top-level source modules under the project's (de-wrapped) source root(s).
45
+ * Each is a directory (a module) or a significant source file directly under
46
+ * the root. Non-product dirs (tests/fixtures/examples) and barrel/entry files
47
+ * are excluded. Capped to keep the table readable.
48
+ * @returns {Array<{ name: string, kind: 'module'|'file', path: string }>}
49
+ */
50
+ export function scanComponents(projectDir, config = {}, limit = 30) {
51
+ const out = [];
52
+ const seen = new Set();
53
+ for (const root of resolveSourceRoots(projectDir, config)) {
54
+ const croot = componentRoot(root, config);
55
+ let entries;
56
+ try { entries = readdirSync(croot, { withFileTypes: true }); } catch { continue; }
57
+ for (const e of entries) {
58
+ if (e.name.startsWith('.')) continue;
59
+ let kind;
60
+ if (e.isDirectory()) {
61
+ if (IGNORE_DIRS.has(e.name) || isNonProductDir(e.name, config)) continue;
62
+ kind = 'module';
63
+ } else if (e.isFile() && CODE_EXT.has(extname(e.name))) {
64
+ if (/^(index|__init__)\./.test(e.name)) continue; // barrels/entry-init aren't components
65
+ kind = 'file';
66
+ } else continue;
67
+ const rel = toPosix(relative(projectDir, join(croot, e.name)));
68
+ if (seen.has(rel)) continue;
69
+ seen.add(rel);
70
+ out.push({ name: e.name, kind, path: rel });
71
+ }
72
+ }
73
+ out.sort((a, b) => (a.kind === b.kind ? a.path.localeCompare(b.path) : (a.kind === 'module' ? -1 : 1)));
74
+ return out.slice(0, limit);
75
+ }
76
+
77
+ // ── Test inventory ────────────────────────────────────────────────────────────
78
+
79
+ // Test FILE conventions across ecosystems: JS *.test/*.spec, Python test_*.py /
80
+ // *_test.py, Go *_test.go, Ruby *_spec.rb / *_test.rb.
81
+ const TEST_FILE_RE = /(?:\.(?:test|spec)\.[cm]?[jt]sx?|(?:^|\/)test_[^/]*\.py|_test\.(?:py|go)|_spec\.rb|(?:^|\/)[^/]*_test\.rb)$/i;
82
+ // Fixture/mock subdirs that sit INSIDE a test dir but aren't themselves tests.
83
+ const FIXTURE_DIRS = new Set(['fixtures', '__fixtures__', 'testdata', 'test-fixtures', 'testfixtures', 'mocks', '__mocks__', 'snapshots', '__snapshots__']);
84
+ const TEST_DIRS = ['tests', 'test', '__tests__', 'spec', 'e2e'];
85
+
86
+ function countCases(content, ext) {
87
+ let re;
88
+ if (['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx'].includes(ext)) re = /\b(?:it|test)\s*\(/g;
89
+ else if (ext === '.py') re = /^[ \t]*(?:async[ \t]+)?def[ \t]+test_/gm;
90
+ else if (ext === '.go') re = /^[ \t]*func[ \t]+Test[A-Z]/gm;
91
+ else if (ext === '.rs') re = /#\[(?:test|tokio::test)\]/g;
92
+ else if (ext === '.rb') re = /^[ \t]*(?:it|test|specify)\b/gm;
93
+ else return 0;
94
+ let n = 0;
95
+ while (re.exec(content) !== null) n++;
96
+ return n;
97
+ }
98
+
99
+ function walkTestFiles(dir, projectDir, acc) {
100
+ let entries;
101
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
102
+ for (const e of entries) {
103
+ if (e.name.startsWith('.') || IGNORE_DIRS.has(e.name)) continue;
104
+ if (e.isDirectory()) {
105
+ if (FIXTURE_DIRS.has(e.name)) continue; // fixtures/mocks inside a test dir aren't tests
106
+ walkTestFiles(join(dir, e.name), projectDir, acc);
107
+ } else if (e.isFile() && CODE_EXT.has(extname(e.name))) {
108
+ const rel = toPosix(relative(projectDir, join(dir, e.name)));
109
+ if (TEST_FILE_RE.test(rel)) acc.add(rel);
110
+ }
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Test files + per-file case counts. Walks the conventional test dirs (tests/,
116
+ * test/, __tests__, spec/, e2e/) plus each source root (co-located tests),
117
+ * skipping fixture/mock subdirs. Case counts are per-language (it()/test(),
118
+ * `def test_`, `func Test`, `#[test]`, …).
119
+ * @returns {{ files: Array<{file:string,cases:number}>, totalCases:number, totalFiles:number }}
120
+ */
121
+ export function scanTestInventory(projectDir, config = {}) {
122
+ const root = resolve(projectDir);
123
+ const acc = new Set();
124
+ for (const td of TEST_DIRS) {
125
+ const d = join(root, td);
126
+ if (existsSync(d)) walkTestFiles(d, root, acc);
127
+ }
128
+ for (const sr of resolveSourceRoots(projectDir, config)) walkTestFiles(sr, root, acc);
129
+
130
+ const files = [];
131
+ let totalCases = 0;
132
+ for (const rel of acc) {
133
+ const content = readScannable(join(root, rel));
134
+ const cases = content === null ? 0 : countCases(content, extname(rel));
135
+ files.push({ file: rel, cases });
136
+ totalCases += cases;
137
+ }
138
+ files.sort((a, b) => b.cases - a.cases || a.file.localeCompare(b.file));
139
+ return { files, totalCases, totalFiles: files.length };
140
+ }
@@ -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,6 +232,63 @@ 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,
@@ -269,9 +326,18 @@ export function grepEnvUsage(projectDir, config = {}) {
269
326
  if (!CODE_EXTENSIONS.has(extname(filePath))) return;
270
327
  const rel = relative(projectDir, filePath);
271
328
  if (shouldIgnore(rel, config)) return;
329
+ // v0.26 (Bug #7): a token that appears only in a test/fixture file is not a
330
+ // product env read. Skip non-product paths by default (no .docguardignore).
331
+ if (isNonProductPath(rel.replace(/\\/g, '/'), config)) return;
272
332
  const content = readScannable(filePath);
273
333
  if (content === null) return; // unreadable, generated, or too large to scan
274
334
  if (!content.includes('env')) return;
335
+ // v0.26 (Bug #7): classify chars so we count env vars actually READ in code,
336
+ // not ones MENTIONED inside a string literal (a detection signature like
337
+ // `r"os.environ.get('JWT_SECRET')"`) or a comment. We test the position of
338
+ // the access KEYWORD (process/os/import) — for a real read the keyword is
339
+ // code while only the argument 'X' is a string, so the name is still caught.
340
+ const kind = classifyChars(content, extname(filePath));
275
341
  // patterns[2] is the import.meta.env one — its matches are Vite-injected
276
342
  // when the name is an intrinsic, and must not be reported as user env vars.
277
343
  for (let i = 0; i < patterns.length; i++) {
@@ -279,6 +345,7 @@ export function grepEnvUsage(projectDir, config = {}) {
279
345
  const rx = new RegExp(patterns[i].source, 'g');
280
346
  const isViteSource = i === 2;
281
347
  while ((m = rx.exec(content)) !== null) {
348
+ if (kind[m.index] !== 0) continue; // keyword inside a string/comment → a mention, not a read
282
349
  if (isViteSource && VITE_INTRINSICS.has(m[1])) continue;
283
350
  names.add(m[1]);
284
351
  }
@@ -314,6 +381,7 @@ export function grepEnvUsage(projectDir, config = {}) {
314
381
  try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
315
382
  for (const e of entries) {
316
383
  if (IGNORE_DIRS.has(e.name) || e.name.startsWith('.')) continue;
384
+ if (e.isDirectory() && isNonProductDir(e.name, config)) continue; // v0.26: skip test/fixture dirs in env detection
317
385
  const full = join(dir, e.name);
318
386
  if (e.isDirectory()) walk(full);
319
387
  else if (e.isFile()) visit(full);
@@ -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'];
@@ -47,15 +47,33 @@ const esc = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
47
47
  function applyReplaceCount(projectDir, fix) {
48
48
  const full = resolve(projectDir, fix.file);
49
49
  if (!existsSync(full)) return { applied: false };
50
+ // Bug #2 (fail-closed): NEVER overwrite a number without provenance proving
51
+ // the "actual" describes the SAME subject. The Metrics-Consistency validator
52
+ // stamps `actualSource` (e.g. "docguard.guard.checks") only for claims it
53
+ // verified are bound to DocGuard. A fix lacking it is refused rather than risk
54
+ // corrupting a correct, unrelated number.
55
+ if (!fix.actualSource) {
56
+ return { applied: false, detail: `${fix.file}: skipped "${fix.found} ${fix.label}" → "${fix.actual}" — no provenance (actualSource) to prove same subject` };
57
+ }
50
58
  const content = readFileSync(full, 'utf-8');
51
59
  // v0.15.2 hotfix: case-insensitive label match. Mirrors the validator's
52
60
  // detection regex (which is `gi`). Without `i` here, the applier would
53
61
  // skip "21 Validators" (capitalized) even though Metrics-Consistency
54
62
  // detected it — leaving the user with a warning they couldn't auto-fix.
55
- // The /docguard.diagnose run on canonical-spec-kit surfaced this.
56
63
  const re = new RegExp(`\\b${esc(fix.found)}(\\s+(?:automated\\s+)?${esc(fix.label)}\\b)`, 'gi');
57
- const next = content.replace(re, `${fix.actual}$1`);
58
- if (next === content) return { applied: false };
64
+ // Only rewrite occurrences on a DocGuard-bound line (same predicate as the
65
+ // validator's subject-binding) so a stray "<found> <label>" elsewhere in the
66
+ // file is never collateral-damaged by the global replace.
67
+ let changed = false;
68
+ const next = content.replace(re, (m, tail, offset, str) => {
69
+ const lineStart = str.lastIndexOf('\n', offset) + 1;
70
+ let lineEnd = str.indexOf('\n', offset);
71
+ if (lineEnd === -1) lineEnd = str.length;
72
+ if (!/docguard/i.test(str.slice(lineStart, lineEnd))) return m; // not bound → leave untouched
73
+ changed = true;
74
+ return `${fix.actual}${tail}`;
75
+ });
76
+ if (!changed || next === content) return { applied: false };
59
77
  writeFileSync(full, next, 'utf-8');
60
78
  return { applied: true, detail: `${fix.file}: "${fix.found} ${fix.label}" → "${fix.actual} ${fix.label}"` };
61
79
  }
@@ -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.0"
6
+ version: "0.25.1"
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"
@@ -28,22 +28,18 @@ provides:
28
28
  - name: "speckit.docguard.guard"
29
29
  file: "commands/guard.md"
30
30
  description: "Run 19-validator quality gate with severity triage and remediation plan"
31
- aliases: ["speckit.docguard.guard"]
32
31
 
33
32
  - name: "speckit.docguard.fix"
34
33
  file: "commands/generate.md"
35
34
  description: "AI-driven documentation repair with codebase research and validation loops"
36
- aliases: ["speckit.docguard.fix"]
37
35
 
38
36
  - name: "speckit.docguard.review"
39
37
  file: "commands/diagnose.md"
40
38
  description: "Cross-document semantic consistency analysis (read-only)"
41
- aliases: ["speckit.docguard.review"]
42
39
 
43
40
  - name: "speckit.docguard.score"
44
41
  file: "commands/score.md"
45
42
  description: "CDD maturity score with ROI-based improvement roadmap"
46
- aliases: ["speckit.docguard.score"]
47
43
 
48
44
  - name: "speckit.docguard.diagnose"
49
45
  file: "commands/diagnose.md"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "docguard-cli",
3
- "version": "0.25.0",
3
+ "version": "0.26.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": {