OverviewFilesCommitsIssuesDocsReleasesPeers

codetree / lib / sections.js

306 lines · javascript ref:
/**
 * 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
}