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.
@@ -10,6 +10,70 @@ import { c, docHasSection } from '../shared.mjs';
10
10
  import { validateSecurity } from '../validators/security.mjs';
11
11
  import { runGuardInternal } from './guard.mjs';
12
12
 
13
+ /**
14
+ * Detect whether the project configures a test runner (the "Check 3" of the
15
+ * testing score). Extracted as an exported seam so it's unit-testable without
16
+ * the full score pipeline.
17
+ *
18
+ * Recognises, in order: standalone config files; pytest config inside
19
+ * pyproject.toml / tox.ini; node:test via projectTypeConfig or scripts.test;
20
+ * a `scripts.test` that invokes a known runner; Vitest configured INSIDE
21
+ * vite.config.* (field report #3 — `vitest/config` import or a `test:` block);
22
+ * and runner configs in common workspace subdirs.
23
+ *
24
+ * @param {string} dir
25
+ * @param {object} config
26
+ * @returns {boolean}
27
+ */
28
+ export function detectTestRunner(dir, config = {}) {
29
+ const testConfigFiles = ['jest.config.js', 'jest.config.ts', 'vitest.config.ts', 'vitest.config.js', 'pytest.ini', 'setup.cfg', '.mocharc.yml'];
30
+ if (testConfigFiles.some((f) => existsSync(resolve(dir, f)))) return true;
31
+
32
+ // Python: pytest config usually lives inside pyproject.toml ([tool.pytest.ini_options])
33
+ // or tox.ini ([pytest]) — not a standalone file.
34
+ for (const [file, marker] of [['pyproject.toml', /\[tool\.pytest/], ['tox.ini', /\[pytest\]/]]) {
35
+ const p = resolve(dir, file);
36
+ if (!existsSync(p)) continue;
37
+ try { if (marker.test(readFileSync(p, 'utf-8'))) return true; } catch { /* skip */ }
38
+ }
39
+
40
+ // node:test has no config file — recognize it via projectTypeConfig or package.json.
41
+ const ptc = config.projectTypeConfig || {};
42
+ if (ptc.testFramework === 'node:test') return true;
43
+ const pkgPath = resolve(dir, 'package.json');
44
+ if (existsSync(pkgPath)) {
45
+ try {
46
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
47
+ const testScript = pkg.scripts?.test || '';
48
+ if (testScript.includes('node --test') || testScript.includes('node:test')) return true;
49
+ // v0.27 (field report #3): a `scripts.test` that runs a known runner IS a
50
+ // configured test runner, even without a standalone config file.
51
+ if (/\b(vitest|jest|mocha|ava|playwright|cypress|pytest)\b/.test(testScript)) return true;
52
+ } catch { /* skip */ }
53
+ }
54
+
55
+ // v0.27 (field report #3): Vitest configured INSIDE vite.config.* rather than a
56
+ // standalone vitest.config (`vitest/config` import + a `test:` block).
57
+ for (const f of ['vite.config.ts', 'vite.config.js', 'vite.config.mts', 'vite.config.mjs']) {
58
+ const p = resolve(dir, f);
59
+ if (!existsSync(p)) continue;
60
+ try {
61
+ const src = readFileSync(p, 'utf-8');
62
+ if (/vitest\/config/.test(src) || /^\s*test\s*:/m.test(src)) return true;
63
+ } catch { /* skip */ }
64
+ }
65
+
66
+ // Workspace subdirs: a runner config one level down still configures the project.
67
+ const subConfigs = ['vitest.config.ts', 'vitest.config.js', 'jest.config.ts', 'jest.config.js', 'vite.config.ts'];
68
+ for (const sub of ['backend', 'frontend', 'server', 'client', 'app', 'web', 'api']) {
69
+ for (const f of subConfigs) {
70
+ if (existsSync(resolve(dir, sub, f))) return true;
71
+ }
72
+ }
73
+
74
+ return false;
75
+ }
76
+
13
77
  /**
14
78
  * v0.18-P3: map score categories to the validator keys that contribute.
15
79
  * One category can roll up multiple validators (e.g. "environment" pulls
@@ -585,38 +649,7 @@ function calcTestingScore(dir, config) {
585
649
  else failures.push({ issue: 'TEST-SPEC.md missing', fixCmd: 'docguard fix --doc test-spec' });
586
650
 
587
651
  // ── Check 3: Test config or built-in runner (15 pts) ──
588
- const testConfigFiles = ['jest.config.js', 'jest.config.ts', 'vitest.config.ts', 'vitest.config.js', 'pytest.ini', 'setup.cfg', '.mocharc.yml'];
589
- let hasTestRunner = testConfigFiles.some(f => existsSync(resolve(dir, f)));
590
-
591
- // Python: pytest config usually lives inside pyproject.toml ([tool.pytest.ini_options])
592
- // or tox.ini ([pytest]) — not a standalone file. Detect those too, so a uv/pytest
593
- // project isn't told to "add a test runner" it already configured (field report, Issue B).
594
- if (!hasTestRunner) {
595
- for (const [file, marker] of [['pyproject.toml', /\[tool\.pytest/], ['tox.ini', /\[pytest\]/]]) {
596
- const p = resolve(dir, file);
597
- if (!existsSync(p)) continue;
598
- try { if (marker.test(readFileSync(p, 'utf-8'))) { hasTestRunner = true; break; } } catch { /* skip */ }
599
- }
600
- }
601
-
602
- // node:test has no config file — recognize it via projectTypeConfig or package.json.
603
- if (!hasTestRunner) {
604
- const ptc = config.projectTypeConfig || {};
605
- if (ptc.testFramework === 'node:test') {
606
- hasTestRunner = true;
607
- } else {
608
- const pkgPath = resolve(dir, 'package.json');
609
- if (existsSync(pkgPath)) {
610
- try {
611
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
612
- const testScript = pkg.scripts?.test || '';
613
- if (testScript.includes('node --test') || testScript.includes('node:test')) hasTestRunner = true;
614
- } catch { /* skip */ }
615
- }
616
- }
617
- }
618
-
619
- if (hasTestRunner) score += 15;
652
+ if (detectTestRunner(dir, config)) score += 15;
620
653
  else failures.push({ issue: 'no test runner config detected (jest/vitest/pytest/node:test)' });
