OverviewFilesCommitsIssuesDocsReleasesPeers

codetree / index.js

826 lines · javascript ref:
#!/usr/bin/env bare

import process from 'process'
import fs from 'fs'
import { resolve } from 'path'
import { scan } from './lib/scanner.js'

const args = process.argv.slice(2)
const command = args[0]
const pretty = args.includes('--pretty') || args.includes('-p')

if (!command || command === '--help' || command === '-h') {
  printUsage()
  process.exit(0)
}

// Commands that take a module name as args[1]
const MODULE_COMMANDS = ['module', 'exports', 'deps', 'file', 'imports']
const positionalArgs = args.filter(a => !a.startsWith('-') && a !== command)
const hasModuleArg = MODULE_COMMANDS.includes(command)
const dir = resolve(hasModuleArg ? (positionalArgs[1] || '.') : (positionalArgs[0] || '.'))
const opts = {}

if (args.includes('--depth')) {
  const idx = args.indexOf('--depth')
  opts.maxDepth = parseInt(args[idx + 1], 10) || 2
}

// --ignore "pattern1,pattern2" — extra ignore patterns (in addition to .codetreeignore
// and the built-in skip list). Commas separate patterns; use gitignore-style syntax.
if (args.includes('--ignore')) {
  const idx = args.indexOf('--ignore')
  const raw = args[idx + 1] || ''
  opts.ignore = raw.split(',').map(s => s.trim()).filter(Boolean)
}

// --shallow — parse only the entry file per module. Drops AST analysis of the
// rest of the project; keeps module names, deps, and top-level exports.
// For multi-project recursive scans this is 10-100x faster.
if (args.includes('--shallow')) {
  opts.shallow = true
}

// --no-watch — disable fs.watch in serve mode. Useful for huge trees that
// exceed the inotify watch limit (ENOSPC). Server still serves the snapshot.
if (args.includes('--no-watch')) {
  opts.watch = false
}

// Cross-filesystem dep crawling (Phase 0b). CLI flags override .codetree.json.
// --auto-crawl enables, --no-auto-crawl disables, --sibling-dirs adds explicit
// extra scan roots (comma-separated).
const cli = {}
if (args.includes('--auto-crawl')) cli.autoCrawl = true
if (args.includes('--no-auto-crawl')) cli.autoCrawl = false
if (args.includes('--sibling-dirs')) {
  const idx = args.indexOf('--sibling-dirs')
  const raw = args[idx + 1] || ''
  cli.siblingDirs = raw.split(',').map(s => s.trim()).filter(Boolean)
}
if (Object.keys(cli).length > 0) opts.cli = cli

// Suggest-fixes mode (Phase 0c).
//   --suggest              Print proposed patches, no writes.
//   --apply                Apply high-confidence fixes.
//   --check                Exit 1 if staleness/validator thresholds breach.
//   --confidence TIER      'high' | 'medium' | 'low' (default 'high').
//   --dry-run              With --apply: show what would change, don't write.
//   --format json|text     Output format (default text).
//   --threshold N          Override maxStalenessScore in --check.
//   --target PATH          Restrict suggestions to a single spec file.
const suggestMode = args.includes('--suggest')
const applyMode = args.includes('--apply')
const checkMode = args.includes('--check')
const dryRun = args.includes('--dry-run')

let confidence = 'high'
if (args.includes('--confidence')) {
  const idx = args.indexOf('--confidence')
  const v = (args[idx + 1] || '').toLowerCase()
  if (['high', 'medium', 'low'].includes(v)) confidence = v
}

let format = 'text'
if (args.includes('--format')) {
  const idx = args.indexOf('--format')
  const v = (args[idx + 1] || '').toLowerCase()
  if (['text', 'json'].includes(v)) format = v
}

let cliThreshold = null
if (args.includes('--threshold')) {
  const idx = args.indexOf('--threshold')
  const raw = args[idx + 1]
  const n = parseFloat(raw)
  if (!isNaN(n)) cliThreshold = n
}

let targetFile = null
if (args.includes('--target')) {
  const idx = args.indexOf('--target')
  targetFile = args[idx + 1] || null
}

