docguard-cli 0.22.1 → 0.24.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.
Files changed (61) hide show
  1. package/README.md +4 -4
  2. package/cli/commands/demo.mjs +1 -1
  3. package/cli/commands/diff.mjs +19 -8
  4. package/cli/commands/explain.mjs +178 -17
  5. package/cli/commands/fix.mjs +17 -2
  6. package/cli/commands/generate.mjs +2 -2
  7. package/cli/commands/guard.mjs +86 -11
  8. package/cli/commands/hooks.mjs +12 -7
  9. package/cli/commands/init.mjs +18 -6
  10. package/cli/commands/score.mjs +147 -61
  11. package/cli/commands/setup.mjs +2 -2
  12. package/cli/commands/trace.mjs +3 -101
  13. package/cli/commands/upgrade.mjs +61 -13
  14. package/cli/config.mjs +245 -0
  15. package/cli/docguard.mjs +21 -217
  16. package/cli/ensure-skills.mjs +24 -26
  17. package/cli/scanners/api-doc.mjs +17 -3
  18. package/cli/scanners/doc-tools.mjs +32 -15
  19. package/cli/scanners/frontend.mjs +24 -8
  20. package/cli/scanners/js-ast.mjs +432 -0
  21. package/cli/scanners/memory-plan.mjs +1 -1
  22. package/cli/scanners/py-ast.mjs +213 -0
  23. package/cli/scanners/routes.mjs +194 -69
  24. package/cli/scanners/schemas.mjs +97 -51
  25. package/cli/scanners/speckit.mjs +14 -0
  26. package/cli/shared-git.mjs +0 -0
  27. package/cli/shared-ignore.mjs +16 -1
  28. package/cli/shared-source.mjs +59 -2
  29. package/cli/shared-trace-patterns.mjs +118 -0
  30. package/cli/shared.mjs +60 -1
  31. package/cli/validator-markers.mjs +91 -0
  32. package/cli/validators/api-surface.mjs +37 -3
  33. package/cli/validators/canonical-sync.mjs +22 -19
  34. package/cli/validators/doc-quality.mjs +27 -44
  35. package/cli/validators/docs-coverage.mjs +13 -0
  36. package/cli/validators/docs-diff.mjs +16 -6
  37. package/cli/validators/docs-sync.mjs +4 -3
  38. package/cli/validators/drift.mjs +3 -2
  39. package/cli/validators/freshness.mjs +47 -15
  40. package/cli/validators/metadata-sync.mjs +21 -11
  41. package/cli/validators/metrics-consistency.mjs +45 -17
  42. package/cli/validators/security.mjs +13 -5
  43. package/cli/validators/structure.mjs +6 -5
  44. package/cli/validators/surface-sync.mjs +7 -5
  45. package/cli/validators/test-spec.mjs +76 -51
  46. package/cli/validators/todo-tracking.mjs +4 -2
  47. package/cli/validators/traceability.mjs +12 -54
  48. package/cli/writers/sections.mjs +32 -19
  49. package/docs/commands.md +1 -1
  50. package/docs/configuration.md +11 -0
  51. package/docs/faq.md +1 -1
  52. package/extensions/spec-kit-docguard/README.md +1 -1
  53. package/extensions/spec-kit-docguard/extension.yml +2 -2
  54. package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +2 -2
  55. package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +2 -2
  56. package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
  57. package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
  58. package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +2 -1
  59. package/extensions/spec-kit-docguard/templates/github-workflows/docguard-autofix.yml +3 -2
  60. package/extensions/spec-kit-docguard/templates/github-workflows/docguard-guard.yml +2 -2
  61. package/package.json +5 -3
@@ -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
- if (!existsSync(destSkill)) {
308
- // New install
309
- writeFileSync(destSkill, readFileSync(srcSkill, 'utf-8'), 'utf-8');
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
 
@@ -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 param syntax so `:id` and `{id}` match.
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: :param and {param} {}
38
- p = p.replace(/\{[^}/]+\}/g, '{}').replace(/:[^/]+/g, '{}');
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 pkgPath = resolve(dir, 'package.json');
219
- if (existsSync(pkgPath)) {
220
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
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 pkgPath = resolve(dir, 'package.json');
240
- if (existsSync(pkgPath)) {
241
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
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 pkgPath = resolve(dir, 'package.json');
320
- if (existsSync(pkgPath)) {
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
- const IGNORE_DIRS = new Set([
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) { try { return readFileSync(p, 'utf-8'); } catch { return ''; } }
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
- return { ...stack, routerType, screens, components, stores, hooks, contexts, apiCalls, i18n };
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
  }