/**
* TreeReducer — subscribes to scan-stream events and builds the same graph
* structure that walkGraph() used to return synchronously.
*
* Stateful. Apply events in order. Call .finalize() after scan-end to get
* the same shape as before: { modules, files, root, dirTree, projectType,
* languages, resolutionContext }.
*
* Each reducer is a single subscriber — different reducers (doc-cruft,
* metrics, UI updaters) can listen to the same event source in parallel.
*/
export class TreeReducer {
constructor () {
this.root = null
this.projectType = null
this.languages = []
this.resolutionContext = null
this.modules = new Map() // modName -> mod
this.files = new Map() // rootRelPath -> fileInfo
this.dirTree = null
}
/** Attach this reducer to a scan-stream emitter. */
attach (emitter) {
emitter.on('scan-start', e => this.onScanStart(e))
emitter.on('module-start', e => this.onModuleStart(e))
emitter.on('file-discovered', e => this.onFileDiscovered(e))
emitter.on('file-parsed', e => this.onFileParsed(e))
emitter.on('import-edge', e => this.onImportEdge(e))
emitter.on('scan-end', e => this.onScanEnd(e))
return this
}
onScanStart (e) {
this.root = e.root
this.projectType = e.projectType
this.languages = e.languages
this.resolutionContext = e.resolutionContext
}
onModuleStart (e) {
this.modules.set(e.name, {
name: e.name,
version: e.version,
path: e.path,
absPath: e.absPath,
entry: e.entry,
dependencies: e.dependencies,
devDependencies: e.devDependencies,
importMaps: e.importMaps,
testCommand: e.testCommand,
type: e.type,
projectType: e.projectType,
files: new Map()
})
}
onFileDiscovered (e) {
// Intentionally NOT retaining e.source. That was the main memory leak that
// OOM'd recursive scans. Downstream consumers can re-read from absPath
// via /api/file if they actually need the bytes.
const fileInfo = {
absPath: e.absPath,
relPath: e.relPath,
relInPkg: e.relInPkg,
size: e.size,
localImports: [],
parsed: null // filled by onFileParsed
}
this.files.set(e.relPath, fileInfo)
const mod = this.modules.get(e.modName)
if (mod) mod.files.set(e.relInPkg, fileInfo)
}
onFileParsed (e) {
const mod = this.modules.get(e.modName)
if (!mod) return
const file = mod.files.get(e.relInPkg)
if (!file) return
file.parsed = e.parsed
}
onImportEdge (e) {
const mod = this.modules.get(e.modName)
if (!mod) return
const file = mod.files.get(e.fromRelInPkg)
if (!file) return
const edge = { specifier: e.specifier, resolved: e.resolvedRelPath }
if (e.names && e.names.length > 0) edge.names = e.names.slice()
if (e.implicit) edge.implicit = true
file.localImports.push(edge)
}
onScanEnd (e) {
this.dirTree = e.dirTree
}
/** Return the graph in the shape walkGraph() used to produce. */
finalize () {
return {
modules: this.modules,
files: this.files,
root: this.root,
dirTree: this.dirTree,
projectType: this.projectType,
languages: this.languages,
resolutionContext: this.resolutionContext
}
}
}