async function main () {
  switch (command) {
    case 'scan': return cmdScan()
    case 'module': return cmdModule()
    case 'file': return cmdFile()
    case 'exports': return cmdExports()
    case 'deps': return cmdDeps()
    case 'imports': return cmdImports()
    case 'docs': return cmdDocs()
    case 'categories': return cmdCategories()
    case 'disjuncts': return cmdDisjuncts()
    case 'misfits': return cmdMisfits()
    case 'derive': return cmdDerive()
    case 'summary': return cmdSummary()
    case 'serve': return cmdServe()
    default:
      console.error(`Unknown command: ${command}`)
      printUsage()
      process.exit(1)
  }
}

async function cmdScan () {
  const tree = await scan(dir, opts)
  output(tree)
}

async function cmdModule () {
  const name = args[1]
  if (!name) {
    console.error('Usage: codetree module <name> [dir]')
    process.exit(1)
  }

  const tree = await scan(dir, opts)
  const mod = tree.modules[name]

  if (!mod) {
    console.error(`Module not found: ${name}`)
    console.error(`Available: ${tree.system.modules.join(', ')}`)
    process.exit(1)
  }

  output(mod)
}

async function cmdFile () {
  const filePath = args[1]
  if (!filePath) {
    console.error('Usage: codetree file <path> [dir]')
    process.exit(1)
  }

  const tree = await scan(dir, opts)

  for (const mod of Object.values(tree.modules)) {
    if (mod.files[filePath]) {
      output(mod.files[filePath])
      return
    }
  }

  console.error(`File not found: ${filePath}`)
  process.exit(1)
}

async function cmdExports () {
  const name = args[1]
  if (!name) {
    console.error('Usage: codetree exports <module> [dir]')
    process.exit(1)
  }

  const tree = await scan(dir, opts)
  const mod = tree.modules[name]

  if (!mod) {
    console.error(`Module not found: ${name}`)
    process.exit(1)
  }

  if (pretty) {
    console.log(`${mod.name}@${mod.version} public API:`)
    for (const name of mod.publicAPI) {
      console.log(`  ${name}`)
    }
    console.log()
    console.log('File exports:')
    for (const [file, data] of Object.entries(mod.files)) {
      if (data.exports.length === 0) continue
      console.log(`  ${file}:`)
      for (const exp of data.exports) {
        const type = exp.type ? ` (${exp.type})` : ''
        const from = exp.from ? ` from ${exp.from}` : ''
        console.log(`    ${exp.name}${type}${from}`)
      }
    }
  } else {
    output({
      module: mod.name,
      publicAPI: mod.publicAPI,
      fileExports: Object.fromEntries(
        Object.entries(mod.files)
          .filter(([_, f]) => f.exports.length > 0)
          .map(([k, f]) => [k, f.exports])
      )
    })
  }
}

async function cmdDeps () {
  const name = args[1]
  if (!name) {
    console.error('Usage: codetree deps <module> [dir]')
    process.exit(1)
  }

  const tree = await scan(dir, opts)
  const mod = tree.modules[name]

  if (!mod) {
    console.error(`Module not found: ${name}`)
    process.exit(1)
  }

  if (pretty) {
    console.log(`${mod.name} dependencies:`)
    for (const [dep, ver] of Object.entries(mod.dependencies)) {
      const isLocal = tree.modules[dep] ? ' (local)' : ''
      console.log(`  ${dep}: ${ver}${isLocal}`)
    }
    console.log()

    // Reverse deps
    const reverseDeps = []
    for (const [otherName, otherMod] of Object.entries(tree.modules)) {
      if (otherMod.dependencies[name]) {
        reverseDeps.push(otherName)
      }
    }
    if (reverseDeps.length > 0) {
      console.log(`Used by:`)
      for (const rd of reverseDeps) {
        console.log(`  ${rd}`)
      }
    }
  } else {
    const reverseDeps = []
    for (const [otherName, otherMod] of Object.entries(tree.modules)) {
      if (otherMod.dependencies[name]) reverseDeps.push(otherName)
    }
    output({
      module: mod.name,
      dependencies: mod.dependencies,
      usedBy: reverseDeps
    })
  }
}

