docguard-cli 0.11.2 → 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
 
@@ -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
 
@@ -243,14 +309,55 @@ export function runGuard(projectDir, config, flags) {
243
309
  const badgeUrl = `https://img.shields.io/badge/CDD_Guard-${data.passed}%2F${data.total}_passed-${bColor}`;
244
310
  console.log(`\n ${c.dim}📎 Badge: ![CDD Guard](${badgeUrl})${c.reset}`);
245
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
+
246
336
  // Spec-kit reminder — persistent nudge if not initialized
247
337
  if (!isSpecKitInitialized(projectDir)) {
248
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}`);
249
339
  }
250
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
+
251
357
  console.log('');
252
358
 
253
- if (data.errors > 0) process.exit(1);
254
- 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);
255
362
  process.exit(0);
256
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
+ }
package/cli/docguard.mjs CHANGED
@@ -40,10 +40,12 @@ import { runPublish } from './commands/publish.mjs';
40
40
  import { runTrace } from './commands/trace.mjs';
41
41
  import { runLlms } from './commands/llms.mjs';
42
42
  import { runSetup } from './commands/setup.mjs';
43
+ import { runUpgrade } from './commands/upgrade.mjs';
43
44
  import { ensureSkills } from './ensure-skills.mjs';
44
45
 
45
46
  // ── Shared constants (imported to break circular dependencies) ──────────
46
47
  import { c, PROFILES } from './shared.mjs';
48
+ import { mergeIgnoreFile } from './shared-ignore.mjs';
47
49
  export { c, PROFILES };
48
50
 
49
51
  // ── Config Loading ─────────────────────────────────────────────────────────
@@ -138,6 +140,9 @@ export function loadConfig(projectDir) {
138
140
  merged.testPatterns.push(merged.testPattern);
139
141
  }
140
142
  }
143
+ // Merge .docguardignore patterns into config.ignore so every validator
144
+ // honors them without having to know about the file.
145
+ mergeIgnoreFile(projectDir, merged);
141
146
  return merged;
142
147
  } catch (e) {
143
148
  console.error(`${c.red}Error parsing .docguard.json: ${e.message}${c.reset}`);
@@ -148,6 +153,9 @@ export function loadConfig(projectDir) {
148
153
  // No config file — auto-detect everything
149
154
  defaults.projectType = autoDetectProjectType(projectDir);
150
155
  defaults.projectTypeConfig = getProjectTypeDefaults(defaults.projectType);
156
+ // .docguardignore is read even when no .docguard.json exists — keeps
157
+ // ignore-only projects (no config but want to skip paths) working.
158
+ mergeIgnoreFile(projectDir, defaults);
151
159
  return defaults;
152
160
  }
153
161
 
@@ -367,6 +375,12 @@ async function main() {
367
375
  i++;
368
376
  } else if (args[i] === '--show-failing') {
369
377
  flags.showFailing = true;
378
+ } else if (args[i] === '--check-only') {
379
+ flags.checkOnly = true;
380
+ } else if (args[i] === '--apply') {
381
+ flags.apply = true;
382
+ } else if (args[i] === '--changed-only') {
383
+ flags.changedOnly = true;
370
384
  } else if (args[i] === '--doc' && args[i + 1]) {
371
385
  flags.doc = args[i + 1];
372
386
  i++;
@@ -405,12 +419,21 @@ async function main() {
405
419
  process.exit(0);
406
420
  }
407
421
 
408
- printBanner();
422
+ // In JSON mode the entire stdout MUST be parseable JSON. The banner and
423
+ // ensureSkills' install message would corrupt the output for any
424
+ // programmatic consumer (CI, dashboards, the Score-on-PR Action recipe).
425
+ // Headless flags (`--write`, `--check-only`, `--auto`) also suppress chrome.
426
+ const jsonMode = flags.format === 'json';
427
+ const headless = jsonMode || flags.write || flags.checkOnly || flags.changedOnly;
428
+
429
+ if (!jsonMode) printBanner();
409
430
 
410
431
  const config = loadConfig(projectDir);
411
432
 
412
- // Silent auto-check: install skills/commands if missing
413
- if (command !== 'setup' && command !== 'init') {
433
+ // Silent auto-check: install skills/commands if missing. Skip entirely in
434
+ // headless modes where the user wants deterministic, parseable output and
435
+ // doesn't expect side effects on their AI-agent skill directories.
436
+ if (command !== 'setup' && command !== 'init' && !headless) {
414
437
  ensureSkills(projectDir, flags);
415
438
  }
416
439
 
@@ -478,6 +501,10 @@ async function main() {
478
501
  case 'llms':
479
502
  runLlms(projectDir, config, flags);
480
503
  break;
504
+ case 'upgrade':
505
+ case 'update':
506
+ await runUpgrade(projectDir, config, flags);
507
+ break;
481
508
  default:
482
509
  console.error(`${c.red}Unknown command: ${command}${c.reset}`);
483
510
  console.log(`Run ${c.cyan}docguard --help${c.reset} for usage.`);