docguard-cli 0.11.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -343,7 +343,7 @@ DocGuard provides AI agent slash commands for integrated workflows. Installed au
343
343
 
344
344
  | Command | What It Does |
345
345
  |:--------|:-------------|
346
- | `/docguard.guard` | Run quality validation — check all 20 validators |
346
+ | `/docguard.guard` | Run quality validation — check all 21 validators |
347
347
  | `/docguard.review` | Analyze doc quality and suggest improvements |
348
348
  | `/docguard.fix` | Generate targeted fix prompts for specific issues |
349
349
  | `/docguard.score` | Show CDD maturity score with category breakdown |
@@ -433,20 +433,42 @@ DocGuard runs its own `guard`, `score`, `diff`, `diagnose`, and `badge` commands
433
433
 
434
434
  ## ⚙️ CI/CD Integration
435
435
 
436
- ### GitHub Actions
436
+ > **Full recipes:** see [`docs-canonical/CI-RECIPES.md`](./docs-canonical/CI-RECIPES.md) for guard, auto-fix (commits mechanical fixes back to PRs), nightly sync, score-on-PR, and pre-commit configs.
437
+
438
+ ### GitHub Actions — Guard (most common)
437
439
 
438
440
  ```yaml
439
- name: DocGuard CDD Check
440
- on: [pull_request]
441
+ name: DocGuard Guard
442
+ on: [pull_request, push]
441
443
  jobs:
442
444
  docguard:
443
445
  runs-on: ubuntu-latest
444
446
  steps:
445
447
  - uses: actions/checkout@v4
446
- - uses: actions/setup-node@v4
447
- with: { node-version: '20' }
448
- - run: npx docguard-cli guard
449
- - run: npx docguard-cli score --format json
448
+ with: { fetch-depth: 0 }
449
+ - uses: raccioly/docguard@v0.12.0
450
+ with:
451
+ command: guard
452
+ ```
453
+
454
+ ### GitHub Actions — Auto-Fix (commits mechanical fixes back)
455
+
456
+ ```yaml
457
+ name: DocGuard Auto-Fix
458
+ on: { pull_request: { types: [opened, synchronize, reopened] } }
459
+ permissions: { contents: write, pull-requests: write }
460
+ jobs:
461
+ autofix:
462
+ runs-on: ubuntu-latest
463
+ if: github.event.pull_request.head.repo.full_name == github.repository
464
+ steps:
465
+ - uses: actions/checkout@v4
466
+ with:
467
+ ref: ${{ github.event.pull_request.head.ref }}
468
+ token: ${{ secrets.GITHUB_TOKEN }}
469
+ fetch-depth: 0
470
+ - uses: raccioly/docguard@v0.12.0
471
+ with: { command: fix, auto-commit: 'true', comment-on-pr: 'true' }
450
472
  ```
451
473
 
452
474
  ### Pre-commit Hook
@@ -455,14 +477,11 @@ jobs:
455
477
  npx docguard-cli hooks --type pre-commit