async function cmdImports () {
  const name = args[1]
  if (!name) {
    console.error('Usage: codetree imports <module> [dir]')
    process.exit(1)
  }

  const tree = await scan(dir, opts)
  const mod = tree.modules[name]

  if (!mod) {
    console.error(`Module not found: ${name}`)
    console.error(`Available: ${tree.system.modules.join(', ')}`)
    process.exit(1)
  }

  if (!mod.importGraph) {
    console.error('No import graph data (no local imports found)')
    process.exit(1)
  }

  if (pretty) {
    const graph = mod.importGraph
    console.log(`${mod.name} — internal architecture\n`)

    // Directory dependency matrix
    const dirs = Object.keys(graph.directories).sort()
    if (dirs.length > 0) {
      console.log('Directory dependency matrix:')
      for (const srcDir of dirs) {
        const targets = graph.directoryMatrix[srcDir]
        if (!targets) continue
        const pairs = Object.entries(targets)
          .filter(([tgt]) => tgt !== srcDir)
          .sort((a, b) => b[1] - a[1])
        if (pairs.length === 0) continue
        const targetStr = pairs.map(([tgt, count]) => `${tgt} (${count})`).join(', ')
        console.log(`  ${srcDir} -> ${targetStr}`)
      }
      console.log()

      // Directory stats
      console.log('Directory stats:')
      for (const d of dirs) {
        const s = graph.directories[d]
        console.log(`  ${d}: ${s.files} files, fan-in ${s.incomingEdges}, fan-out ${s.outgoingEdges}, internal ${s.internalEdges}`)
      }
      console.log()
    }

    // Sub-modules
    if (mod.subModules && Object.keys(mod.subModules).length > 0) {
      console.log('Discovered sub-modules:')
      const sorted = Object.entries(mod.subModules).sort((a, b) => b[1].fanIn - a[1].fanIn)
      for (const [dir, sm] of sorted) {
        const idx = sm.indexFile ? ' [index.js]' : ''
        const reexp = sm.hasReExports ? ' [re-exports]' : ''
        console.log(`  ${dir}${idx}${reexp}`)
        console.log(`    ${sm.files.length} files, fan-in ${sm.fanIn}, fan-out ${sm.fanOut}, cohesion ${sm.cohesion}`)
        if (sm.publicExports.length > 0) {
          console.log(`    exports: ${sm.publicExports.join(', ')}`)
        }
      }
    }
  } else {
    output({
      module: mod.name,
      importGraph: mod.importGraph,
      subModules: mod.subModules
    })
  }
}

async function cmdDocs () {
  const tree = await scan(dir, opts)

  // --suggest / --apply / --check routes to the suggest-fixes engine (Phase 0c).
  if (suggestMode || applyMode || checkMode) {
    await runSuggestOrApplyOrCheck(tree)
    return
  }

  if (!tree.docCruft) {
    console.error('No documentation found')
    process.exit(1)
  }

  const cruft = tree.docCruft

  if (pretty) {
    console.log(`Documentation cruft report\n`)
    console.log(`Total: ${cruft.summary.totalDocs} docs, ${cruft.summary.totalLines} lines`)
    console.log(`Fresh (score <= 0.3): ${cruft.summary.freshDocs}`)
    console.log(`Stale (score > 0.5): ${cruft.summary.staleDocs}`)
    console.log(`References: ${cruft.summary.validReferences} valid, ${cruft.summary.missingReferences} missing`)
    console.log()

    for (const doc of cruft.docs) {
      if (doc.references.total === 0 && doc.staleness.score < 0.3) continue

      const icon = doc.staleness.score > 0.5 ? 'STALE' : doc.staleness.score > 0.3 ? 'WARN ' : 'FRESH'
      console.log(`${icon} ${doc.path}  (${doc.lines} lines, modified ${doc.lastModified}, score ${doc.staleness.score})`)

      if (doc.references.missing > 0) {
        const missing = doc.references.details.filter(d => d.status === 'missing')
        for (const m of missing.slice(0, 5)) {
          console.log(`       missing: ${m.ref} (${m.type}, line ${m.line})`)
        }
        if (missing.length > 5) {
          console.log(`       ... and ${missing.length - 5} more`)
        }
      }
    }
  } else {
    output(cruft)
  }
}

