OverviewFilesCommitsIssuesDocsReleasesPeers

codetree commits / 48c1098

Phase 0c — suggest-fixes mode
Turns codetree from "reports drift" into "proposes + applies patches." This is what makes Phase 1 doc reconciliation mechanical rather than manual eyeballing. New lib/suggest-fixes.js (~500 LOC): suggestFixes(tree, specs, opts) → { fixes, applicable, byFile } formatSuggest(result, opts) → human-readable text formatSuggestJson(result) → machine-readable JSON applyFixes(result, tree, opts) → writes high-confidence fixes Confidence tiers (CONFIDENCE_ORDER): high mechanical, unambiguous (LOC drift, deprecation marker, exact-match numeric claims) medium likely but needs review (Last verified date stale beyond N days) low narrative, human judgment required (BUG-N refs, NOT YET BUILT, MUST BUILD, CURRENT BUG, validator failures with ambiguous fixes) Detectors implemented: - detectLocDrift spec claims `file.js N LOC` vs actual line count - detectDateStale `**Last verified:** YYYY-MM-DD` older than opts.staleDateDays (default 30) - detectDeprecatedMarker config lists module as deprecated but spec has no **Status:** deprecated header → insert - detectValidatorFailures surfaces 'fail' claims from the validator as low-confidence review items (skips LOC duplicates — already handled above) - detectNarrativeFlags greps all spec files for stale narrative markers (BUG-N, NOT YET BUILT, etc.) - detectTestCountDrift flags `**Tests:** N pass` claims for review (codetree is a scanner, not a test runner — can't verify test counts directly) CLI (index.js): codetree docs <dir> --suggest print all proposals, no writes codetree docs <dir> --apply write high-confidence fixes codetree docs <dir> --apply --dry-run show diff, don't write codetree docs <dir> --check exit 1 if thresholds breached codetree docs <dir> --confidence TIER high | medium | low (default high) codetree docs <dir> --format json|text (default text) codetree docs <dir> --threshold N override maxStalenessScore in --check codetree docs <dir> --target PATH restrict to one spec file lib/tree.js now preserves absPath + _external/_externalRoot markers on each module entry. Required for suggest-fixes to resolve file paths for crawler-added modules (Phase 0b created modules can live outside tree.root). readActualLOC falls back to disk for files not in mod.files — spec may reference files the scanner doesn't parse (non-source, or parse failed). Real-world check against pear-git-v3: --suggest → 31 fixes (5 high, 0 medium, 26 low) --apply --dry-run → 5 high-confidence LOC-drift fixes across 5 files --check → exit 1 (54 threshold breaches, real) 96/96 tests, 478/478 asserts pass. 12 new tests exercising each detector, dry-run, real-write, format functions, with self-contained tmpdir fixtures (invariant I1 — don't couple to pear-git-v3's spec state). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
48c109801c3ab4d36c7657f4659719c6ea14d11d
by plankatron <fleeky@gmail.com> · 2026-04-16T17:31:28.000Z

Changed files (4) +1217 -0

index.js +147 -0
……@@ -59,6 +59,48 @@ if (args.includes('--sibling-dirs')) {
5959}
6060if (Object.keys(cli).length > 0) opts.cli = cli
6161
62// Suggest-fixes mode (Phase 0c).
63// --suggest Print proposed patches, no writes.
64// --apply Apply high-confidence fixes.
65// --check Exit 1 if staleness/validator thresholds breach.
66// --confidence TIER 'high' | 'medium' | 'low' (default 'high').
67// --dry-run With --apply: show what would change, don't write.
68// --format json|text Output format (default text).
69// --threshold N Override maxStalenessScore in --check.
70// --target PATH Restrict suggestions to a single spec file.
71const suggestMode = args.includes('--suggest')
72const applyMode = args.includes('--apply')
73const checkMode = args.includes('--check')
74const dryRun = args.includes('--dry-run')
75
76let confidence = 'high'
77if (args.includes('--confidence')) {
78 const idx = args.indexOf('--confidence')
79 const v = (args[idx + 1] || '').toLowerCase()
80 if (['high', 'medium', 'low'].includes(v)) confidence = v
81}
82
83let format = 'text'
84if (args.includes('--format')) {
85 const idx = args.indexOf('--format')
86 const v = (args[idx + 1] || '').toLowerCase()
87 if (['text', 'json'].includes(v)) format = v
88}
89
90let cliThreshold = null
91if (args.includes('--threshold')) {
92 const idx = args.indexOf('--threshold')
93 const raw = args[idx + 1]
94 const n = parseFloat(raw)
95 if (!isNaN(n)) cliThreshold = n
96}
97
98let targetFile = null
99if (args.includes('--target')) {
100 const idx = args.indexOf('--target')
101 targetFile = args[idx + 1] || null
102}
103
62104async function main () {
63105 switch (command) {
64106 case 'scan': return cmdScan()
……@@ -291,6 +333,12 @@ async function cmdImports () {
291333async function cmdDocs () {
292334 const tree = await scan(dir, opts)
293335
336 // --suggest / --apply / --check routes to the suggest-fixes engine (Phase 0c).
337 if (suggestMode || applyMode || checkMode) {
338 await runSuggestOrApplyOrCheck(tree)
339 return
340 }
341
294342 if (!tree.docCruft) {
295343 console.error('No documentation found')
296344 process.exit(1)
……@@ -327,6 +375,95 @@ async function cmdDocs () {
327375 }
328376}
329377
378async function runSuggestOrApplyOrCheck (tree) {
379 const { suggestFixes, formatSuggest, formatSuggestJson, applyFixes } =
380 await import('./lib/suggest-fixes.js')
381 const { parseSpecs } = await import('./lib/doc-parser.js')
382
383 // Locate specDir (same logic scanner uses for validation)
384 const specDir = tree.docs && tree.docs.specDir
385 if (!specDir) {
386 console.error('No specs found in ' + dir + ' (expected spec/ or docs/spec/)')
387 process.exit(1)
388 }
389
390 const specs = parseSpecs(specDir)
391 const result = suggestFixes(tree, specs, {
392 confidenceFloor: confidence,
393 targetFile
394 })
395
396 // --check: threshold gate
397 if (checkMode) {
398 const config = tree.config || {}
399 const thresholds = config.thresholds || {}
400 const staleLimit = cliThreshold != null ? cliThreshold : (thresholds.maxStalenessScore != null ? thresholds.maxStalenessScore : 0.3)
401 const maxValidatorFailures = thresholds.maxValidatorFailures != null ? thresholds.maxValidatorFailures : 0
402 const maxMissingRatio = thresholds.maxMissingRatio != null ? thresholds.maxMissingRatio : 0.1
403
404 let fails = 0
405 const cruft = tree.docCruft
406 const validation = tree.validation
407
408 if (cruft) {
409 for (const doc of cruft.docs) {
410 if (doc.staleness.score > staleLimit) {
411 console.error('stale: ' + doc.path + ' score=' + doc.staleness.score + ' > ' + staleLimit)
412 fails++
413 }
414 if (doc.staleness.missingRatio > maxMissingRatio) {
415 console.error('missing-ratio: ' + doc.path + ' missingRatio=' + doc.staleness.missingRatio + ' > ' + maxMissingRatio)
416 fails++
417 }
418 }
419 }
420
421 if (validation && validation.summary && validation.summary.fail > maxValidatorFailures) {
422 console.error('validator: ' + validation.summary.fail + ' failures > ' + maxValidatorFailures)
423 fails++
424 }
425
426 if (fails > 0) {
427 console.error('\ncodetree --check FAILED (' + fails + ' threshold breaches)')
428 process.exit(1)
429 }
430
431 console.log('codetree --check PASSED')
432 console.log(' ' + (cruft ? cruft.docs.length : 0) + ' docs scanned')
433 console.log(' ' + (validation && validation.summary ? validation.summary.fail : 0) + ' validator failures (max: ' + maxValidatorFailures + ')')
434 console.log(' stale-threshold: ' + staleLimit + ', missing-ratio threshold: ' + maxMissingRatio)
435 return
436 }
437
438 // --apply: write fixes
439 if (applyMode) {
440 const applyResult = applyFixes(result, tree, { dryRun })
441 if (format === 'json') {
442 console.log(JSON.stringify(applyResult, null, 2))
443 } else {
444 console.log((dryRun ? '[dry-run] ' : '') + 'applied ' + applyResult.applied + ' fix' +
445 (applyResult.applied === 1 ? '' : 'es') + ' across ' + applyResult.files.length + ' file' +
446 (applyResult.files.length === 1 ? '' : 's'))
447 for (const f of applyResult.files) {
448 console.log(' ' + f.file + ' (' + f.changes + ' change' + (f.changes === 1 ? '' : 's') + ')')
449 }
450 const remaining = result.fixes.length - applyResult.applied
451 if (remaining > 0) {
452 console.log('\n' + remaining + ' fix' + (remaining === 1 ? '' : 'es') +
453 ' deferred — run --suggest to see manual-review items.')
454 }
455 }
456 return
457 }
458
459 // --suggest: print proposals
460 if (format === 'json') {
461 console.log(formatSuggestJson(result))
462 } else {
463 console.log(formatSuggest(result, { confidenceFloor: confidence }))
464 }
465}
466
330467async function cmdCategories () {
331468 const tree = await scan(dir, opts)
332469
……@@ -457,6 +594,16 @@ Options:
457594 --sibling-dirs a,b Explicit extra scan roots, comma-separated, resolved
458595 relative to the scan root
459596
597Docs subcommands (use with "codetree docs <dir>"):
598 --suggest Print proposed patches (no writes)
599 --apply Write high-confidence fixes
600 --check Exit 1 if staleness/validator thresholds breach
601 --confidence TIER high | medium | low (default: high)
602 --dry-run With --apply: show what would change without writing
603 --format TYPE text | json (default: text)
604 --threshold N Override maxStalenessScore for --check (e.g. 0.3)
605 --target PATH Restrict to one spec file (relative to project root)
606
460607Ignore sources (merged, in this order):
461608 built-in defaults (node_modules, .git, vendor, .pear-git, .claude, build, dist)
462609 .codetreeignore file at scan root
lib/suggest-fixes.js +604 -0
……@@ -0,0 +1,604 @@
1import { readFileSync, writeFileSync, statSync, existsSync } from 'fs'
2import { resolve, join, relative } from 'path'
3
4/**
5 * Suggest-fixes engine for docs ↔ code drift.
6 *
7 * Given a scanned tree + parsed specs + validator report, produces a
8 * list of proposed fixes, each tagged with a confidence tier:
9 *
10 * high — mechanical, unambiguous (LOC count, date, known rename)
11 * medium — likely but needs review (path moved, single-rename guess)
12 * low — narrative, needs human judgment (BUG-N text, rewording)
13 *
14 * Consumers:
15 * `codetree docs --suggest <spec.md>` — prints all fixes, writes nothing
16 * `codetree docs --apply <spec.md>` — writes high-confidence fixes
17 * `codetree docs --check` — exits 1 if thresholds breached
18 */
19
20export const CONFIDENCE_ORDER = ['high', 'medium', 'low']
21
22function ge (tier, floor) {
23 const t = CONFIDENCE_ORDER.indexOf(tier)
24 const f = CONFIDENCE_ORDER.indexOf(floor)
25 return t >= 0 && f >= 0 && t <= f // higher = lower index
26}
27
28/**
29 * @param {object} tree - built code tree (from scan())
30 * @param {object} specs - parsed specs (from parseSpecs())
31 * @param {object} [opts]
32 * opts.targetFile — restrict fixes to a single spec file path (relative to tree.root)
33 * opts.confidenceFloor — 'high' | 'medium' | 'low' (default 'high')
34 * opts.staleDateDays — days after which `Last verified:` is flagged (default 30)
35 * @returns {object}
36 * {
37 * fixes: Array<Fix>,
38 * applicable: Array<Fix>, // fixes >= confidenceFloor
39 * byFile: Map<file, Array<Fix>>
40 * }
41 */
42export function suggestFixes (tree, specs, opts = {}) {
43 const fixes = []
44
45 fixes.push(...detectLocDrift(tree, specs))
46 fixes.push(...detectDateStale(tree, specs, opts))
47 fixes.push(...detectDeprecatedMarker(tree, specs))
48 fixes.push(...detectValidatorFailures(tree))
49 fixes.push(...detectNarrativeFlags(tree, specs))
50 fixes.push(...detectTestCountDrift(tree, specs))
51
52 // Optional: filter to a single target file
53 let filtered = fixes
54 if (opts.targetFile) {
55 const target = opts.targetFile
56 filtered = fixes.filter(f => f.file === target || f.file.endsWith(target))
57 }
58
59 const floor = opts.confidenceFloor || 'high'
60 const applicable = filtered.filter(f => ge(f.confidence, floor))
61
62 const byFile = new Map()
63 for (const f of filtered) {
64 if (!byFile.has(f.file)) byFile.set(f.file, [])
65 byFile.get(f.file).push(f)
66 }
67
68 return { fixes: filtered, applicable, byFile }
69}
70
71// ====== DETECTORS ======
72
73function detectLocDrift (tree, specs) {
74 const fixes = []
75 for (const [name, spec] of Object.entries(specs.modules || {})) {
76 if (!spec.filename || !spec.fileLOC) continue
77 const mod = tree.modules[name]
78 if (!mod) continue
79
80 // Build current "file N LOC, file M LOC" claim on the spec's
81 // "Source verified" line, then synthesize the corrected string.
82 const claimEntries = Object.entries(spec.fileLOC)
83 if (claimEntries.length === 0) continue
84
85 const updatedEntries = []
86 let anyDrift = false
87 for (const [fname, claimedLOC] of claimEntries) {
88 const actualLOC = readActualLOC(tree, mod, fname)
89 if (actualLOC === null) {
90 updatedEntries.push([fname, claimedLOC, null])
91 continue
92 }
93 if (actualLOC !== claimedLOC) anyDrift = true
94 updatedEntries.push([fname, claimedLOC, actualLOC])
95 }
96
97 if (!anyDrift) continue
98
99 // Locate the spec file
100 const specPath = findSpecFile(tree, spec)
101 if (!specPath) continue
102 const content = readFileSync(specPath, 'utf-8')
103 const lines = content.split('\n')
104
105 // Find the **Source verified:** line
106 let lineNum = -1
107 for (let i = 0; i < lines.length; i++) {
108 if (/\*\*Source verified:\*\*/.test(lines[i])) {
109 lineNum = i + 1
110 break
111 }
112 }
113 if (lineNum === -1) continue
114
115 const current = lines[lineNum - 1]
116 const newInner = updatedEntries
117 .map(([f, _claim, actual]) => actual !== null ? (f + ' ' + actual + ' LOC') : (f + ' ' + _claim + ' LOC'))
118 .join(', ')
119 // Replace only the parenthetical — keep date + rest of line intact
120 const suggested = current.replace(/\(([^)]*)\)/, '(' + newInner + ')')
121
122 if (suggested !== current) {
123 fixes.push({
124 file: relative(tree.root, specPath),
125 line: lineNum,
126 current,
127 suggested,
128 confidence: 'high',
129 type: 'loc-drift',
130 reason: 'File LOC count drifted: ' +
131 updatedEntries
132 .filter(([_, c, a]) => a !== null && c !== a)
133 .map(([f, c, a]) => f + ' ' + c + '→' + a)
134 .join(', ')
135 })
136 }
137 }
138 return fixes
139}
140
141function detectTestCountDrift (tree, specs) {
142 // Spec claims `**Tests:** N pass`. We can't run tests here (codetree is a
143 // scanner, not a test runner). But if the tree has a testCommand and a
144 // recent `npm test` result file on disk, we could parse it. For now, flag
145 // test-count claims as MANUAL REVIEW when the spec is older than N days.
146 // A richer integration can hook this up via a sidecar tool.
147 const fixes = []
148 for (const [name, spec] of Object.entries(specs.modules || {})) {
149 if (!spec.filename || spec.testCount == null) continue
150 const specPath = findSpecFile(tree, spec)
151 if (!specPath) continue
152 const content = readFileSync(specPath, 'utf-8')
153 const lines = content.split('\n')
154
155 let lineNum = -1
156 for (let i = 0; i < lines.length; i++) {
157 if (/\*\*Tests:\*\*/.test(lines[i])) {
158 lineNum = i + 1
159 break
160 }
161 }
162 if (lineNum === -1) continue
163
164 fixes.push({
165 file: relative(tree.root, specPath),
166 line: lineNum,
167 current: lines[lineNum - 1],
168 suggested: null,
169 confidence: 'low',
170 type: 'test-count-manual',
171 reason: 'Test count claim found; codetree can\'t run tests to verify. ' +
172 'Run `npm test` in ' + (spec.location || name) + ' and update manually.'
173 })
174 }
175 return fixes
176}
177
178function detectDateStale (tree, specs, opts) {
179 const fixes = []
180 const staleDays = opts.staleDateDays || 30
181 const now = Date.now()
182 const dayMs = 24 * 60 * 60 * 1000
183
184 // System spec
185 if (specs.system && specs.system.lastVerified) {
186 const verifiedMs = Date.parse(specs.system.lastVerified)
187 if (!isNaN(verifiedMs)) {
188 const ageDays = Math.floor((now - verifiedMs) / dayMs)
189 if (ageDays > staleDays) {
190 const systemPath = findSystemSpec(tree)
191 if (systemPath) {
192 const content = readFileSync(systemPath, 'utf-8')
193 const lines = content.split('\n')
194 let lineNum = -1
195 for (let i = 0; i < lines.length; i++) {
196 if (/\*\*Last verified:\*\*/.test(lines[i])) {
197 lineNum = i + 1
198 break
199 }
200 }
201 if (lineNum >= 0) {
202 fixes.push({
203 file: relative(tree.root, systemPath),
204 line: lineNum,
205 current: lines[lineNum - 1],
206 suggested: lines[lineNum - 1].replace(/\d{4}-\d{2}-\d{2}/, todayIso()),
207 confidence: 'medium',
208 type: 'date-stale',
209 reason: 'Last verified was ' + ageDays + ' days ago (threshold: ' + staleDays + ' days). ' +
210 'Suggested: bump to today. Confidence medium because the right date depends on whether ' +
211 'the spec has actually been re-verified against the code.'
212 })
213 }
214 }
215 }
216 }
217 }
218
219 // Per-module Source verified
220 for (const [name, spec] of Object.entries(specs.modules || {})) {
221 if (!spec.sourceVerified) continue
222 const verifiedMs = Date.parse(spec.sourceVerified)
223 if (isNaN(verifiedMs)) continue
224 const ageDays = Math.floor((now - verifiedMs) / dayMs)
225 if (ageDays <= staleDays) continue
226
227 const specPath = findSpecFile(tree, spec)
228 if (!specPath) continue
229 const content = readFileSync(specPath, 'utf-8')
230 const lines = content.split('\n')
231 let lineNum = -1
232 for (let i = 0; i < lines.length; i++) {
233 if (/\*\*Source verified:\*\*/.test(lines[i])) {
234 lineNum = i + 1
235 break
236 }
237 }
238 if (lineNum === -1) continue
239
240 // Don't auto-suggest the date if LOC is also drifting — the user will fix
241 // it together and today's date reflects the re-verification. But we do
242 // surface it as medium confidence.
243 const suggested = lines[lineNum - 1].replace(/\d{4}-\d{2}-\d{2}/, todayIso())
244
245 fixes.push({
246 file: relative(tree.root, specPath),
247 line: lineNum,
248 current: lines[lineNum - 1],
249 suggested,
250 confidence: 'medium',
251 type: 'date-stale',
252 reason: spec.sourceVerified + ' → today (' + todayIso() + '). ' +
253 'Module has been source-verified ' + ageDays + ' days ago.'
254 })
255 }
256 return fixes
257}
258
259function detectDeprecatedMarker (tree, specs) {
260 const fixes = []
261 const config = tree.config || {}
262 const deprecated = config.deprecatedModules || []
263
264 for (const depName of deprecated) {
265 const spec = specs.modules && specs.modules[depName]
266 if (!spec) continue
267 if (spec.deprecated) continue // already marked
268
269 const specPath = findSpecFile(tree, spec)
270 if (!specPath) continue
271 const content = readFileSync(specPath, 'utf-8')
272 const lines = content.split('\n')
273
274 // Find the Location line — insert **Status:** deprecated right after it
275 let locLineNum = -1
276 for (let i = 0; i < lines.length; i++) {
277 if (/\*\*Location:\*\*/.test(lines[i])) {
278 locLineNum = i + 1
279 break
280 }
281 }
282 if (locLineNum === -1) continue
283
284 fixes.push({
285 file: relative(tree.root, specPath),
286 line: locLineNum,
287 current: lines[locLineNum - 1],
288 suggested: lines[locLineNum - 1] + '\n**Status:** deprecated',
289 confidence: 'high',
290 type: 'deprecation-marker',
291 reason: 'Config lists "' + depName + '" as deprecated but the spec has no **Status:** deprecated header. ' +
292 'Insert after the Location line to suppress undocumented-X warnings.'
293 })
294 }
295 return fixes
296}
297
298function detectValidatorFailures (tree) {
299 // Surface validator failures as low-confidence review flags. The spec
300 // claims something that the code doesn't match — but whether the fix
301 // is "change the spec" or "change the code" is a judgment call.
302 const fixes = []
303 if (!tree.validation) return fixes
304
305 for (const [modName, modReport] of Object.entries(tree.validation.modules || {})) {
306 for (const check of modReport.checks) {
307 if (check.status !== 'fail') continue
308
309 // LOC drift is already handled with high confidence above; skip duplicates.
310 if (check.claim && /LOC/.test(check.claim) && check.expected != null && check.actual != null) continue
311
312 // Export / method claims: low confidence, point operator at the discrepancy
313 fixes.push({
314 file: modReport.specFile ? 'spec/modules/' + modReport.specFile : '(unknown)',
315 line: null,
316 current: check.claim,
317 suggested: null,
318 confidence: 'low',
319 type: 'validator-fail',
320 reason: (check.detail || check.claim) + ' (module: ' + modName + ')'
321 })
322 }
323 }
324 return fixes
325}
326
327function detectNarrativeFlags (tree, specs) {
328 // Scan all spec content for "NOT YET BUILT", "MUST BUILD", "BUG-N",
329 // "CURRENT BUG" — these are narrative notes that usually go stale fast.
330 // Flag as low-confidence for manual review, don't propose auto-fixes.
331 const fixes = []
332 const patterns = [
333 { regex: /NOT YET BUILT/g, reason: 'Narrative claim may be stale — verify if feature is now built.' },
334 { regex: /MUST BUILD/g, reason: 'Narrative claim may be stale — verify if feature is now built.' },
335 { regex: /CURRENT BUG/g, reason: 'Narrative bug claim — verify whether bug is now fixed.' },
336 { regex: /\bBUG-\d+\b/g, reason: 'BUG-N reference — verify whether bug is now resolved in BUGS.md.' }
337 ]
338
339 const docs = collectAllSpecFiles(tree, specs)
340 for (const specPath of docs) {
341 let content
342 try { content = readFileSync(specPath, 'utf-8') } catch { continue }
343 const lines = content.split('\n')
344
345 for (let i = 0; i < lines.length; i++) {
346 for (const pattern of patterns) {
347 if (pattern.regex.test(lines[i])) {
348 fixes.push({
349 file: relative(tree.root, specPath),
350 line: i + 1,
351 current: lines[i],
352 suggested: null,
353 confidence: 'low',
354 type: 'narrative-flag',
355 reason: pattern.reason
356 })
357 }
358 pattern.regex.lastIndex = 0
359 }
360 }
361 }
362 return fixes
363}
364
365// ====== HELPERS ======
366
367function readActualLOC (tree, mod, fname) {
368 // Find the file in the module. mod.files is keyed by path relative to
369 // module root, BUT the scanner only includes files it parsed successfully.
370 // For LOC counting, a file can exist on disk and be LOC-countable even if
371 // parsing failed (or it's a non-source file). So we use mod.files as the
372 // primary index, then fall back to disk lookup against the module's root.
373 //
374 // Path resolution: crawler-added modules (mod._external === true) are
375 // NOT inside tree.root — their real location is mod._externalRoot or
376 // mod.absPath. Use mod.absPath as the canonical module root.
377 const modRoot = mod.absPath || (mod._externalRoot
378 ? join(mod._externalRoot, mod.path === '.' ? '' : mod.path)
379 : resolve(tree.root, mod.path))
380
381 // 1. Try mod.files index (fast path, when scanner parsed it)
382 for (const [relPath, fileInfo] of Object.entries(mod.files || {})) {
383 const base = relPath.split('/').pop()
384 if (base === fname || relPath === fname) {
385 if (fileInfo.lines) return fileInfo.lines
386 try {
387 return readFileSync(resolve(modRoot, relPath), 'utf-8').split('\n').length
388 } catch {
389 // fall through to disk-fallback below
390 }
391 }
392 }
393
394 // 2. Disk fallback — spec mentioned a filename not in mod.files. Try
395 // common locations within the module root.
396 const candidates = [
397 resolve(modRoot, fname),
398 resolve(modRoot, 'lib', fname),
399 resolve(modRoot, 'src', fname)
400 ]
401 for (const p of candidates) {
402 try {
403 return readFileSync(p, 'utf-8').split('\n').length
404 } catch {
405 // try next
406 }
407 }
408 return null
409}
410
411function findSpecFile (tree, spec) {
412 // Assume spec.filename is like "hyper-orgs.md"; spec lives under
413 // tree.root/spec/modules/.
414 const candidates = [
415 join(tree.root, 'spec', 'modules', spec.filename),
416 join(tree.root, 'docs', 'spec', 'modules', spec.filename)
417 ]
418 for (const p of candidates) {
419 if (existsSync(p)) return p
420 }
421 return null
422}
423
424function findSystemSpec (tree) {
425 const candidates = [
426 join(tree.root, 'spec', 'SYSTEM.md'),
427 join(tree.root, 'docs', 'spec', 'SYSTEM.md')
428 ]
429 for (const p of candidates) {
430 if (existsSync(p)) return p
431 }
432 return null
433}
434
435function collectAllSpecFiles (tree, specs) {
436 const out = []
437 const sysPath = findSystemSpec(tree)
438 if (sysPath) out.push(sysPath)
439 for (const spec of Object.values(specs.modules || {})) {
440 const p = findSpecFile(tree, spec)
441 if (p) out.push(p)
442 }
443 // Also include spec/flows/*.md and spec/BUGS.md if they exist
444 const extras = [
445 join(tree.root, 'spec', 'BUGS.md'),
446 join(tree.root, 'spec', 'flows', 'auth.md'),
447 join(tree.root, 'spec', 'flows', 'deploy.md'),
448 join(tree.root, 'spec', 'flows', 'issues.md'),
449 join(tree.root, 'spec', 'flows', 'replication.md')
450 ]
451 for (const p of extras) {
452 if (existsSync(p)) out.push(p)
453 }
454 return out
455}
456
457function todayIso () {
458 return new Date().toISOString().split('T')[0]
459}
460
461// ====== FORMATTERS ======
462
463/**
464 * Human-readable suggest output.
465 */
466export function formatSuggest (result, opts = {}) {
467 const lines = []
468 const show = opts.confidenceFloor || 'low' // show everything by default
469
470 const byConfidence = {
471 high: result.fixes.filter(f => f.confidence === 'high'),
472 medium: result.fixes.filter(f => f.confidence === 'medium'),
473 low: result.fixes.filter(f => f.confidence === 'low')
474 }
475
476 lines.push('# codetree suggest')
477 lines.push('')
478 lines.push('Total: ' + result.fixes.length + ' proposed fixes ' +
479 '(' + byConfidence.high.length + ' high, ' +
480 byConfidence.medium.length + ' medium, ' +
481 byConfidence.low.length + ' low).')
482 lines.push('Applicable at --confidence ' + (opts.confidenceFloor || 'high') +
483 ': ' + result.applicable.length)
484 lines.push('')
485
486 for (const tier of ['high', 'medium', 'low']) {
487 if (byConfidence[tier].length === 0) continue
488 if (!ge(tier, show)) continue
489
490 const isAuto = ge(tier, opts.confidenceFloor || 'high')
491 const heading = tier === 'high'
492 ? '## High-confidence fixes (auto-applicable)'
493 : tier === 'medium'
494 ? '## Medium-confidence fixes' + (isAuto ? ' (applicable at current floor)' : ' (MANUAL REVIEW)')
495 : '## Low-confidence — MANUAL REVIEW'
496 lines.push(heading)
497 lines.push('')
498
499 for (const fix of byConfidence[tier]) {
500 lines.push('### ' + fix.file + (fix.line != null ? ':' + fix.line : '') + ' [' + fix.type + ']')
501 lines.push('')
502 if (fix.current) {
503 lines.push('current:')
504 lines.push(' ' + fix.current.trim())
505 }
506 if (fix.suggested !== null && fix.suggested !== undefined) {
507 lines.push('suggested:')
508 for (const l of String(fix.suggested).split('\n')) {
509 lines.push(' ' + l)
510 }
511 } else {
512 lines.push('suggested: (manual review needed)')
513 }
514 lines.push('reason: ' + fix.reason)
515 lines.push('')
516 }
517 }
518
519 return lines.join('\n')
520}
521
522/**
523 * Machine-readable JSON output.
524 */
525export function formatSuggestJson (result) {
526 return JSON.stringify({
527 total: result.fixes.length,
528 applicable: result.applicable.length,
529 fixes: result.fixes
530 }, null, 2)
531}
532
533// ====== APPLY ======
534
535/**
536 * Apply the applicable fixes (those >= confidenceFloor) to their files.
537 * Only single-line suggestions are applied; multi-line suggested blocks
538 * (e.g. inserting a **Status:** line) are also supported by detecting
539 * a trailing newline.
540 *
541 * @param {object} result - suggestFixes output
542 * @param {object} tree - built tree (needs tree.root for absolute paths)
543 * @param {object} [opts]
544 * opts.dryRun — if true, don't write, just return what would change
545 * @returns { applied: number, skipped: number, files: Array<{file, changes}> }
546 */
547export function applyFixes (result, tree, opts = {}) {
548 const dryRun = !!opts.dryRun
549 const filesToWrite = new Map()
550
551 for (const fix of result.applicable) {
552 if (fix.suggested == null) continue
553 if (fix.line == null) continue
554
555 const specPath = resolve(tree.root, fix.file)
556 if (!existsSync(specPath)) continue
557
558 let entry = filesToWrite.get(specPath)
559 if (!entry) {
560 const content = readFileSync(specPath, 'utf-8')
561 entry = { content, lines: content.split('\n'), changes: [] }
562 filesToWrite.set(specPath, entry)
563 }
564
565 const idx = fix.line - 1
566 if (idx < 0 || idx >= entry.lines.length) continue
567 if (entry.lines[idx] !== fix.current) {
568 // File changed since suggest was generated — skip to be safe
569 continue
570 }
571
572 // Suggested may span multiple lines (e.g. insert + keep original)
573 const suggestedLines = String(fix.suggested).split('\n')
574 entry.changes.push({ line: fix.line, fix })
575 // Apply: replace lines[idx] with all suggestedLines
576 entry.lines.splice(idx, 1, ...suggestedLines)
577 // Fix up line numbers for subsequent fixes on the same file by using
578 // the updated array for subsequent matching.
579 // (We keep original fix.line for reporting; we don't re-index.)
580 }
581
582 const fileResults = []
583 let applied = 0
584 for (const [path, entry] of filesToWrite) {
585 if (entry.changes.length === 0) continue
586 const newContent = entry.lines.join('\n')
587 if (newContent === entry.content) continue
588 if (!dryRun) {
589 writeFileSync(path, newContent, 'utf-8')
590 }
591 applied += entry.changes.length
592 fileResults.push({
593 file: relative(tree.root, path),
594 changes: entry.changes.length,
595 dryRun
596 })
597 }
598
599 return {
600 applied,
601 skipped: result.fixes.length - applied,
602 files: fileResults
603 }
604}
lib/tree.js +7 -0
……@@ -33,6 +33,7 @@ export function buildTree (graph) {
3333 // Build module entry
3434 const moduleEntry = {
3535 path: mod.path,
36 absPath: mod.absPath || null,
3637 name: mod.name,
3738 version: mod.version,
3839 type: mod.type,
……@@ -44,6 +45,12 @@ export function buildTree (graph) {
4445 files: {}
4546 }
4647
48 // Preserve cross-filesystem-crawl markers (Phase 0b) so downstream
49 // consumers (suggest-fixes, validator) know when a module lives outside
50 // the primary scan root.
51 if (mod._external) moduleEntry._external = true
52 if (mod._externalRoot) moduleEntry._externalRoot = mod._externalRoot
53
4754 // Build file entries from the parsed results already attached by scan-stream
4855 // via the file-parsed event. This used to re-parse every file here, which
4956 // was a ~2x CPU waste and caused OOM on large recursive scans.
test/suggest-fixes.test.js +459 -0
……@@ -0,0 +1,459 @@
1import { test } from 'brittle'
2import { mkdirSync, writeFileSync, readFileSync, rmSync } from 'fs'
3import { tmpdir } from 'os'
4import { join } from 'path'
5
6import { scan } from '../lib/scanner.js'
7import { parseSpecs } from '../lib/doc-parser.js'
8import {
9 suggestFixes,
10 formatSuggest,
11 formatSuggestJson,
12 applyFixes,
13 CONFIDENCE_ORDER
14} from '../lib/suggest-fixes.js'
15
16/**
17 * Phase 0c — suggest-fixes mode.
18 *
19 * Tests use self-contained tmpdir fixtures (invariant I1 — don't couple to
20 * pear-git-v3's current spec state).
21 */
22
23function mkFixture (name) {
24 const dir = join(tmpdir(), 'codetree-suggest-' + name + '-' + Date.now())
25 mkdirSync(dir, { recursive: true })
26 return dir
27}
28
29function cleanup (dir) {
30 rmSync(dir, { recursive: true, force: true })
31}
32
33function mkProject (root, modules, specs) {
34 // modules: { name: { files: { 'index.js': 'source content ... ' } } }
35 // specs: { 'SYSTEM.md': '# ...', 'modules/name.md': '...' }
36 writeFileSync(join(root, 'package.json'), JSON.stringify({
37 name: 'fixture', version: '0.1.0', main: 'index.js'
38 }))
39
40 // Top-level index.js so the root itself is a module
41 if (!modules.__root) {
42 writeFileSync(join(root, 'index.js'), '// fixture root\n')
43 }
44
45 for (const [modName, mod] of Object.entries(modules)) {
46 if (modName === '__root') continue
47 const modDir = join(root, modName)
48 mkdirSync(modDir, { recursive: true })
49 writeFileSync(join(modDir, 'package.json'), JSON.stringify({
50 name: modName, version: '0.1.0', main: 'index.js'
51 }))
52 for (const [fname, content] of Object.entries(mod.files || {})) {
53 writeFileSync(join(modDir, fname), content)
54 }
55 }
56
57 const specDir = join(root, 'spec')
58 const modulesDir = join(specDir, 'modules')
59 mkdirSync(modulesDir, { recursive: true })
60 for (const [path, content] of Object.entries(specs)) {
61 const fullPath = join(specDir, path)
62 const parent = fullPath.substring(0, fullPath.lastIndexOf('/'))
63 mkdirSync(parent, { recursive: true })
64 writeFileSync(fullPath, content)
65 }
66}
67
68// --- LOC drift detection ---
69
70test('suggest: detects LOC drift with high confidence', async (t) => {
71 const root = mkFixture('loc-drift')
72 try {
73 const code = 'export function a () {}\n'.repeat(50) // 50 lines of code
74 mkProject(root, {
75 mymod: { files: { 'index.js': code } }
76 }, {
77 'modules/mymod.md': [
78 '# mymod -- Module Spec',
79 '',
80 '**Location:** `mymod/`',
81 '**Tests:** 0 pass',
82 '**Source verified:** 2026-04-10 (index.js 10 LOC)',
83 '',
84 '## What it is',
85 '',
86 'A test module.',
87 ''
88 ].join('\n')
89 })
90
91 const tree = await scan(root)
92 const specs = parseSpecs(join(root, 'spec'))
93 const result = suggestFixes(tree, specs)
94
95 const drift = result.fixes.find(f => f.type === 'loc-drift')
96 t.ok(drift, 'LOC drift detected')
97 t.is(drift.confidence, 'high', 'high confidence')
98 t.ok(drift.suggested.includes('index.js 50 LOC') ||
99 drift.suggested.includes('index.js 51 LOC'), 'suggests actual LOC (50 or 51)')
100 t.ok(drift.current.includes('index.js 10 LOC'), 'current has old LOC')
101 } finally {
102 cleanup(root)
103 }
104})
105
106test('suggest: no LOC drift when spec matches code', async (t) => {
107 const root = mkFixture('no-drift')
108 try {
109 const code = 'x\n'.repeat(20) // exactly 21 lines (20 x's + 1 empty from trailing \n)
110 const actualLines = (code + '').split('\n').length
111 mkProject(root, {
112 mymod: { files: { 'index.js': code } }
113 }, {
114 'modules/mymod.md': [
115 '# mymod -- Module Spec',
116 '',
117 '**Location:** `mymod/`',
118 '**Tests:** 0 pass',
119 '**Source verified:** 2026-04-10 (index.js ' + actualLines + ' LOC)',
120 '',
121 '## What it is',
122 '',
123 'No drift.',
124 ''
125 ].join('\n')
126 })
127
128 const tree = await scan(root)
129 const specs = parseSpecs(join(root, 'spec'))
130 const result = suggestFixes(tree, specs)
131
132 const drift = result.fixes.find(f => f.type === 'loc-drift')
133 t.absent(drift, 'no LOC drift when spec is accurate')
134 } finally {
135 cleanup(root)
136 }
137})
138
139// --- Date staleness detection ---
140
141test('suggest: flags stale Last verified date (medium confidence)', async (t) => {
142 const root = mkFixture('date-stale')
143 try {
144 // Use a date from over a year ago
145 const oldDate = '2024-01-01'
146 mkProject(root, {
147 mymod: { files: { 'index.js': 'export {}\n' } }
148 }, {
149 'SYSTEM.md': [
150 '# System',
151 '',
152 '**Last verified:** ' + oldDate,
153 '',
154 '### Core',
155 '',
156 '| Module | Location | Purpose |',
157 '|---|---|---|',
158 '| **mymod** | `mymod/` | Test |',
159 ''
160 ].join('\n')
161 })
162
163 const tree = await scan(root)
164 const specs = parseSpecs(join(root, 'spec'))
165 const result = suggestFixes(tree, specs, { staleDateDays: 7 })
166
167 const stale = result.fixes.find(f => f.type === 'date-stale' && f.file === 'spec/SYSTEM.md')
168 t.ok(stale, 'stale date detected')
169 t.is(stale.confidence, 'medium', 'medium confidence (requires review)')
170 t.ok(/\d{4}-\d{2}-\d{2}/.test(stale.suggested), 'suggests a new date')
171 t.not(stale.suggested.includes(oldDate), 'suggested is not the old date')
172 } finally {
173 cleanup(root)
174 }
175})
176
177// --- Deprecation marker detection ---
178
179test('suggest: adds Status: deprecated when config lists module as deprecated', async (t) => {
180 const root = mkFixture('deprec')
181 try {
182 mkProject(root, {
183 legacymod: { files: { 'index.js': 'export class Foo {}\n' } }
184 }, {
185 'modules/legacymod.md': [
186 '# legacymod -- Module Spec',
187 '',
188 '**Location:** `legacymod/`',
189 '',
190 '## What it is',
191 '',
192 'Old module.',
193 ''
194 ].join('\n')
195 })
196
197 // Config says legacymod is deprecated but spec has no marker
198 writeFileSync(join(root, '.codetree.json'), JSON.stringify({
199 deprecatedModules: ['legacymod']
200 }))
201
202 const tree = await scan(root)
203 const specs = parseSpecs(join(root, 'spec'))
204 const result = suggestFixes(tree, specs)
205
206 const fix = result.fixes.find(f => f.type === 'deprecation-marker')
207 t.ok(fix, 'deprecation marker fix proposed')
208 t.is(fix.confidence, 'high', 'high confidence')
209 t.ok(fix.suggested.includes('**Status:** deprecated'), 'inserts Status: deprecated')
210 } finally {
211 cleanup(root)
212 }
213})
214
215test('suggest: no deprecation marker fix when spec already has one', async (t) => {
216 const root = mkFixture('already-deprec')
217 try {
218 mkProject(root, {
219 legacymod: { files: { 'index.js': 'export {}\n' } }
220 }, {
221 'modules/legacymod.md': [
222 '# legacymod -- Module Spec',
223 '',
224 '**Location:** `legacymod/`',
225 '**Status:** deprecated',
226 '',
227 '## What it is',
228 '',
229 'Already marked.',
230 ''
231 ].join('\n')
232 })
233 writeFileSync(join(root, '.codetree.json'), JSON.stringify({
234 deprecatedModules: ['legacymod']
235 }))
236
237 const tree = await scan(root)
238 const specs = parseSpecs(join(root, 'spec'))
239 const result = suggestFixes(tree, specs)
240
241 const fix = result.fixes.find(f => f.type === 'deprecation-marker')
242 t.absent(fix, 'no fix when marker already present')
243 } finally {
244 cleanup(root)
245 }
246})
247
248// --- Narrative flags (low confidence) ---
249
250test('suggest: flags BUG-N references as low-confidence manual review', async (t) => {
251 const root = mkFixture('narrative')
252 try {
253 mkProject(root, {}, {
254 'BUGS.md': [
255 '# Bugs',
256 '',
257 'BUG-1: something old',
258 'BUG-42: another old thing',
259 '',
260 'NOT YET BUILT: the X feature',
261 ''
262 ].join('\n')
263 })
264
265 const tree = await scan(root)
266 const specs = parseSpecs(join(root, 'spec'))
267 const result = suggestFixes(tree, specs)
268
269 const narrativeFixes = result.fixes.filter(f => f.type === 'narrative-flag')
270 t.ok(narrativeFixes.length >= 3, 'at least 3 narrative flags (2 BUG-N + NOT YET BUILT)')
271 for (const f of narrativeFixes) {
272 t.is(f.confidence, 'low', 'narrative flags are low confidence')
273 }
274 } finally {
275 cleanup(root)
276 }
277})
278
279// --- Confidence filtering ---
280
281test('suggest: confidenceFloor filters applicable fixes', async (t) => {
282 const root = mkFixture('confidence')
283 try {
284 const code = 'line\n'.repeat(100)
285 mkProject(root, {
286 mymod: { files: { 'index.js': code } }
287 }, {
288 'modules/mymod.md': [
289 '# mymod -- Module Spec',
290 '',
291 '**Location:** `mymod/`',
292 '**Source verified:** 2020-01-01 (index.js 10 LOC)',
293 '',
294 '## What it is',
295 '',
296 'BUG-5: something',
297 ''
298 ].join('\n')
299 })
300
301 const tree = await scan(root)
302 const specs = parseSpecs(join(root, 'spec'))
303
304 const high = suggestFixes(tree, specs, { confidenceFloor: 'high' })
305 const med = suggestFixes(tree, specs, { confidenceFloor: 'medium' })
306 const low = suggestFixes(tree, specs, { confidenceFloor: 'low' })
307
308 t.ok(high.applicable.length >= 1, 'high: at least 1 applicable (LOC fix)')
309 t.ok(med.applicable.length >= high.applicable.length, 'medium: ≥ high (includes date-stale)')
310 t.ok(low.applicable.length >= med.applicable.length, 'low: ≥ medium (includes BUG-N)')
311 } finally {
312 cleanup(root)
313 }
314})
315
316// --- Apply ---
317
318test('apply: dry-run does not write files', async (t) => {
319 const root = mkFixture('apply-dry')
320 try {
321 const code = 'x\n'.repeat(100)
322 mkProject(root, {
323 mymod: { files: { 'index.js': code } }
324 }, {
325 'modules/mymod.md': [
326 '# mymod -- Module Spec',
327 '',
328 '**Location:** `mymod/`',
329 '**Source verified:** 2026-04-10 (index.js 10 LOC)',
330 '',
331 '## What it is',
332 '',
333 'Test.',
334 ''
335 ].join('\n')
336 })
337
338 const specPath = join(root, 'spec', 'modules', 'mymod.md')
339 const before = readFileSync(specPath, 'utf-8')
340
341 const tree = await scan(root)
342 const specs = parseSpecs(join(root, 'spec'))
343 const result = suggestFixes(tree, specs)
344 const applyResult = applyFixes(result, tree, { dryRun: true })
345
346 t.ok(applyResult.applied >= 1, 'applied count > 0')
347
348 const after = readFileSync(specPath, 'utf-8')
349 t.is(before, after, 'file unchanged in dry-run')
350 } finally {
351 cleanup(root)
352 }
353})
354
355test('apply: real apply writes the fix to disk', async (t) => {
356 const root = mkFixture('apply-real')
357 try {
358 const code = 'x\n'.repeat(75)
359 mkProject(root, {
360 mymod: { files: { 'index.js': code } }
361 }, {
362 'modules/mymod.md': [
363 '# mymod -- Module Spec',
364 '',
365 '**Location:** `mymod/`',
366 '**Source verified:** 2026-04-10 (index.js 10 LOC)',
367 '',
368 '## What it is',
369 '',
370 'Test.',
371 ''
372 ].join('\n')
373 })
374
375 const specPath = join(root, 'spec', 'modules', 'mymod.md')
376 const tree = await scan(root)
377 const specs = parseSpecs(join(root, 'spec'))
378 const result = suggestFixes(tree, specs)
379 const applyResult = applyFixes(result, tree, { dryRun: false })
380
381 t.ok(applyResult.applied >= 1, 'applied count > 0')
382
383 const after = readFileSync(specPath, 'utf-8')
384 t.not(after.includes('index.js 10 LOC'), 'old LOC removed')
385 t.ok(/index\.js 7[56] LOC/.test(after), 'new LOC (75 or 76) written')
386 } finally {
387 cleanup(root)
388 }
389})
390
391// --- Format ---
392
393test('format: formatSuggest produces human-readable text', async (t) => {
394 const root = mkFixture('format-text')
395 try {
396 const code = 'x\n'.repeat(50)
397 mkProject(root, {
398 mymod: { files: { 'index.js': code } }
399 }, {
400 'modules/mymod.md': [
401 '# mymod -- Module Spec',
402 '',
403 '**Location:** `mymod/`',
404 '**Source verified:** 2026-04-10 (index.js 10 LOC)',
405 '',
406 '## What it is\n\nTest.\n'
407 ].join('\n')
408 })
409
410 const tree = await scan(root)
411 const specs = parseSpecs(join(root, 'spec'))
412 const result = suggestFixes(tree, specs)
413 const text = formatSuggest(result)
414
415 t.ok(text.includes('codetree suggest'), 'has header')
416 t.ok(text.includes('[loc-drift]'), 'has fix type')
417 t.ok(text.includes('current:'), 'shows current')
418 t.ok(text.includes('suggested:'), 'shows suggested')
419 } finally {
420 cleanup(root)
421 }
422})
423
424test('format: formatSuggestJson produces parseable JSON', async (t) => {
425 const root = mkFixture('format-json')
426 try {
427 const code = 'x\n'.repeat(50)
428 mkProject(root, {
429 mymod: { files: { 'index.js': code } }
430 }, {
431 'modules/mymod.md': [
432 '# mymod -- Module Spec',
433 '',
434 '**Location:** `mymod/`',
435 '**Source verified:** 2026-04-10 (index.js 10 LOC)',
436 '',
437 '## What it is\n\nTest.\n'
438 ].join('\n')
439 })
440
441 const tree = await scan(root)
442 const specs = parseSpecs(join(root, 'spec'))
443 const result = suggestFixes(tree, specs)
444 const json = formatSuggestJson(result)
445
446 const parsed = JSON.parse(json)
447 t.ok(typeof parsed.total === 'number', 'total is a number')
448 t.ok(typeof parsed.applicable === 'number', 'applicable is a number')
449 t.ok(Array.isArray(parsed.fixes), 'fixes is an array')
450 } finally {
451 cleanup(root)
452 }
453})
454
455test('CONFIDENCE_ORDER exported in expected order', (t) => {
456 t.is(CONFIDENCE_ORDER[0], 'high')
457 t.is(CONFIDENCE_ORDER[1], 'medium')
458 t.is(CONFIDENCE_ORDER[2], 'low')
459})