OverviewFilesCommitsIssuesDocsReleasesPeers

codetree / lib / suggest-fixes.js

609 lines · javascript ref:
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
  }
}