OverviewFilesCommitsIssuesDocsReleasesPeers

codetree / lib / tree.js

338 lines · javascript ref:
import { basename, dirname } from 'path'

/**
 * Build the complete JSON tree from a walk graph.
 * Combines traverse output (module/file structure) with parser output (AST analysis).
 */
export function buildTree (graph) {
  const tree = {
    generated: new Date().toISOString(),
    root: graph.root,
    projectType: graph.projectType || 'unknown',
    languages: graph.languages || [],
    system: {
      modules: [],
      dependencies: {}
    },
    modules: {},
    dirTree: graph.dirTree || null
  }

  for (const [name, mod] of graph.modules) {
    tree.system.modules.push(name)

    // Build dependency list (only local/known deps)
    const deps = []
    for (const dep of Object.keys(mod.dependencies)) {
      deps.push(dep)
    }
    if (deps.length > 0) {
      tree.system.dependencies[name] = deps
    }

    // Build module entry
    const moduleEntry = {
      path: mod.path,
      absPath: mod.absPath || null,
      name: mod.name,
      version: mod.version,
      type: mod.type,
      entry: mod.entry ? basename(mod.entry) : null,
      dependencies: mod.dependencies,
      // devDependencies are declared too — a package imported only by tests or
      // examples is not a missing dependency. Without this the tree could not
      // tell "undeclared" from "declared as a dev dep".
      devDependencies: mod.devDependencies || {},
      importMaps: Object.keys(mod.importMaps).length > 0 ? mod.importMaps : undefined,
      testCommand: mod.testCommand,
      publicAPI: [],
      files: {}
    }

    // Preserve cross-filesystem-crawl markers (Phase 0b) so downstream
    // consumers (suggest-fixes, validator) know when a module lives outside
    // the primary scan root.
    if (mod._external) moduleEntry._external = true
    if (mod._externalRoot) moduleEntry._externalRoot = mod._externalRoot

    // Build file entries from the parsed results already attached by scan-stream
    // via the file-parsed event. This used to re-parse every file here, which
    // was a ~2x CPU waste and caused OOM on large recursive scans.
    for (const [relInPkg, fileInfo] of mod.files) {
      const parsed = fileInfo.parsed
      if (!parsed || parsed.error) continue

      const fileEntry = {
        role: classifyRole(relInPkg),
        imports: parsed.imports,
        exports: parsed.exports,
        localImports: fileInfo.localImports || []
      }

      // Add structural data only if present (some fields are language-specific — may be undefined)
      if (parsed.classes && parsed.classes.length > 0) fileEntry.classes = parsed.classes
      if (parsed.functions && parsed.functions.length > 0) fileEntry.functions = parsed.functions
      if (parsed.constants && parsed.constants.length > 0) fileEntry.constants = parsed.constants
      if (parsed.state && parsed.state.length > 0) fileEntry.state = parsed.state
      if (parsed.errorContracts && parsed.errorContracts.length > 0) fileEntry.errorContracts = parsed.errorContracts
      if (parsed.delegates && parsed.delegates.length > 0) fileEntry.delegates = parsed.delegates
      // Language-specific additional fields
      if (parsed.signals && parsed.signals.length > 0) fileEntry.signals = parsed.signals
      if (parsed.enums && parsed.enums.length > 0) fileEntry.enums = parsed.enums
      if (parsed.namespaces && parsed.namespaces.length > 0) fileEntry.namespaces = parsed.namespaces
      if (parsed.uniforms && parsed.uniforms.length > 0) fileEntry.uniforms = parsed.uniforms
      if (parsed.buffers && parsed.buffers.length > 0) fileEntry.buffers = parsed.buffers

      moduleEntry.files[relInPkg] = fileEntry
    }

    // Compute import graph (aggregates per-file localImports into directory-level architecture)
    moduleEntry.importGraph = computeImportGraph(moduleEntry)

    // Discover sub-modules from import graph (directory clusters that act as internal modules)
    moduleEntry.subModules = discoverSubModules(moduleEntry)

    // Compute public API from entry file
    const entryKey = mod.entry ? findEntryKey(mod, moduleEntry.files) : null
    if (entryKey && moduleEntry.files[entryKey]) {
      moduleEntry.publicAPI = computePublicAPI(moduleEntry.files[entryKey])
    }

    tree.modules[name] = moduleEntry
  }

  // Sort module list
  tree.system.modules.sort()

  // Compute shared dependencies
  tree.system.sharedDeps = computeSharedDeps(tree)

  return tree
}

