docguard-cli 0.9.11 → 0.11.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 (55) hide show
  1. package/PHILOSOPHY.md +59 -106
  2. package/README.md +26 -3
  3. package/cli/commands/diagnose.mjs +171 -58
  4. package/cli/commands/diff.mjs +110 -137
  5. package/cli/commands/fix.mjs +152 -4
  6. package/cli/commands/generate.mjs +148 -27
  7. package/cli/commands/guard.mjs +45 -24
  8. package/cli/commands/hooks.mjs +40 -2
  9. package/cli/commands/score.mjs +22 -0
  10. package/cli/commands/sync.mjs +123 -0
  11. package/cli/docguard.mjs +22 -0
  12. package/cli/scanners/api-doc.mjs +122 -0
  13. package/cli/scanners/doc-tools.mjs +1 -1
  14. package/cli/scanners/frontend.mjs +438 -0
  15. package/cli/scanners/integrations.mjs +116 -0
  16. package/cli/scanners/memory-plan.mjs +242 -0
  17. package/cli/scanners/project-type.mjs +310 -0
  18. package/cli/scanners/routes.mjs +194 -32
  19. package/cli/scanners/schemas.mjs +174 -1
  20. package/cli/shared-source.mjs +247 -0
  21. package/cli/validators/api-surface.mjs +254 -0
  22. package/cli/validators/architecture.mjs +4 -3
  23. package/cli/validators/changelog.mjs +45 -4
  24. package/cli/validators/doc-quality.mjs +3 -2
  25. package/cli/validators/docs-coverage.mjs +9 -14
  26. package/cli/validators/docs-diff.mjs +117 -66
  27. package/cli/validators/docs-sync.mjs +30 -24
  28. package/cli/validators/drift.mjs +6 -2
  29. package/cli/validators/environment.mjs +43 -3
  30. package/cli/validators/freshness.mjs +4 -3
  31. package/cli/validators/metadata-sync.mjs +17 -7
  32. package/cli/validators/metrics-consistency.mjs +9 -4
  33. package/cli/validators/schema-sync.mjs +19 -10
  34. package/cli/validators/security.mjs +20 -7
  35. package/cli/validators/structure.mjs +8 -1
  36. package/cli/validators/test-spec.mjs +26 -17
  37. package/cli/validators/todo-tracking.mjs +21 -8
  38. package/cli/validators/traceability.mjs +61 -36
  39. package/cli/writers/api-reference.mjs +101 -0
  40. package/cli/writers/mechanical.mjs +116 -0
  41. package/cli/writers/sections.mjs +148 -0
  42. package/commands/docguard.fix.md +19 -3
  43. package/commands/docguard.guard.md +5 -4
  44. package/docs/doc-sections.md +37 -0
  45. package/docs/quickstart.md +1 -1
  46. package/extensions/spec-kit-docguard/README.md +8 -5
  47. package/extensions/spec-kit-docguard/commands/fix.md +74 -0
  48. package/extensions/spec-kit-docguard/commands/generate.md +25 -2
  49. package/extensions/spec-kit-docguard/commands/guard.md +6 -5
  50. package/extensions/spec-kit-docguard/commands/sync.md +62 -0
  51. package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +11 -1
  52. package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +3 -2
  53. package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +111 -0
  54. package/package.json +1 -1
  55. package/templates/commands/docguard.guard.md +3 -3
@@ -6,6 +6,10 @@
6
6
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
7
7
  import { resolve, join, extname, basename } from 'node:path';
8
8
  import { c } from '../shared.mjs';
9
+ import { collectPackageJsons, detectDocker, grepEnvUsage, resolveSourceRoots } from '../shared-source.mjs';
10
+ import { parseApiReferenceDoc, compareEndpoints } from '../scanners/api-doc.mjs';
11
+ import { resolveApiSurface } from '../validators/api-surface.mjs';
12
+ import { collectCodeTests } from '../validators/docs-diff.mjs';
9
13
 
