/**
* Sections — the connector model over a scanned tree.
*
* A germ (a file, or a module) plus its typed connectors is a Section.
* Imports are `-` connectors (demands). Exports are `+` connectors (supplies).
* A dependency edge is not primitive: it is the MATING of one germ's `-name`
* with another germ's `+name`. That is exactly what a module resolver does.
*
* The payoff is the connectors that DON'T mate:
* dead an unmated `+` — exported, nothing takes it
* unresolved an unmated `-` — imported, nothing supplies it
* leak a module whose files demand a package its package.json omits
*
* Scales currently built: 'file' and 'module'. A 'member' scale (params and
* returns as connectors) needs call-site extraction, which no parser does yet.
*/
// A file's exports are its public boundary when it is the module's entry —
// nothing inside the scan needs to import them for them to be legitimate.
function entryKeyFor (mod) {
if (!mod.entry) return mod.files['index.js'] ? 'index.js' : null
return mod.files[mod.entry] ? mod.entry : (mod.files['index.js'] ? 'index.js' : null)
}
// Node/Bare builtins and bare specifiers resolve outside the scan by design.
function isBareSpecifier (spec) {
return !spec.startsWith('.') && !spec.startsWith('/')
}
// Runtime builtins are supplied by the runtime, never by package.json. A
// project may additionally redirect them through package.json "imports"
// (bare-fs for fs, and so on) — either way they are not a missing dependency.
const BUILTINS = new Set([
'assert', 'async_hooks', 'buffer', 'child_process', 'cluster', 'console',
'constants', 'crypto', 'dgram', 'dns', 'events', 'fs', 'http', 'http2',
'https', 'module', 'net', 'os', 'path', 'perf_hooks', 'process', 'punycode',
'querystring', 'readline', 'repl', 'stream', 'string_decoder', 'sys',
'timers', 'tls', 'tty', 'url', 'util', 'v8', 'vm', 'worker_threads', 'zlib'
])
function isRuntimeSupplied (spec, mod) {
const bare = spec.startsWith('node:') ? spec.slice(5) : spec
if (BUILTINS.has(bare) || BUILTINS.has(bare.split('/')[0])) return true
const maps = mod.importMaps || {}
if (maps[spec]) return true
// package.json "imports" supports trailing wildcards, e.g. "fs/*"
for (const key of Object.keys(maps)) {
if (key.endsWith('*') && spec.startsWith(key.slice(0, -1))) return true
}
return false
}
export function buildSections (tree, opts = {}) {
const config = opts.config || tree.config || {}
const externalPackages = new Set(config.externalPackages || [])
const sections = []
const byId = new Map()
const connectors = new Map()
let connSeq = 0
function addConn (section, conn) {
conn.id = 'c' + (connSeq++)
conn.germId = section.id
// A `-` mates at most one `+`. A `+` is a BROADCAST supply — one export can
// serve every file that imports it — so it mates 0..N demands. This is where
// code departs from a literal jigsaw, whose tabs are strictly 1:1.
conn.mate = null
conn.mates = []
section.conns.push(conn)
connectors.set(conn.id, conn)
return conn
}
for (const [modName, mod] of Object.entries(tree.modules)) {
const entryKey = entryKeyFor(mod)
// root-relative resolved path -> package-relative file key, so an edge's
// `resolved` can be turned back into the germ it points at.
const resolvedToKey = new Map()
for (const filePath of Object.keys(mod.files)) {
const rootRel = mod.path === '.' ? filePath : mod.path + '/' + filePath
resolvedToKey.set(rootRel, filePath)
}
// --- file-scale germs ---
for (const [filePath, file] of Object.entries(mod.files)) {
const section = {
id: 'file:' + modName + ':' + filePath,
scale: 'file',
germ: {
name: filePath,
module: modName,
path: mod.path === '.' ? filePath : mod.path + '/' + filePath,
role: file.role || 'lib',
isEntry: filePath === entryKey
},
conns: []
}
// `+` — every export is a supply. A re-export (`export { x } from './y'`)
// is a pass-through tab: it is wired to another germ's tab, not to a
// definition in this file.
for (const exp of file.exports || []) {
if (!exp.name || exp.name === '*') continue
addConn(section, {
pol: '+',
type: exp.name,
scope: 'local',
reExport: !!exp.from,
from: exp.from || null,
site: { file: filePath }
})
}
// `-` — every imported name is a demand. Local edges carry the resolved
// target so mating is per-symbol, not per-file.
const resolvedSpecs = new Set()
for (const edge of file.localImports || []) {
resolvedSpecs.add(edge.specifier)
const targetKey = resolvedToKey.get(edge.resolved)
for (const name of edge.names || []) {
addConn(section, {
pol: '-',
type: name,
scope: 'local',
target: targetKey ? 'file:' + modName + ':' + targetKey : null,
specifier: edge.specifier,
site: { file: filePath }
})
}
}
// Imports the resolver never produced an edge for: either a package
// (expected to live outside the scan) or a relative path that points at
// nothing (a real broken import).
for (const imp of file.imports || []) {
if (!imp.specifier || resolvedSpecs.has(imp.specifier)) continue
const bare = isBareSpecifier(imp.specifier)
const scope = bare
? (externalPackages.has(imp.specifier) || mod.dependencies[imp.specifier] ? 'external' : 'package')
: 'unresolved'
for (const name of imp.names || []) {
addConn(section, {
pol: '-',
type: name,
scope,
target: null,
specifier: imp.specifier,
site: { file: filePath }
})
}
}
sections.push(section)
byId.set(section.id, section)
}
// --- module-scale germ ---
const modSection = {
id: 'module:' + modName,
scale: 'module',
germ: { name: modName, module: modName, path: mod.path, version: mod.version },
conns: []
}
for (const name of mod.publicAPI || []) {
addConn(modSection, { pol: '+', type: name, scope: 'local', site: { file: entryKey } })
}
for (const dep of Object.keys(mod.dependencies || {})) {
addConn(modSection, { pol: '-', type: dep, scope: 'package', site: { file: 'package.json' } })
}
sections.push(modSection)
byId.set(modSection.id, modSection)
}
// --- mating ---
// A `-` mates the `+` of the same type on the germ its specifier resolved to.
for (const section of sections) {
if (section.scale !== 'file') continue
for (const conn of section.conns) {
if (conn.pol !== '-' || conn.mate || !conn.target) continue
const target = byId.get(conn.target)
if (!target) continue
const supply = target.conns.find(c => c.pol === '+' && c.type === conn.type)
if (!supply) continue
conn.mate = supply.id
conn.mates = [supply.id]
supply.mates.push(conn.id)
if (!supply.mate) supply.mate = conn.id
}
}
// Module `-` (a package.json dep) mates the module germ that supplies it.
for (const section of sections) {
if (section.scale !== 'module') continue
for (const conn of section.conns) {
if (conn.pol !== '-') continue
const target = byId.get('module:' + conn.type)
if (target) conn.mate = 'module:' + conn.type
}
}
const index = {
sections,
byId,
connectorById (id) { return connectors.get(id) || null },
/** Every connector that found no partner, grouped by what that means. */
misfits () {
const dead = []
const unresolved = []
for (const section of sections) {
if (section.scale !== 'file') continue
for (const conn of section.conns) {
if (conn.mates.length > 0) continue
if (conn.pol === '+') {
// Entry-file exports face outward — the module's public surface.
if (section.germ.isEntry) continue
dead.push({ type: conn.type, germ: section.germ.path, scale: 'file', conn })
} else if (conn.scope === 'unresolved' || (conn.scope === 'local' && conn.target)) {
unresolved.push({
type: conn.type,
germ: section.germ.path,
specifier: conn.specifier,
conn
})
}
}
}
return { dead, unresolved, leak: leaks(), unused: unusedDeps() }
},
/** One line per germ: the connector set, stably ordered. */
disjunct (section) {
const minus = section.conns.filter(c => c.pol === '-')
.map(c => '-' + c.type).sort()
const plus = section.conns.filter(c => c.pol === '+')
.map(c => '+' + c.type).sort()
return section.germ.name + ': ' + [...minus, ...plus].join(' ')
}
}
// Every package specifier the module's files actually demand.
function demandedPackages (modName) {
const out = new Set()
for (const section of sections) {
if (section.scale !== 'file' || section.germ.module !== modName) continue
for (const conn of section.conns) {
if (conn.pol !== '-') continue
if (conn.scope === 'package' || conn.scope === 'external') out.add(conn.specifier)
}
}
return out
}
// A module leaks when its files demand a package its package.json omits.
// Runtime builtins are supplied by the runtime, so they never count.
function leaks () {
const out = []
for (const [modName, mod] of Object.entries(tree.modules)) {
const declared = new Set([
...Object.keys(mod.dependencies || {}),
...Object.keys(mod.devDependencies || {})
])
const seen = new Set()
for (const section of sections) {
if (section.scale !== 'file' || section.germ.module !== modName) continue
for (const conn of section.conns) {
if (conn.pol !== '-' || conn.scope !== 'package') continue
if (declared.has(conn.specifier) || seen.has(conn.specifier)) continue
if (isRuntimeSupplied(conn.specifier, mod)) continue
seen.add(conn.specifier)
out.push({ module: modName, specifier: conn.specifier, germ: section.germ.path })
}
}
}
return out
}
// The mirror of a leak: package.json declares a dependency that no file in
// the module ever demands. An unmated `-` at module scale.
function unusedDeps () {
const out = []
for (const [modName, mod] of Object.entries(tree.modules)) {
const demanded = demandedPackages(modName)
const allDeclared = { ...(mod.dependencies || {}), ...(mod.devDependencies || {}) }
for (const dep of Object.keys(allDeclared)) {
// A local dep is satisfied by the module germ it points at, not by an
// import — don't report workspace siblings as unused.
if (tree.modules[dep]) continue
if (demanded.has(dep)) continue
// Scoped/subpath imports: `foo` is used if anything imports `foo/bar`.
let viaSubpath = false
for (const spec of demanded) {
if (spec.startsWith(dep + '/')) { viaSubpath = true; break }
}
if (viaSubpath) continue
out.push({ module: modName, specifier: dep })
}
}
return out
}
return index
}