621
654
 
622
655
  // ── Check 4: CI test step (15 pts) ──
package/cli/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
@@ -43,8 +43,10 @@ import { runSetup } from './commands/setup.mjs';
43
43
  import { runUpgrade } from './commands/upgrade.mjs';
44
44
  import { runImpact } from './commands/impact.mjs';
45
45
  import { runExplain } from './commands/explain.mjs';
46
+ import { runFeedback } from './commands/feedback.mjs';
46
47
  import { runMemory } from './commands/memory.mjs';
47
48
  import { runDemo } from './commands/demo.mjs';
49
+ import { runAgent } from './commands/agent.mjs';
48
50
  import { ensureSkills } from './ensure-skills.mjs';
49
51
 
50
52
  // ── Shared constants (imported to break circular dependencies) ──────────
@@ -83,7 +85,9 @@ ${c.bold}Tools (situational, but day-to-day useful)${c.reset}
83
85
  ${c.green}diagnose${c.reset} AI orchestrator — guard → emit fix prompts in one command
84
86
  ${c.green}fix${c.reset} Generate AI fix instructions for specific docs
85
87
  ${c.green}generate${c.reset} Reverse-engineer canonical docs from existing code (${c.cyan}--plan${c.reset} for AI scan)
86
- ${c.green}explain${c.reset} Explain a validator key or warning text
88
+ ${c.green}agent${c.reset} One-shot agent task graph ordered tasks, pre-filled code-truth, per-task verify (${c.cyan}--format json${c.reset})
89
+ ${c.green}explain${c.reset} Explain a validator key, warning text, or finding code (${c.cyan}docguard explain SEC001${c.reset})
90
+ ${c.green}feedback${c.reset} Report likely false positives back to DocGuard (local-first + 1-click prefilled issue)
87
91
  ${c.green}memory${c.reset} Show what DocGuard remembers (${c.cyan}--diff${c.reset} drills into drift)
