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
@@ -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
- const lines = content.split('\n');
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
- results.total = 1;
104
- if (findings.length === 0) {
105
- results.passed = 1;
106
- } else {
107
- for (const f of findings) {
108
- results.errors.push(`${f.file}: possible ${f.label} found`);
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
- if (content.includes(section)) {
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
- } else if (status && status.includes('✅')) {
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
- results.passed++;
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 spec entries parsed, check if tests exist anywhere
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 (src/**/__tests__/, src/**/*.test.*)
156
+ // 2. Check co-located tests (honors config.sourceRoot + workspaces)
144
157
  let hasColocated = false;
145
158
  if (!hasTestDir) {
146
- const sourceRoots = ['src', 'app', 'lib', 'packages'];
147
- for (const root of sourceRoots) {
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
- results.passed = 1;
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 TODOs/FIXMEs ──
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 TODOs ──────────────────────────────────────────────────────
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
- const contentLower = content.toLowerCase();
189
- const todoTextLower = todo.text.toLowerCase().trim();
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
- docs.push({ file, content: readFileSync(fullPath, 'utf-8') });
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
- let totalSources = 0;
133
+ // Bolt: Fast early return using .some() instead of .filter()
134
+ let hasSource = false;
134
135
  for (const pattern of traceInfo.sourcePatterns) {
135
- const matches = projectFiles.filter(f => pattern.glob.test(f));
136
- totalSources += matches.length;
136
+ if (projectFiles.some(f => pattern.glob.test(f))) {
137
+ hasSource = true;
138
+ break;
139
+ }
137
140
  }
138
141
 
139
- if (totalSources > 0) {
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
- // If no requirement IDs found, silently pass — this project doesn't use them
215
- if (reqIds.size === 0) {
216
- return { errors, warnings, passed, total };
217
- }
262
+ return reqIds;
263
+ }
218
264
 
219
- // ── Step 2: Scan test files for requirement ID references ──
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
- // ── Step 3: Report traceability results ──
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
+ }