| … | … | @@ -59,6 +59,48 @@ if (args.includes('--sibling-dirs')) { |
| 59 | 59 | } |
| 60 | 60 | if (Object.keys(cli).length > 0) opts.cli = cli |
| 61 | 61 | |
| 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. | |
| 71 | const suggestMode = args.includes('--suggest') | |
| 72 | const applyMode = args.includes('--apply') | |
| 73 | const checkMode = args.includes('--check') | |
| 74 | const dryRun = args.includes('--dry-run') | |
| 75 | ||
| 76 | let confidence = 'high' | |
| 77 | if (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 | ||
| 83 | let format = 'text' | |
| 84 | if (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 | ||
| 90 | let cliThreshold = null | |
| 91 | if (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 | ||
| 98 | let targetFile = null | |
| 99 | if (args.includes('--target')) { | |
| 100 | const idx = args.indexOf('--target') | |
| 101 | targetFile = args[idx + 1] || null | |
| 102 | } | |
| 103 | ||
| 62 | 104 | async function main () { |
| 63 | 105 | switch (command) { |
| 64 | 106 | case 'scan': return cmdScan() |
| … | … | @@ -291,6 +333,12 @@ async function cmdImports () { |
| 291 | 333 | async function cmdDocs () { |
| 292 | 334 | const tree = await scan(dir, opts) |
| 293 | 335 | |
| 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 | ||
| 294 | 342 | if (!tree.docCruft) { |
| 295 | 343 | console.error('No documentation found') |
| 296 | 344 | process.exit(1) |
| … | … | @@ -327,6 +375,95 @@ async function cmdDocs () { |
| 327 | 375 | } |
| 328 | 376 | } |
| 329 | 377 | |
| 378 | async 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 | ||
| 330 | 467 | async function cmdCategories () { |
| 331 | 468 | const tree = await scan(dir, opts) |
| 332 | 469 | |
| … | … | @@ -457,6 +594,16 @@ Options: |
| 457 | 594 | --sibling-dirs a,b Explicit extra scan roots, comma-separated, resolved |
| 458 | 595 | relative to the scan root |
| 459 | 596 | |
| 597 | Docs 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 | ||
| 460 | 607 | Ignore sources (merged, in this order): |
| 461 | 608 | built-in defaults (node_modules, .git, vendor, .pear-git, .claude, build, dist) |
| 462 | 609 | .codetreeignore file at scan root |
| … | … | @@ -0,0 +1,604 @@ |
| 1 | import { readFileSync, writeFileSync, statSync, existsSync } from 'fs' | |
| 2 | import { 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 | ||
| 20 | export const CONFIDENCE_ORDER = ['high', 'medium', 'low'] | |
| 21 | ||
| 22 | function 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 | */ | |
| 42 | export 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 | ||
| 73 | function 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 | ||
| 141 | function 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 | ||
| 178 | function 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 | ||
| 259 | function 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 | ||
| 298 | function 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 | ||
| 327 | function 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 | ||
| 367 | function 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 | ||
| 411 | function 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 | ||
| 424 | function 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 | ||
| 435 | function 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 | ||
| 457 | function todayIso () { | |
| 458 | return new Date().toISOString().split('T')[0] | |
| 459 | } | |
| 460 | ||
| 461 | // ====== FORMATTERS ====== | |
| 462 | ||
| 463 | /** | |
| 464 | * Human-readable suggest output. | |
| 465 | */ | |
| 466 | export 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 | */ | |
| 525 | export 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 | */ | |
| 547 | export 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 | } |
| … | … | @@ -33,6 +33,7 @@ export function buildTree (graph) { |
| 33 | 33 | // Build module entry |
| 34 | 34 | const moduleEntry = { |
| 35 | 35 | path: mod.path, |
| 36 | absPath: mod.absPath || null, | |
| 36 | 37 | name: mod.name, |
| 37 | 38 | version: mod.version, |
| 38 | 39 | type: mod.type, |
| … | … | @@ -44,6 +45,12 @@ export function buildTree (graph) { |
| 44 | 45 | files: {} |
| 45 | 46 | } |
| 46 | 47 | |
| 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 | ||
| 47 | 54 | // Build file entries from the parsed results already attached by scan-stream |
| 48 | 55 | // via the file-parsed event. This used to re-parse every file here, which |
| 49 | 56 | // was a ~2x CPU waste and caused OOM on large recursive scans. |
| … | … | @@ -0,0 +1,459 @@ |
| 1 | import { test } from 'brittle' | |
| 2 | import { mkdirSync, writeFileSync, readFileSync, rmSync } from 'fs' | |
| 3 | import { tmpdir } from 'os' | |
| 4 | import { join } from 'path' | |
| 5 | ||
| 6 | import { scan } from '../lib/scanner.js' | |
| 7 | import { parseSpecs } from '../lib/doc-parser.js' | |
| 8 | import { | |
| 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 | ||
| 23 | function mkFixture (name) { | |
| 24 | const dir = join(tmpdir(), 'codetree-suggest-' + name + '-' + Date.now()) | |
| 25 | mkdirSync(dir, { recursive: true }) | |
| 26 | return dir | |
| 27 | } | |
| 28 | ||
| 29 | function cleanup (dir) { | |
| 30 | rmSync(dir, { recursive: true, force: true }) | |
| 31 | } | |
| 32 | ||
| 33 | function 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 | ||
| 70 | test('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 | ||
| 106 | test('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 | ||
| 141 | test('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 | ||
| 179 | test('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 | ||
| 215 | test('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 | ||
| 250 | test('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 | ||
| 281 | test('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 | ||
| 318 | test('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 | ||
| 355 | test('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 | ||
| 393 | test('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 | ||
| 424 | test('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 | ||
| 455 | test('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 | }) |