88
92
  ${c.green}trace${c.reset} Requirements traceability matrix (${c.cyan}--reverse${c.reset} for code→doc map)
89
93
  ${c.green}upgrade${c.reset} Migrate ${c.cyan}.docguard.json${c.reset} schema + CLI (${c.cyan}--apply --pr${c.reset} for team-wide PR)
@@ -192,6 +196,15 @@ const COMMAND_HELP = {
192
196
  ],
193
197
  examples: ['docguard generate', 'docguard generate --plan', 'docguard generate --plan --write', 'docguard generate --plan --format json'],
194
198
  },
199
+ agent: {
200
+ summary: 'One-shot agent task graph: ordered, dependency-aware, with pre-filled code-truth + per-task verify.',
201
+ usage: 'docguard agent [--profile <name>] [--format json]',
202
+ flags: [
203
+ ['--format json', 'Machine-readable task graph (the agent-executable artifact)'],
204
+ ['--profile <name>', 'Preview a profile (cli/library/standard/…) without running init first'],
205
+ ],
206
+ examples: ['docguard agent', 'docguard agent --format json', 'docguard agent --profile cli --format json'],
207
+ },
195
208
  guard: {
196
209
  summary: 'Validate code against canonical docs (all validators).',
197
210
  usage: 'docguard guard [--format json] [--changed-only] [--fail-on-warning]',
@@ -268,6 +281,12 @@ const COMMAND_HELP = {
268
281
  flags: [['--diff', 'Drill into drift between memory and code']],
269
282
  examples: ['docguard memory', 'docguard memory --diff'],
270
283
  },
284
+ feedback: {
285
+ summary: 'Report likely false positives back to DocGuard. Collects the low-confidence findings of a guard run, saves a full local record under .docguard/feedback/, and prints a one-click, prefilled, redacted GitHub issue URL (zero typing, no source code or secret values).',
286
+ usage: 'docguard feedback [--format json]',
287
+ flags: [['--format json', 'Machine-readable list of reportable findings + URLs']],
288
+ examples: ['docguard feedback'],
289
+ },
271
290
  };
272
291
 
