docguard-cli 0.11.2 → 0.13.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 (31) hide show
  1. package/README.md +35 -16
  2. package/cli/commands/fix.mjs +55 -0
  3. package/cli/commands/guard.mjs +129 -5
  4. package/cli/commands/init.mjs +52 -1
  5. package/cli/commands/sync.mjs +50 -0
  6. package/cli/commands/trace.mjs +105 -0
  7. package/cli/commands/upgrade.mjs +250 -0
  8. package/cli/docguard.mjs +39 -3
  9. package/cli/shared-git.mjs +0 -0
  10. package/cli/shared-ignore.mjs +50 -0
  11. package/cli/shared.mjs +62 -0
  12. package/cli/validators/cross-reference.mjs +289 -0
  13. package/cli/validators/docs-sync.mjs +15 -0
  14. package/cli/validators/freshness.mjs +5 -10
  15. package/cli/validators/generated-staleness.mjs +97 -0
  16. package/cli/writers/fix-memory.mjs +133 -0
  17. package/cli/writers/mechanical.mjs +22 -0
  18. package/commands/docguard.guard.md +2 -2
  19. package/docs/quickstart.md +1 -1
  20. package/extensions/spec-kit-docguard/README.md +1 -1
  21. package/extensions/spec-kit-docguard/commands/guard.md +1 -1
  22. package/extensions/spec-kit-docguard/extension.yml +11 -1
  23. package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +2 -2
  24. package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +3 -3
  25. package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
  26. package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
  27. package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +1 -1
  28. package/extensions/spec-kit-docguard/templates/github-workflows/docguard-autofix.yml +51 -0
  29. package/extensions/spec-kit-docguard/templates/github-workflows/docguard-guard.yml +48 -0
  30. package/package.json +1 -1
  31. package/templates/commands/docguard.guard.md +2 -2