async function runSuggestOrApplyOrCheck (tree) {
  const { suggestFixes, formatSuggest, formatSuggestJson, applyFixes } =
    await import('./lib/suggest-fixes.js')
  const { parseSpecs } = await import('./lib/doc-parser.js')

  // Locate specDir (same logic scanner uses for validation)
  const specDir = tree.docs && tree.docs.specDir
  if (!specDir) {
    console.error('No specs found in ' + dir + ' (expected spec/ or docs/spec/)')
    process.exit(1)
  }

  const specs = parseSpecs(specDir)
  const result = suggestFixes(tree, specs, {
    confidenceFloor: confidence,
    targetFile
  })

  // --check: threshold gate
  if (checkMode) {
    const config = tree.config || {}
    const thresholds = config.thresholds || {}
    const staleLimit = cliThreshold != null ? cliThreshold : (thresholds.maxStalenessScore != null ? thresholds.maxStalenessScore : 0.3)
    const maxValidatorFailures = thresholds.maxValidatorFailures != null ? thresholds.maxValidatorFailures : 0
    const maxMissingRatio = thresholds.maxMissingRatio != null ? thresholds.maxMissingRatio : 0.1

    let fails = 0
    const cruft = tree.docCruft
    const validation = tree.validation

    if (cruft) {
      for (const doc of cruft.docs) {
        if (doc.staleness.score > staleLimit) {
          console.error('stale: ' + doc.path + ' score=' + doc.staleness.score + ' > ' + staleLimit)
          fails++
        }
        if (doc.staleness.missingRatio > maxMissingRatio) {
          console.error('missing-ratio: ' + doc.path + ' missingRatio=' + doc.staleness.missingRatio + ' > ' + maxMissingRatio)
          fails++
        }
      }
    }

    if (validation && validation.summary && validation.summary.fail > maxValidatorFailures) {
      console.error('validator: ' + validation.summary.fail + ' failures > ' + maxValidatorFailures)
      fails++
    }

    if (fails > 0) {
      console.error('\ncodetree --check FAILED (' + fails + ' threshold breaches)')
      process.exit(1)
    }

    console.log('codetree --check PASSED')
    console.log('  ' + (cruft ? cruft.docs.length : 0) + ' docs scanned')
    console.log('  ' + (validation && validation.summary ? validation.summary.fail : 0) + ' validator failures (max: ' + maxValidatorFailures + ')')
    console.log('  stale-threshold: ' + staleLimit + ', missing-ratio threshold: ' + maxMissingRatio)
    return
  }

  // --apply: write fixes
  if (applyMode) {
    const applyResult = applyFixes(result, tree, { dryRun })
    if (format === 'json') {
      console.log(JSON.stringify(applyResult, null, 2))
    } else {
      console.log((dryRun ? '[dry-run] ' : '') + 'applied ' + applyResult.applied + ' fix' +
        (applyResult.applied === 1 ? '' : 'es') + ' across ' + applyResult.files.length + ' file' +
        (applyResult.files.length === 1 ? '' : 's'))
      for (const f of applyResult.files) {
        console.log('  ' + f.file + '  (' + f.changes + ' change' + (f.changes === 1 ? '' : 's') + ')')
      }
      const remaining = result.fixes.length - applyResult.applied
      if (remaining > 0) {
        console.log('\n' + remaining + ' fix' + (remaining === 1 ? '' : 'es') +
          ' deferred — run --suggest to see manual-review items.')
      }
    }
    return
  }

  // --suggest: print ALL proposals (the --confidence flag affects `result.applicable`
  // for --apply purposes; the printed suggestion view should show every tier so the
  // operator can see what MANUAL REVIEW items are queued up).
  if (format === 'json') {
    console.log(formatSuggestJson(result))
  } else {
    console.log(formatSuggest(result, { confidenceFloor: 'low', appliedFloor: confidence }))
  }
}

async function cmdCategories () {
  const tree = await scan(dir, opts)

  // Build the same category mapping the visualization uses — see render.js initGraph().
  // Start with SYSTEM.md categories (if any), then fall back to shared-dep clustering.
  const categories = tree.categories || {}
  const moduleToCategory = {}
  for (const [cat, mods] of Object.entries(categories)) {
    for (const m of mods) moduleToCategory[m] = cat
  }

  const shared = tree.system.sharedDeps || {}
  const depToMostShared = {}
  for (const [dep, users] of Object.entries(shared)) {
    for (const u of users) {
      const prev = depToMostShared[u]
      if (!prev || users.length > shared[prev].length) depToMostShared[u] = dep
    }
  }
  for (const name of tree.system.modules) {
    if (moduleToCategory[name]) continue
    const topDep = depToMostShared[name]
    moduleToCategory[name] = topDep ? (topDep + ' users') : 'Other'
  }

  // Invert for display: category → [modules]
  const byCategory = {}
  for (const [name, cat] of Object.entries(moduleToCategory)) {
    if (!byCategory[cat]) byCategory[cat] = []
    byCategory[cat].push(name)
  }

  if (pretty) {
    console.log(`Module categories for ${tree.root}\n`)
    const sorted = Object.entries(byCategory).sort((a, b) => b[1].length - a[1].length)
    for (const [cat, names] of sorted) {
      console.log(`  ${cat} (${names.length})`)
      for (const n of names.sort()) console.log(`    ${n}`)
    }
    console.log()
    const specNames = new Set()
    for (const mods of Object.values(categories)) for (const m of mods) specNames.add(m)
    const total = Object.keys(moduleToCategory).length
    const fromSpec = [...Object.keys(moduleToCategory)].filter(m => specNames.has(m)).length
    const autoDerived = total - fromSpec
    console.log(`Source: ${fromSpec} from SYSTEM.md, ${autoDerived} auto-derived from shared dependencies`)
  } else {
    output({
      source: Object.keys(categories).length > 0 ? 'mixed' : 'auto',
      fromSystemMd: categories,
      assignments: moduleToCategory,
      byCategory
    })
  }
}

