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.
- package/PHILOSOPHY.md +59 -106
- package/README.md +26 -3
- package/cli/commands/diagnose.mjs +171 -58
- package/cli/commands/diff.mjs +110 -137
- package/cli/commands/fix.mjs +152 -4
- package/cli/commands/generate.mjs +148 -27
- package/cli/commands/guard.mjs +45 -24
- package/cli/commands/hooks.mjs +40 -2
- package/cli/commands/score.mjs +22 -0
- package/cli/commands/sync.mjs +123 -0
- package/cli/docguard.mjs +22 -0
- package/cli/scanners/api-doc.mjs +122 -0
- package/cli/scanners/doc-tools.mjs +1 -1
- package/cli/scanners/frontend.mjs +438 -0
- package/cli/scanners/integrations.mjs +116 -0
- package/cli/scanners/memory-plan.mjs +242 -0
- package/cli/scanners/project-type.mjs +310 -0
- package/cli/scanners/routes.mjs +194 -32
- package/cli/scanners/schemas.mjs +174 -1
- package/cli/shared-source.mjs +247 -0
- package/cli/validators/api-surface.mjs +254 -0
- package/cli/validators/architecture.mjs +4 -3
- package/cli/validators/changelog.mjs +45 -4
- package/cli/validators/doc-quality.mjs +3 -2
- package/cli/validators/docs-coverage.mjs +9 -14
- package/cli/validators/docs-diff.mjs +117 -66
- package/cli/validators/docs-sync.mjs +30 -24
- package/cli/validators/drift.mjs +6 -2
- package/cli/validators/environment.mjs +43 -3
- package/cli/validators/freshness.mjs +4 -3
- package/cli/validators/metadata-sync.mjs +17 -7
- package/cli/validators/metrics-consistency.mjs +9 -4
- package/cli/validators/schema-sync.mjs +19 -10
- package/cli/validators/security.mjs +20 -7
- package/cli/validators/structure.mjs +8 -1
- package/cli/validators/test-spec.mjs +26 -17
- package/cli/validators/todo-tracking.mjs +21 -8
- package/cli/validators/traceability.mjs +61 -36
- package/cli/writers/api-reference.mjs +101 -0
- package/cli/writers/mechanical.mjs +116 -0
- package/cli/writers/sections.mjs +148 -0
- package/commands/docguard.fix.md +19 -3
- package/commands/docguard.guard.md +5 -4
- package/docs/doc-sections.md +37 -0
- package/docs/quickstart.md +1 -1
- package/extensions/spec-kit-docguard/README.md +8 -5
- package/extensions/spec-kit-docguard/commands/fix.md +74 -0
- package/extensions/spec-kit-docguard/commands/generate.md +25 -2
- package/extensions/spec-kit-docguard/commands/guard.md +6 -5
- package/extensions/spec-kit-docguard/commands/sync.md +62 -0
- package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +11 -1
- package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +3 -2
- package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +111 -0
- package/package.json +1 -1
- package/templates/commands/docguard.guard.md +3 -3
package/cli/commands/diff.mjs
CHANGED
|
@@ -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
|
-
|
|
102
|
+
const docPath = existsSync(apiRefPath) ? apiRefPath : (existsSync(archPath) ? archPath : null);
|
|
103
|
+
if (!docPath) return null;
|
|
97
104
|
|
|
98
|
-
const
|
|
105
|
+
const documented = parseApiReferenceDoc(readFileSync(docPath, 'utf-8'));
|
|
99
106
|
|
|
100
|
-
//
|
|
101
|
-
const
|
|
102
|
-
|
|
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
|
-
|
|
112
|
-
|
|
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:
|
|
135
|
-
onlyInCode:
|
|
136
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
173
|
-
if (
|
|
174
|
-
|
|
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
|
-
//
|
|
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
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
const
|
|
228
|
-
|
|
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 => !
|
|
238
|
-
onlyInCode: [...codeEntities].filter(ce => !
|
|
239
|
-
matched: [...codeEntities].filter(ce =>
|
|
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
|
-
//
|
|
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
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
282
|
-
|
|
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 = {
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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 = /`([
|
|
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
|
-
|
|
341
|
-
const
|
|
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:
|
|
359
|
-
onlyInCode:
|
|
360
|
-
matched:
|
|
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
|
|
package/cli/commands/fix.mjs
CHANGED
|
@@ -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
|
-
|
|
624
|
+
execFileSync(process.execPath, [cliPath, 'init', '--dir', projectDir], {
|
|
477
625
|
encoding: 'utf-8',
|
|
478
626
|
stdio: 'pipe',
|
|
479
627
|
});
|