456
478
  ```
457
479
 
458
- ### GitHub Marketplace
480
+ ### Workflow starters (copy directly)
459
481
 
460
- ```yaml
461
- - uses: raccioly/docguard@v0.9.7
462
- with:
463
- command: guard
464
- fail-on-warning: true
465
- ```
482
+ Two ready-to-use templates ship with the Spec Kit extension and as standalone files:
483
+ - `extensions/spec-kit-docguard/templates/github-workflows/docguard-guard.yml` — mandatory CI gate
484
+ - `extensions/spec-kit-docguard/templates/github-workflows/docguard-autofix.yml` — PR auto-fix
466
485
 
467
486
  ---
468
487
 
@@ -10,6 +10,8 @@ import { collectPackageJsons, detectDocker, grepEnvUsage, resolveSourceRoots } f
10
10
  import { parseApiReferenceDoc, compareEndpoints } from '../scanners/api-doc.mjs';
11
11
  import { resolveApiSurface } from '../validators/api-surface.mjs';
12
12
  import { collectCodeTests } from '../validators/docs-diff.mjs';
13
+ import { scanSchemasDeep } from '../scanners/schemas.mjs';
14
+ import { detectDocTools } from '../scanners/doc-tools.mjs';
13
15
 
14
16
  const IGNORE_DIRS = new Set([
15
17
  'node_modules', '.git', '.next', 'dist', 'build',
@@ -163,22 +165,17 @@ function diffEntities(dir, config = {}) {
163
165
  docEntities.add(name.toLowerCase());
164
166
  }
165
167
 
166
- // Find model/entity files in code monorepo-aware (honors config.sourceRoot/workspaces).
168
+ // Use the REAL exported entity names from scanSchemasDeep, not file basenames
169
+ // (a file `dynamoModels.ts` exports `User`/`Order`/etc. — its basename is not
170
+ // an entity). scanSchemasDeep covers JS ORMs, SQLAlchemy/Pydantic, Diesel,
171
+ // Go structs, JPA, Rails, and OpenAPI schemas.
172
+ const docTools = detectDocTools(dir);
173
+ const schemas = scanSchemasDeep(dir, {}, docTools);
167
174
  const codeEntities = new Set();
168
- const modelSubdirs = ['models', 'entities', 'schema', 'schemas', 'prisma'];
169
- const roots = resolveSourceRoots(dir, config);
170
- for (const root of roots) {
171
- for (const sub of modelSubdirs) {
172
- const modelDir = join(root, sub);
173
- if (!existsSync(modelDir)) continue;
174
- const files = getFilesRecursive(modelDir);
175
- for (const f of files) {
176
- const name = basename(f, extname(f)).toLowerCase();
177
- // Skip non-entity infrastructure/aggregation filenames.
178
- if (CODE_ENTITY_NOISE.has(name)) continue;
179
- codeEntities.add(name);
180
- }
181
- }
175
+ for (const e of (schemas.entities || [])) {
176
+ const n = String(e.name || '').toLowerCase();
177
+ if (!n || CODE_ENTITY_NOISE.has(n)) continue;
178
+ codeEntities.add(n);
182
179
  }
183
180
 
184
181
  // No code-side entity source (e.g. DynamoDB single-table design with no model
@@ -207,7 +204,8 @@ function diffEnvVars(dir, config = {}) {
207
204
 
208
205
  // Extract env var names from ENVIRONMENT.md
209
206
  const docVars = new Set();
210
- const varRegex = /`([A-Z][A-Z0-9_]{2,})`/g;
207
+ // Reject names ending in `_` (e.g. the literal prefix `VITE_` in prose).
208
+ const varRegex = /`([A-Z][A-Z0-9_]*[A-Z0-9])`/g;
211
209
  let match;
212
210
  while ((match = varRegex.exec(content)) !== null) {
213
211
  docVars.add(match[1]);
@@ -220,7 +218,7 @@ function diffEnvVars(dir, config = {}) {
220
218
  const envExamplePath = resolve(dir, envFile);
221
219
  if (existsSync(envExamplePath)) {
222
220
  const envContent = readFileSync(envExamplePath, 'utf-8');
223
- const envRegex = /^([A-Z][A-Z0-9_]+)\s*=/gm;
221
+ const envRegex = /^([A-Z][A-Z0-9_]*[A-Z0-9])\s*=/gm;
224
222
  while ((match = envRegex.exec(envContent)) !== null) {
225
223
  codeVars.add(match[1]);
226
224
  }
@@ -7,8 +7,9 @@
7
7
  * runGuardInternal() → returns data, no side effects (for diagnose, ci)
8
8
  */
9
9
 
10
- import { c } from '../shared.mjs';
10
+ import { c, resolveSeverity } from '../shared.mjs';
11
11
  import { detectAgentMode, isSpecKitInitialized } from '../ensure-skills.mjs';
12
+ import { checkUpgradeStatus } from './upgrade.mjs';
12
13
  import { validateStructure, validateDocSections } from '../validators/structure.mjs';
13
14
  import { validateDrift } from '../validators/drift.mjs';
14
15
  import { validateChangelog } from '../validators/changelog.mjs';
@@ -25,6 +26,7 @@ import { validateMetadataSync } from '../validators/metadata-sync.mjs';
25
26
  import { validateMetricsConsistency } from '../validators/metrics-consistency.mjs';
26
27
  import { validateDocsCoverage } from '../validators/docs-coverage.mjs';
27
28
  import { validateDocQuality } from '../validators/doc-quality.mjs';
29
+ import { validateCrossReferences } from '../validators/cross-reference.mjs';
28
30
  import { validateTodoTracking } from '../validators/todo-tracking.mjs';
29
31
  import { validateSchemaSync } from '../validators/schema-sync.mjs';
30
32
  import { validateSpecKitIntegration } from '../scanners/speckit.mjs';
@@ -100,6 +102,7 @@ export function runGuardInternal(projectDir, config) {
100
102
  { key: 'todoTracking', name: 'TODO-Tracking', fn: () => validateTodoTracking(projectDir, config) },
101
103
  { key: 'schemaSync', name: 'Schema-Sync', fn: () => validateSchemaSync(projectDir, config) },
102
104
  { key: 'specKit', name: 'Spec-Kit', fn: () => validateSpecKitIntegration(projectDir, config) },
105
+ { key: 'crossReference', name: 'Cross-Reference', fn: () => validateCrossReferences(projectDir, config) },
103
106
  // Metrics-Consistency runs post-loop (needs guard results)
104
107
  ];
105
108
 
@@ -133,6 +136,25 @@ export function runGuardInternal(projectDir, config) {
133
136
  const totalPassed = activeResults.reduce((sum, r) => sum + r.passed, 0);
134
137
  const totalChecks = activeResults.reduce((sum, r) => sum + r.total, 0);
135
138
 
139
+ // Per-validator severity overrides (v0.5 schema). Affects EXIT-CODE only,
140
+ // not display. Annotate each validator with its resolved severity and roll
141
+ // up effective error/warning counts:
142
+ // - high → validator's warnings get promoted to "effective errors"
143
+ // - low → validator's warnings are demoted (ignored for exit code)
144
+ // - medium (default) → warnings stay as-is
145
+ for (const v of activeResults) {
146
+ v.severity = resolveSeverity(config, v.key);
147
+ }
148
+ let effectiveErrors = totalErrors;
149
+ let effectiveWarnings = 0;
150
+ for (const v of activeResults) {
151
+ const wCount = v.warnings.length;
152
+ if (wCount === 0) continue;
153
+ if (v.severity === 'high') effectiveErrors += wCount;
154
+ else if (v.severity === 'low') { /* ignored for exit */ }
155
+ else effectiveWarnings += wCount;
156
+ }
157
+
136
158
  const overallStatus = totalErrors > 0 ? 'FAIL' : totalWarnings > 0 ? 'WARN' : 'PASS';
137
159
 
138
160
  return {
@@ -143,22 +165,66 @@ export function runGuardInternal(projectDir, config) {
143
165
  total: totalChecks,
144
166
  errors: totalErrors,
145
167
  warnings: totalWarnings,
168
+ // v0.5: severity-aware counts for exit-code logic. The display still uses
169
+ // the raw counts above so users see every warning, but CI only fails on
170
+ // things they've marked as high-severity.
171
+ effectiveErrors,
172
+ effectiveWarnings,
146
173
  validators: results,
147
174
  timestamp: new Date().toISOString(),
148
175
  };
149
176
  }
150
177
 
178
+ /**
179
+ * The "pre-commit lite" validator set — fast checks suitable for running
180
+ * on every commit/save. Tuned for <2s wall-clock on average repos.
181
+ *
182
+ * The list is intentionally short: validators that catch >80% of the
183
+ * common doc drift that developers introduce mid-feature (route added but
184
+ * not documented, env var renamed but not updated in ENVIRONMENT.md,
185
+ * endpoint deleted but still in API-REFERENCE.md). Heavy validators —
186
+ * Freshness (git log), Traceability (REQ scan), Doc-Quality (prose lint) —
187
+ * stay off for speed.
188
+ */
189
+ export const CHANGED_ONLY_VALIDATORS = ['docsSync', 'environment', 'apiSurface'];
190
+
191
+ /**
192
+ * Build a validators map that enables only the pre-commit-lite set.
193
+ * Used by `docguard guard --changed-only`.
194
+ */
195
+ function liteValidatorsConfig() {
196
+ const all = [
197
+ 'structure', 'docsSync', 'drift', 'changelog', 'testSpec', 'environment',
198
+ 'security', 'architecture', 'freshness', 'traceability', 'docsDiff',
199
+ 'apiSurface', 'metadataSync', 'docsCoverage', 'docQuality', 'todoTracking',
200
+ 'schemaSync', 'specKit', 'crossReference', 'metricsConsistency',
201
+ ];
202
+ const out = {};
203
+ for (const k of all) out[k] = CHANGED_ONLY_VALIDATORS.includes(k);
204
+ return out;
205
+ }
206
+
151
207
  /**
152
208
  * Public guard — prints results and exits.
153
209
  */
154
210
  export function runGuard(projectDir, config, flags) {
211
+ // --changed-only: pre-commit lite mode. Overrides the validator set to a
212
+ // fast subset (Docs-Sync, Environment, API-Surface). Designed for husky/
213
+ // lefthook hooks; expects to finish in under 2 seconds.
214
+ if (flags.changedOnly) {
215
+ config = { ...config, validators: liteValidatorsConfig() };
216
+ console.log(`${c.cyan}⚡ docguard guard --changed-only${c.reset} ${c.dim}(running ${CHANGED_ONLY_VALIDATORS.length} fast validators only — pre-commit lite mode)${c.reset}\n`);
217
+ }
218
+
155
219
  const data = runGuardInternal(projectDir, config);
156
220
 
157
221
  // ── JSON output ──
158
222
  if (flags.format === 'json') {
159
223
  console.log(JSON.stringify(data, null, 2));
160
- if (data.errors > 0) process.exit(1);
161
- if (data.warnings > 0) process.exit(2);
224
+ // Use severity-aware effective counts for exit code; raw counts stay in the JSON
225
+ // for display tools that want to show the full picture.
226
+ if (data.effectiveErrors > 0) process.exit(1);
227
+ if (data.effectiveWarnings > 0) process.exit(2);
162
228
  process.exit(0);
163
229
  }
164
230
 
@@ -194,16 +260,26 @@ export function runGuard(projectDir, config, flags) {
194
260
  console.log(` ${c.yellow}⚠️ ${v.name}${c.reset} ${qBadge}${c.dim} ${v.passed}/${v.total} checks passed${c.reset}`);
195
261
  }
196
262
 
197
- if (flags.verbose || v.status === 'fail') {
263
+ // --show-failing forces enumeration of every error/warning regardless of
264
+ // overall validator status — useful when a validator passes overall
265
+ // (passed < total) without surfacing the specific failing checks.
266
+ const show = flags.verbose || flags.showFailing;
267
+ if (show || v.status === 'fail') {
198
268
  for (const err of v.errors) {
199
269
  console.log(` ${c.red}✗ ${err}${c.reset}`);
200
270
  }
201
271
  }
202
- if (flags.verbose || v.status === 'warn') {
272
+ if (show || v.status === 'warn') {
203
273
  for (const warn of v.warnings) {
204
274
  console.log(` ${c.yellow}⚠ ${warn}${c.reset}`);
205
275
  }
206
276
  }
277
+ // If a validator reports passed < total but has no errors/warnings, surface
278
+ // the gap honestly so users aren't left wondering where the deficit went.
279
+ if (v.status === 'pass' && v.total > v.passed && v.errors.length === 0 && v.warnings.length === 0) {
280
+ const gap = v.total - v.passed;
281
+ console.log(` ${c.yellow}⚠ ${gap} check(s) did not pass but emitted no message — likely a validator bug. Please file an issue.${c.reset}`);
282
+ }
207
283
  }
208
284
 
209
285
  // Summary
@@ -233,14 +309,55 @@ export function runGuard(projectDir, config, flags) {
233
309
  const badgeUrl = `https://img.shields.io/badge/CDD_Guard-${data.passed}%2F${data.total}_passed-${bColor}`;