// One line per germ: its connector set. Diffable — a commit that changes a
// disjunct changed the architecture; one that doesn't, didn't.
async function cmdDisjuncts () {
  const { buildSections } = await import('./lib/sections.js')
  const tree = await scan(dir, opts)
  const index = buildSections(tree)

  const wantScale = args.includes('--scale') ? args[args.indexOf('--scale') + 1] : 'file'
  const sections = index.sections.filter(s => s.scale === wantScale)

  if (format === 'json') {
    output(sections.map(s => ({ id: s.id, germ: s.germ, disjunct: index.disjunct(s) })))
    return
  }
  for (const s of sections) console.log(index.disjunct(s))
}

// The connectors that found no partner. This is the whole point of the model:
// a dead export and a broken import are the same question asked twice.
async function cmdMisfits () {
  const { buildSections } = await import('./lib/sections.js')
  const tree = await scan(dir, opts)
  const index = buildSections(tree)
  const m = index.misfits()

  if (format === 'json') {
    output({
      dead: m.dead.map(d => ({ germ: d.germ, type: d.type })),
      unresolved: m.unresolved.map(u => ({ germ: u.germ, type: u.type, specifier: u.specifier })),
      leak: m.leak,
      unused: m.unused
    })
    return
  }

  console.log('DEAD exports (' + m.dead.length + ') — exported, nothing imports it')
  for (const d of m.dead) console.log('  ' + d.germ + '  +' + d.type)

  console.log('\nUNRESOLVED imports (' + m.unresolved.length + ') — imported, nothing supplies it')
  for (const u of m.unresolved) {
    console.log('  ' + u.germ + '  -' + u.type + '  (' + u.specifier + ')')
  }

  console.log('\nLEAKED deps (' + m.leak.length + ') — used in code, absent from package.json')
  for (const l of m.leak) console.log('  ' + l.module + '  ' + l.specifier + '  (' + l.germ + ')')

  console.log('\nUNUSED deps (' + m.unused.length + ') — in package.json, never imported')
  for (const u of m.unused) console.log('  ' + u.module + '  ' + u.specifier)

  const total = m.dead.length + m.unresolved.length + m.leak.length + m.unused.length
  console.log('\n' + index.sections.length + ' sections, ' + total + ' misfits')
}