273
292
  function printCommandHelp(command) {
@@ -474,16 +493,42 @@ async function main() {
474
493
  // .agent/.specify writes, which were a surprising side effect of a bare
475
494
  // `generate --plan` (and were already suppressed for `--plan --write`).
476
495
  const jsonMode = flags.format === 'json';
477
- const headless = jsonMode || flags.write || flags.checkOnly || flags.changedOnly || flags.quiet || flags.plan;
496
+ // `agent` emits a machine task graph (JSON by default) it must be banner-
497
+ // free and side-effect-free like the other read-only commands.
498
+ const headless = jsonMode || flags.write || flags.checkOnly || flags.changedOnly || flags.quiet || flags.plan || command === 'agent';
478
499
 
479
500
  if (!headless) printBanner();
480
501
 
481
502
  const config = loadConfig(projectDir);
482
503
 
504
+ // Commands that only READ and REPORT — they must never mutate the working
505
+ // tree. Scaffolding (ensureSkills → .agent/.specify, spawning `specify`)
506
+ // belongs to setup/init/generate and the `init --with` family, where the
507
+ // user is establishing or expanding their setup, not auditing it.
508
+ //
509
+ // v0.26 (field report Bug #3): a bare `docguard guard` used to run
510
+ // ensureSkills → auto-init Spec Kit → spawn `specify` and write ~9 files into
511
+ // the tree BEFORE printing results. Surprising for a *validate* command, and
512
+ // fatal for a read-only CI audit or a clean-tree precondition check. These
513
+ // commands are now exempt regardless of flags. (`audit` is the guard alias;
514
+ // `diff`/`impact` only read; `demo` runs against a throwaway fixture.)
515
+ const READ_ONLY_COMMANDS = new Set([
516
+ 'guard', 'audit', 'score', 'diff', 'impact',
517
+ 'diagnose', 'trace', 'explain', 'memory', 'demo', 'agent',
518
+ // feedback only writes its own .docguard/feedback/ — it must NOT scaffold
519
+ // skills or touch source, so it's gated out of ensureSkills like the rest.
520
+ 'feedback',
521
+ ]);
522
+
483
523
  // 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) {
524
+ // headless modes (deterministic, parseable output; no side effects expected)
525
+ // and for read-only commands (see above).
526
+ if (
527
+ command !== 'setup' &&
528
+ command !== 'init' &&
529
+ !READ_ONLY_COMMANDS.has(command) &&
530
+ !headless
531
+ ) {
487
532
  ensureSkills(projectDir, flags);
488
533
  }
489
534
 
@@ -566,6 +611,11 @@ async function main() {
566
611
  case 'generate':
567
612
  runGenerate(projectDir, config, flags);
568
613
  break;
614
+ case 'agent':
615
+ // v0.26 (field report §2): one-shot, dependency-ordered task graph with
616
+ // pre-filled code-truth + per-task verify. Read-only; JSON by default.
617
+ runAgent(projectDir, config, flags);
618
+ break;
569
619
  case 'hooks':
570
620
  await runInit(projectDir, config, { ...flags, with: ['hooks'], skipPrompts: true });
571
621
  break;
@@ -607,6 +657,12 @@ async function main() {
607
657
  case 'explain':
608
658
  runExplain(projectDir, config, flags);
609
659
  break;
660
+ case 'feedback':
661
+ // v0.27 (field report #3 / LLM feedback loop): collect low-confidence
662
+ // findings (likely false positives) → local record + 1-click prefilled,
663
+ // redacted, capped GitHub issue URL. Opt-in; nothing filed automatically.
664
+ runFeedback(projectDir, config, flags);
665
+ break;
610
666
  case 'memory':
611
667
  runMemory(projectDir, config, flags);
612
668
  break;
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Findings — the structured, LLM-addressable result unit (v0.27).
3
+ *
4
+ * Background (LLM field report #3): DocGuard's whole job is to tell an agent
5
+ * what to do NEXT. A free-text `errors`/`warnings` string can't carry a stable
6
+ * code (for `explain <CODE>` + inline suppression), a confidence (the signal
7
+ * the false-positive feedback loop runs on), or a machine-readable suggested
8
+ * action. A Finding carries all three.
9
+ *
10
+ * The migration is INCREMENTAL and BACKWARD-COMPATIBLE. A validator that opts in
11
+ * builds `Finding[]` and returns `resultFromFindings(...)`, which still emits the
12
+ * exact `{ errors, warnings, passed, total }` shape every existing consumer
13
+ * (guard counts + exit code, diagnose, score, ci, `--format json`) already reads
14
+ * — PLUS a `findings` array that guard renders richly (each issue gets its
15
+ * `→ suggestion`). Validators that haven't migrated keep returning their
16
+ * hand-built results and render exactly as before. Nothing regresses.
17
+ *
18
+ * Zero npm dependencies — pure Node.js built-ins.
19
+ *
20
+ * @typedef {Object} Suggestion
21
+ * @property {'fix'|'suppress'|'review'|'report'} kind
22
+ * @property {string} text One concise line: what to do next.
23
+ * @property {string} [command] Optional CLI/skill command to run.
24
+ * @property {string} [pragma] Optional inline suppression snippet.
25
+ *
26
+ * @typedef {Object} Finding
27
+ * @property {string} code Stable code, e.g. 'SEC001' (see CODES).
28
+ * @property {string} validator Owning validator key.
29
+ * @property {'error'|'warn'} severity
30
+ * @property {'high'|'low'} confidence 'low' = candidate false positive.
31
+ * @property {string} message Concise, NO ansi colour.
32
+ * @property {string|null} location 'path:line' or 'path'.
33
+ * @property {Suggestion|null} suggestion
34
+ * @property {boolean} reportable Surface in `docguard feedback`.
35
+ * @property {string|null} redactedContext Safe-to-share context for a report.
36
+ */
37
+
38
+ /**
39
+ * Stable finding-code registry. `docguard explain <CODE>` reads this, and
40
+ * inline `// docguard:ignore <CODE>` keys off it. Keep codes append-only — a
41
+ * published code is a public surface we don't renumber.
42
+ */
43
+ export const CODES = {
44
+ SEC001: {
45
+ validator: 'security',
46
+ title: 'Hardcoded password',
47
+ help: 'A `password`/`passwd`/`pwd` assignment with a quoted literal value (8+ chars). If the value is natural-language UI copy or a validation message — not a credential — this is a false positive: DocGuard now flags those low-confidence, but you can suppress inline.',
48
+ suppress: '// docguard:ignore SEC001 — UI copy, not a credential',
49
+ },
50
+ SEC002: {
51
+ validator: 'security',
52
+ title: 'Hardcoded API key',
53
+ help: 'An `api_key`/`apikey` assignment with a quoted literal value (16+ chars). Move it to an environment variable and read it via `process.env`.',
54
+ suppress: '// docguard:ignore SEC002 — sample value in fixture',
55
+ },
56
+ SEC003: {
57
+ validator: 'security',
58
+ title: 'Hardcoded secret key',
59
+ help: 'A `secret_key`/`secretkey` assignment with a quoted literal value (16+ chars). Move it to an environment variable.',
60
+ suppress: '// docguard:ignore SEC003 — reason',
61
+ },
62
+ SEC004: {
63
+ validator: 'security',
64
+ title: 'Hardcoded access token',
65
+ help: 'An `access_token`/`accesstoken` assignment with a quoted literal value (16+ chars). Move it to an environment variable.',
66
+ suppress: '// docguard:ignore SEC004 — reason',
67
+ },
68
+ SEC005: {
69
+ validator: 'security',
70
+ title: 'AWS Access Key ID',
71
+ help: 'A string matching the AWS Access Key ID format (AKIA…). Rotate it immediately if real, and move credentials to the AWS credential chain / environment.',
72
+ suppress: '// docguard:ignore SEC005 — documented example key',
73
+ },
74
+ SEC006: {
75
+ validator: 'security',
76
+ title: 'API secret key (Stripe/OpenAI pattern)',
77
+ help: 'A string matching a live/test secret-key format (sk-…, sk_live_…). Rotate it if real and move it to an environment variable.',
78
+ suppress: '// docguard:ignore SEC006 — reason',
79
+ },
80
+ SEC010: {
81
+ validator: 'security',
82
+ title: '.env not in .gitignore',
83
+ help: 'No `.env` entry was found in .gitignore, so a local `.env` could be committed. Add `.env` (and `.env.local`) to .gitignore.',
84
+ suppress: null,
85
+ },
86
+ SEC011: {
87
+ validator: 'security',
88
+ title: 'No source files scanned for secrets',
89
+ help: 'The secret scan matched zero source files — usually a too-broad ignore config or a wrong sourceRoot. A scan that checks nothing is a dangerous false ✅.',
90
+ suppress: null,
91
+ },
92
+ };
93
+
94
+ /**
95
+ * Build a Finding with sane defaults. `reportable` defaults to true for
96
+ * low-confidence findings — low confidence IS the feedback signal.
97
+ *
98
+ * @param {Partial<Finding>} f
99
+ * @returns {Finding}
100
+ */
101
+ export function mkFinding(f) {
102
+ const severity = f.severity === 'error' ? 'error' : 'warn';
103
+ const confidence = f.confidence === 'low' ? 'low' : 'high';
104
+ return {
105
+ code: f.code || null,
106
+ validator: f.validator || null,
107
+ severity,
108
+ confidence,
109
+ message: f.message || '',
110
+ location: f.location || null,
111
+ suggestion: f.suggestion || null,
112
+ reportable: f.reportable === true || confidence === 'low',
113
+ redactedContext: f.redactedContext || null,
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Derive the legacy `{ errors, warnings, passed, total }` result from a list of
119
+ * findings, keeping `findings` attached for the rich renderer. ONE source of
120
+ * truth — the strings guard counts and the findings guard renders can never
121
+ * disagree because they're computed from the same array.
122
+ *
123
+ * @param {Finding[]} findings
124
+ * @param {{passed?:number, total?:number, applicable?:boolean}} [opts]
125
+ */
126
+ export function resultFromFindings(findings, opts = {}) {
127
+ const errors = [];
128
+ const warnings = [];
129
+ for (const f of findings) {
130
+ if (f.severity === 'error') errors.push(f.message);
131
+ else warnings.push(f.message);
132
+ }
133
+ const res = {
134
+ errors,
135
+ warnings,
136
+ passed: opts.passed || 0,
137
+ total: opts.total != null ? opts.total : 0,
138
+ findings,
139
+ };
140
+ if (opts.applicable !== undefined) res.applicable = opts.applicable;
141
+ return res;
142
+ }
143
+
144
+ /**
145
+ * Does an inline `docguard:ignore` pragma in `text` suppress finding `code`?
146
+ *
147
+ * Accepted forms (mirrors the ergonomics of eslint-disable / ruff `# noqa`):
148
+ * docguard:ignore → suppresses ANY code on the line
149
+ * docguard:ignore SEC001 → suppresses exactly SEC001
150
+ * docguard:ignore SEC001,DQ002 → comma list
151
+ * docguard:ignore SEC* → prefix wildcard
152
+ * docguard:ignore all → suppresses any code
153
+ * docguard:ignore-secret → convenience alias for any SEC* code
154
+ *
155
+ * @param {string} text
156
+ * @param {string} code
157
+ * @returns {boolean}
158
+ */
159
+ export function suppressesCode(text, code) {
160
+ if (!text || !code) return false;
161
+ const m = text.match(/docguard:ignore(-secret)?\b[ \t]*([A-Za-z0-9_,*-]+)?/i);
162
+ if (!m) return false;
163
+ if (m[1]) return /^SEC/i.test(code); // ignore-secret alias
164
+ const arg = (m[2] || '').trim();
165
+ if (!arg) return true; // bare ignore → any code
166
+ return arg.split(',').map((s) => s.trim()).some((tok) => {
167
+ if (!tok) return false;
168
+ if (tok.toLowerCase() === 'all') return true;
169
+ if (tok.endsWith('*')) return code.toUpperCase().startsWith(tok.slice(0, -1).toUpperCase());
170
+ return tok.toUpperCase() === code.toUpperCase();
171
+ });
172
+ }
173
+
174
+ /**
175
+ * Source-line suppression: an ignore pragma counts if it's on the flagged line
176
+ * OR the line directly above it (so a comment can sit above the offending
177
+ * statement, the common style for non-trailing-comment languages).
178
+ */
179
+ export function lineSuppresses(code, line, prevLine = '') {
180
+ return suppressesCode(line, code) || suppressesCode(prevLine, code);
181
+ }
182
+
183
+ /**
184
+ * Flatten a one-line, colour-free rendering of a suggestion — used by JSON
185
+ * consumers, diagnose, and the feedback body. Guard does its own coloured
186
+ * rendering and does not use this.
187
+ */
188
+ export function suggestionLine(s) {
189
+ if (!s) return '';
190
+ let out = s.text || '';
191
+ if (s.command) out += ` → ${s.command}`;
192
+ else if (s.pragma) out += ` → ${s.pragma}`;
193
+ return out;
194
+ }
@@ -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
+ }