234
310
  console.log(`\n ${c.dim}📎 Badge: ![CDD Guard](${badgeUrl})${c.reset}`);
235
311
 
312
+ // Schema upgrade nudge — fires when the project's .docguard.json schema is
313
+ // behind the CLI's CURRENT_SCHEMA_VERSION. Cheap, file-local check; no
314
+ // network access. Suppressed in JSON output to keep machine consumers clean.
315
+ if (!flags || flags.format !== 'json') {
316
+ const upgradeHint = checkUpgradeStatus(projectDir);
317
+ if (upgradeHint) {
318
+ console.log(`\n ${c.yellow}↑ ${upgradeHint}${c.reset}`);
319
+ }
320
+
321
+ // K-6 / S-2: sweep-needed nudge. Aggregates freshness warnings — if 2+
322
+ // canonical docs are stale (matching the "X code commits since last doc
323
+ // update" pattern), suggest a single `docguard sync --write` pass that
324
+ // refreshes every code-truth section in one shot. Individual freshness
325
+ // warnings already named the docs; this nudge just turns "5 warnings"
326
+ // into one actionable recommendation.
327
+ const freshness = data.validators.find(v => v.key === 'freshness');
328
+ if (freshness && freshness.warnings) {
329
+ const staleDocs = freshness.warnings.filter(w => /\d+ code commits since/.test(w));
330
+ if (staleDocs.length >= 2) {
331
+ console.log(`\n ${c.yellow}↻ ${staleDocs.length} docs are stale (10+ commits since last update). Run ${c.cyan}docguard sync --write${c.yellow} to refresh code-truth sections in one pass.${c.reset}`);
332
+ }
333
+ }
334
+ }
335
+
236
336
  // Spec-kit reminder — persistent nudge if not initialized
