docguard-cli 0.25.1 → 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 +3 -0
- package/cli/commands/agent.mjs +135 -0
- package/cli/commands/generate.mjs +12 -1
- package/cli/config.mjs +6 -1
- package/cli/docguard.mjs +43 -4
- package/cli/scanners/inventory.mjs +140 -0
- package/cli/scanners/memory-plan.mjs +84 -14
- package/cli/scanners/project-type.mjs +60 -4
- package/cli/scanners/routes.mjs +11 -5
- package/cli/shared-ignore.mjs +40 -0
- package/cli/shared-source.mjs +69 -1
- package/cli/validators/freshness.mjs +31 -2
- package/cli/validators/metrics-consistency.mjs +26 -2
- package/cli/writers/mechanical.mjs +21 -3
- package/package.json +1 -1
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
485
|
-
//
|
|
486
|
-
if (
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
//
|
|
273
|
-
|
|
319
|
+
// TEST-SPEC — always (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
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
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
|
+
}
|
package/cli/scanners/routes.mjs
CHANGED
|
@@ -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
|
|
82
|
-
// fixtures dir with fake routes doesn't pollute
|
|
83
|
-
// RESULTS (route.file → project-relative) keeps
|
|
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
|
|
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
|
}
|
package/cli/shared-ignore.mjs
CHANGED
|
@@ -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
|
*
|
package/cli/shared-source.mjs
CHANGED
|
@@ -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
|
|
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
|
|
112
|
+
`${relPath} says "${found} ${label}" but DocGuard's own ${label} count is ${actuals[key]}. Fix with \`docguard fix --write\``
|
|
108
113
|
);
|
|
109
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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
|
}
|
package/package.json
CHANGED