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
}