10
14
  const IGNORE_DIRS = new Set([
11
15
  'node_modules', '.git', '.next', 'dist', 'build',
@@ -30,10 +34,10 @@ export function runDiff(projectDir, config, flags) {
30
34
  // 2. Entities documented vs models in code
31
35
  results.push(diffEntities(projectDir, config));
32
36
 
33
- // 3. Env vars documented vs .env.example
37
+ // 3. Env vars documented vs .env.example + source usage
34
38
  results.push(diffEnvVars(projectDir, config));
35
39
 
36
- // 4. Tech stack documented vs package.json
40
+ // 4. Tech stack documented vs package.json(s)
37
41
  results.push(diffTechStack(projectDir, config));
38
42
 
39
43
  // 5. Tests documented vs tests that exist
@@ -91,59 +95,39 @@ export function runDiff(projectDir, config, flags) {
91
95
 
92
96
  // ── Diff Functions ─────────────────────────────────────────────────────────
93
97
 
94
- function diffRoutes(dir) {
98
+ function diffRoutes(dir, config = {}) {
99
+ // Documented surface: prefer the dedicated API reference, fall back to ARCHITECTURE.md.
100
+ const apiRefPath = resolve(dir, 'docs-canonical/API-REFERENCE.md');
95
101
  const archPath = resolve(dir, 'docs-canonical/ARCHITECTURE.md');
96
- if (!existsSync(archPath)) return null;
102
+ const docPath = existsSync(apiRefPath) ? apiRefPath : (existsSync(archPath) ? archPath : null);
103
+ if (!docPath) return null;
97
104
 
98
- const content = readFileSync(archPath, 'utf-8');
105
+ const documented = parseApiReferenceDoc(readFileSync(docPath, 'utf-8'));
99
106
 
100
- // Extract route-like patterns from ARCHITECTURE.md
101
- const docRoutes = new Set();
102
- const routeRegex = /(?:\/api\/\S+|(?:GET|POST|PUT|DELETE|PATCH)\s+(\/\S+))/gi;
103
- let match;
104
- while ((match = routeRegex.exec(content)) !== null) {
105
- const route = match[1] || match[0];
106
- // Skip markdown table syntax and non-route content
107
- if (route.startsWith('|') || route.startsWith('(') || route.length < 3) continue;
108
- docRoutes.add(route);
109
- }
107
+ // Actual surface: OpenAPI spec (sourceRoot-aware) monorepo code scan.
108
+ const surface = resolveApiSurface(dir, config);
109
+ if (surface.confidence === 'none' && documented.length === 0) return null;
110
110
 
111
- // Also check for paths in tables
112
- const pathRegex = /`(\/api\/[^`]+)`/g;
113
- while ((match = pathRegex.exec(content)) !== null) {
114
- docRoutes.add(match[1]);
115
- }
116
-
117
- // Find route files in code
118
- const codeRoutes = new Set();
119
- const routeDirs = ['src/routes', 'src/app/api', 'routes', 'api'];
120
- for (const rd of routeDirs) {
121
- const routeDir = resolve(dir, rd);
122
- if (!existsSync(routeDir)) continue;
123
-
124
- const files = getFilesRecursive(routeDir);
125
- for (const f of files) {
126
- const rel = f.replace(dir + '/', '');
127
- codeRoutes.add(rel);
128
- }
129
- }
111
+ const { documentedButAbsent, presentButUndocumented, matched } =
112
+ compareEndpoints(documented, surface.endpoints);
130
113
 
114
+ const fmt = (e) => `${e.method} ${e.path}`;
131
115
  return {
132
116
  title: 'API Routes',
133
117
  icon: '🛣️',
134
- onlyInDocs: [...docRoutes].filter(r => ![...codeRoutes].some(cr => cr.includes(r.replace(/\//g, '/')))),
135
- onlyInCode: [...codeRoutes].filter(cr => {
136
- const name = basename(cr, extname(cr));
137
- return ![...docRoutes].some(dr => dr.includes(name));
138
- }),
139
- matched: [...codeRoutes].filter(cr => {
140
- const name = basename(cr, extname(cr));
141
- return [...docRoutes].some(dr => dr.includes(name));
142
- }),
118
+ onlyInDocs: documentedButAbsent.map(fmt),
119
+ onlyInCode: presentButUndocumented.map(fmt),
120
+ matched: matched.map(fmt),
143
121
  };
144
122
  }
145
123
 
146
- function diffEntities(dir) {
124
+ // Non-entity filenames commonly found in model/schema dirs (infra, not entities).
125
+ const CODE_ENTITY_NOISE = new Set([
126
+ 'index', 'types', 'type', 'schema', 'schemas', 'registry', 'paths', 'openapi',
127
+ 'models', 'model', 'utils', 'helpers', 'constants', 'config', 'common', 'base',
128
+ ]);
129
+
130
+ function diffEntities(dir, config = {}) {
147
131
  const dataModelPath = resolve(dir, 'docs-canonical/DATA-MODEL.md');
148
132
  if (!existsSync(dataModelPath)) return null;
149
133
 
@@ -165,82 +149,57 @@ function diffEntities(dir) {
165
149
  'Testing', 'Deployment', 'Monitoring', 'Operations', 'Security',
166
150
  ]);
167
151
 
168
- const headerRegex = /^### (\S+)/gm;
152
+ // Extract entity names ONLY from "### EntityName" headings. The previous
153
+ // table-cell extractor produced garbage tokens (table, index, foreign, string…);
154
+ // headings are the only reliable entity source in a DATA-MODEL doc.
155
+ const headerRegex = /^#{3,4}\s+(.+)$/gm;
169
156
  let match;
170
157
  while ((match = headerRegex.exec(content)) !== null) {
171
- const name = match[1].replace(/[`*]/g, '');
172
- // Skip template placeholders (<!-- ... -->) and noise words
173
- if (name.startsWith('<!--') || name.length <= 2 || HEADER_NOISE.has(name) || HEADER_NOISE.has(name.toLowerCase())) {
174
- continue;
175
- }
176
- // Skip hyphenated words (e.g., 'Trade-offs', 'Set-up') — these are section titles, not entities
177
- if (name.includes('-')) continue;
158
+ const name = match[1].replace(/[`*]/g, '').trim();
159
+ if (name.startsWith('<!--') || name.length <= 2) continue;
160
+ if (HEADER_NOISE.has(name) || HEADER_NOISE.has(name.toLowerCase())) continue;
161
+ // Entity headings are a single PascalCase/snake_case identifier — not a phrase.
162
+ if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(name)) continue;
178
163
  docEntities.add(name.toLowerCase());
179
164
  }
180
165
 
181
- // Also check tables for entity references
182
- const tableRegex = /\|\s*(?:`)?(\w+)(?:`)?\s*\|/g;
183
- // Filter out common table headers, template placeholders, and markdown noise
184
- const TABLE_NOISE = new Set([
185
- 'entity', 'field', 'type', 'from', 'to', 'table', 'index', 'storage',
186
- 'required', 'default', 'constraints', 'description', 'name', 'value',
187
- 'status', 'version', 'category', 'technology', 'license', 'purpose',
188
- 'cascade', 'relationship', 'notes', 'date', 'author', 'changes',
189
- 'metadata', 'tbd', 'fields', 'todo', 'example', 'primary', 'key',
190
- 'none', 'see', 'detected', 'yes', 'no', 'all', 'the', 'for', 'not',
191
- 'add', 'database', 'orm', 'source', 'unit', 'test', 'integration',
192
- 'metric', 'target', 'current', 'journey', 'file', 'score', 'weight',
193
- 'weighted', 'method', 'provider', 'token', 'expiry', 'role',
194
- 'permissions', 'secret', 'rotation', 'access', 'variable', 'tool',
195
- 'command', 'run', 'component', 'responsibility', 'location', 'tests',
196
- // Data types — common in table schemas, not entity names
197
- 'string', 'boolean', 'number', 'integer', 'float', 'double', 'decimal',
198
- 'array', 'object', 'null', 'undefined', 'enum', 'varchar', 'text',
199
- 'timestamp', 'uuid', 'bigint', 'serial', 'json', 'jsonb', 'blob',
200
- 'char', 'date', 'time', 'datetime', 'binary', 'bit', 'money',
201
- // Common table headers and template words
202
- 'true', 'false', 'header', 'checks', 'project', 'count', 'grade',
203
- 'breakdown', 'issuecount', 'autofixable', 'projectname', 'projecttype',
204
- // Common doc section words (not entity names)
205
- 'trade', 'offs', 'tradeoffs', 'setup', 'overview', 'summary',
206
- 'details', 'configuration', 'reference', 'pattern', 'patterns',
207
- 'strategy', 'approach', 'impact', 'benefit', 'risk', 'concern',
208
- 'action', 'result', 'outcome', 'inverted', 'composite', 'secondary',
209
- ]);
210
- while ((match = tableRegex.exec(content)) !== null) {
211
- const name = match[1];
212
- // Skip short names (<=3 chars) and noise words
213
- if (name.length > 3 && !TABLE_NOISE.has(name.toLowerCase())) {
214
- docEntities.add(name.toLowerCase());
215
- }
216
- }
217
-
218
- // Find model/entity files in code
166
+ // Find model/entity files in code — monorepo-aware (honors config.sourceRoot/workspaces).
219
167
  const codeEntities = new Set();
220
- const modelDirs = ['src/models', 'models', 'src/entities', 'entities', 'src/schema', 'schema', 'prisma'];
221
- for (const md of modelDirs) {
222
- const modelDir = resolve(dir, md);
223
- if (!existsSync(modelDir)) continue;
224
-
225
- const files = getFilesRecursive(modelDir);
226
- for (const f of files) {
227
- const name = basename(f, extname(f)).toLowerCase();
228
- if (name !== 'index') {
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;
229
179
  codeEntities.add(name);
230
180
  }
231
181
  }
232
182
  }
233
183
 
184
+ // No code-side entity source (e.g. DynamoDB single-table design with no model
185
+ // files) → cannot reliably diff. Skip rather than flag every documented entity.
186
+ if (codeEntities.size === 0) return null;
187
+
188
+ // Exact (normalized) matching — no fuzzy bidirectional substring includes().
189
+ const norm = (s) => s.replace(/[_-]/g, '').replace(/s$/, '');
190
+ const codeNorm = new Set([...codeEntities].map(norm));
191
+ const docNorm = new Map([...docEntities].map(d => [norm(d), d]));
192
+
234
193
  return {
235
194
  title: 'Data Entities',
236
195
  icon: '🗃️',
237
- onlyInDocs: [...docEntities].filter(d => ![...codeEntities].some(ce => ce.includes(d) || d.includes(ce))),
238
- onlyInCode: [...codeEntities].filter(ce => ![...docEntities].some(d => d.includes(ce) || ce.includes(d))),
239
- matched: [...codeEntities].filter(ce => [...docEntities].some(d => d.includes(ce) || ce.includes(d))),
196
+ onlyInDocs: [...docEntities].filter(d => !codeNorm.has(norm(d))),
197
+ onlyInCode: [...codeEntities].filter(ce => !docNorm.has(norm(ce))),
198
+ matched: [...codeEntities].filter(ce => docNorm.has(norm(ce))),
240
199
  };
241
200
  }
242
201
 
243
- function diffEnvVars(dir) {
202
+ function diffEnvVars(dir, config = {}) {
244
203
  const envDocPath = resolve(dir, 'docs-canonical/ENVIRONMENT.md');
245
204
  if (!existsSync(envDocPath)) return null;
246
205
 
@@ -254,16 +213,20 @@ function diffEnvVars(dir) {
254
213
  docVars.add(match[1]);
255
214
  }
256
215
 
257
- // Read .env.example
216
+ // Code-side truth = .env.example/.env.template entries UNION process.env /
217
+ // import.meta.env usage across the (monorepo-aware) source roots.
258
218
  const codeVars = new Set();
259
- const envExamplePath = resolve(dir, '.env.example');
260
- if (existsSync(envExamplePath)) {
261
- const envContent = readFileSync(envExamplePath, 'utf-8');
262
- const envRegex = /^([A-Z][A-Z0-9_]+)\s*=/gm;
263
- while ((match = envRegex.exec(envContent)) !== null) {
264
- codeVars.add(match[1]);
219
+ for (const envFile of ['.env.example', '.env.template']) {
220
+ const envExamplePath = resolve(dir, envFile);
221
+ if (existsSync(envExamplePath)) {
222
+ const envContent = readFileSync(envExamplePath, 'utf-8');
223
+ const envRegex = /^([A-Z][A-Z0-9_]+)\s*=/gm;
224
+ while ((match = envRegex.exec(envContent)) !== null) {
225
+ codeVars.add(match[1]);
226
+ }
265
227
  }
266
228
  }
229
+ for (const name of grepEnvUsage(dir, config)) codeVars.add(name);
267
230
 
268
231
  if (docVars.size === 0 && codeVars.size === 0) return null;
269
232
 
@@ -276,13 +239,15 @@ function diffEnvVars(dir) {
276
239
  };
277
240
  }
278
241
 
279
- function diffTechStack(dir) {
242
+ function diffTechStack(dir, config = {}) {
280
243
  const archPath = resolve(dir, 'docs-canonical/ARCHITECTURE.md');
281
- const pkgPath = resolve(dir, 'package.json');
282
- if (!existsSync(archPath) || !existsSync(pkgPath)) return null;
244
+ if (!existsSync(archPath)) return null;
245
+
246
+ // Monorepo-aware: merge dependencies across root + source-root + workspace packages.
247
+ const pkgs = collectPackageJsons(dir, config);
248
+ if (pkgs.length === 0) return null;
283
249
 
284
250
  const archContent = readFileSync(archPath, 'utf-8');
285
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
286
251
 
287
252
  // Extract tech from ARCHITECTURE.md
288
253
  const docTech = new Set();
@@ -296,9 +261,12 @@ function diffTechStack(dir) {
296
261
  }
297
262
  }
298
263
 
299
- // Extract from package.json
264
+ // Extract from merged package.json dependencies
300
265
  const codeTech = new Set();
301
- const allDeps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
266
+ const allDeps = {};
267
+ for (const { pkg } of pkgs) {
268
+ Object.assign(allDeps, pkg.dependencies || {}, pkg.devDependencies || {});
269
+ }
302
270
  const depMap = {
303
271
  'react': 'React', 'next': 'Next.js', 'vue': 'Vue', 'express': 'Express',
304
272
  'fastify': 'Fastify', 'hono': 'Hono', 'prisma': 'Prisma', '@prisma/client': 'Prisma',
@@ -311,6 +279,9 @@ function diffTechStack(dir) {
311
279
  if (allDeps[dep]) codeTech.add(tech);
312
280
  }
313
281
 
282
+ // Docker via Dockerfile/compose (not an npm dependency).
283
+ if (detectDocker(dir, config)) codeTech.add('Docker');
284
+
314
285
  if (docTech.size === 0 && codeTech.size === 0) return null;
315
286
 
316
287
  return {
@@ -322,42 +293,44 @@ function diffTechStack(dir) {
322
293
  };
323
294
  }
324
295
 
325
- function diffTests(dir) {
296
+ function diffTests(dir, config = {}) {
326
297
  const testSpecPath = resolve(dir, 'docs-canonical/TEST-SPEC.md');
327
298
  if (!existsSync(testSpecPath)) return null;
328
299
 
329
- const content = readFileSync(testSpecPath, 'utf-8');
330
-
331
- // Extract test file references from TEST-SPEC.md
300
+ // Strip fenced code blocks (shell commands inside ``` ``` were mis-parsed as
301
+ // test files), then extract whitespace-free test tokens (literals or globs).
302
+ const content = readFileSync(testSpecPath, 'utf-8').replace(/```[\s\S]*?```/g, '');
332
303
  const docTests = new Set();
333
- const testFileRegex = /`([^`]*\.(?:test|spec)\.[^`]+)`/g;
304
+ const testFileRegex = /`([^`\s]*\.(?:test|spec)\.[a-zA-Z0-9]+)`/g;
334
305
  let match;
335
306
  while ((match = testFileRegex.exec(content)) !== null) {
336
307
  docTests.add(match[1]);
337
308
  }
338
309
 
339
- // Find actual test files
340
- const codeTests = new Set();
341
- const testDirs = ['tests', 'test', '__tests__', 'spec', 'e2e'];
342
- for (const td of testDirs) {
343
- const testDir = resolve(dir, td);
344
- if (!existsSync(testDir)) continue;
345
-
346
- const files = getFilesRecursive(testDir);
347
- for (const f of files) {
348
- const rel = f.replace(dir + '/', '');
349
- codeTests.add(rel);
350
- }
351
- }
310
+ // Find actual test files (monorepo-aware: configured patterns + recursive
311
+ // co-located/nested scan under each source root + root-level test dirs).
312
+ const codeTests = collectCodeTests(dir, config);
352
313
 
353
314
  if (docTests.size === 0 && codeTests.size === 0) return null;
354
315
 
316
+ // Glob-aware matching (documented entries are often patterns or basenames).
317
+ const codeArr = [...codeTests];
318
+ const docArr = [...docTests];
319
+ const matches = (docEntry, codeRel) => {
320
+ const entry = String(docEntry).trim();
321
+ const hasSlash = entry.includes('/');
322
+ const target = hasSlash ? entry : basename(entry);
323
+ const subject = hasSlash ? codeRel : basename(codeRel);
324
+ const rx = new RegExp('^' + target.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*+/g, '.*') + '$');
325
+ return rx.test(subject);
326
+ };
327
+
355
328
  return {
356
329
  title: 'Test Files',
357
330
  icon: '🧪',
358
- onlyInDocs: [...docTests].filter(t => !codeTests.has(t)),
359
- onlyInCode: [...codeTests].filter(t => !docTests.has(t)),
360
- matched: [...docTests].filter(t => codeTests.has(t)),
331
+ onlyInDocs: docArr.filter(d => !codeArr.some(c => matches(d, c))),
332
+ onlyInCode: codeArr.filter(c => !docArr.some(d => matches(d, c))),
333
+ matched: docArr.filter(d => codeArr.some(c => matches(d, c))),
361
334
  };
362
335
  }
363
336
 
@@ -13,11 +13,66 @@
13
13
  * --auto Create skeleton files (NOT content) via init
14
14
  */
15
15
 
16
- import { existsSync, readFileSync, mkdirSync } from 'node:fs';
16
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
17
17
  import { resolve, basename, dirname } from 'node:path';
18
- import { execSync } from 'node:child_process';
18
+ import { execSync, execFileSync } from 'node:child_process';
19
19
  import { fileURLToPath } from 'node:url';
20
20
  import { c } from '../shared.mjs';
21
+ import { computeApiSurfaceDrift } from '../validators/api-surface.mjs';
22
+ import { removeEndpoints, hasGeneratedMarker } from '../writers/api-reference.mjs';
23
+ import { applyMechanicalFixes } from '../writers/mechanical.mjs';
24
+ import { runGuardInternal } from './guard.mjs';
25
+
26
+ const API_DOC = 'docs-canonical/API-REFERENCE.md';
27
+
28
+ /**
29
+ * Apply DETERMINISTIC, no-LLM API-surface fixes: remove endpoints documented in
30
+ * API-REFERENCE.md that the OpenAPI spec confirms no longer exist. Removes the
31
+ * summary-table row and the detail block. Never rewrites prose.
32
+ *
33
+ * Safety: only edits a doc carrying the `<!-- docguard:generated true -->`
34
+ * marker, unless `force` is set. Idempotent.
35
+ *
36
+ * @returns {{ applied: boolean, removed: Array<{method,path}>, skipped?: string }}
37
+ */
38
+ export function applyApiSurfaceWrites(projectDir, config, { force = false } = {}) {
39
+ const drift = computeApiSurfaceDrift(projectDir, config);
40
+ // Only spec-confirmed absences are safe to delete deterministically.
41
+ const removable = drift.confidence === 'spec' ? drift.documentedButAbsent : [];
42
+ if (removable.length === 0) return { applied: false, removed: [] };
43
+
44
+ const apiDocPath = resolve(projectDir, API_DOC);
45
+ if (!existsSync(apiDocPath)) return { applied: false, removed: [] };
46
+
47
+ const content = readFileSync(apiDocPath, 'utf-8');
48
+ if (!hasGeneratedMarker(content) && !force) {
49
+ return {
50
+ applied: false,
51
+ removed: [],
52
+ skipped: `${API_DOC} is not marked '<!-- docguard:generated true -->'. ` +
53
+ `Re-run with --force to edit it, or fix it via an AI agent (/docguard.fix --doc api-reference).`,
54
+ };
55
+ }
56
+
57
+ const { content: newContent, removed } = removeEndpoints(content, removable);
58
+ if (removed.length === 0 || newContent === content) {
59
+ return { applied: false, removed: [] }; // idempotent no-op
60
+ }
61
+
62
+ writeFileSync(apiDocPath, newContent, 'utf-8');
63
+ // Map removed keys back to {method,path} for reporting.
64
+ const removedEndpoints = removable.filter(e => removed.includes(`${e.method.toUpperCase()} ${normalizeForKey(e.path)}`));
65
+ return { applied: true, removed: removedEndpoints.length ? removedEndpoints : removable };
66
+ }
67
+
68
+ // Local mirror of api-doc normalizePath for matching removed keys (avoids an
69
+ // extra import cycle); only used for display reconciliation.
70
+ function normalizeForKey(p) {
71
+ let s = String(p).trim().replace(/^[|`'"\s]+/, '').replace(/[|`'"\s]+$/, '').split(/[?#]/)[0];
72
+ s = s.replace(/\{[^}/]+\}/g, '{}').replace(/:[^/]+/g, '{}');
73
+ if (s.length > 1) s = s.replace(/\/+$/, '');
74
+ return s;
75
+ }
21
76
 
22
77
  // ── Document Quality Definitions ───────────────────────────────────────────
23
78
  // What each doc SHOULD contain, and what to look for in the codebase
@@ -144,6 +199,39 @@ WRITE THE DOCUMENT:
144
199
  IMPORTANT: Reference REAL test files. If there are no tests yet, document what SHOULD be tested.`,
145
200
  },
146
201
 
202
+ 'docs-canonical/API-REFERENCE.md': {
203
+ label: 'API Reference',
204
+ purpose: 'Document every HTTP endpoint so the docs match the real API surface (no phantom or missing routes)',
205
+ qualitySignals: [
206
+ 'Every documented endpoint exists in the OpenAPI spec or route definitions',
207
+ 'No endpoints that were removed from code are still documented',
208
+ 'Every real endpoint in code is documented',
209
+ 'Method + path + auth + request/response shapes are accurate',
210
+ 'No TODO or example placeholders',
211
+ ],
212
+ aiResearchInstructions: `
213
+ RESEARCH STEPS:
214
+ 1. Find the authoritative API surface FIRST:
215
+ - Look for an OpenAPI/Swagger spec (openapi.yaml/json, swagger.yaml) under the project root,
216
+ the source-root package (e.g. backend/), and docs/ — this is the source of truth.
217
+ - If no spec, scan route definitions: Express \`app.get/post/...\`, Next.js app/api route.ts,
218
+ Fastify/Hono \`.get/.post\`, FastAPI/Django decorators — under the configured sourceRoot.
219
+ 2. Build the real list of {METHOD, path} endpoints from that surface.
220
+ 3. Read the CURRENT docs-canonical/API-REFERENCE.md and extract its documented endpoints.
221
+ 4. Diff the two lists:
222
+ - DOCUMENTED-BUT-ABSENT: in the doc but NOT in code → these are stale, DELETE them.
223
+ - PRESENT-BUT-UNDOCUMENTED: in code but NOT in the doc → ADD them.
224
+
225
+ WRITE THE DOCUMENT:
226
+ - Remove every endpoint that no longer exists in code (e.g. a deleted integration's routes).
227
+ - Add every real endpoint that is missing, with method, path, auth requirement, and request/response.
228
+ - Keep the existing table/heading format the doc already uses.
229
+ - After editing, also update CHANGELOG.md ([Unreleased]) and DRIFT-LOG.md to record the removal/addition.
230
+
231
+ IMPORTANT: The OpenAPI spec / route code is the source of truth — the doc must conform to it, not vice versa.
232
+ Do NOT invent endpoints. Use REAL method+path values.`,
233
+ },
234
+
147
235
  'docs-canonical/ENVIRONMENT.md': {
148
236
  label: 'Environment',
149
237
  purpose: 'Document setup steps, dependencies, and environment variables',
@@ -174,6 +262,57 @@ IMPORTANT: A new contributor should be able to follow this doc and have the proj
174
262
  },
175
263
  };
176
264
 
265
+ // ── Deterministic --write mode ───────────────────────────────────────────────
266
+
267
+ /**
268
+ * Collect every structured mechanical fix surfaced by the validators and apply
269
+ * them deterministically (no LLM). Covers: remove-endpoint (API-Surface),
270
+ * replace-count (Metrics-Consistency), replace-version (Metadata-Sync),
271
+ * insert-changelog-unreleased (Changelog).
272
+ * @returns {{ applied: object[], skipped: object[], total: number }}
273
+ */
274
+ export function applyAllMechanicalFixes(projectDir, config, { force = false } = {}) {
275
+ const guardData = runGuardInternal(projectDir, config);
276
+ const fixes = [];
277
+ for (const v of guardData.validators) {
278
+ if (Array.isArray(v.fixes)) fixes.push(...v.fixes);
279
+ }
280
+ const { applied, skipped } = applyMechanicalFixes(projectDir, fixes, { force });
281
+ return { applied, skipped, total: fixes.length };
282
+ }
283
+
284
+ function runWriteMode(projectDir, config, flags) {
285
+ const isJson = flags.format === 'json';
286
+ const { applied, skipped, total } = applyAllMechanicalFixes(projectDir, config, { force: flags.force });
287
+
288
+ if (isJson) {
289
+ console.log(JSON.stringify({
290
+ status: applied.length ? 'applied' : (total ? 'skipped' : 'clean'),
291
+ applied,
292
+ skipped,
293
+ }, null, 2));
294
+ return;
295
+ }
296
+
297
+ console.log(`${c.bold}🔧 DocGuard Fix --write — ${config.projectName}${c.reset}\n`);
298
+ if (total === 0) {
299
+ console.log(` ${c.green}✅ No mechanical fixes needed — the docs match the code.${c.reset}\n`);
300
+ return;
301
+ }
302
+ if (applied.length === 0) {
303
+ console.log(` ${c.dim}Nothing applied (idempotent or gated).${c.reset}`);
304
+ for (const s of skipped) console.log(` ${c.yellow}⚠ ${s.type}: ${s.reason}${c.reset}`);
305
+ console.log('');
306
+ return;
307
+ }
308
+ console.log(` ${c.green}✅ Applied ${applied.length} deterministic fix(es):${c.reset}`);
309
+ for (const a of applied) console.log(` ${c.green}✔ ${a.detail}${c.reset}`);
310
+ if (skipped.length) {
311
+ for (const s of skipped) console.log(` ${c.yellow}⚠ ${s.type}: ${s.reason}${c.reset}`);
312
+ }
313
+ console.log(`\n ${c.dim}Verify with ${c.cyan}docguard guard${c.dim}, then commit. Prose rewrites still need an AI agent (${c.cyan}/docguard.fix${c.dim}).${c.reset}\n`);
314
+ }
315
+
177
316
  // ── Main Entry ─────────────────────────────────────────────────────────────
178
317
 
179
318
  export function runFix(projectDir, config, flags) {
@@ -182,6 +321,12 @@ export function runFix(projectDir, config, flags) {
182
321
  const autoFix = flags.auto || false;
183
322
  const specificDoc = flags.doc || null;
184
323
 
324
+ // --write: deterministically APPLY mechanical fixes (no LLM). Currently:
325
+ // remove API-REFERENCE.md endpoints the OpenAPI spec confirms no longer exist.
326
+ if (flags.write) {
327
+ return runWriteMode(projectDir, config, flags);
328
+ }
329
+
185
330
  // If --doc flag is provided, generate a deep prompt for that specific document
186
331
  if (specificDoc) {
187
332
  return generateDocPrompt(projectDir, config, specificDoc);
@@ -414,12 +559,15 @@ function generateDocPrompt(projectDir, config, docName) {
414
559
  'testspec': 'docs-canonical/TEST-SPEC.md',
415
560
  'environment': 'docs-canonical/ENVIRONMENT.md',
416
561
  'env': 'docs-canonical/ENVIRONMENT.md',
562
+ 'api-reference': 'docs-canonical/API-REFERENCE.md',
563
+ 'api': 'docs-canonical/API-REFERENCE.md',
564
+ 'apireference': 'docs-canonical/API-REFERENCE.md',
417
565
  };
418
566
 
419
567
  const filePath = mapping[normalized];
420
568
  if (!filePath) {
421
569
  console.error(`${c.red}Unknown document: ${docName}${c.reset}`);
422
- console.log(`${c.dim}Available: architecture, data-model, security, test-spec, environment${c.reset}`);
570
+ console.log(`${c.dim}Available: architecture, data-model, security, test-spec, environment, api-reference${c.reset}`);
423
571
  process.exit(1);
424
572
  }
425
573
 
@@ -473,7 +621,7 @@ function autoFixIssues(projectDir, config, issues) {
473
621
 
474
622
  try {
475
623
  const cliPath = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'docguard.mjs');
476
- execSync(`node ${cliPath} init --dir "${projectDir}"`, {
624
+ execFileSync(process.execPath, [cliPath, 'init', '--dir', projectDir], {
477
625
  encoding: 'utf-8',
478
626
  stdio: 'pipe',
479
627
  });