// The expression floor: one file's operations, wires, zones and exits.
// Spec: docs/EXPRESSION-FLOOR.md. This exists so the MODEL is verifiable before
// any renderer does — if the decomposition is wrong, it is wrong here first.
async function cmdDerive () {
  const { deriveFile } = await import('./lib/derive.js')

  const target = positionalArgs[0]
  if (!target) {
    console.error('derive needs a file: codetree derive <file.js>')
    process.exit(1)
  }
  const path = resolve(target)

  let source
  try {
    source = fs.readFileSync(path, 'utf-8')
  } catch (err) {
    console.error('cannot read ' + path + ': ' + (err.message || err))
    process.exit(1)
  }

  const model = deriveFile(source, path)

  if (format === 'json') {
    output(model)
    return
  }

  if (model.error) {
    console.log(path + ' — PARSE ERROR: ' + model.error)
    process.exit(1)
  }

  const want = (name) => {
    const flags = ['--nodes', '--zones', '--exits', '--names', '--functions', '--symbols']
    const asked = flags.filter(f => args.includes(f))
    return asked.length === 0 || asked.includes('--' + name)
  }

  const rel = path.split('/').slice(-2).join('/')
  console.log(rel + ' — ' + model.nodes.length + ' nodes · ' + model.zones.length +
    ' zones · ' + model.exits.length + ' exits · ' + model.names.length +
    ' names · ' + model.functions.length + ' functions' +
    (model.language ? '  [' + model.language + ']' : ''))

  // A partial model looks complete. Say so loudly, at the top (spec E7).
  if (model.partial) {
    console.log('  PARTIAL — ' + model.partialReason +
      '; structure is missing, do not trust this model')
  }

  const zoneById = new Map(model.zones.map(z => [z.id, z]))
  const line = (r) => String(r.startLine).padStart(4)
  const clip = (text, n) => {
    const flat = text.replace(/\s+/g, ' ')
    return flat.length > n ? flat.slice(0, n - 1) + '\u2026' : flat
  }
  const landing = (l) => {
    if (!l) return '?'
    if (l.on === 'zone-entry' || l.on === 'zone-exit' || l.on === 'catch-entry') {
      const z = zoneById.get(l.id)
      return l.on + ' ' + (z ? z.shape + '@' + z.range.startLine : '?')
    }
    return l.on
  }

  // The file level: what a node would be if you opened this file (spec L1-L2).
  if (want('symbols') && model.symbols && model.symbols.length > 0) {
    console.log('\nFILE-LEVEL SYMBOLS')
    for (const sym of model.symbols) {
      // A const symbol has no interior of its own; a class has members; anything
      // with a body reports what the container query returns for it.
      const inside = sym.fn
        ? model.nodes.filter(n => n.container === sym.fn).length + ' ops, ' +
          model.zones.filter(z => z.container === sym.fn).length + ' zones, ' +
          model.names.filter(n => n.container === sym.fn).length + ' wires'
        : (sym.members.length > 0 ? sym.members.length + ' members' : '')
      console.log('  ' + line(sym.range) + '  ' + sym.kind.padEnd(13) +
        sym.name.padEnd(26) + inside)
    }
  }

  if (want('functions') && model.functions.length > 0) {
    console.log('\nFUNCTIONS')
    for (const fn of model.functions) {
      console.log('  ' + line(fn.range) + '  ' + (fn.parent ? '  ' : '') +
        fn.name + '(' + fn.params.join(', ') + ')' +
        (fn.async ? '  async' : '') + (fn.parent ? '  (nested)' : '') +
        '  lines ' + fn.range.startLine + '-' + fn.range.endLine)
    }
  }

  if (want('zones') && model.zones.length > 0) {
    console.log('\nZONES')
    for (const z of model.zones) {
      const indent = '  '.repeat(z.depth)
      const bits = []
      if (z.item) bits.push('item=' + z.item)
      if (z.carried.length > 0) bits.push('carried=' + z.carried.join(','))
      if (z.bands) bits.push('bands=' + z.bands.length)
      if (z.exits.length > 0) bits.push('exits=' + z.exits.length)
      console.log('  ' + line(z.range) + '  ' + indent + z.shape.padEnd(13) +
        (z.condition ? clip(z.condition.text, 34) : '').padEnd(35) +
        'L' + z.range.startLine + '-' + z.range.endLine +
        (bits.length > 0 ? '  ' + bits.join(' ') : ''))
    }
  }

  if (want('names') && model.names.length > 0) {
    console.log('\nNAMED WIRES')
    for (const n of model.names) {
      console.log('  ' + line(n.range) + '  ' + n.name.padEnd(22) + 'from ' + n.from)
    }
  }

  if (want('nodes') && model.nodes.length > 0) {
    console.log('\nOPERATIONS')
    for (const n of model.nodes) {
      const ins = n.inputs.map(i => {
        if (i.literal) return i.name
        if (i.source === 'node') return '<' + i.name + '>'
        return i.name + '[' + i.source + ']'
      })
      console.log('  ' + line(n.range) + '  ' + n.label.padEnd(20) +
        (n.writes ? '-> ' + n.writes + '  ' : '') +
        (ins.length > 0 ? '<- ' + clip(ins.join(', '), 70) : ''))
    }
  }

  if (want('exits') && model.exits.length > 0) {
    console.log('\nEXITS')
    for (const e of model.exits) {
      console.log('  ' + line(e.range) + '  ' + e.kind.padEnd(10) + '-> ' + landing(e.lands))
    }
  }
}

