import { readFileSync, writeFileSync, statSync, existsSync } from 'fs'
import { resolve, join, relative } from 'path'
/**
* Suggest-fixes engine for docs ↔ code drift.
*
* Given a scanned tree + parsed specs + validator report, produces a
* list of proposed fixes, each tagged with a confidence tier:
*
* high — mechanical, unambiguous (LOC count, date, known rename)
* medium — likely but needs review (path moved, single-rename guess)
* low — narrative, needs human judgment (BUG-N text, rewording)
*
* Consumers:
* `codetree docs --suggest <spec.md>` — prints all fixes, writes nothing
* `codetree docs --apply <spec.md>` — writes high-confidence fixes
* `codetree docs --check` — exits 1 if thresholds breached
*/
export const CONFIDENCE_ORDER = ['high', 'medium', 'low']
function ge (tier, floor) {
const t = CONFIDENCE_ORDER.indexOf(tier)
const f = CONFIDENCE_ORDER.indexOf(floor)
return t >= 0 && f >= 0 && t <= f // higher = lower index
}
/**
* @param {object} tree - built code tree (from scan())
* @param {object} specs - parsed specs (from parseSpecs())
* @param {object} [opts]
* opts.targetFile — restrict fixes to a single spec file path (relative to tree.root)
* opts.confidenceFloor — 'high' | 'medium' | 'low' (default 'high')
* opts.staleDateDays — days after which `Last verified:` is flagged (default 30)
* @returns {object}
* {
* fixes: Array<Fix>,
* applicable: Array<Fix>, // fixes >= confidenceFloor
* byFile: Map<file, Array<Fix>>
* }
*/
export function suggestFixes (tree, specs, opts = {}) {
const fixes = []
fixes.push(...detectLocDrift(tree, specs))
fixes.push(...detectDateStale(tree, specs, opts))
fixes.push(...detectDeprecatedMarker(tree, specs))
fixes.push(...detectValidatorFailures(tree))
fixes.push(...detectNarrativeFlags(tree, specs))
fixes.push(...detectTestCountDrift(tree, specs))
// Optional: filter to a single target file
let filtered = fixes
if (opts.targetFile) {
const target = opts.targetFile
filtered = fixes.filter(f => f.file === target || f.file.endsWith(target))
}
const floor = opts.confidenceFloor || 'high'
const applicable = filtered.filter(f => ge(f.confidence, floor))
const byFile = new Map()
for (const f of filtered) {
if (!byFile.has(f.file)) byFile.set(f.file, [])
byFile.get(f.file).push(f)
}
return { fixes: filtered, applicable, byFile }
}
// ====== DETECTORS ======
function detectLocDrift (tree, specs) {
const fixes = []
for (const [name, spec] of Object.entries(specs.modules || {})) {
if (!spec.filename || !spec.fileLOC) continue
const mod = tree.modules[name]
if (!mod) continue
// Build current "file N LOC, file M LOC" claim on the spec's
// "Source verified" line, then synthesize the corrected string.
const claimEntries = Object.entries(spec.fileLOC)
if (claimEntries.length === 0) continue
const updatedEntries = []
let anyDrift = false
for (const [fname, claimedLOC] of claimEntries) {
const actualLOC = readActualLOC(tree, mod, fname)
if (actualLOC === null) {
updatedEntries.push([fname, claimedLOC, null])
continue
}
if (actualLOC !== claimedLOC) anyDrift = true
updatedEntries.push([fname, claimedLOC, actualLOC])
}
if (!anyDrift) continue
// Locate the spec file
const specPath = findSpecFile(tree, spec)
if (!specPath) continue
const content = readFileSync(specPath, 'utf-8')
const lines = content.split('\n')
// Find the **Source verified:** line
let lineNum = -1
for (let i = 0; i < lines.length; i++) {
if (/\*\*Source verified:\*\*/.test(lines[i])) {
lineNum = i + 1
break
}
}
if (lineNum === -1) continue
const current = lines[lineNum - 1]
const newInner = updatedEntries
.map(([f, _claim, actual]) => actual !== null ? (f + ' ' + actual + ' LOC') : (f + ' ' + _claim + ' LOC'))
.join(', ')
// Replace only the parenthetical — keep date + rest of line intact
const suggested = current.replace(/\(([^)]*)\)/, '(' + newInner + ')')
if (suggested !== current) {
fixes.push({
file: relative(tree.root, specPath),
line: lineNum,
current,
suggested,
confidence: 'high',
type: 'loc-drift',
reason: 'File LOC count drifted: ' +
updatedEntries
.filter(([_, c, a]) => a !== null && c !== a)
.map(([f, c, a]) => f + ' ' + c + '→' + a)
.join(', ')
})
}
}
return fixes
}
function detectTestCountDrift (tree, specs) {
// Spec claims `**Tests:** N pass`. We can't run tests here (codetree is a
// scanner, not a test runner). But if the tree has a testCommand and a
// recent `npm test` result file on disk, we could parse it. For now, flag
// test-count claims as MANUAL REVIEW when the spec is older than N days.
// A richer integration can hook this up via a sidecar tool.
const fixes = []
for (const [name, spec] of Object.entries(specs.modules || {})) {
if (!spec.filename || spec.testCount == null) continue
const specPath = findSpecFile(tree, spec)
if (!specPath) continue
const content = readFileSync(specPath, 'utf-8')
const lines = content.split('\n')
let lineNum = -1
for (let i = 0; i < lines.length; i++) {
if (/\*\*Tests:\*\*/.test(lines[i])) {
lineNum = i + 1
break
}
}
if (lineNum === -1) continue
fixes.push({
file: relative(tree.root, specPath),
line: lineNum,
current: lines[lineNum - 1],
suggested: null,
confidence: 'low',
type: 'test-count-manual',
reason: 'Test count claim found; codetree can\'t run tests to verify. ' +
'Run `npm test` in ' + (spec.location || name) + ' and update manually.'
})
}
return fixes
}
function detectDateStale (tree, specs, opts) {
const fixes = []
const staleDays = opts.staleDateDays || 30
const now = Date.now()
const dayMs = 24 * 60 * 60 * 1000
// System spec
if (specs.system && specs.system.lastVerified) {
const verifiedMs = Date.parse(specs.system.lastVerified)
if (!isNaN(verifiedMs)) {
const ageDays = Math.floor((now - verifiedMs) / dayMs)
if (ageDays > staleDays) {
const systemPath = findSystemSpec(tree)
if (systemPath) {
const content = readFileSync(systemPath, 'utf-8')
const lines = content.split('\n')
let lineNum = -1
for (let i = 0; i < lines.length; i++) {
if (/\*\*Last verified:\*\*/.test(lines[i])) {
lineNum = i + 1
break
}
}
if (lineNum >= 0) {
fixes.push({
file: relative(tree.root, systemPath),
line: lineNum,
current: lines[lineNum - 1],
suggested: lines[lineNum - 1].replace(/\d{4}-\d{2}-\d{2}/, todayIso()),
confidence: 'medium',
type: 'date-stale',
reason: 'Last verified was ' + ageDays + ' days ago (threshold: ' + staleDays + ' days). ' +
'Suggested: bump to today. Confidence medium because the right date depends on whether ' +
'the spec has actually been re-verified against the code.'
})
}
}
}
}
}
// Per-module Source verified
for (const [name, spec] of Object.entries(specs.modules || {})) {
if (!spec.sourceVerified) continue
const verifiedMs = Date.parse(spec.sourceVerified)
if (isNaN(verifiedMs)) continue
const ageDays = Math.floor((now - verifiedMs) / dayMs)
if (ageDays <= staleDays) continue
const specPath = findSpecFile(tree, spec)
if (!specPath) continue
const content = readFileSync(specPath, 'utf-8')
const lines = content.split('\n')
let lineNum = -1
for (let i = 0; i < lines.length; i++) {
if (/\*\*Source verified:\*\*/.test(lines[i])) {
lineNum = i + 1
break
}
}
if (lineNum === -1) continue
// Don't auto-suggest the date if LOC is also drifting — the user will fix
// it together and today's date reflects the re-verification. But we do
// surface it as medium confidence.
const suggested = lines[lineNum - 1].replace(/\d{4}-\d{2}-\d{2}/, todayIso())
fixes.push({
file: relative(tree.root, specPath),
line: lineNum,
current: lines[lineNum - 1],
suggested,
confidence: 'medium',
type: 'date-stale',
reason: spec.sourceVerified + ' → today (' + todayIso() + '). ' +
'Module has been source-verified ' + ageDays + ' days ago.'
})
}
return fixes
}
function detectDeprecatedMarker (tree, specs) {
const fixes = []
const config = tree.config || {}
const deprecated = config.deprecatedModules || []
for (const depName of deprecated) {
const spec = specs.modules && specs.modules[depName]
if (!spec) continue
if (spec.deprecated) continue // already marked
const specPath = findSpecFile(tree, spec)
if (!specPath) continue
const content = readFileSync(specPath, 'utf-8')
const lines = content.split('\n')
// Find the Location line — insert **Status:** deprecated right after it
let locLineNum = -1
for (let i = 0; i < lines.length; i++) {
if (/\*\*Location:\*\*/.test(lines[i])) {
locLineNum = i + 1
break
}
}
if (locLineNum === -1) continue
fixes.push({
file: relative(tree.root, specPath),
line: locLineNum,
current: lines[locLineNum - 1],
suggested: lines[locLineNum - 1] + '\n**Status:** deprecated',
confidence: 'high',
type: 'deprecation-marker',
reason: 'Config lists "' + depName + '" as deprecated but the spec has no **Status:** deprecated header. ' +
'Insert after the Location line to suppress undocumented-X warnings.'
})
}
return fixes
}
function detectValidatorFailures (tree) {
// Surface validator failures as low-confidence review flags. The spec
// claims something that the code doesn't match — but whether the fix
// is "change the spec" or "change the code" is a judgment call.
const fixes = []
if (!tree.validation) return fixes
for (const [modName, modReport] of Object.entries(tree.validation.modules || {})) {
for (const check of modReport.checks) {
if (check.status !== 'fail') continue
// LOC drift is already handled with high confidence above; skip duplicates.
if (check.claim && /LOC/.test(check.claim) && check.expected != null && check.actual != null) continue
// Export / method claims: low confidence, point operator at the discrepancy
fixes.push({
file: modReport.specFile ? 'spec/modules/' + modReport.specFile : '(unknown)',
line: null,
current: check.claim,
suggested: null,
confidence: 'low',
type: 'validator-fail',
reason: (check.detail || check.claim) + ' (module: ' + modName + ')'
})
}
}
return fixes
}
function detectNarrativeFlags (tree, specs) {
// Scan all spec content for "NOT YET BUILT", "MUST BUILD", "BUG-N",
// "CURRENT BUG" — these are narrative notes that usually go stale fast.
// Flag as low-confidence for manual review, don't propose auto-fixes.
const fixes = []
const patterns = [
{ regex: /NOT YET BUILT/g, reason: 'Narrative claim may be stale — verify if feature is now built.' },
{ regex: /MUST BUILD/g, reason: 'Narrative claim may be stale — verify if feature is now built.' },
{ regex: /CURRENT BUG/g, reason: 'Narrative bug claim — verify whether bug is now fixed.' },
{ regex: /\bBUG-\d+\b/g, reason: 'BUG-N reference — verify whether bug is now resolved in BUGS.md.' }
]
const docs = collectAllSpecFiles(tree, specs)
for (const specPath of docs) {
let content
try { content = readFileSync(specPath, 'utf-8') } catch { continue }
const lines = content.split('\n')
for (let i = 0; i < lines.length; i++) {
for (const pattern of patterns) {
if (pattern.regex.test(lines[i])) {
fixes.push({
file: relative(tree.root, specPath),
line: i + 1,
current: lines[i],
suggested: null,
confidence: 'low',
type: 'narrative-flag',
reason: pattern.reason
})
}
pattern.regex.lastIndex = 0
}
}
}
return fixes
}
// ====== HELPERS ======
function readActualLOC (tree, mod, fname) {
// Find the file in the module. mod.files is keyed by path relative to
// module root, BUT the scanner only includes files it parsed successfully.
// For LOC counting, a file can exist on disk and be LOC-countable even if
// parsing failed (or it's a non-source file). So we use mod.files as the
// primary index, then fall back to disk lookup against the module's root.
//
// Path resolution: crawler-added modules (mod._external === true) are
// NOT inside tree.root — their real location is mod._externalRoot or
// mod.absPath. Use mod.absPath as the canonical module root.
const modRoot = mod.absPath || (mod._externalRoot
? join(mod._externalRoot, mod.path === '.' ? '' : mod.path)
: resolve(tree.root, mod.path))
// 1. Try mod.files index (fast path, when scanner parsed it)
for (const [relPath, fileInfo] of Object.entries(mod.files || {})) {
const base = relPath.split('/').pop()
if (base === fname || relPath === fname) {
if (fileInfo.lines) return fileInfo.lines
try {
return readFileSync(resolve(modRoot, relPath), 'utf-8').split('\n').length
} catch {
// fall through to disk-fallback below
}
}
}
// 2. Disk fallback — spec mentioned a filename not in mod.files. Try
// common locations within the module root.
const candidates = [
resolve(modRoot, fname),
resolve(modRoot, 'lib', fname),
resolve(modRoot, 'src', fname)
]
for (const p of candidates) {
try {
return readFileSync(p, 'utf-8').split('\n').length
} catch {
// try next
}
}
return null
}
function findSpecFile (tree, spec) {
// Assume spec.filename is like "hyper-orgs.md"; spec lives under
// tree.root/spec/modules/.
const candidates = [
join(tree.root, 'spec', 'modules', spec.filename),
join(tree.root, 'docs', 'spec', 'modules', spec.filename)
]
for (const p of candidates) {
if (existsSync(p)) return p
}
return null
}
function findSystemSpec (tree) {
const candidates = [
join(tree.root, 'spec', 'SYSTEM.md'),
join(tree.root, 'docs', 'spec', 'SYSTEM.md')
]
for (const p of candidates) {
if (existsSync(p)) return p
}
return null
}
function collectAllSpecFiles (tree, specs) {
const out = []
const sysPath = findSystemSpec(tree)
if (sysPath) out.push(sysPath)
for (const spec of Object.values(specs.modules || {})) {
const p = findSpecFile(tree, spec)
if (p) out.push(p)
}
// Also include spec/flows/*.md and spec/BUGS.md if they exist
const extras = [
join(tree.root, 'spec', 'BUGS.md'),
join(tree.root, 'spec', 'flows', 'auth.md'),
join(tree.root, 'spec', 'flows', 'deploy.md'),
join(tree.root, 'spec', 'flows', 'issues.md'),
join(tree.root, 'spec', 'flows', 'replication.md')
]
for (const p of extras) {
if (existsSync(p)) out.push(p)
}
return out
}
function todayIso () {
return new Date().toISOString().split('T')[0]
}
// ====== FORMATTERS ======
/**
* Human-readable suggest output.
*
* opts.confidenceFloor — tier floor for DISPLAY (default 'low' = show all)
* opts.appliedFloor — tier floor used by --apply (for labeling sections)
*/
export function formatSuggest (result, opts = {}) {
const lines = []
const show = opts.confidenceFloor || 'low' // show everything by default
const appliedFloor = opts.appliedFloor || opts.confidenceFloor || 'high'
const byConfidence = {
high: result.fixes.filter(f => f.confidence === 'high'),
medium: result.fixes.filter(f => f.confidence === 'medium'),
low: result.fixes.filter(f => f.confidence === 'low')
}
lines.push('# codetree suggest')
lines.push('')
lines.push('Total: ' + result.fixes.length + ' proposed fixes ' +
'(' + byConfidence.high.length + ' high, ' +
byConfidence.medium.length + ' medium, ' +
byConfidence.low.length + ' low).')
lines.push('Applicable at --confidence ' + appliedFloor +
': ' + result.applicable.length)
lines.push('')
for (const tier of ['high', 'medium', 'low']) {
if (byConfidence[tier].length === 0) continue
if (!ge(tier, show)) continue
const isAuto = ge(tier, appliedFloor)
const heading = tier === 'high'
? '## High-confidence fixes (auto-applicable)'
: tier === 'medium'
? '## Medium-confidence fixes' + (isAuto ? ' (applicable at current floor)' : ' (MANUAL REVIEW)')
: '## Low-confidence — MANUAL REVIEW'
lines.push(heading)
lines.push('')
for (const fix of byConfidence[tier]) {
lines.push('### ' + fix.file + (fix.line != null ? ':' + fix.line : '') + ' [' + fix.type + ']')
lines.push('')
if (fix.current) {
lines.push('current:')
lines.push(' ' + fix.current.trim())
}
if (fix.suggested !== null && fix.suggested !== undefined) {
lines.push('suggested:')
for (const l of String(fix.suggested).split('\n')) {
lines.push(' ' + l)
}
} else {
lines.push('suggested: (manual review needed)')
}
lines.push('reason: ' + fix.reason)
lines.push('')
}
}
return lines.join('\n')
}
/**
* Machine-readable JSON output.
*/
export function formatSuggestJson (result) {
return JSON.stringify({
total: result.fixes.length,
applicable: result.applicable.length,
fixes: result.fixes
}, null, 2)
}
// ====== APPLY ======
/**
* Apply the applicable fixes (those >= confidenceFloor) to their files.
* Only single-line suggestions are applied; multi-line suggested blocks
* (e.g. inserting a **Status:** line) are also supported by detecting
* a trailing newline.
*
* @param {object} result - suggestFixes output
* @param {object} tree - built tree (needs tree.root for absolute paths)
* @param {object} [opts]
* opts.dryRun — if true, don't write, just return what would change
* @returns { applied: number, skipped: number, files: Array<{file, changes}> }
*/
export function applyFixes (result, tree, opts = {}) {
const dryRun = !!opts.dryRun
const filesToWrite = new Map()
for (const fix of result.applicable) {
if (fix.suggested == null) continue
if (fix.line == null) continue
const specPath = resolve(tree.root, fix.file)
if (!existsSync(specPath)) continue
let entry = filesToWrite.get(specPath)
if (!entry) {
const content = readFileSync(specPath, 'utf-8')
entry = { content, lines: content.split('\n'), changes: [] }
filesToWrite.set(specPath, entry)
}
const idx = fix.line - 1
if (idx < 0 || idx >= entry.lines.length) continue
if (entry.lines[idx] !== fix.current) {
// File changed since suggest was generated — skip to be safe
continue
}
// Suggested may span multiple lines (e.g. insert + keep original)
const suggestedLines = String(fix.suggested).split('\n')
entry.changes.push({ line: fix.line, fix })
// Apply: replace lines[idx] with all suggestedLines
entry.lines.splice(idx, 1, ...suggestedLines)
// Fix up line numbers for subsequent fixes on the same file by using
// the updated array for subsequent matching.
// (We keep original fix.line for reporting; we don't re-index.)
}
const fileResults = []
let applied = 0
for (const [path, entry] of filesToWrite) {
if (entry.changes.length === 0) continue
const newContent = entry.lines.join('\n')
if (newContent === entry.content) continue
if (!dryRun) {
writeFileSync(path, newContent, 'utf-8')
}
applied += entry.changes.length
fileResults.push({
file: relative(tree.root, path),
changes: entry.changes.length,
dryRun
})
}
return {
applied,
skipped: result.fixes.length - applied,
files: fileResults
}
}