237
337
  if (!isSpecKitInitialized(projectDir)) {
238
338
  console.log(`\n ${c.yellow}💡${c.reset} ${c.dim}Enhance DocGuard with Spec Kit: ${c.cyan}uv tool install specify-cli --from git+https://github.com/github/spec-kit.git${c.reset}`);
239
339
  }
240
340
 
341
+ // When severity overrides demoted warnings to "low" (or promoted them to
342
+ // "high"), show a one-line note so the user knows the exit code may not
343
+ // match what they expected from reading the warning count.
344
+ const severityShifted =
345
+ data.effectiveErrors !== data.errors || data.effectiveWarnings !== data.warnings;
346
+ if (severityShifted) {
347
+ const upgraded = data.effectiveErrors - data.errors;
348
+ const ignored = data.warnings - data.effectiveWarnings - upgraded;
349
+ const parts = [];
350
+ if (upgraded > 0) parts.push(`${upgraded} warning(s) escalated to fail (severity=high)`);
351
+ if (ignored > 0) parts.push(`${ignored} warning(s) ignored for exit code (severity=low)`);
352
+ if (parts.length > 0) {
353
+ console.log(`\n ${c.dim}Severity override: ${parts.join('; ')}.${c.reset}`);
354
+ }
355
+ }
356
+
241
357
  console.log('');