async function cmdSummary () {
  const tree = await scan(dir, opts)

  console.log(`Scanned: ${tree.root}`)
  console.log(`Modules: ${tree.system.modules.length}`)
  console.log()

  for (const name of tree.system.modules) {
    const mod = tree.modules[name]
    const fileCount = Object.keys(mod.files).length
    const exportCount = mod.publicAPI.length
    const deps = Object.keys(mod.dependencies).length

    let classCount = 0
    let methodCount = 0
    for (const file of Object.values(mod.files)) {
      if (file.classes) {
        classCount += file.classes.length
        for (const cls of file.classes) {
          methodCount += cls.methods.length + cls.getters.length
        }
      }
    }

    console.log(`  ${name}@${mod.version}`)
    console.log(`    ${fileCount} files, ${exportCount} exports, ${classCount} classes, ${methodCount} members, ${deps} deps`)
  }

  console.log()
  const sharedCount = Object.keys(tree.system.sharedDeps || {}).length
  console.log(`Shared dependencies: ${sharedCount}`)
}

async function cmdServe () {
  // Defer to server module
  const { startServer } = await import('./lib/server.js')
  const tree = await scan(dir, opts)
  const port = parseInt(args.find(a => /^\d{4,5}$/.test(a)) || '9078', 10)
  const prefixIdx = args.indexOf('--prefix')
  const prefix = prefixIdx >= 0 ? args[prefixIdx + 1] : ''
  startServer(tree, { port, prefix, ignore: opts.ignore, graph: tree.__graph, watch: opts.watch })
}

function output (data) {
  console.log(JSON.stringify(data, null, pretty ? 2 : undefined))
}

function printUsage () {
  console.log(`codetree — structural code scanner

Usage:
  codetree scan [dir]              Full scan, outputs JSON tree
  codetree module <name> [dir]     Show one module's structure
  codetree file <path> [dir]       Show one file's structure
  codetree exports <module> [dir]  List public API surface
  codetree deps <module> [dir]     Show dependency chain
  codetree imports <module> [dir]  Show internal import graph and sub-modules
  codetree docs [dir]              Documentation cruft report
  codetree categories [dir]        Show module categorization (SYSTEM.md + auto)
  codetree disjuncts [dir]         One line per germ: its connector set
  codetree misfits [dir]           Dead exports, unresolved imports, leaked deps
  codetree derive <file>           Expression floor: operations, wires, zones, exits
  codetree summary [dir]           Human-readable overview
  codetree serve [dir] [port]      Start HTTP visualization

Options:
  --pretty, -p       Pretty-print JSON / human-readable output
  --depth <n>        Max directory depth for project discovery (default: 2)
  --ignore <patterns> Extra ignore patterns, comma-separated, gitignore-style
  --shallow           Only parse each module's entry file (no cross-file analysis).
                      10-100x faster for multi-project/recursive scans.
  --auto-crawl       Enable cross-filesystem dep crawling (follows package.json
                      file:../foo deps + walks up 2 ancestors for sibling deps)
  --no-auto-crawl    Disable auto-crawl (overrides .codetree.json setting)
  --sibling-dirs a,b Explicit extra scan roots, comma-separated, resolved
                      relative to the scan root

Docs subcommands (use with "codetree docs <dir>"):
  --suggest          Print proposed patches (no writes)
  --apply            Write high-confidence fixes
  --check            Exit 1 if staleness/validator thresholds breach
  --confidence TIER  high | medium | low (default: high)
  --dry-run          With --apply: show what would change without writing
  --format TYPE      text | json (default: text)
  --threshold N      Override maxStalenessScore for --check (e.g. 0.3)
  --target PATH      Restrict to one spec file (relative to project root)

Ignore sources (merged, in this order):
  built-in defaults (node_modules, .git, vendor, .pear-git, .claude, build, dist)
  .codetreeignore file at scan root
  any directory containing a .codetree-private marker
  --ignore CLI flag (this takes effect last)

Examples:
  codetree scan .                  Scan current directory
  codetree summary ../hyper-fleeky Summarize all modules
  codetree exports hyper-orgs -p   Show hyper-orgs public API
  codetree imports pear-git -p     Show pear-git internal architecture
  codetree docs ../pear-git -p     Find stale documentation
  codetree derive lib/derive.js    Show one file's expression-floor model
  codetree serve . 9078            Serve visualization on port 9078`)
}

main().catch(err => {
  console.error(err.message || err)
  process.exit(1)
})