docguard-cli 0.23.0 → 0.25.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 +1 -1
- package/cli/commands/diff.mjs +1 -1
- package/cli/commands/explain.mjs +178 -17
- package/cli/commands/fix.mjs +17 -2
- package/cli/commands/generate.mjs +69 -3
- package/cli/commands/guard.mjs +86 -11
- package/cli/commands/hooks.mjs +12 -7
- package/cli/commands/init.mjs +24 -8
- package/cli/commands/score.mjs +147 -61
- package/cli/commands/setup.mjs +2 -2
- package/cli/commands/sync.mjs +6 -0
- package/cli/commands/trace.mjs +3 -3
- package/cli/commands/upgrade.mjs +61 -13
- package/cli/config.mjs +18 -1
- package/cli/docguard.mjs +156 -2
- package/cli/ensure-skills.mjs +24 -26
- package/cli/scanners/api-doc.mjs +17 -3
- package/cli/scanners/doc-tools.mjs +32 -15
- package/cli/scanners/frontend.mjs +24 -8
- package/cli/scanners/js-ast.mjs +432 -0
- package/cli/scanners/memory-plan.mjs +1 -1
- package/cli/scanners/project-type.mjs +11 -4
- package/cli/scanners/py-ast.mjs +213 -0
- package/cli/scanners/routes.mjs +194 -69
- package/cli/scanners/schemas.mjs +97 -51
- package/cli/shared-git.mjs +0 -0
- package/cli/shared-ignore.mjs +23 -2
- package/cli/shared-source.mjs +59 -2
- package/cli/shared-trace-patterns.mjs +13 -0
- package/cli/shared.mjs +92 -1
- package/cli/validator-markers.mjs +91 -0
- package/cli/validators/api-surface.mjs +37 -3
- package/cli/validators/canonical-sync.mjs +22 -19
- package/cli/validators/doc-quality.mjs +2 -42
- package/cli/validators/docs-coverage.mjs +13 -0
- package/cli/validators/docs-sync.mjs +4 -3
- package/cli/validators/drift.mjs +3 -2
- package/cli/validators/freshness.mjs +47 -15
- package/cli/validators/generated-staleness.mjs +16 -1
- package/cli/validators/metadata-sync.mjs +21 -11
- package/cli/validators/metrics-consistency.mjs +45 -17
- package/cli/validators/security.mjs +13 -5
- package/cli/validators/structure.mjs +6 -5
- package/cli/validators/surface-sync.mjs +7 -5
- package/cli/validators/test-spec.mjs +76 -51
- package/cli/validators/todo-tracking.mjs +4 -2
- package/cli/validators/traceability.mjs +11 -3
- package/cli/writers/sections.mjs +32 -19
- package/docs/commands.md +1 -1
- package/docs/configuration.md +11 -0
- package/docs/faq.md +1 -1
- package/extensions/spec-kit-docguard/README.md +1 -1
- package/extensions/spec-kit-docguard/extension.yml +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +2 -1
- package/extensions/spec-kit-docguard/templates/github-workflows/docguard-autofix.yml +3 -2
- package/extensions/spec-kit-docguard/templates/github-workflows/docguard-guard.yml +2 -2
- package/package.json +5 -3
package/cli/commands/upgrade.mjs
CHANGED
|
@@ -71,14 +71,48 @@ function readProjectSchemaVersion(projectDir) {
|
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Normalize the `requiredFiles` block to the canonical object shape
|
|
76
|
+
* (`{ canonical: [...], agentFile, changelog, driftLog }`). Validators read
|
|
77
|
+
* `config.requiredFiles?.canonical || []`, so a config whose `requiredFiles` is
|
|
78
|
+
* a bare array (a legacy/hand-edited shape) or an object that lost its
|
|
79
|
+
* `canonical` key makes Traceability/Structure/Freshness silently see an empty
|
|
80
|
+
* list and PASS — the exact false-green this tool exists to prevent.
|
|
81
|
+
*
|
|
82
|
+
* Returns `{ cfg, changed, notes }`. We only TRANSFORM the unambiguous case (a
|
|
83
|
+
* bare array can only ever have been the canonical doc list); any other broken
|
|
84
|
+
* shape is SURFACED as a note rather than guessed at.
|
|
85
|
+
*/
|
|
86
|
+
function normalizeRequiredFiles(cfg) {
|
|
87
|
+
const rf = cfg.requiredFiles;
|
|
88
|
+
const notes = [];
|
|
89
|
+
if (rf === undefined || rf === null) {
|
|
90
|
+
// No requiredFiles at all — validators default to empty and check nothing.
|
|
91
|
+
// Don't fabricate a list (we can't know the project's docs); surface it.
|
|
92
|
+
notes.push('⚠ requiredFiles is missing — Traceability/Structure/Freshness have nothing to verify (silent-pass risk). Run `docguard init` to generate it.');
|
|
93
|
+
return { cfg, changed: false, notes };
|
|
94
|
+
}
|
|
95
|
+
if (Array.isArray(rf)) {
|
|
96
|
+
notes.push('Normalized legacy requiredFiles array → { canonical: [...] } so canonical-doc checks see your docs again.');
|
|
97
|
+
return { cfg: { ...cfg, requiredFiles: { canonical: rf } }, changed: true, notes };
|
|
98
|
+
}
|
|
99
|
+
if (typeof rf === 'object' && !Array.isArray(rf.canonical)) {
|
|
100
|
+
notes.push('⚠ requiredFiles.canonical is missing or not an array — canonical-doc checks will silently pass. Restore it or run `docguard init`.');
|
|
101
|
+
}
|
|
102
|
+
return { cfg, changed: false, notes };
|
|
103
|
+
}
|
|
104
|
+
|
|
74
105
|
/**
|
|
75
106
|
* Idempotent migration: walk the project config and add any fields introduced
|
|
76
|
-
* since the stored schema version
|
|
107
|
+
* since the stored schema version, then normalize the requiredFiles shape.
|
|
108
|
+
* Returns { changed, newConfig, notes }.
|
|
77
109
|
*
|
|
78
|
-
* Each migration is keyed by the version it migrates TO. Adding a new
|
|
79
|
-
* version means adding one entry here.
|
|
110
|
+
* Each version migration is keyed by the version it migrates TO. Adding a new
|
|
111
|
+
* schema version means adding one entry here. The requiredFiles normalization
|
|
112
|
+
* runs regardless of the version delta — a config can sit at the current
|
|
113
|
+
* version yet still carry a hand-edited shape that breaks validators silently.
|
|
80
114
|
*/
|
|
81
|
-
function migrateSchema(cfg, fromVersion) {
|
|
115
|
+
export function migrateSchema(cfg, fromVersion) {
|
|
82
116
|
const migrations = {
|
|
83
117
|
// v0.4 — pre-0.4 schemas (no `version` field, often `project` instead
|
|
84
118
|
// of `projectName`) normalize here. Rename `project` → `projectName`
|
|
@@ -99,16 +133,24 @@ function migrateSchema(cfg, fromVersion) {
|
|
|
99
133
|
let current = { ...cfg };
|
|
100
134
|
let changed = false;
|
|
101
135
|
const target = CURRENT_SCHEMA_VERSION;
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
136
|
+
|
|
137
|
+
// Version-keyed field migrations (additive). Skipped when already current.
|
|
138
|
+
if (compareVersions(fromVersion, target) < 0) {
|
|
139
|
+
for (const [ver, fn] of Object.entries(migrations)) {
|
|
140
|
+
if (compareVersions(fromVersion, ver) < 0 && compareVersions(ver, target) <= 0) {
|
|
141
|
+
current = fn(current);
|
|
142
|
+
changed = true;
|
|
143
|
+
}
|
|
108
144
|
}
|
|
145
|
+
if (changed) current.version = target;
|
|
109
146
|
}
|
|
110
|
-
|
|
111
|
-
|
|
147
|
+
|
|
148
|
+
// requiredFiles shape normalization — independent of the version delta.
|
|
149
|
+
const norm = normalizeRequiredFiles(current);
|
|
150
|
+
current = norm.cfg;
|
|
151
|
+
if (norm.changed) changed = true;
|
|
152
|
+
|
|
153
|
+
return { changed, newConfig: current, notes: norm.notes };
|
|
112
154
|
}
|
|
113
155
|
|
|
114
156
|
/**
|
|
@@ -291,7 +333,13 @@ export async function runUpgrade(projectDir, _config, flags) {
|
|
|
291
333
|
if (schemaBehind && projectSchema) {
|
|
292
334
|
const cfgPath = resolve(projectDir, '.docguard.json');
|
|
293
335
|
const cfg = JSON.parse(readFileSync(cfgPath, 'utf-8'));
|
|
294
|
-
const { changed, newConfig } = migrateSchema(cfg, projectSchema);
|
|
336
|
+
const { changed, newConfig, notes } = migrateSchema(cfg, projectSchema);
|
|
337
|
+
// Surface requiredFiles findings (a normalization that happened, or a
|
|
338
|
+
// broken shape we refuse to guess at) before reporting the version bump.
|
|
339
|
+
for (const n of notes || []) {
|
|
340
|
+
const warn = n.startsWith('⚠');
|
|
341
|
+
console.log(` ${warn ? c.yellow : c.dim}${warn ? '' : '• '}${n}${c.reset}`);
|
|
342
|
+
}
|
|
295
343
|
if (changed) {
|
|
296
344
|
// v0.14-P4: --pr opens a PR for review instead of in-place editing.
|
|
297
345
|
// Useful when the team wants a reviewable diff or has branch-protected
|
package/cli/config.mjs
CHANGED
|
@@ -10,13 +10,17 @@
|
|
|
10
10
|
|
|
11
11
|
import { existsSync, readFileSync } from 'node:fs';
|
|
12
12
|
import { resolve, basename } from 'node:path';
|
|
13
|
-
import { c, PROFILES } from './shared.mjs';
|
|
13
|
+
import { c, PROFILES, SEVERITY_LEVELS } from './shared.mjs';
|
|
14
14
|
import { mergeIgnoreFile } from './shared-ignore.mjs';
|
|
15
15
|
|
|
16
16
|
export function loadConfig(projectDir) {
|
|
17
17
|
const configPath = resolve(projectDir, '.docguard.json');
|
|
18
18
|
const defaults = {
|
|
19
19
|
projectName: basename(projectDir),
|
|
20
|
+
// Legacy/unversioned fallback ONLY — the value a config is ASSUMED to be
|
|
21
|
+
// when its file has no `version` field. NOT the current schema version
|
|
22
|
+
// (that's CURRENT_SCHEMA_VERSION in shared.mjs, written by `init`). Kept low
|
|
23
|
+
// on purpose so a versionless (pre-0.4) config still trips the upgrade nudge.
|
|
20
24
|
version: '0.2',
|
|
21
25
|
profile: 'standard',
|
|
22
26
|
requiredFiles: {
|
|
@@ -89,6 +93,19 @@ export function loadConfig(projectDir) {
|
|
|
89
93
|
const merged = deepMerge(withProfile, normalizeConfig(userConfig));
|
|
90
94
|
merged.profile = profileName;
|
|
91
95
|
|
|
96
|
+
// v0.24: severity accepts only high|medium|low and changes EXIT-CODE
|
|
97
|
+
// weight — it never mutes a warning from display. A value like "off"
|
|
98
|
+
// silently fell back to "medium", so users who wrote severity:{k:"off"}
|
|
99
|
+
// expecting silence still saw the warning and got no feedback (field
|
|
100
|
+
// report). Surface the misconfig and point at the real disable switch.
|
|
101
|
+
if (merged.severity && typeof merged.severity === 'object') {
|
|
102
|
+
for (const [key, val] of Object.entries(merged.severity)) {
|
|
103
|
+
if (typeof val === 'string' && !SEVERITY_LEVELS.has(val.toLowerCase())) {
|
|
104
|
+
console.error(`${c.yellow}⚠ .docguard.json: severity.${key} = "${val}" is not a valid level${c.reset} ${c.dim}(use high | medium | low). To silence a validator entirely, set ${c.reset}${c.cyan}validators.${key}: false${c.dim}.${c.reset}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
92
109
|
// Auto-detect project type if not set
|
|
93
110
|
if (!merged.projectType) {
|
|
94
111
|
merged.projectType = autoDetectProjectType(projectDir);
|
package/cli/docguard.mjs
CHANGED
|
@@ -125,7 +125,7 @@ ${c.bold}Options:${c.reset}
|
|
|
125
125
|
code-truth skeleton. Add --write to scaffold, --format json
|
|
126
126
|
for the machine-readable manifest.
|
|
127
127
|
--doc <name> Generate AI prompt for specific doc (architecture, security, etc.)
|
|
128
|
-
--profile <p>
|
|
128
|
+
--profile <p> Profile: starter, standard, cli, library, enterprise (init command)
|
|
129
129
|
--tax Show estimated documentation maintenance cost (with score)
|
|
130
130
|
--help Show this help message
|
|
131
131
|
--version Show version
|
|
@@ -133,6 +133,8 @@ ${c.bold}Options:${c.reset}
|
|
|
133
133
|
${c.bold}Profiles:${c.reset}
|
|
134
134
|
${c.green}starter${c.reset} Minimal CDD — just ARCHITECTURE.md + CHANGELOG (side projects)
|
|
135
135
|
${c.green}standard${c.reset} Full CDD — all 5 canonical docs (default, team projects)
|
|
136
|
+
${c.green}cli${c.reset} CLI tool — no HTTP API / DB (ARCHITECTURE, TEST-SPEC, SECURITY, ENVIRONMENT)
|
|
137
|
+
${c.green}library${c.reset} Library — public API, no HTTP/DB (ARCHITECTURE, API-REFERENCE, TEST-SPEC)
|
|
136
138
|
${c.green}enterprise${c.reset} Strict CDD — all docs + all validators + freshness enforced
|
|
137
139
|
|
|
138
140
|
${c.bold}Examples:${c.reset}
|
|
@@ -158,6 +160,135 @@ ${c.bold}Learn more:${c.reset}
|
|
|
158
160
|
`);
|
|
159
161
|
}
|
|
160
162
|
|
|
163
|
+
// ── Per-command help (v0.24, field report B6) ───────────────────────────────
|
|
164
|
+
// `docguard <command> --help` now prints the flags + examples for THAT command,
|
|
165
|
+
// so a flag like `generate --plan --write` or `init --skeleton` is discoverable.
|
|
166
|
+
// Only flags that genuinely apply to each command are listed (derived from the
|
|
167
|
+
// vetted global help above). Commands without a focused entry fall back to the
|
|
168
|
+
// global help, which still lists them.
|
|
169
|
+
const COMMAND_HELP = {
|
|
170
|
+
init: {
|
|
171
|
+
summary: 'Bootstrap CDD docs — smart-scans existing code, or writes blank templates.',
|
|
172
|
+
usage: 'docguard init [--skeleton|--wizard] [--profile <p>] [--with <name>] [--fix]',
|
|
173
|
+
flags: [
|
|
174
|
+
['--skeleton', 'Blank templates instead of the smart scan-and-propose'],
|
|
175
|
+
['--wizard', 'Guided interactive onboarding'],
|
|
176
|
+
['--with <name>', 'Add a scaffolder: agents, hooks, ci, badge, llms, publish'],
|
|
177
|
+
['--profile <p>', 'starter | standard | cli | library | enterprise (default: standard)'],
|
|
178
|
+
['--skip-prompts', 'Non-interactive; create the profile defaults (CI)'],
|
|
179
|
+
['--fix', 'Headless: create any missing required docs from templates'],
|
|
180
|
+
['--force', 'Overwrite existing files (.bak backup kept)'],
|
|
181
|
+
],
|
|
182
|
+
examples: ['docguard init', 'docguard init --skeleton', 'docguard init --profile starter --skip-prompts', 'docguard init --with ci'],
|
|
183
|
+
},
|
|
184
|
+
generate: {
|
|
185
|
+
summary: 'Reverse-engineer canonical docs from existing code.',
|
|
186
|
+
usage: 'docguard generate [--plan [--write] [--format json]] [--force]',
|
|
187
|
+
flags: [
|
|
188
|
+
['--plan', 'AI scan: emit the agent task manifest + code-truth skeleton'],
|
|
189
|
+
['--write', 'With --plan: scaffold the skeleton docs to disk'],
|
|
190
|
+
['--format json', 'With --plan: machine-readable manifest'],
|
|
191
|
+
['--force', 'Overwrite existing docs (.bak backup kept)'],
|
|
192
|
+
],
|
|
193
|
+
examples: ['docguard generate', 'docguard generate --plan', 'docguard generate --plan --write', 'docguard generate --plan --format json'],
|
|
194
|
+
},
|
|
195
|
+
guard: {
|
|
196
|
+
summary: 'Validate code against canonical docs (all validators).',
|
|
197
|
+
usage: 'docguard guard [--format json] [--changed-only] [--fail-on-warning]',
|
|
198
|
+
flags: [
|
|
199
|
+
['--format json', 'Machine-readable results for CI'],
|
|
200
|
+
['--changed-only', 'Only validate docs/code touched in the working tree'],
|
|
201
|
+
['--fail-on-warning', 'Exit non-zero on warnings (strict CI)'],
|
|
202
|
+
],
|
|
203
|
+
examples: ['docguard guard', 'docguard guard --format json'],
|
|
204
|
+
},
|
|
205
|
+
score: {
|
|
206
|
+
summary: 'CDD maturity score (0–100).',
|
|
207
|
+
usage: 'docguard score [--diff] [--tax] [--format json]',
|
|
208
|
+
flags: [
|
|
209
|
+
['--diff', 'Delta between two refs'],
|
|
210
|
+
['--tax', 'Estimated documentation maintenance cost'],
|
|
211
|
+
['--format json', 'Machine-readable score'],
|
|
212
|
+
],
|
|
213
|
+
examples: ['docguard score', 'docguard score --tax'],
|
|
214
|
+
},
|
|
215
|
+
diff: {
|
|
216
|
+
summary: 'Show gaps between docs and code.',
|
|
217
|
+
usage: 'docguard diff [--since <ref>]',
|
|
218
|
+
flags: [['--since <ref>', 'Restrict to files changed since <ref> (impact mode)']],
|
|
219
|
+
examples: ['docguard diff', 'docguard diff --since HEAD~5'],
|
|
220
|
+
},
|
|
221
|
+
sync: {
|
|
222
|
+
summary: 'Refresh code-truth doc sections (preview by default).',
|
|
223
|
+
usage: 'docguard sync [--write] [--since <ref>]',
|
|
224
|
+
flags: [
|
|
225
|
+
['--write', 'Apply the refresh (default is a dry-run preview)'],
|
|
226
|
+
['--since <ref>', 'Only sync sections whose source files changed since <ref>'],
|
|
227
|
+
],
|
|
228
|
+
examples: ['docguard sync', 'docguard sync --write'],
|
|
229
|
+
},
|
|
230
|
+
fix: {
|
|
231
|
+
summary: 'Generate AI fix instructions for docs (or apply deterministic fixes).',
|
|
232
|
+
usage: 'docguard fix [--doc <name>] [--auto] [--write] [--force]',
|
|
233
|
+
flags: [
|
|
234
|
+
['--doc <name>', 'Target a specific doc (architecture, security, …)'],
|
|
235
|
+
['--auto', 'Auto-fix what is mechanically possible'],
|
|
236
|
+
['--write', 'Apply deterministic fixes in place (docguard:generated docs)'],
|
|
237
|
+
['--force', 'Allow edits outside docguard:generated docs'],
|
|
238
|
+
],
|
|
239
|
+
examples: ['docguard fix --doc architecture', 'docguard diagnose'],
|
|
240
|
+
},
|
|
241
|
+
trace: {
|
|
242
|
+
summary: 'Requirements traceability matrix.',
|
|
243
|
+
usage: 'docguard trace [--reverse]',
|
|
244
|
+
flags: [['--reverse', 'Code→doc map instead of doc→code']],
|
|
245
|
+
examples: ['docguard trace', 'docguard trace --reverse'],
|
|
246
|
+
},
|
|
247
|
+
upgrade: {
|
|
248
|
+
summary: 'Migrate .docguard.json schema + CLI.',
|
|
249
|
+
usage: 'docguard upgrade [--apply] [--pr]',
|
|
250
|
+
flags: [
|
|
251
|
+
['--apply', 'Write the migration (default is a preview)'],
|
|
252
|
+
['--pr', 'Open a team-wide PR with the migration'],
|
|
253
|
+
],
|
|
254
|
+
examples: ['docguard upgrade', 'docguard upgrade --apply'],
|
|
255
|
+
},
|
|
256
|
+
ci: {
|
|
257
|
+
summary: 'Generate CI / pipeline config.',
|
|
258
|
+
usage: 'docguard ci [--threshold <n>] [--fail-on-warning]',
|
|
259
|
+
flags: [
|
|
260
|
+
['--threshold <n>', 'Minimum score for CI pass'],
|
|
261
|
+
['--fail-on-warning', 'Fail CI on warnings'],
|
|
262
|
+
],
|
|
263
|
+
examples: ['docguard ci'],
|
|
264
|
+
},
|
|
265
|
+
memory: {
|
|
266
|
+
summary: 'Show what DocGuard remembers about the project.',
|
|
267
|
+
usage: 'docguard memory [--diff]',
|
|
268
|
+
flags: [['--diff', 'Drill into drift between memory and code']],
|
|
269
|
+
examples: ['docguard memory', 'docguard memory --diff'],
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
function printCommandHelp(command) {
|
|
274
|
+
const h = COMMAND_HELP[command];
|
|
275
|
+
if (!h) { printHelp(); return; } // no focused entry — global help still lists it
|
|
276
|
+
printBanner();
|
|
277
|
+
console.log(`${c.bold}docguard ${command}${c.reset} — ${h.summary}\n`);
|
|
278
|
+
console.log(`${c.bold}Usage:${c.reset}\n ${h.usage}\n`);
|
|
279
|
+
if (h.flags?.length) {
|
|
280
|
+
console.log(`${c.bold}Options:${c.reset}`);
|
|
281
|
+
for (const [flag, desc] of h.flags) console.log(` ${c.cyan}${flag.padEnd(18)}${c.reset} ${desc}`);
|
|
282
|
+
console.log('');
|
|
283
|
+
}
|
|
284
|
+
if (h.examples?.length) {
|
|
285
|
+
console.log(`${c.bold}Examples:${c.reset}`);
|
|
286
|
+
for (const ex of h.examples) console.log(` ${c.dim}${ex}${c.reset}`);
|
|
287
|
+
console.log('');
|
|
288
|
+
}
|
|
289
|
+
console.log(`${c.dim}All commands: ${c.cyan}docguard --help${c.reset}`);
|
|
290
|
+
}
|
|
291
|
+
|
|
161
292
|
// ── Main ───────────────────────────────────────────────────────────────────
|
|
162
293
|
async function main() {
|
|
163
294
|
const args = process.argv.slice(2);
|
|
@@ -241,6 +372,12 @@ async function main() {
|
|
|
241
372
|
// Default stays on (discoverability), but lets minimalist library
|
|
242
373
|
// projects skip the .specify/.agent/commands scaffolding.
|
|
243
374
|
flags.noSpecKit = true;
|
|
375
|
+
} else if (args[i] === '--spec-kit') {
|
|
376
|
+
// v0.24: explicit opt-IN to the Spec Kit framework scaffold. The
|
|
377
|
+
// `starter` profile skips that scaffold by default (minimal, for side
|
|
378
|
+
// projects — it would otherwise drop ~30 files); pass --spec-kit to
|
|
379
|
+
// include it anyway. No effect on other profiles (already on by default).
|
|
380
|
+
flags.specKit = true;
|
|
244
381
|
} else if (args[i] === '--pin') {
|
|
245
382
|
// v0.17-P1: `docguard guard --pin` records the running CLI version
|
|
246
383
|
// into .docguard.json (`docguardVersion` field) after a successful run.
|
|
@@ -298,6 +435,11 @@ async function main() {
|
|
|
298
435
|
flags.debate = true;
|
|
299
436
|
} else if (args[i] === '--stdout') {
|
|
300
437
|
flags.stdout = true;
|
|
438
|
+
} else if (args[i] === '--help' || args[i] === '-h') {
|
|
439
|
+
// v0.24: capture --help anywhere on the line, not just as the bare
|
|
440
|
+
// command. Previously `docguard generate --help` fell through the parser
|
|
441
|
+
// and executed generate, scaffolding files into the cwd (field report).
|
|
442
|
+
flags.help = true;
|
|
301
443
|
}
|
|
302
444
|
}
|
|
303
445
|
|
|
@@ -313,14 +455,26 @@ async function main() {
|
|
|
313
455
|
process.exit(0);
|
|
314
456
|
}
|
|
315
457
|
|
|
458
|
+
// v0.24: `docguard <command> --help` shows that command's own flags +
|
|
459
|
+
// examples (field report B6); commands without a focused entry fall back to
|
|
460
|
+
// the global help. Non-destructive (generate no longer scaffolds on --help).
|
|
461
|
+
if (flags.help) {
|
|
462
|
+
printCommandHelp(command);
|
|
463
|
+
process.exit(0);
|
|
464
|
+
}
|
|
465
|
+
|
|
316
466
|
// In JSON mode the entire stdout MUST be parseable JSON. The banner and
|
|
317
467
|
// ensureSkills' install message would corrupt the output for any
|
|
318
468
|
// programmatic consumer (CI, dashboards, the Score-on-PR Action recipe).
|
|
319
469
|
// Headless flags (`--write`, `--check-only`, `--auto`) also suppress chrome.
|
|
320
470
|
// v0.16-P5: --quiet (-q) joins the headless club for users who want
|
|
321
471
|
// banner-free output without committing to a specific machine format.
|
|
472
|
+
// v0.24 (field report): `--plan` is a read-only preview — "show me, don't
|
|
473
|
+
// touch" — so it joins the club to suppress the banner AND ensureSkills'
|
|
474
|
+
// .agent/.specify writes, which were a surprising side effect of a bare
|
|
475
|
+
// `generate --plan` (and were already suppressed for `--plan --write`).
|
|
322
476
|
const jsonMode = flags.format === 'json';
|
|
323
|
-
const headless = jsonMode || flags.write || flags.checkOnly || flags.changedOnly || flags.quiet;
|
|
477
|
+
const headless = jsonMode || flags.write || flags.checkOnly || flags.changedOnly || flags.quiet || flags.plan;
|
|
324
478
|
|
|
325
479
|
if (!headless) printBanner();
|
|
326
480
|
|
package/cli/ensure-skills.mjs
CHANGED
|
@@ -46,9 +46,12 @@ const __dirname = dirname(__filename);
|
|
|
46
46
|
const SKILLS_SOURCE = resolve(__dirname, '..', 'extensions', 'spec-kit-docguard', 'skills');
|
|
47
47
|
const COMMANDS_SOURCE = resolve(__dirname, '..', 'commands');
|
|
48
48
|
|
|
49
|
-
// Destination in the user's project
|
|
49
|
+
// Destination in the user's project. Commands live UNDER `.agent/` alongside
|
|
50
|
+
// skills (was root `commands/`, which polluted the project namespace and got
|
|
51
|
+
// mis-scanned as source). `.agent/commands/` is the generic spec-kit convention
|
|
52
|
+
// agents already discover, and keeps DocGuard's footprint in one place.
|
|
50
53
|
const SKILLS_DEST = '.agent/skills';
|
|
51
|
-
const COMMANDS_DEST = 'commands';
|
|
54
|
+
const COMMANDS_DEST = '.agent/commands';
|
|
52
55
|
|
|
53
56
|
// ── Agent Mode Detection ────────────────────────────────────────────────
|
|
54
57
|
|
|
@@ -195,14 +198,6 @@ export function isSpecKitInitialized(projectDir) {
|
|
|
195
198
|
|
|
196
199
|
// ── Spec-Kit Integration Gate ───────────────────────────────────────────
|
|
197
200
|
|
|
198
|
-
// Read DocGuard package version (for skill auto-update)
|
|
199
|
-
const PKG_VERSION = (() => {
|
|
200
|
-
try {
|
|
201
|
-
const pkg = JSON.parse(readFileSync(resolve(__dirname, '..', 'package.json'), 'utf-8'));
|
|
202
|
-
return pkg.version || '0.0.0';
|
|
203
|
-
} catch { return '0.0.0'; }
|
|
204
|
-
})();
|
|
205
|
-
|
|
206
201
|
const SPEC_KIT_INSTALL_CMD = 'uv tool install specify-cli --from git+https://github.com/github/spec-kit.git';
|
|
207
202
|
|
|
208
203
|
/**
|
|
@@ -225,6 +220,13 @@ export function ensureSpecKit(projectDir, flags = {}) {
|
|
|
225
220
|
return { specKitReady: true };
|
|
226
221
|
}
|
|
227
222
|
|
|
223
|
+
// Caller opted out of the Spec Kit framework scaffold (--no-spec-kit, or the
|
|
224
|
+
// minimal `starter` profile which passes noSpecKit through). Don't auto-init
|
|
225
|
+
// and don't nag — DocGuard's own skills/commands still install below.
|
|
226
|
+
if (flags.noSpecKit) {
|
|
227
|
+
return { specKitReady: false, skipped: true };
|
|
228
|
+
}
|
|
229
|
+
|
|
228
230
|
// Spec-kit CLI available — auto-initialize
|
|
229
231
|
if (isSpecKitAvailable()) {
|
|
230
232
|
if (!silent) {
|
|
@@ -298,26 +300,22 @@ export function ensureSkills(projectDir, flags = {}) {
|
|
|
298
300
|
|
|
299
301
|
for (const skillDir of skillDirs) {
|
|
300
302
|
const destDir = resolve(projectDir, SKILLS_DEST, skillDir);
|
|
301
|
-
if (!existsSync(destDir)) {
|
|
302
|
-
mkdirSync(destDir, { recursive: true });
|
|
303
|
-
}
|
|
304
303
|
const srcSkill = resolve(SKILLS_SOURCE, skillDir, 'SKILL.md');
|
|
305
304
|
const destSkill = resolve(destDir, 'SKILL.md');
|
|
306
305
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
306
|
+
const srcContent = readFileSync(srcSkill, 'utf-8');
|
|
307
|
+
const installedContent = existsSync(destSkill) ? readFileSync(destSkill, 'utf-8') : null;
|
|
308
|
+
|
|
309
|
+
// Content-equality gate: write only when the bundled skill differs from
|
|
310
|
+
// what's on disk. Covers a fresh install AND a genuine update, but stops
|
|
311
|
+
// the per-command rewrite churn the old version-marker gate caused — a
|
|
312
|
+
// skill whose SKILL.md lacked a `docguard:version:` marker compared as
|
|
313
|
+
// '0.0.0', so it was rewritten (and announced) on EVERY command, even
|
|
314
|
+
// read-only ones like `explain`/`score` (field report, Issue D).
|
|
315
|
+
if (installedContent !== srcContent) {
|
|
316
|
+
if (!existsSync(destDir)) mkdirSync(destDir, { recursive: true });
|
|
317
|
+
writeFileSync(destSkill, srcContent, 'utf-8');
|
|
310
318
|
result.skillsInstalled = true;
|
|
311
|
-
} else {
|
|
312
|
-
// Auto-update: check if package version is newer than installed
|
|
313
|
-
const installedContent = readFileSync(destSkill, 'utf-8');
|
|
314
|
-
const versionMatch = installedContent.match(/docguard:version:\s*(\S+)/);
|
|
315
|
-
const installedVersion = versionMatch ? versionMatch[1] : '0.0.0';
|
|
316
|
-
|
|
317
|
-
if (installedVersion !== PKG_VERSION) {
|
|
318
|
-
writeFileSync(destSkill, readFileSync(srcSkill, 'utf-8'), 'utf-8');
|
|
319
|
-
result.skillsInstalled = true;
|
|
320
|
-
}
|
|
321
319
|
}
|
|
322
320
|
}
|
|
323
321
|
|
package/cli/scanners/api-doc.mjs
CHANGED
|
@@ -22,7 +22,17 @@ const HTTP_METHODS = new Set(['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', '
|
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
24
|
* Normalize an API path for comparison.
|
|
25
|
-
* Strips decoration and collapses
|
|
25
|
+
* Strips decoration and collapses ALL dynamic-segment syntaxes to a single `{}`
|
|
26
|
+
* placeholder so an endpoint documented one way matches the same endpoint
|
|
27
|
+
* emitted another way:
|
|
28
|
+
* Express/colon `/users/:id` → `/users/{}`
|
|
29
|
+
* OpenAPI/brace `/users/{id}` → `/users/{}`
|
|
30
|
+
* Next.js bracket `/users/[id]` → `/users/{}`
|
|
31
|
+
* catch-all `/auth/[...nextauth]`, `/auth/:nextauth*` → `/auth/{}`
|
|
32
|
+
* optional c-all `/shop/[[...filters]]` → `/shop/{}`
|
|
33
|
+
* Without the bracket rule, a doc written in Next.js `[id]` syntax never matched
|
|
34
|
+
* the code-scan's `:id`, so every dynamic route double-fired as both
|
|
35
|
+
* "documented-but-absent" and "undocumented" (field test: hugocross_revamp).
|
|
26
36
|
* @param {string} raw
|
|
27
37
|
* @returns {string} normalized path (e.g. "/api/users/{}") or '' if not a path
|
|
28
38
|
*/
|
|
@@ -34,8 +44,12 @@ export function normalizePath(raw) {
|
|
|
34
44
|
// cut query string / fragment
|
|
35
45
|
p = p.split(/[?#]/)[0];
|
|
36
46
|
if (!p.startsWith('/')) return '';
|
|
37
|
-
// collapse param syntax
|
|
38
|
-
|
|
47
|
+
// collapse every param syntax to {}: Next.js [id]/[...slug]/[[...slug]],
|
|
48
|
+
// OpenAPI {param}, and colon :param (incl. catch-all :param*).
|
|
49
|
+
p = p
|
|
50
|
+
.replace(/\[{1,2}[^\]]*\]{1,2}/g, '{}')
|
|
51
|
+
.replace(/\{[^}/]+\}/g, '{}')
|
|
52
|
+
.replace(/:[^/]+/g, '{}');
|
|
39
53
|
// strip trailing slash (but keep root "/")
|
|
40
54
|
if (p.length > 1) p = p.replace(/\/+$/, '');
|
|
41
55
|
return p;
|
|
@@ -9,6 +9,21 @@
|
|
|
9
9
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
10
10
|
import { resolve, join } from 'node:path';
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Read + parse a JSON file, returning null on any error (missing, unreadable,
|
|
14
|
+
* malformed). A bare `JSON.parse(readFileSync(...))` on a malformed
|
|
15
|
+
* `package.json` used to THROW out of a detector and abort the entire
|
|
16
|
+
* `detectDocTools` scan — which then made the memory plan (and every validator
|
|
17
|
+
* that compares against it) see empty "truth" and falsely pass. Fail soft.
|
|
18
|
+
*/
|
|
19
|
+
function readJsonSafe(path) {
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(readFileSync(path, 'utf-8'));
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
12
27
|
/**
|
|
13
28
|
* Detect all documentation tools present in the project.
|
|
14
29
|
* @param {string} dir - Project root directory
|
|
@@ -57,6 +72,7 @@ export function detectOpenAPI(dir) {
|
|
|
57
72
|
endpoints: spec.endpoints,
|
|
58
73
|
schemas: spec.schemas,
|
|
59
74
|
info: spec.info,
|
|
75
|
+
parseIncomplete: spec.parseIncomplete === true,
|
|
60
76
|
};
|
|
61
77
|
}
|
|
62
78
|
}
|
|
@@ -122,6 +138,14 @@ function parseOpenAPISpec(content, filename) {
|
|
|
122
138
|
}
|
|
123
139
|
} catch { /* spec parsing failed, return empty */ }
|
|
124
140
|
|
|
141
|
+
// Honest-failure signal: a spec that clearly declares a `paths:` section but
|
|
142
|
+
// yielded ZERO endpoints means our parser couldn't extract them (anchors,
|
|
143
|
+
// unresolved $ref, folded scalars, or a YAML feature the minimal parser
|
|
144
|
+
// doesn't cover). Flag it so the caller can WARN and fall back to code
|
|
145
|
+
// scanning, instead of silently reporting "no API surface" — a false green.
|
|
146
|
+
const hasPathsKey = /(^|\n)[ \t]*["']?paths["']?[ \t]*:/.test(content);
|
|
147
|
+
result.parseIncomplete = hasPathsKey && result.endpoints.length === 0;
|
|
148
|
+
|
|
125
149
|
return result;
|
|
126
150
|
}
|
|
127
151
|
|
|
@@ -215,12 +239,9 @@ function detectTypeDoc(dir) {
|
|
|
215
239
|
}
|
|
216
240
|
|
|
217
241
|
// Check package.json devDeps
|
|
218
|
-
const
|
|
219
|
-
if (
|
|
220
|
-
|
|
221
|
-
if (pkg.devDependencies?.typedoc) {
|
|
222
|
-
return { found: true, config: 'package.json (devDependency)' };
|
|
223
|
-
}
|
|
242
|
+
const pkg = readJsonSafe(resolve(dir, 'package.json'));
|
|
243
|
+
if (pkg?.devDependencies?.typedoc) {
|
|
244
|
+
return { found: true, config: 'package.json (devDependency)' };
|
|
224
245
|
}
|
|
225
246
|
|
|
226
247
|
return { found: false };
|
|
@@ -236,12 +257,9 @@ function detectJSDoc(dir) {
|
|
|
236
257
|
}
|
|
237
258
|
}
|
|
238
259
|
|
|
239
|
-
const
|
|
240
|
-
if (
|
|
241
|
-
|
|
242
|
-
if (pkg.devDependencies?.jsdoc) {
|
|
243
|
-
return { found: true, config: 'package.json (devDependency)' };
|
|
244
|
-
}
|
|
260
|
+
const pkg = readJsonSafe(resolve(dir, 'package.json'));
|
|
261
|
+
if (pkg?.devDependencies?.jsdoc) {
|
|
262
|
+
return { found: true, config: 'package.json (devDependency)' };
|
|
245
263
|
}
|
|
246
264
|
|
|
247
265
|
return { found: false };
|
|
@@ -316,9 +334,8 @@ function detectRedocly(dir) {
|
|
|
316
334
|
// ── Swagger UI ─────────────────────────────────────────────────────────────
|
|
317
335
|
|
|
318
336
|
function detectSwagger(dir) {
|
|
319
|
-
const
|
|
320
|
-
if (
|
|
321
|
-
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
337
|
+
const pkg = readJsonSafe(resolve(dir, 'package.json'));
|
|
338
|
+
if (pkg) {
|
|
322
339
|
const allDeps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
323
340
|
if (allDeps['swagger-ui-express'] || allDeps['@fastify/swagger'] || allDeps['swagger-jsdoc']) {
|
|
324
341
|
return {
|
|
@@ -16,12 +16,9 @@
|
|
|
16
16
|
|
|
17
17
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
18
18
|
import { resolve, join, relative, basename, extname } from 'node:path';
|
|
19
|
-
import { resolveSourceRoots, collectPackageJsons } from '../shared-source.mjs';
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
'node_modules', '.git', '.next', 'dist', 'build', 'coverage',
|
|
23
|
-
'.cache', '__pycache__', '.venv', 'vendor', '.turbo', '.vercel',
|
|
24
|
-
]);
|
|
19
|
+
import { resolveSourceRoots, collectPackageJsons, readScannable } from '../shared-source.mjs';
|
|
20
|
+
import { DEFAULT_IGNORE_DIRS as IGNORE_DIRS, shouldIgnore, relPosix } from '../shared-ignore.mjs';
|
|
21
|
+
import { extractJsxRouteScreens } from './js-ast.mjs';
|
|
25
22
|
const UI_EXT = new Set(['.tsx', '.jsx']);
|
|
26
23
|
|
|
27
24
|
function walk(dir, onFile, depth = 0) {
|
|
@@ -36,7 +33,7 @@ function walk(dir, onFile, depth = 0) {
|
|
|
36
33
|
}
|
|
37
34
|
}
|
|
38
35
|
|
|
39
|
-
function readSafe(p) {
|
|
36
|
+
function readSafe(p) { return readScannable(p) ?? ''; } // size-capped; skips bundles
|
|
40
37
|
|
|
41
38
|
/** Normalize a route path param syntax to {param} and strip trailing slash. */
|
|
42
39
|
function normRoute(p) {
|
|
@@ -111,6 +108,15 @@ function scanReactRouterScreens(roots, projectDir) {
|
|
|
111
108
|
if (!content.includes('<Route') && !content.includes('createBrowserRouter') &&
|
|
112
109
|
!content.includes('useRoutes') && !content.includes('createRoutesFrom')) return;
|
|
113
110
|
|
|
111
|
+
// AST-first: scopes each route's element JSX exactly (nested auth wrappers,
|
|
112
|
+
// layouts, and multi-line elements no longer truncate or mis-pick the
|
|
113
|
+
// screen). `null` → parse failure → the window-based regex fallback below.
|
|
114
|
+
const astScreens = extractJsxRouteScreens(content, file);
|
|
115
|
+
if (astScreens) {
|
|
116
|
+
for (const s of astScreens) add(s.path, pickScreenComponent(s.components), file);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
114
120
|
let m;
|
|
115
121
|
const re = new RegExp(pathRe.source, 'g');
|
|
116
122
|
while ((m = re.exec(content)) !== null) {
|
|
@@ -434,5 +440,15 @@ export function scanFrontend(projectDir, config = {}) {
|
|
|
434
440
|
a.path.localeCompare(b.path) || a.method.localeCompare(b.method));
|
|
435
441
|
const i18n = scanI18n(projectDir, roots);
|
|
436
442
|
|
|
437
|
-
|
|
443
|
+
// Honor .docguardignore / config.ignore — drop entries whose source file the
|
|
444
|
+
// user excluded (e.g. a fixtures/storybook dir). Entries without a `file`
|
|
445
|
+
// (or in i18n) are unaffected.
|
|
446
|
+
const keep = (arr) => Array.isArray(arr)
|
|
447
|
+
? arr.filter(x => !x || !x.file || !shouldIgnore(relPosix(projectDir, resolve(projectDir, x.file)), config))
|
|
448
|
+
: arr;
|
|
449
|
+
return {
|
|
450
|
+
...stack, routerType,
|
|
451
|
+
screens: keep(screens), components: keep(components), stores: keep(stores),
|
|
452
|
+
hooks: keep(hooks), contexts: keep(contexts), apiCalls: keep(apiCalls), i18n,
|
|
453
|
+
};
|
|
438
454
|
}
|