@@ -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,21 @@ 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;
384
+ } else if (args[i] === '--reverse') {
385
+ flags.reverse = true;
386
+ } else if (args[i] === '--history') {
387
+ flags.history = true;
388
+ } else if (!args[i].startsWith('--') && i > 0) {
389
+ // Positional args go into flags.args for commands that take them (e.g.
390
+ // `docguard trace --reverse <path>`). Skip the command itself (i === 0).
391
+ flags.args = flags.args || [];
392
+ flags.args.push(args[i]);
370
393
  } else if (args[i] === '--doc' && args[i + 1]) {
371
394
  flags.doc = args[i + 1];
372
395
  i++;
@@ -405,12 +428,21 @@ async function main() {
405
428
  process.exit(0);
406
429
  }
407
430
 
408
- printBanner();
431
+ // In JSON mode the entire stdout MUST be parseable JSON. The banner and
432
+ // ensureSkills' install message would corrupt the output for any
433
+ // programmatic consumer (CI, dashboards, the Score-on-PR Action recipe).
434
+ // Headless flags (`--write`, `--check-only`, `--auto`) also suppress chrome.
435
+ const jsonMode = flags.format === 'json';
436
+ const headless = jsonMode || flags.write || flags.checkOnly || flags.changedOnly;
437
+
438
+ if (!jsonMode) printBanner();
409
439
 
410
440
  const config = loadConfig(projectDir);
411
441
 
412
- // Silent auto-check: install skills/commands if missing
413
- if (command !== 'setup' && command !== 'init') {
442
+ // Silent auto-check: install skills/commands if missing. Skip entirely in
443
+ // headless modes where the user wants deterministic, parseable output and
444
+ // doesn't expect side effects on their AI-agent skill directories.
445
+ if (command !== 'setup' && command !== 'init' && !headless) {
414
446
  ensureSkills(projectDir, flags);
415
447
  }
416
448
 
@@ -478,6 +510,10 @@ async function main() {
478
510
  case 'llms':
479
511
  runLlms(projectDir, config, flags);
480
512
  break;
513
+ case 'upgrade':
514
+ case 'update':
515
+ await runUpgrade(projectDir, config, flags);
516
+ break;
481
517
  default:
482
518
  console.error(`${c.red}Unknown command: ${command}${c.reset}`);
483
519
  console.log(`Run ${c.cyan}docguard --help${c.reset} for usage.`);
Binary file
@@ -40,6 +40,56 @@ export const DEFAULT_IGNORE_DIRS = new Set([
40
40
  const ALWAYS_REJECT_PATH_RE =
41
41
  /(?:^|[/\\])(?:node_modules|\.claude[/\\]worktrees|\.git[/\\]worktrees|\.jj)(?:[/\\]|$)/;
42
42
 
43
+ /**
44
+ * Read `.docguardignore` from a project directory and return its patterns.
45
+ *
46
+ * Format: gitignore-style — one pattern per line, `#` for comments, blank lines
47
+ * ignored. Returned patterns are normalized but not transformed (callers
48
+ * decide whether to expand directory globs).
49
+ *
50
+ * Returns [] if the file is missing or unreadable — never throws.
51
+ */
52
+ import { readFileSync, existsSync } from 'node:fs';
53
+ import { resolve as resolvePath } from 'node:path';
54
+
55
+ export function loadDocguardIgnore(projectDir) {
56
+ const p = resolvePath(projectDir, '.docguardignore');
57
+ if (!existsSync(p)) return [];
58
+ try {
59
+ const raw = readFileSync(p, 'utf-8');
60
+ return raw
61
+ .split(/\r?\n/)
62
+ .map(line => line.trim())
63
+ .filter(line => line && !line.startsWith('#'));
64
+ } catch {
65
+ return [];
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Merge `.docguardignore` patterns into a config object's `ignore` array.
71
+ *
72
+ * Used at config-load time so every validator sees the combined set without
73
+ * having to know about the file. Mutates and returns the config for ergonomics.
74
+ *
75
+ * Idempotent — calling twice produces the same result. Skips duplicates.
76
+ */
77
+ export function mergeIgnoreFile(projectDir, config) {
78
+ const filePatterns = loadDocguardIgnore(projectDir);
79
+ if (filePatterns.length === 0) return config;
80
+ const existing = Array.isArray(config.ignore) ? config.ignore : [];
81
+ const seen = new Set(existing);
82
+ const merged = [...existing];
83
+ for (const p of filePatterns) {
84
+ if (!seen.has(p)) {
85
+ merged.push(p);
86
+ seen.add(p);
87
+ }
88
+ }
89
+ config.ignore = merged;
90
+ return config;
91
+ }
92
+
43
93
  /**
44
94
  * Convert a glob pattern to a RegExp.
45
95
  * Supports: * (any chars except /), ** (any path segments), . (literal dot).
package/cli/shared.mjs CHANGED
@@ -4,6 +4,68 @@
4
4
  * All commands import from here instead of docguard.mjs.
5
5
  */
6
6
 
7
+ /**
8
+ * Current .docguard.json schema version that this CLI version writes via
9
+ * `docguard init`. Bump this when adding fields that need migration (e.g.
10
+ * v0.12 adds `severity` overrides per validator).
11
+ *
12
+ * The post-guard nudge fires when an existing project's stored
13
+ * `.docguard.json.version` is BEHIND this constant — pointing users at
14
+ * `docguard upgrade` to migrate.
15
+ */
16
+ export const CURRENT_SCHEMA_VERSION = '0.5';
17
+
18
+ /**
19
+ * Allowed severity values for per-validator `severity` overrides in
20
+ * `.docguard.json`. Affects EXIT-CODE behavior of `docguard guard`:
21
+ * - 'high': warnings from this validator fail CI (exit 1)
22
+ * - 'medium': default — warnings exit 2 (informational)
23
+ * - 'low': warnings ignored for exit code (exit 0)
24
+ *
25
+ * Display (the per-validator status lines and the summary) is unchanged
26
+ * regardless of severity — severity is a CI/operational knob, not a UI one.
27
+ */
28
+ export const SEVERITY_LEVELS = new Set(['high', 'medium', 'low']);
29
+
30
+ /**
31
+ * Resolve a validator's effective severity from config.
32
+ * Returns 'medium' (default) if no override is set or the override is bogus.
33
+ */
34
+ export function resolveSeverity(config, validatorKey) {
35
+ const s = config && config.severity && config.severity[validatorKey];
36
+ if (typeof s === 'string' && SEVERITY_LEVELS.has(s.toLowerCase())) {
37
+ return s.toLowerCase();
38
+ }
39
+ return 'medium';
40
+ }
41
+
42
+ /**
43
+ * Parse a dotted-decimal version string into a tuple of integers for
44
+ * comparison. Tolerates extra suffixes (e.g. `0.4-beta` → [0, 4]).
45
+ * Returns null when the string is unparseable.
46
+ */
47
+ export function parseVersion(v) {
48
+ if (!v || typeof v !== 'string') return null;
49
+ const m = v.match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
50
+ if (!m) return null;
51
+ return [Number(m[1] || 0), Number(m[2] || 0), Number(m[3] || 0)];
52
+ }
53
+
54
+ /**
55
+ * Compare two version strings. Returns -1 if a<b, 0 if equal, 1 if a>b.
56
+ * Unparseable inputs sort as equal (no nag).
57
+ */
58
+ export function compareVersions(a, b) {
59
+ const pa = parseVersion(a);
60
+ const pb = parseVersion(b);
61
+ if (!pa || !pb) return 0;
62
+ for (let i = 0; i < 3; i++) {
63
+ if (pa[i] < pb[i]) return -1;
64
+ if (pa[i] > pb[i]) return 1;
65
+ }
66
+ return 0;
67
+ }
68
+
7
69
  // ── Colors (ANSI escape codes, zero deps) ──────────────────────────────────
8
70
  export const c = {
9
71
  reset: '\x1b[0m',