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
|
@@ -58,6 +58,7 @@ export function validateSecurity(projectDir, config) {
|
|
|
58
58
|
const results = { name: 'security', errors: [], warnings: [], passed: 0, total: 0 };
|
|
59
59
|
|
|
60
60
|
const findings = [];
|
|
61
|
+
let scanned = 0;
|
|
61
62
|
|
|
62
63
|
walkDir(projectDir, (filePath) => {
|
|
63
64
|
const ext = extname(filePath);
|
|
@@ -73,13 +74,17 @@ export function validateSecurity(projectDir, config) {
|
|
|
73
74
|
// Apply config ignore patterns (securityIgnore + global ignore)
|
|
74
75
|
if (shouldIgnore(relPath, config, 'securityIgnore')) return;
|
|
75
76
|
|
|
77
|
+
scanned++;
|
|
76
78
|
const content = readFileSync(filePath, 'utf-8');
|
|
77
|
-
|
|
79
|
+
let lines = null;
|
|
78
80
|
|
|
79
81
|
for (const { pattern, label } of SECRET_PATTERNS) {
|
|
80
82
|
pattern.lastIndex = 0;
|
|
81
83
|
const match = pattern.exec(content);
|
|
82
84
|
if (match) {
|
|
85
|
+
// Lazily initialize lines only when a match is found
|
|
86
|
+
if (!lines) lines = content.split('\n');
|
|
87
|
+
|
|
83
88
|
// Find the line containing this match for context-aware filtering
|
|
84
89
|
const matchPos = match.index;
|
|
85
90
|
let charCount = 0;
|
|
@@ -100,13 +105,21 @@ export function validateSecurity(projectDir, config) {
|
|
|
100
105
|
}
|
|
101
106
|
});
|
|
102
107
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
results.
|
|
108
|
+
// Only count the secret scan as a passed check if we actually scanned files.
|
|
109
|
+
// An empty scan that reports "no secrets" is a dangerous false ✅ — surface it.
|
|
110
|
+
if (scanned > 0) {
|
|
111
|
+
results.total++;
|
|
112
|
+
if (findings.length === 0) {
|
|
113
|
+
results.passed++;
|
|
114
|
+
} else {
|
|
115
|
+
for (const f of findings) {
|
|
116
|
+
results.errors.push(`${f.file}: possible ${f.label} found`);
|
|
117
|
+
}
|
|
109
118
|
}
|
|
119
|
+
} else {
|
|
120
|
+
results.warnings.push(
|
|
121
|
+
'No source files were scanned for secrets — check config.sourceRoot / ignore patterns'
|
|
122
|
+
);
|
|
110
123
|
}
|
|
111
124
|
|
|
112
125
|
// Check .gitignore includes .env
|
|
@@ -76,7 +76,14 @@ export function validateDocSections(projectDir, config) {
|
|
|
76
76
|
|
|
77
77
|
for (const section of sections) {
|
|
78
78
|
results.total++;
|
|
79
|
-
|
|
79
|
+
// Match an actual heading at line start (any level), not a substring that
|
|
80
|
+
// could appear in a table-of-contents link or a code block.
|
|
81
|
+
const headingText = section.replace(/^#+\s*/, '');
|
|
82
|
+
const headingRe = new RegExp(
|
|
83
|
+
'^#{2,6}\\s+' + headingText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b',
|
|
84
|
+
'm'
|
|
85
|
+
);
|
|
86
|
+
if (headingRe.test(content)) {
|
|
80
87
|
results.passed++;
|
|
81
88
|
} else {
|
|
82
89
|
results.warnings.push(`${file}: missing section "${section}"`);
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
7
7
|
import { resolve } from 'node:path';
|
|
8
|
+
import { resolveSourceRoots } from '../shared-source.mjs';
|
|
8
9
|
|
|
9
10
|
export function validateTestSpec(projectDir, config) {
|
|
10
11
|
const results = { name: 'test-spec', errors: [], warnings: [], passed: 0, total: 0 };
|
|
@@ -43,6 +44,9 @@ export function validateTestSpec(projectDir, config) {
|
|
|
43
44
|
// Skip template/example rows and italic placeholder rows
|
|
44
45
|
if (sourceFile.startsWith('<!--') || sourceFile === 'Source File' || sourceFile.startsWith('*')) continue;
|
|
45
46
|
|
|
47
|
+
// Author-declared gaps (❌/⚠️) are surfaced as warnings. A ✅ glyph is the
|
|
48
|
+
// author's CLAIM, not proof — it is NOT counted as a pass. The real pass
|
|
49
|
+
// comes from the file-existence checks below (code truth, not the glyph).
|
|
46
50
|
if (status && status.includes('❌')) {
|
|
47
51
|
results.total++;
|
|
48
52
|
results.warnings.push(
|
|
@@ -53,9 +57,6 @@ export function validateTestSpec(projectDir, config) {
|
|
|
53
57
|
results.warnings.push(
|
|
54
58
|
`TEST-SPEC declares ${sourceFile} as ⚠️ — partial coverage`
|
|
55
59
|
);
|
|
56
|
-
} else if (status && status.includes('✅')) {
|
|
57
|
-
results.total++;
|
|
58
|
-
results.passed++;
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
// ── File existence checks ───────────────────────────────────────
|
|
@@ -122,34 +123,41 @@ export function validateTestSpec(projectDir, config) {
|
|
|
122
123
|
results.warnings.push(
|
|
123
124
|
`E2E Journey #${num} (${journey}) — missing test: ${testFile}`
|
|
124
125
|
);
|
|
125
|
-
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// For a ✅ journey, verify the referenced test file actually exists
|
|
130
|
+
// rather than trusting the glyph.
|
|
131
|
+
const cleanTest = testFile ? testFile.replace(/`/g, '').trim() : '';
|
|
132
|
+
if (cleanTest && cleanTest !== '—' && !cleanTest.includes('N/A')) {
|
|
126
133
|
results.total++;
|
|
127
|
-
|
|
134
|
+
if (existsSync(resolve(projectDir, cleanTest))) {
|
|
135
|
+
results.passed++;
|
|
136
|
+
} else {
|
|
137
|
+
results.warnings.push(
|
|
138
|
+
`E2E Journey #${num} (${journey}) marked ✅ but test file not found: ${cleanTest}`
|
|
139
|
+
);
|
|
140
|
+
}
|
|
128
141
|
}
|
|
129
142
|
}
|
|
130
143
|
}
|
|
131
144
|
}
|
|
132
145
|
|
|
133
|
-
// If no test
|
|
146
|
+
// If TEST-SPEC.md declared no service-to-test mappings, there is nothing to
|
|
147
|
+
// verify against. Do NOT manufacture a 1/1 pass just because tests exist
|
|
148
|
+
// somewhere — that rendered a confident green ✅ for a doc that mapped nothing.
|
|
134
149
|
if (results.total === 0) {
|
|
135
|
-
results.total = 1;
|
|
136
|
-
|
|
137
150
|
// 1. Check top-level test dirs
|
|
138
151
|
const commonTestDirs = ['tests', 'test', '__tests__', 'spec'];
|
|
139
152
|
const hasTestDir = commonTestDirs.some(d =>
|
|
140
153
|
existsSync(resolve(projectDir, d))
|
|
141
154
|
);
|
|
142
155
|
|
|
143
|
-
// 2. Check co-located tests (
|
|
156
|
+
// 2. Check co-located tests (honors config.sourceRoot + workspaces)
|
|
144
157
|
let hasColocated = false;
|
|
145
158
|
if (!hasTestDir) {
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
const rootPath = resolve(projectDir, root);
|
|
149
|
-
if (existsSync(rootPath) && hasTestFilesRecursive(rootPath)) {
|
|
150
|
-
hasColocated = true;
|
|
151
|
-
break;
|
|
152
|
-
}
|
|
159
|
+
for (const rootPath of resolveSourceRoots(projectDir, config)) {
|
|
160
|
+
if (hasTestFilesRecursive(rootPath)) { hasColocated = true; break; }
|
|
153
161
|
}
|
|
154
162
|
}
|
|
155
163
|
|
|
@@ -161,7 +169,8 @@ export function validateTestSpec(projectDir, config) {
|
|
|
161
169
|
}
|
|
162
170
|
|
|
163
171
|
if (hasTestDir || hasColocated || hasConfigTests) {
|
|
164
|
-
|
|
172
|
+
// Tests exist but the spec maps none of them → not applicable, not a pass.
|
|
173
|
+
results.note = 'TEST-SPEC.md declares no service-to-test mappings';
|
|
165
174
|
} else {
|
|
166
175
|
results.warnings.push(
|
|
167
176
|
'No test directory or co-located test files found. ' +
|
|
@@ -67,7 +67,7 @@ export function validateTodoTracking(projectDir, config) {
|
|
|
67
67
|
results.passed += skipResults.passed;
|
|
68
68
|
results.total += skipResults.total;
|
|
69
69
|
|
|
70
|
-
// ── Part 2: Untracked
|
|
70
|
+
// ── Part 2: Untracked Annotations ──
|
|
71
71
|
const todoResults = checkUntrackedTodos(projectDir, config);
|
|
72
72
|
results.errors.push(...todoResults.errors);
|
|
73
73
|
results.warnings.push(...todoResults.warnings);
|
|
@@ -105,6 +105,10 @@ function checkSkippedTests(projectDir, config) {
|
|
|
105
105
|
let content;
|
|
106
106
|
try { content = readFileSync(fullPath, 'utf-8'); } catch { continue; }
|
|
107
107
|
|
|
108
|
+
// Fast early-return: skip expensive string split if no skip patterns exist
|
|
109
|
+
const hasSkip = SKIP_PATTERNS.some(p => p.test(content));
|
|
110
|
+
if (!hasSkip) continue;
|
|
111
|
+
|
|
108
112
|
const lines = content.split('\n');
|
|
109
113
|
|
|
110
114
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -151,7 +155,7 @@ function checkSkippedTests(projectDir, config) {
|
|
|
151
155
|
return { errors, warnings, passed, total };
|
|
152
156
|
}
|
|
153
157
|
|
|
154
|
-
// ──── Untracked
|
|
158
|
+
// ──── Untracked Annotations ────────────────────────────────────────────────
|
|
155
159
|
|
|
156
160
|
/**
|
|
157
161
|
* Scan source files for TODO/FIXME annotations and check if they appear
|
|
@@ -183,15 +187,19 @@ function checkUntrackedTodos(projectDir, config) {
|
|
|
183
187
|
for (const todo of todos) {
|
|
184
188
|
// Check if the TODO is tracked in documentation
|
|
185
189
|
// Improved matching: check full text AND file location context
|
|
190
|
+
// ⚡ Bolt: Precompute string lowercasing and trimming once per TODO
|
|
191
|
+
// instead of inside the trackingContent.some loop
|
|
192
|
+
const todoTextLower = todo.text.toLowerCase().trim();
|
|
193
|
+
const searchText = todoTextLower.length > 20
|
|
194
|
+
? todoTextLower.substring(0, 40)
|
|
195
|
+
: todoTextLower;
|
|
196
|
+
|
|
186
197
|
const isTracked = trackingContent.some(doc => {
|
|
187
198
|
const content = doc.content;
|
|
188
|
-
|
|
189
|
-
const
|
|
199
|
+
// ⚡ Bolt: use the precomputed lowercased content
|
|
200
|
+
const contentLower = doc.contentLower;
|
|
190
201
|
|
|
191
202
|
// Match 1: Full TODO text appears in the doc (at least 20 chars or full text)
|
|
192
|
-
const searchText = todoTextLower.length > 20
|
|
193
|
-
? todoTextLower.substring(0, 40)
|
|
194
|
-
: todoTextLower;
|
|
195
203
|
const hasText = contentLower.includes(searchText);
|
|
196
204
|
|
|
197
205
|
// Match 2: File location appears nearby in the doc
|
|
@@ -240,7 +248,9 @@ function loadTrackingDocs(projectDir, config) {
|
|
|
240
248
|
const fullPath = resolve(projectDir, file);
|
|
241
249
|
if (existsSync(fullPath)) {
|
|
242
250
|
try {
|
|
243
|
-
|
|
251
|
+
const content = readFileSync(fullPath, 'utf-8');
|
|
252
|
+
// ⚡ Bolt: Precompute lowercased content during file load to avoid N*M overhead
|
|
253
|
+
docs.push({ file, content, contentLower: content.toLowerCase() });
|
|
244
254
|
} catch { /* ignore */ }
|
|
245
255
|
}
|
|
246
256
|
}
|
|
@@ -306,6 +316,9 @@ function findTodos(rootDir, dir, todos, config) {
|
|
|
306
316
|
let content;
|
|
307
317
|
try { content = readFileSync(full, 'utf-8'); } catch { continue; }
|
|
308
318
|
|
|
319
|
+
// Fast early-return: skip expensive string split if no TODO patterns exist
|
|
320
|
+
if (!TODO_PATTERN.test(content)) continue;
|
|
321
|
+
|
|
309
322
|
const lines = content.split('\n');
|
|
310
323
|
|
|
311
324
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -130,13 +130,16 @@ export function validateTraceability(projectDir, config) {
|
|
|
130
130
|
}
|
|
131
131
|
|
|
132
132
|
// Count matching source files
|
|
133
|
-
|
|
133
|
+
// ⚡ Bolt: Fast early return using .some() instead of .filter()
|
|
134
|
+
let hasSource = false;
|
|
134
135
|
for (const pattern of traceInfo.sourcePatterns) {
|
|
135
|
-
|
|
136
|
-
|
|
136
|
+
if (projectFiles.some(f => pattern.glob.test(f))) {
|
|
137
|
+
hasSource = true;
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
137
140
|
}
|
|
138
141
|
|
|
139
|
-
if (
|
|
142
|
+
if (hasSource) {
|
|
140
143
|
passed++;
|
|
141
144
|
} else {
|
|
142
145
|
warnings.push(`${docName} — exists but no matching source code found (unlinked doc)`);
|
|
@@ -186,6 +189,46 @@ function validateRequirementTraceability(projectDir, config, projectFiles) {
|
|
|
186
189
|
: DEFAULT_REQ_PATTERNS;
|
|
187
190
|
|
|
188
191
|
// ── Step 1: Collect requirement IDs from documentation ──
|
|
192
|
+
const reqIds = collectRequirementIds(projectDir, config, patterns);
|
|
193
|
+
|
|
194
|
+
// If no requirement IDs found, silently pass — this project doesn't use them
|
|
195
|
+
if (reqIds.size === 0) {
|
|
196
|
+
return { errors, warnings, passed, total };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ── Step 2: Scan test files for requirement ID references ──
|
|
200
|
+
const testRefs = scanTestFilesForReferences(projectDir, projectFiles, patterns);
|
|
201
|
+
|
|
202
|
+
// ── Step 3: Report traceability results ──
|
|
203
|
+
|
|
204
|
+
// Check each documented requirement has at least one test reference
|
|
205
|
+
for (const [reqId, location] of reqIds) {
|
|
206
|
+
total++;
|
|
207
|
+
if (testRefs.has(reqId)) {
|
|
208
|
+
passed++;
|
|
209
|
+
} else {
|
|
210
|
+
warnings.push(
|
|
211
|
+
`Requirement ${reqId} (${location.file}:${location.line}) has no test coverage. ` +
|
|
212
|
+
`Add @req ${reqId} comment to the test that verifies this requirement`
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Check for orphaned test refs (tests referencing non-existent requirements)
|
|
218
|
+
for (const [reqId, refs] of testRefs) {
|
|
219
|
+
if (!reqIds.has(reqId)) {
|
|
220
|
+
total++;
|
|
221
|
+
warnings.push(
|
|
222
|
+
`Test references ${reqId} (${refs[0].file}:${refs[0].line}) but no requirement ` +
|
|
223
|
+
`with this ID exists in documentation. Remove the reference or add the requirement to docs`
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return { errors, warnings, passed, total };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function collectRequirementIds(projectDir, config, patterns) {
|
|
189
232
|
const reqIds = new Map(); // reqId → { file, line }
|
|
190
233
|
const docSearchPaths = getRequirementDocPaths(projectDir, config);
|
|
191
234
|
|
|
@@ -193,6 +236,11 @@ function validateRequirementTraceability(projectDir, config, projectFiles) {
|
|
|
193
236
|
if (!existsSync(docPath)) continue;
|
|
194
237
|
|
|
195
238
|
const content = readFileSync(docPath, 'utf-8');
|
|
239
|
+
|
|
240
|
+
// Fast early-return: skip expensive string split if no requirement patterns exist
|
|
241
|
+
const hasMatch = patterns.some(p => { p.lastIndex = 0; return p.test(content); });
|
|
242
|
+
if (!hasMatch) continue;
|
|
243
|
+
|
|
196
244
|
const lines = content.split('\n');
|
|
197
245
|
const docName = relative(projectDir, docPath);
|
|
198
246
|
|
|
@@ -211,12 +259,10 @@ function validateRequirementTraceability(projectDir, config, projectFiles) {
|
|
|
211
259
|
}
|
|
212
260
|
}
|
|
213
261
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
return { errors, warnings, passed, total };
|
|
217
|
-
}
|
|
262
|
+
return reqIds;
|
|
263
|
+
}
|
|
218
264
|
|
|
219
|
-
|
|
265
|
+
function scanTestFilesForReferences(projectDir, projectFiles, patterns) {
|
|
220
266
|
const testFiles = projectFiles.filter(f =>
|
|
221
267
|
/\.(test|spec)\.(mjs|cjs|[jt]sx?)$/.test(f) ||
|
|
222
268
|
/__tests__\//.test(f) ||
|
|
@@ -231,6 +277,11 @@ function validateRequirementTraceability(projectDir, config, projectFiles) {
|
|
|
231
277
|
|
|
232
278
|
let content;
|
|
233
279
|
try { content = readFileSync(fullPath, 'utf-8'); } catch { continue; }
|
|
280
|
+
|
|
281
|
+
// Fast early-return: skip expensive string split if no requirement patterns exist
|
|
282
|
+
const hasMatch = patterns.some(p => { p.lastIndex = 0; return p.test(content); });
|
|
283
|
+
if (!hasMatch) continue;
|
|
284
|
+
|
|
234
285
|
const lines = content.split('\n');
|
|
235
286
|
|
|
236
287
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -246,33 +297,7 @@ function validateRequirementTraceability(projectDir, config, projectFiles) {
|
|
|
246
297
|
}
|
|
247
298
|
}
|
|
248
299
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
// Check each documented requirement has at least one test reference
|
|
252
|
-
for (const [reqId, location] of reqIds) {
|
|
253
|
-
total++;
|
|
254
|
-
if (testRefs.has(reqId)) {
|
|
255
|
-
passed++;
|
|
256
|
-
} else {
|
|
257
|
-
warnings.push(
|
|
258
|
-
`Requirement ${reqId} (${location.file}:${location.line}) has no test coverage. ` +
|
|
259
|
-
`Add @req ${reqId} comment to the test that verifies this requirement`
|
|
260
|
-
);
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
// Check for orphaned test refs (tests referencing non-existent requirements)
|
|
265
|
-
for (const [reqId, refs] of testRefs) {
|
|
266
|
-
if (!reqIds.has(reqId)) {
|
|
267
|
-
total++;
|
|
268
|
-
warnings.push(
|
|
269
|
-
`Test references ${reqId} (${refs[0].file}:${refs[0].line}) but no requirement ` +
|
|
270
|
-
`with this ID exists in documentation. Remove the reference or add the requirement to docs`
|
|
271
|
-
);
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
return { errors, warnings, passed, total };
|
|
300
|
+
return testRefs;
|
|
276
301
|
}
|
|
277
302
|
|
|
278
303
|
/**
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API-REFERENCE.md Writer — deterministic, structural edits only.
|
|
3
|
+
*
|
|
4
|
+
* Used by `docguard fix --write` to MECHANICALLY remove endpoints that are
|
|
5
|
+
* documented but no longer exist in the actual API surface. This performs NO
|
|
6
|
+
* content rewriting (that needs an LLM) — it only deletes the structural pieces
|
|
7
|
+
* that document a now-absent endpoint:
|
|
8
|
+
* 1. its summary-table row: | `GET` | `/api/...` | ... |
|
|
9
|
+
* 2. its detail block: #### GET `/api/...` … up to the next heading
|
|
10
|
+
*
|
|
11
|
+
* Pure string transform — idempotent, no disk I/O here.
|
|
12
|
+
*
|
|
13
|
+
* Zero NPM dependencies — pure Node.js built-ins only.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { normalizePath, endpointKey } from '../scanners/api-doc.mjs';
|
|
17
|
+
|
|
18
|
+
const HTTP_METHODS = new Set(['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']);
|
|
19
|
+
const HEADING_RE = /^#{1,6}\s/;
|
|
20
|
+
// An endpoint detail heading: "#### GET `/path`" (backticks optional, any level).
|
|
21
|
+
const ENDPOINT_HEADING_RE = /^#{2,6}\s+`?(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)`?\s+`?(\/[^\s`|]+)`?/i;
|
|
22
|
+
|
|
23
|
+
/** True if the doc is DocGuard-generated (safe for `--write` to edit). */
|
|
24
|
+
export function hasGeneratedMarker(content) {
|
|
25
|
+
return /<!--\s*docguard:generated\s+true\s*-->/i.test(content || '');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* If a markdown line is an API summary-table row, return its endpoint key.
|
|
30
|
+
* Row shape: | `GET` | `/api/...` | ... |
|
|
31
|
+
*/
|
|
32
|
+
function tableRowKey(line) {
|
|
33
|
+
if (!line.includes('|')) return null;
|
|
34
|
+
const cells = line.split('|').map(s => s.trim()).filter(s => s.length > 0);
|
|
35
|
+
if (cells.length < 2) return null;
|
|
36
|
+
const method = cells[0].replace(/`/g, '').trim().toUpperCase();
|
|
37
|
+
if (!HTTP_METHODS.has(method)) return null;
|
|
38
|
+
for (let i = 1; i < cells.length; i++) {
|
|
39
|
+
const cand = cells[i].replace(/`/g, '').trim();
|
|
40
|
+
if (cand.startsWith('/')) return endpointKey(method, cand);
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** If a line is an endpoint detail heading, return its endpoint key. */
|
|
46
|
+
function headingKey(line) {
|
|
47
|
+
const m = line.match(ENDPOINT_HEADING_RE);
|
|
48
|
+
if (!m) return null;
|
|
49
|
+
return endpointKey(m[1], m[2]);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Remove the table row(s) and detail block(s) for the given endpoints.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} content - API-REFERENCE.md content
|
|
56
|
+
* @param {Array<{method:string,path:string}>} endpoints - endpoints to remove
|
|
57
|
+
* @returns {{ content: string, removed: string[] }} new content + removed keys
|
|
58
|
+
*/
|
|
59
|
+
export function removeEndpoints(content, endpoints) {
|
|
60
|
+
const targets = new Set((endpoints || []).map(e => endpointKey(e.method, e.path)));
|
|
61
|
+
if (targets.size === 0) return { content, removed: [] };
|
|
62
|
+
|
|
63
|
+
const lines = content.split('\n');
|
|
64
|
+
const out = [];
|
|
65
|
+
const removed = new Set();
|
|
66
|
+
let skippingBlock = false;
|
|
67
|
+
|
|
68
|
+
for (const line of lines) {
|
|
69
|
+
const isHeading = HEADING_RE.test(line);
|
|
70
|
+
|
|
71
|
+
if (isHeading) {
|
|
72
|
+
// Any heading terminates a block we were skipping.
|
|
73
|
+
const hk = headingKey(line);
|
|
74
|
+
if (hk && targets.has(hk)) {
|
|
75
|
+
// Start (or continue into a new) skipped detail block.
|
|
76
|
+
skippingBlock = true;
|
|
77
|
+
removed.add(hk);
|
|
78
|
+
continue; // drop the heading line itself
|
|
79
|
+
}
|
|
80
|
+
// A non-target heading ends skipping and is kept.
|
|
81
|
+
skippingBlock = false;
|
|
82
|
+
out.push(line);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (skippingBlock) continue; // inside a removed detail block
|
|
87
|
+
|
|
88
|
+
// Not skipping: drop a matching summary-table row.
|
|
89
|
+
const rk = tableRowKey(line);
|
|
90
|
+
if (rk && targets.has(rk)) {
|
|
91
|
+
removed.add(rk);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
out.push(line);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { content: out.join('\n'), removed: [...removed] };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export { normalizePath };
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mechanical Fix Registry — applies deterministic, no-LLM fixes in place.
|
|
3
|
+
*
|
|
4
|
+
* Validators surface structured `fixes[]` actions; this module knows how to
|
|
5
|
+
* apply each TYPE safely and idempotently. These are surgical token/structure
|
|
6
|
+
* edits the validator already located precisely — never prose rewrites.
|
|
7
|
+
*
|
|
8
|
+
* Fix types:
|
|
9
|
+
* - replace-count : stale "N validators/checks" → actual count (Metrics-Consistency)
|
|
10
|
+
* - replace-version : stale version ref → current version (Metadata-Sync)
|
|
11
|
+
* - insert-changelog-unreleased : add a `## [Unreleased]` header (Changelog)
|
|
12
|
+
* - remove-endpoint : delete a documented-but-absent endpoint (API-Surface; delegated)
|
|
13
|
+
*
|
|
14
|
+
* Pure file edits, no LLM. Zero NPM dependencies.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { resolve } from 'node:path';
|
|
19
|
+
import { removeEndpoints, hasGeneratedMarker } from './api-reference.mjs';
|
|
20
|
+
|
|
21
|
+
const esc = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
22
|
+
|
|
23
|
+
/** replace-count: "<found> <label>" → "<actual> <label>" in the file. */
|
|
24
|
+
function applyReplaceCount(projectDir, fix) {
|
|
25
|
+
const full = resolve(projectDir, fix.file);
|
|
26
|
+
if (!existsSync(full)) return { applied: false };
|
|
27
|
+
const content = readFileSync(full, 'utf-8');
|
|
28
|
+
const re = new RegExp(`\\b${esc(fix.found)}(\\s+(?:automated\\s+)?${esc(fix.label)}\\b)`, 'g');
|
|
29
|
+
const next = content.replace(re, `${fix.actual}$1`);
|
|
30
|
+
if (next === content) return { applied: false };
|
|
31
|
+
writeFileSync(full, next, 'utf-8');
|
|
32
|
+
return { applied: true, detail: `${fix.file}: "${fix.found} ${fix.label}" → "${fix.actual} ${fix.label}"` };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** replace-version: stale version → current, ONLY in actionable contexts. */
|
|
36
|
+
function applyReplaceVersion(projectDir, fix) {
|
|
37
|
+
const full = resolve(projectDir, fix.file);
|
|
38
|
+
if (!existsSync(full)) return { applied: false };
|
|
39
|
+
const content = readFileSync(full, 'utf-8');
|
|
40
|
+
const f = esc(fix.found);
|
|
41
|
+
// Mirror metadata-sync's actionable detection so we never touch prose.
|
|
42
|
+
const patterns = [
|
|
43
|
+
new RegExp(`((?:archive|tags|releases|download)\\/v?)${f}`, 'g'),
|
|
44
|
+
new RegExp(`(@)${f}`, 'g'),
|
|
45
|
+
new RegExp(`(version:\\s*["']?)${f}`, 'g'),
|
|
46
|
+
];
|
|
47
|
+
let next = content;
|
|
48
|
+
for (const re of patterns) next = next.replace(re, `$1${fix.actual}`);
|
|
49
|
+
if (next === content) return { applied: false };
|
|
50
|
+
writeFileSync(full, next, 'utf-8');
|
|
51
|
+
return { applied: true, detail: `${fix.file}: v${fix.found} → v${fix.actual}` };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** insert-changelog-unreleased: add `## [Unreleased]` after the title/intro. */
|
|
55
|
+
function applyInsertChangelogUnreleased(projectDir, fix) {
|
|
56
|
+
const full = resolve(projectDir, fix.file);
|
|
57
|
+
if (!existsSync(full)) return { applied: false };
|
|
58
|
+
const content = readFileSync(full, 'utf-8');
|
|
59
|
+
if (/\[unreleased\]/i.test(content)) return { applied: false }; // idempotent
|
|
60
|
+
const lines = content.split('\n');
|
|
61
|
+
// Insert before the first version heading `## [x.y.z]`, else after the H1, else top.
|
|
62
|
+
let idx = lines.findIndex(l => /^##\s*\[\d/.test(l));
|
|
63
|
+
if (idx < 0) {
|
|
64
|
+
const h1 = lines.findIndex(l => /^#\s/.test(l));
|
|
65
|
+
idx = h1 >= 0 ? h1 + 1 : 0;
|
|
66
|
+
}
|
|
67
|
+
const block = idx > 0 && lines[idx - 1].trim() !== '' ? ['', '## [Unreleased]', ''] : ['## [Unreleased]', ''];
|
|
68
|
+
lines.splice(idx, 0, ...block);
|
|
69
|
+
writeFileSync(full, lines.join('\n'), 'utf-8');
|
|
70
|
+
return { applied: true, detail: `${fix.file}: added ## [Unreleased]` };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** remove-endpoint: delegate to the API-REFERENCE writer (marker-gated). */
|
|
74
|
+
function applyRemoveEndpoint(projectDir, fix, { force = false } = {}) {
|
|
75
|
+
const full = resolve(projectDir, fix.doc || 'docs-canonical/API-REFERENCE.md');
|
|
76
|
+
if (!existsSync(full)) return { applied: false };
|
|
77
|
+
const content = readFileSync(full, 'utf-8');
|
|
78
|
+
if (!hasGeneratedMarker(content) && !force) {
|
|
79
|
+
return { applied: false, skipped: `${fix.doc} not docguard:generated (use --force)` };
|
|
80
|
+
}
|
|
81
|
+
const { content: next, removed } = removeEndpoints(content, [{ method: fix.method, path: fix.path }]);
|
|
82
|
+
if (removed.length === 0 || next === content) return { applied: false };
|
|
83
|
+
writeFileSync(full, next, 'utf-8');
|
|
84
|
+
return { applied: true, detail: `${fix.doc}: removed ${fix.method} ${fix.path}` };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const APPLIERS = {
|
|
88
|
+
'replace-count': applyReplaceCount,
|
|
89
|
+
'replace-version': applyReplaceVersion,
|
|
90
|
+
'insert-changelog-unreleased': applyInsertChangelogUnreleased,
|
|
91
|
+
'remove-endpoint': applyRemoveEndpoint,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export const MECHANICAL_FIX_TYPES = Object.keys(APPLIERS);
|
|
95
|
+
|
|
96
|
+
/** Apply a single structured fix. Returns { applied, detail?, skipped? }. */
|
|
97
|
+
export function applyMechanicalFix(projectDir, fix, opts = {}) {
|
|
98
|
+
const fn = APPLIERS[fix.type];
|
|
99
|
+
if (!fn) return { applied: false, skipped: `unknown fix type: ${fix.type}` };
|
|
100
|
+
return fn(projectDir, fix, opts);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Apply a batch of fixes; returns a summary.
|
|
105
|
+
* @returns {{ applied: object[], skipped: object[] }}
|
|
106
|
+
*/
|
|
107
|
+
export function applyMechanicalFixes(projectDir, fixes, opts = {}) {
|
|
108
|
+
const applied = [];
|
|
109
|
+
const skipped = [];
|
|
110
|
+
for (const fix of fixes) {
|
|
111
|
+
const r = applyMechanicalFix(projectDir, fix, opts);
|
|
112
|
+
if (r.applied) applied.push({ ...fix, detail: r.detail });
|
|
113
|
+
else if (r.skipped) skipped.push({ ...fix, reason: r.skipped });
|
|
114
|
+
}
|
|
115
|
+
return { applied, skipped };
|
|
116
|
+
}
|