// --- File role classification ---

function classifyRole (relPath) {
  // JS conventions
  if (relPath === 'index.js' || relPath === 'index.mjs') return 'entry'
  if (relPath.startsWith('test/') || relPath.endsWith('.test.js')) return 'test'
  if (relPath.includes('schema')) return 'schema'
  if (relPath.startsWith('bin/')) return 'bin'

  // Godot conventions
  if (relPath.startsWith('autoloads/')) return 'autoload'
  if (relPath.startsWith('scenes/')) return 'scene'
  if (relPath.startsWith('addons/')) return 'addon'

  // C++ conventions
  if (relPath.startsWith('include/') || relPath.endsWith('.h') || relPath.endsWith('.hpp')) return 'header'
  if (relPath.startsWith('src/')) return 'source'
  if (relPath.startsWith('thirdparty/') || relPath.startsWith('vendor/')) return 'thirdparty'

  // Shader conventions
  if (relPath.startsWith('shaders/') || relPath.endsWith('.glsl') || relPath.endsWith('.gdshader')) return 'shader'

  // Shared
  if (relPath.startsWith('lib/')) return 'lib'
  if (relPath.startsWith('scripts/')) return 'script'
  if (relPath.startsWith('examples/') || relPath.startsWith('example/')) return 'example'

  return 'lib'
}

// --- Entry point matching ---

function findEntryKey (mod, files) {
  const entryBase = mod.entry ? basename(mod.entry) : 'index.js'

  // Try exact match
  if (files[entryBase]) return entryBase

  // Try index.js
  if (files['index.js']) return 'index.js'

  return null
}

// --- Public API computation ---

function computePublicAPI (entryFile) {
  const api = []
  for (const exp of entryFile.exports) {
    if (exp.name === '*') continue // Skip star re-exports for now
    api.push(exp.name)
  }
  return api
}

// --- Import graph aggregation ---

function computeImportGraph (moduleEntry) {
  const modPath = moduleEntry.path
  const edges = []
  const dirEdgeCounts = {} // 'srcDir\0tgtDir' -> count
  const dirFiles = {} // dir -> Set of file paths

  // Build lookup: root-relative resolved path -> package-relative file key
  const resolvedToKey = new Map()
  for (const filePath of Object.keys(moduleEntry.files)) {
    // root-relative = modPath/filePath (or just filePath if modPath is '.')
    const rootRel = modPath === '.' ? filePath : modPath + '/' + filePath
    resolvedToKey.set(rootRel, filePath)
  }

  for (const [filePath, file] of Object.entries(moduleEntry.files)) {
    if (!file.localImports || file.localImports.length === 0) continue

    const srcDir = fileDir(filePath)
    if (!dirFiles[srcDir]) dirFiles[srcDir] = new Set()
    dirFiles[srcDir].add(filePath)

    for (const imp of file.localImports) {
      // Convert root-relative resolved path to package-relative key
      const targetKey = resolvedToKey.get(imp.resolved)
      if (!targetKey) continue

      // Find imported symbol names by matching specifier against parsed imports
      const names = []
      if (file.imports) {
        const match = file.imports.find(i => i.specifier === imp.specifier)
        if (match) names.push(...match.names)
      }

      edges.push({ from: filePath, to: targetKey, names })

      const tgtDir = fileDir(targetKey)
      if (!dirFiles[tgtDir]) dirFiles[tgtDir] = new Set()
      dirFiles[tgtDir].add(targetKey)

      // Aggregate directory-level edge
      const pairKey = srcDir + '\0' + tgtDir
      dirEdgeCounts[pairKey] = (dirEdgeCounts[pairKey] || 0) + 1
    }
  }

  // Build directoryMatrix from aggregated pair counts
  const directoryMatrix = {}
  for (const [pairKey, count] of Object.entries(dirEdgeCounts)) {
    const [srcDir, tgtDir] = pairKey.split('\0')
    if (!directoryMatrix[srcDir]) directoryMatrix[srcDir] = {}
    directoryMatrix[srcDir][tgtDir] = count
  }

  // Build per-directory stats
  const directories = {}
  const allDirs = new Set(Object.keys(dirFiles))

  for (const dir of allDirs) {
    let internalEdges = 0
    let incomingEdges = 0
    let outgoingEdges = 0

    // Count edges from this dir (outgoing + internal)
    for (const [pairKey, count] of Object.entries(dirEdgeCounts)) {
      const [src, tgt] = pairKey.split('\0')
      if (src === dir && tgt === dir) internalEdges += count
      else if (src === dir && tgt !== dir) outgoingEdges += count
      else if (tgt === dir && src !== dir) incomingEdges += count
    }

    directories[dir] = {
      files: dirFiles[dir] ? dirFiles[dir].size : 0,
      internalEdges,
      incomingEdges,
      outgoingEdges
    }
  }

  return { edges, directoryMatrix, directories }
}