242
358
 
243
- if (data.errors > 0) process.exit(1);
244
- if (data.warnings > 0) process.exit(2);
359
+ // v0.5: severity-aware exit codes (see runGuardInternal for the rollup).
360
+ if (data.effectiveErrors > 0) process.exit(1);
361
+ if (data.effectiveWarnings > 0) process.exit(2);
245
362
  process.exit(0);
246
363
  }
@@ -168,7 +168,7 @@ export async function runInit(projectDir, config, flags) {
168
168
 
169
169
  const defaultConfig = {
170
170
  projectName: config.projectName,
171
- version: '0.4',
171
+ version: '0.5',
172
172
  profile: profileName,
173
173
  projectType: detectedType,
174
174
  projectTypeConfig: ptc,
@@ -186,6 +186,13 @@ export async function runInit(projectDir, config, flags) {
186
186
  environment: true,
187
187
  freshness: true,
188
188
  },
189
+ // Per-validator severity overrides (v0.5+).
190
+ // 'high': warnings from this validator fail CI (exit 1)
191
+ // 'medium': default — warnings exit 2 (informational)
192
+ // 'low': warnings ignored for exit code (exit 0)
193
+ // Empty by default — every validator uses 'medium'. Add entries to dial
194
+ // strictness up (CI-critical checks) or down (experimental validators).
195
+ severity: {},
189
196
  };
190
197
 
191
198
  writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2) + '\n', 'utf-8');
@@ -196,6 +203,50 @@ export async function runInit(projectDir, config, flags) {
196
203
  console.log(` ${c.yellow}⏭️${c.reset} .docguard.json ${c.dim}(already exists)${c.reset}`);
197
204
  }
198
205
 
206
+ // ── Create .docguardignore ────────────────────────────────────────────
207
+ // Starter ignore file with gitignore-style syntax. Patterns here are merged
208
+ // into config.ignore at load time so every validator honors them.
209
+ const ignorePath = resolve(projectDir, '.docguardignore');
210
+ if (!existsSync(ignorePath)) {
211
+ const ignoreContent = `# .docguardignore — paths to exclude from DocGuard validation.
212
+ # Gitignore-style syntax: one pattern per line, # for comments.
213
+ # Merged into config.ignore (in .docguard.json) at runtime.
214
+ #
215
+ # Common examples:
216
+ # build/ # exclude a directory
217
+ # **/__generated__/** # exclude anything in any __generated__ dir
218
+ # vendor/legacy.ts # exclude a single file
219
+ # **/*.snap # exclude all files matching a glob
220
+ #
221
+ # Build outputs, vendored libs, generated code are good candidates here.
222
+
223
+ # Vendored / generated code that's not yours to document
224
+ **/__generated__/**
225
+ **/generated/**
226
+ **/*.generated.*
227
+
228
+ # Migrations and lock files
229
+ **/migrations/**
230
+ **/*.lock
231
+ package-lock.json
232
+ yarn.lock
233
+ pnpm-lock.yaml
234
+ Cargo.lock
235
+ poetry.lock
236
+
237
+ # Common build artifacts (defaults also cover these, but listing here is clearer)
238
+ # dist/
239
+ # build/
240
+ # coverage/
241
+ `;
242
+ writeFileSync(ignorePath, ignoreContent, 'utf-8');
243
+ created.push('.docguardignore');
244
+ console.log(` ${c.green}✅${c.reset} Created: ${c.cyan}.docguardignore${c.reset} ${c.dim}(gitignore-style exclusions)${c.reset}`);
245
+ } else {
246
+ skipped.push('.docguardignore');
247
+ console.log(` ${c.yellow}⏭️${c.reset} .docguardignore ${c.dim}(already exists)${c.reset}`);
248
+ }
249
+
199
250
  // ── Spec-Kit Integration (Extension-First) ────────────────────────────