function fileDir (filePath) {
  const dir = dirname(filePath)
  return dir === '.' ? '.' : dir
}

// --- Sub-module discovery ---

function discoverSubModules (moduleEntry) {
  const graph = moduleEntry.importGraph
  if (!graph) return {}

  const subModules = {}

  for (const [dir, stats] of Object.entries(graph.directories)) {
    if (dir === '.') continue // root dir is the module itself, not a sub-module
    if (stats.files < 2) continue // single-file dirs aren't sub-modules

    // Check for an index file with re-exports (strong signal).
    // Try common index filenames — language-agnostic lookup.
    const INDEX_CANDIDATES = ['index.js', 'index.mjs', 'index.cjs', 'mod.rs', '__init__.py']
    let indexKey = null
    let indexFile = null
    for (const candidate of INDEX_CANDIDATES) {
      const key = dir + '/' + candidate
      if (moduleEntry.files[key]) {
        indexKey = key
        indexFile = moduleEntry.files[key]
        break
      }
    }
    const hasIndex = indexFile && indexFile.exports && indexFile.exports.length > 0
    const hasReExports = hasIndex && indexFile.exports.some(e => e.from)

    // Collect files belonging to this directory
    const files = []
    for (const filePath of Object.keys(moduleEntry.files)) {
      if (fileDir(filePath) === dir) files.push(filePath)
    }

    // Compute public exports (from index.js if it exists, otherwise union of all exports)
    const publicExports = []
    if (hasIndex) {
      for (const exp of indexFile.exports) {
        if (exp.name !== '*') publicExports.push(exp.name)
      }
    }

    // Cohesion: ratio of internal edges to total edges involving this directory
    const totalEdges = stats.internalEdges + stats.incomingEdges + stats.outgoingEdges
    const cohesion = totalEdges > 0 ? stats.internalEdges / totalEdges : 0

    subModules[dir] = {
      indexFile: hasIndex ? indexKey : null,
      hasReExports,
      files,
      publicExports,
      fanIn: stats.incomingEdges,
      fanOut: stats.outgoingEdges,
      cohesion: Math.round(cohesion * 100) / 100
    }
  }

  return subModules
}

// --- Shared dependency analysis ---

function computeSharedDeps (tree) {
  const depUsers = {} // dep -> [modules that use it]

  for (const [name, mod] of Object.entries(tree.modules)) {
    for (const dep of Object.keys(mod.dependencies || {})) {
      if (!depUsers[dep]) depUsers[dep] = []
      depUsers[dep].push(name)
    }
  }

  // Only include deps used by 2+ modules
  const shared = {}
  for (const [dep, users] of Object.entries(depUsers)) {
    if (users.length >= 2) {
      shared[dep] = users.sort()
    }
  }

  return shared
}