200
251
  // Delegate LLM/IDE detection and spec-kit skill install to `specify init`
201
252
  const specKitAvailable = isSpecKitAvailable();
@@ -0,0 +1,250 @@
1
+ /**
2
+ * `docguard upgrade` — check whether the installed CLI and the project's
3
+ * .docguard.json schema are current, and (with --apply) migrate them.
4
+ *
5
+ * Why this exists:
6
+ * Users were running stale CLI versions against new project setups, getting
7
+ * confusing "validator missing" or "field unknown" warnings. A one-shot
8
+ * `docguard upgrade` lets them see the gap and fix it in seconds.
9
+ *
10
+ * Three modes:
11
+ * docguard upgrade — report current vs latest, exit 0
12
+ * docguard upgrade --check-only — same report, exit 1 if behind (for CI)
13
+ * docguard upgrade --apply — actually run `npm i -g docguard-cli@latest`
14
+ * and migrate .docguard.json if needed
15
+ *
16
+ * Network access (npm registry fetch) is OPTIONAL — if offline, we fall back
17
+ * to "could not check remote version" without erroring.
18
+ */
19
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
20
+ import { resolve, dirname } from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+ import { spawnSync } from 'node:child_process';
23
+
24
+ import { c, CURRENT_SCHEMA_VERSION, compareVersions } from '../shared.mjs';
25
+
26
+ const __dirname = dirname(fileURLToPath(import.meta.url));
27
+ const PKG = JSON.parse(readFileSync(resolve(__dirname, '..', '..', 'package.json'), 'utf-8'));
28
+ const INSTALLED_VERSION = PKG.version;
29
+
30
+ /**
31
+ * Fetch the latest published version from the npm registry. Uses node's
32
+ * built-in fetch (Node 18+). Returns null on timeout/error/offline.
33
+ *
34
+ * 3-second timeout — we never want this command to feel slow.
35
+ */
36
+ async function fetchLatestNpmVersion() {
37
+ const controller = new AbortController();
38
+ const t = setTimeout(() => controller.abort(), 3000);
39
+ try {
40
+ const r = await fetch('https://registry.npmjs.org/docguard-cli/latest', {
41
+ signal: controller.signal,
42
+ headers: { Accept: 'application/json' },
43
+ });
44
+ if (!r.ok) return null;
45
+ const data = await r.json();
46
+ return data && data.version ? data.version : null;
47
+ } catch {
48
+ return null;
49
+ } finally {
50
+ clearTimeout(t);
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Read the project's stored schema version from .docguard.json. Returns:
56
+ * - null : file missing OR unparseable (caller shows "init recommended")
57
+ * - '0.0' : file exists but no `version` field (pre-0.4 schemas — these
58
+ * predate the version field and need migration)
59
+ * - 'x.y' : the stored version string
60
+ */
61
+ function readProjectSchemaVersion(projectDir) {
62
+ const p = resolve(projectDir, '.docguard.json');
63
+ if (!existsSync(p)) return null;
64
+ try {
65
+ const cfg = JSON.parse(readFileSync(p, 'utf-8'));
66
+ // Pre-0.4 schemas (e.g. wu-whatsappinbox's original config from 2024)
67
+ // have no `version` field. Treat as 0.0 so the migration runs end-to-end.
68
+ return cfg.version || '0.0';
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Idempotent migration: walk the project config and add any fields introduced
76
+ * since the stored schema version. Returns { changed, newConfig }.
77
+ *
78
+ * Each migration is keyed by the version it migrates TO. Adding a new schema
79
+ * version means adding one entry here.
80
+ */
81
+ function migrateSchema(cfg, fromVersion) {
82
+ const migrations = {
83
+ // v0.4 — pre-0.4 schemas (no `version` field, often `project` instead
84
+ // of `projectName`) normalize here. Rename `project` → `projectName`
85
+ // when only the old field is present, stamp the version, no other change.
86
+ '0.4': (c) => {
87
+ const out = { ...c, version: '0.4' };
88
+ if (!out.projectName && out.project) {
89
+ out.projectName = out.project;
90
+ delete out.project;
91
+ }
92
+ return out;
93
+ },
94
+ // v0.5 — K-4 per-validator severity overrides. Migration is purely
95
+ // additive: existing projects get an empty severity map and default
96
+ // (medium) behavior. No behavioral change unless they explicitly opt in.
97
+ '0.5': (c) => ({ ...c, severity: c.severity || {}, version: '0.5' }),
98
+ };
99
+ let current = { ...cfg };
100
+ let changed = false;
101
+ const target = CURRENT_SCHEMA_VERSION;
102
+ // No migrations yet — current schema matches the constant.
103
+ if (compareVersions(fromVersion, target) >= 0) return { changed: false, newConfig: current };
104
+ for (const [ver, fn] of Object.entries(migrations)) {
105
+ if (compareVersions(fromVersion, ver) < 0 && compareVersions(ver, target) <= 0) {
106
+ current = fn(current);
107
+ changed = true;
108
+ }
109
+ }
110
+ if (changed) current.version = target;
111
+ return { changed, newConfig: current };
112
+ }
113
+
114
+ /**
115
+ * Apply a CLI upgrade by running `npm install -g docguard-cli@latest`. We
116
+ * shell out instead of importing npm — npm is not a runtime dependency and
117
+ * we want zero-deps. Returns the spawn result.
118
+ */
119
+ function applyCliUpgrade() {
120
+ const r = spawnSync('npm', ['install', '-g', 'docguard-cli@latest'], {
121
+ stdio: 'inherit',
122
+ shell: process.platform === 'win32',
123
+ });
124
+ return r;
125
+ }
126
+
127
+ export async function runUpgrade(projectDir, _config, flags) {
128
+ const checkOnly = flags.checkOnly || flags['check-only'];
129
+ const apply = flags.apply;
130
+
131
+ console.log(`${c.bold}🔧 DocGuard Upgrade${c.reset}`);
132
+ console.log(`${c.dim} Checking CLI and schema versions...${c.reset}\n`);
133
+
134
+ // CLI version check
135
+ const latest = await fetchLatestNpmVersion();
136
+ const cliCmp = latest ? compareVersions(INSTALLED_VERSION, latest) : 0;
137
+ const cliBehind = cliCmp < 0;
138
+
139
+ // Schema version check
140
+ const projectSchema = readProjectSchemaVersion(projectDir);
141
+ const schemaCmp = projectSchema ? compareVersions(projectSchema, CURRENT_SCHEMA_VERSION) : 0;
142
+ const schemaBehind = projectSchema !== null && schemaCmp < 0;
143
+
144
+ // ── Report ──────────────────────────────────────────────────────────────
145
+ console.log(` ${c.cyan}CLI${c.reset} installed: ${c.bold}v${INSTALLED_VERSION}${c.reset}`);
146
+ if (latest) {
147
+ if (cliBehind) {
148
+ console.log(` latest: ${c.yellow}v${latest}${c.reset} ${c.yellow}(behind)${c.reset}`);
149
+ } else if (cliCmp > 0) {
150
+ console.log(` latest: v${latest} ${c.dim}(you're ahead — dev build)${c.reset}`);
151
+ } else {
152
+ console.log(` latest: ${c.green}v${latest}${c.reset} ${c.green}(current)${c.reset}`);
153
+ }
154
+ } else {
155
+ console.log(` latest: ${c.dim}could not check (offline?)${c.reset}`);
156
+ }
157
+
158
+ console.log();
159
+ // Two distinct null cases vs. '0.0' (pre-0.4):
160
+ // null → no .docguard.json at all → run init
161
+ // '0.0' → file exists but missing the `version` field → migration eligible
162
+ // other → real version string
163
+ const labelForProject = projectSchema === null
164
+ ? `${c.dim}(no .docguard.json found)${c.reset}`
165
+ : projectSchema === '0.0'
166
+ ? `${c.yellow}pre-0.4 (no version field)${c.reset}`
167
+ : `${c.bold}v${projectSchema}${c.reset}`;
168
+ console.log(` ${c.cyan}Schema${c.reset} project: ${labelForProject}`);
169
+ if (projectSchema) {
170
+ if (schemaBehind) {
171
+ console.log(` current: ${c.yellow}v${CURRENT_SCHEMA_VERSION}${c.reset} ${c.yellow}(behind)${c.reset}`);
172
+ } else if (schemaCmp > 0) {
173
+ console.log(` current: v${CURRENT_SCHEMA_VERSION} ${c.dim}(project is ahead — newer CLI needed)${c.reset}`);
174
+ } else {
175
+ console.log(` current: ${c.green}v${CURRENT_SCHEMA_VERSION}${c.reset} ${c.green}(current)${c.reset}`);
176
+ }
177
+ } else {
178
+ console.log(` current: v${CURRENT_SCHEMA_VERSION} ${c.dim}— run ${c.cyan}docguard init${c.dim} to create one${c.reset}`);
179
+ }
180
+
181
+ console.log();
182
+
183
+ // ── Decide what to do ───────────────────────────────────────────────────
184
+ const anythingBehind = cliBehind || schemaBehind;
185
+ if (!anythingBehind) {
186
+ console.log(` ${c.green}✅ Everything is up to date.${c.reset}`);
187
+ return;
188
+ }
189
+
190
+ // What needs doing
191
+ console.log(`${c.bold} Recommended actions:${c.reset}`);
192
+ if (cliBehind) {
193
+ console.log(` ${c.yellow}•${c.reset} Upgrade CLI: ${c.cyan}npm install -g docguard-cli@latest${c.reset}`);
194
+ }
195
+ if (schemaBehind) {
196
+ console.log(` ${c.yellow}•${c.reset} Migrate schema: ${c.cyan}docguard upgrade --apply${c.reset} ${c.dim}(or hand-edit .docguard.json)${c.reset}`);
197
+ }
198
+ console.log();
199
+
200
+ // ── --check-only: exit 1 to fail CI ─────────────────────────────────────
201
+ if (checkOnly) {
202
+ console.log(`${c.red}Exit 1 — versions behind (--check-only mode).${c.reset}`);
203
+ process.exit(1);
204
+ }
205
+
206
+ // ── --apply: actually run the migration ─────────────────────────────────
207
+ if (apply) {
208
+ console.log(`${c.bold} Applying upgrades...${c.reset}\n`);
209
+
210
+ if (cliBehind) {
211
+ console.log(` ${c.dim}Running:${c.reset} npm install -g docguard-cli@latest`);
212
+ const r = applyCliUpgrade();
213
+ if (r.status !== 0) {
214
+ console.error(` ${c.red}✗ CLI upgrade failed.${c.reset} Try with sudo, or check npm permissions.`);
215
+ process.exit(1);
216
+ }
217
+ console.log(` ${c.green}✓ CLI upgraded.${c.reset}`);
218
+ }
219
+
220
+ if (schemaBehind && projectSchema) {
221
+ const cfgPath = resolve(projectDir, '.docguard.json');
222
+ const cfg = JSON.parse(readFileSync(cfgPath, 'utf-8'));
223
+ const { changed, newConfig } = migrateSchema(cfg, projectSchema);
224
+ if (changed) {
225
+ writeFileSync(cfgPath, JSON.stringify(newConfig, null, 2) + '\n', 'utf-8');
226
+ console.log(` ${c.green}✓ Schema migrated ${projectSchema} → ${newConfig.version}.${c.reset}`);
227
+ } else {
228
+ console.log(` ${c.dim}Schema migration was a no-op (no recipe registered yet for ${projectSchema} → ${CURRENT_SCHEMA_VERSION}).${c.reset}`);
229
+ }
230
+ }
231
+
232
+ console.log(`\n ${c.green}✅ Upgrade complete.${c.reset} Run ${c.cyan}docguard guard${c.reset} to verify.`);
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Lightweight check for the post-guard nudge — returns a string when the
238
+ * project is behind, null when it's current. Cheap to call; never throws.
239
+ */
240
+ export function checkUpgradeStatus(projectDir) {
241
+ const schema = readProjectSchemaVersion(projectDir);
242
+ if (!schema) return null;
243
+ if (compareVersions(schema, CURRENT_SCHEMA_VERSION) < 0) {
244
+ // '0.0' is the internal sentinel for pre-0.4 schemas (no `version` field).
245
+ // Surface that as a friendlier label so users don't see "Schema 0.0".
246
+ const label = schema === '0.0' ? 'pre-0.4 (no version field)' : `v${schema}`;
247
+ return `Schema ${label} is behind current v${CURRENT_SCHEMA_VERSION}. Run \`docguard upgrade --apply\` to migrate.`;
248
+ }
249
+ return null;
250
+ }