Skip to the content

Capsid menu

scripts/code-map.mjs

342 lines and 43 functions in DrDustinEdwards/devkit, at commit 505cda4 on 2026-10-24. This file on GitHub.

Functions

The functions of code-map.mjs
FunctionLinesComplexityCoveredCRAPMutation
crap36 to 392100%OkCRAP 2No data: mutation testing has not run on this repo yet
loadTypeScript42 to 51370%OkCRAP 3.2No data: mutation testing has not run on this repo yet
functionsOf59 to 941100%OkCRAP 1No data: mutation testing has not run on this repo yet
isFunction64 to 667100%WatchCRAP 7No data: mutation testing has not run on this repo yet
lineOf67 to 671100%OkCRAP 1No data: mutation testing has not run on this repo yet
nameOf68 to 741186%WatchCRAP 11.4No data: mutation testing has not run on this repo yet
branches75 to 872100%OkCRAP 2No data: mutation testing has not run on this repo yet
walk77 to 8313100%WatchCRAP 13No data: mutation testing has not run on this repo yet
visit88 to 913100%OkCRAP 3No data: mutation testing has not run on this repo yet
specifiersOf97 to 1071100%OkCRAP 1No data: mutation testing has not run on this repo yet
visit100 to 1049100%WatchCRAP 9No data: mutation testing has not run on this repo yet
cyclesOf110 to 1413100%OkCRAP 3No data: mutation testing has not run on this repo yet
connect117 to 13810100%WatchCRAP 10No data: mutation testing has not run on this repo yet
<anonymous:140>140 to 1401100%OkCRAP 1No data: mutation testing has not run on this repo yet
innermost148 to 1526100%WatchCRAP 6No data: mutation testing has not run on this repo yet
sourceOf160 to 1653100%OkCRAP 3No data: mutation testing has not run on this repo yet
walk168 to 1741100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:169>169 to 1734100%OkCRAP 4No data: mutation testing has not run on this repo yet
lcovCoverage180 to 1987100%WatchCRAP 7No data: mutation testing has not run on this repo yet
keptReport209 to 2228100%WatchCRAP 8No data: mutation testing has not run on this repo yet
<anonymous:215>215 to 2151100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:216>216 to 2161100%OkCRAP 1No data: mutation testing has not run on this repo yet
codeMap228 to 3208100%WatchCRAP 8No data: mutation testing has not run on this repo yet
onUnRecoverableConfigFileDiagnostic230 to 2301100%OkCRAP 1No data: mutation testing has not run on this repo yet
rel232 to 2321100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:235>235 to 2355100%OkCRAP 5No data: mutation testing has not run on this repo yet
<anonymous:235>235 to 2352100%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:235>235 to 2351100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:241>241 to 30621100%WatchCRAP 21No data: mutation testing has not run on this repo yet
<anonymous:253>253 to 2531100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:268>268 to 2681100%OkCRAP 1No data: mutation testing has not run on this repo yet
body281 to 2811100%OkCRAP 1No data: mutation testing has not run on this repo yet
keyed282 to 2851100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:284>284 to 2842100%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:287>287 to 2871100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:288>288 to 2923100%OkCRAP 3No data: mutation testing has not run on this repo yet
<anonymous:298>298 to 2981100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:300>300 to 3048100%WatchCRAP 8No data: mutation testing has not run on this repo yet
args323 to 33150%WatchCRAP 30No data: mutation testing has not run on this repo yet
json336 to 33630%WatchCRAP 12No data: mutation testing has not run on this repo yet
<anonymous:357>357 to 35710%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:358>358 to 35810%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:358>358 to 3581100%OkCRAP 1No data: mutation testing has not run on this repo yet

Imports

It imports no other module of this repo. No module of this repo imports it.

Source

#!/usr/bin/env node
// The code map: one JSON file per public repository saying,
// for every module, how big it is, what it imports and whether it sits in an import cycle, and for every function its
// cyclomatic complexity, its share of statements covered, its CRAP score and, when a Stryker report is given, its mutants
// and mutation score. Capsid publishes it and the explorer at capsid.dustinedwards.info draws it.
//
// Everything is read through the repository's own TypeScript, so there is no dependency here and no second parser:
// imports are resolved by ts.resolveModuleName with the repository's compiler options, and complexity is counted on the
// syntax tree. Coverage is Vitest's (istanbul's) coverage-final.json, or the lcov file `node --test
// --experimental-test-coverage --test-reporter=lcov` writes, and mutation is Stryker's mutation.json, each mapped onto the
// innermost function whose lines hold a statement (an lcov line) or a mutant. A number nobody measured is null, never 0.
//
// TypeScript and JavaScript alike: .ts, .tsx, .js, .mjs and the rest, never a declaration file or a *.test.* or *.spec.*
// file. A repository with no tsconfig is read as plain modules (allowJs, NodeNext). `--include .` maps every folder, and
// `--exclude <prefix>` (repeatable) leaves a path out.
//
// Mutation runs less often than the map (weekly, design D11), so a run's reports are kept as one smaller file, mutation.json,
// beside the map: the commit and day of the run, and per file its source then and each mutant's status and line. A later map
// reads that file, places each mutant in the function it was made in from the source then, and carries the numbers to a
// function whose lines are unchanged since. A function that changed, or is new, is `stale: true` with no numbers, and the
// map names the run it read as `mutated`.
//
//   node code-map.mjs --out code-map.json [--tsconfig tsconfig.json] [--include src/]... [--exclude <prefix>]... [--coverage <file>]
//     [--mutation <stryker report>]... [--mutation-kept <mutation.json>] [--mutation-out <mutation.json>]
//
// The repository and commit come from GITHUB_REPOSITORY and GITHUB_SHA, or --repo and --sha. code-map.yml carries this file
// byte for byte; after an edit run `node scripts/inline-rollback-guard.mjs`.

import { execFileSync } from "node:child_process";
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { join, relative, resolve, sep } from "node:path";
import { pathToFileURL } from "node:url";

/** CRAP = CC² × (1 − coverage)³ + CC (crap4j's formula), rounded to two places. Null when coverage was not measured. */
export function crap(/** @type {number} */ cc, /** @type {number | null} */ coverage) {
  if (coverage === null) return null;
  return Math.round((cc * cc * (1 - coverage) ** 3 + cc) * 100) / 100;
}

/** The repository's TypeScript, from its own node_modules, so the map parses what its compiler parses. */
export function loadTypeScript(/** @type {string} */ root) {
  for (const base of [join(root, "package.json"), import.meta.url]) {
    try {
      return createRequire(base)("typescript");
    } catch {
      // Try the next place.
    }
  }
  throw new Error("typescript is not installed in the repository; the code map reads its code through it");
}

/**
 * Every function in a source file with its lines and cyclomatic complexity: 1, plus one for each branch (if, ?:, a loop,
 * a case, a catch, &&, ||, ?? and their assignments) in its own body, not in a function nested inside it.
 * @param {any} ts @param {any} sf
 * @returns {{ name: string, line: number, end: number, cc: number }[]}
 */
export function functionsOf(ts, sf) {
  /** @type {{ name: string, line: number, end: number, cc: number }[]} */
  const out = [];
  const K = ts.SyntaxKind;
  const LOGICAL = new Set([K.AmpersandAmpersandToken, K.BarBarToken, K.QuestionQuestionToken, K.AmpersandAmpersandEqualsToken, K.BarBarEqualsToken, K.QuestionQuestionEqualsToken]);
  const isFunction = (/** @type {any} */ n) =>
    ts.isFunctionDeclaration(n) || ts.isFunctionExpression(n) || ts.isArrowFunction(n) || ts.isMethodDeclaration(n) ||
    ts.isConstructorDeclaration(n) || ts.isGetAccessorDeclaration(n) || ts.isSetAccessorDeclaration(n);
  const lineOf = (/** @type {number} */ pos) => sf.getLineAndCharacterOfPosition(pos).line + 1;
  const nameOf = (/** @type {any} */ n) => {
    if (ts.isConstructorDeclaration(n)) return "constructor";
    if (n.name && (ts.isIdentifier(n.name) || ts.isStringLiteral(n.name) || ts.isPrivateIdentifier(n.name))) return n.name.text;
    const p = n.parent;
    if (p && (ts.isVariableDeclaration(p) || ts.isPropertyAssignment(p) || ts.isPropertyDeclaration(p)) && ts.isIdentifier(p.name)) return p.name.text;
    return `<anonymous:${lineOf(n.getStart(sf))}>`;
  };
  const branches = (/** @type {any} */ n) => {
    let count = 0;
    const walk = (/** @type {any} */ c) => {
      if (isFunction(c)) return;
      if (ts.isIfStatement(c) || ts.isConditionalExpression(c) || ts.isForStatement(c) || ts.isForInStatement(c) ||
        ts.isForOfStatement(c) || ts.isWhileStatement(c) || ts.isDoStatement(c) || ts.isCaseClause(c) || ts.isCatchClause(c)) count++;
      else if (ts.isBinaryExpression(c) && LOGICAL.has(c.operatorToken.kind)) count++;
      ts.forEachChild(c, walk);
    };
    if (ts.isBlock(n.body)) ts.forEachChild(n.body, walk);
    else walk(n.body);
    return count;
  };
  const visit = (/** @type {any} */ n) => {
    if (isFunction(n) && n.body) out.push({ name: nameOf(n), line: lineOf(n.getStart(sf)), end: lineOf(n.getEnd()), cc: 1 + branches(n) });
    ts.forEachChild(n, visit);
  };
  visit(sf);
  return out;
}

/** The module specifiers a file imports or re-exports, static and dynamic. @param {any} ts @param {any} sf */
export function specifiersOf(ts, sf) {
  /** @type {string[]} */
  const out = [];
  const visit = (/** @type {any} */ n) => {
    if ((ts.isImportDeclaration(n) || ts.isExportDeclaration(n)) && n.moduleSpecifier && ts.isStringLiteral(n.moduleSpecifier)) out.push(n.moduleSpecifier.text);
    else if (ts.isCallExpression(n) && n.expression.kind === ts.SyntaxKind.ImportKeyword && n.arguments[0] && ts.isStringLiteral(n.arguments[0])) out.push(n.arguments[0].text);
    ts.forEachChild(n, visit);
  };
  visit(sf);
  return out;
}

/** Every import cycle (Tarjan's strongly connected components of two or more modules, or one that imports itself). */
export function cyclesOf(/** @type {Map<string, string[]>} */ graph) {
  let index = 0;
  /** @type {Map<string, number>} */ const idx = new Map();
  /** @type {Map<string, number>} */ const low = new Map();
  /** @type {string[]} */ const stack = [];
  const on = new Set();
  /** @type {string[][]} */ const out = [];
  const connect = (/** @type {string} */ v) => {
    idx.set(v, index);
    low.set(v, index++);
    stack.push(v);
    on.add(v);
    for (const w of graph.get(v) ?? []) {
      if (!idx.has(w)) {
        connect(w);
        low.set(v, Math.min(/** @type {number} */ (low.get(v)), /** @type {number} */ (low.get(w))));
      } else if (on.has(w)) low.set(v, Math.min(/** @type {number} */ (low.get(v)), /** @type {number} */ (idx.get(w))));
    }
    if (low.get(v) === idx.get(v)) {
      /** @type {string[]} */ const component = [];
      let w;
      do {
        w = /** @type {string} */ (stack.pop());
        on.delete(w);
        component.push(w);
      } while (w !== v);
      if (component.length > 1 || (graph.get(v) ?? []).includes(v)) out.push(component.sort());
    }
  };
  for (const v of [...graph.keys()].sort()) if (!idx.has(v)) connect(v);
  return out.sort((a, b) => a[0].localeCompare(b[0]));
}

/**
 * The innermost function whose lines hold `line`, or undefined.
 * @template {{ line: number, end: number }} F
 * @param {F[]} fns @param {number} line
 */
function innermost(fns, line) {
  let best;
  for (const f of fns) if (f.line <= line && line <= f.end && (!best || f.end - f.line < best.end - best.line)) best = f;
  return best;
}

/** A module the map reads: source, not a declaration file and not a test. */
const SOURCE = /\.[cm]?[jt]sx?$/;
const DECLARATION = /\.d\.[cm]?ts$/;
const TEST = /\.(test|spec)\.[cm]?[jt]sx?$/;

/** The JavaScript source a hand-written declaration file describes, when it sits beside it: x.d.mts for x.mjs. */
function sourceOf(/** @type {string} */ file) {
  const m = /^(.*)\.d\.([cm]?)ts$/.exec(file);
  if (!m) return file;
  const js = `${m[1]}.${m[2]}js`;
  return existsSync(js) ? js : file;
}

/** Every file under a directory, leaving out node_modules and dot-folders. @param {string} dir @returns {string[]} */
function walk(dir) {
  return readdirSync(dir).flatMap((name) => {
    if (name === "node_modules" || name.startsWith(".")) return [];
    const full = join(dir, name);
    return statSync(full).isDirectory() ? walk(full) : [full];
  });
}

/**
 * lcov as istanbul's coverage-final.json shape: per file, each instrumented line (DA) a statement with its hit count.
 * Paths are resolved against the repository's root. @param {string} text @param {string} root
 */
export function lcovCoverage(text, root) {
  /** @type {Record<string, { statementMap: Record<string, { start: { line: number } }>, s: Record<string, number> }>} */
  const out = {};
  let file = null;
  for (const raw of text.split("\n")) {
    const line = raw.trim();
    if (line.startsWith("SF:")) {
      // Forward slashes, as TypeScript names its files, so the lookup also matches on Windows.
      const abs = resolve(root, line.slice(3)).replaceAll("\\", "/");
      file = out[abs] ??= { statementMap: {}, s: {} };
    } else if (line.startsWith("DA:") && file) {
      const [n, hits] = line.slice(3).split(",").map(Number);
      const id = String(Object.keys(file.statementMap).length);
      file.statementMap[id] = { start: { line: n } };
      file.s[id] = hits;
    } else if (line === "end_of_record") file = null;
  }
  return out;
}

/** Stryker's statuses: detected and valid, as its mutation score counts them. */
const DETECTED = new Set(["Killed", "Timeout"]);
const VALID = new Set(["Killed", "Timeout", "Survived", "NoCoverage"]);

/**
 * One kept mutation report from a run's Stryker reports (one per chunk or shard): per file its source and each valid
 * mutant's status and line, with the run's commit and day.
 * @param {any[]} reports @param {string} sha @param {string} date
 */
export function keptReport(reports, sha, date) {
  /** @type {Record<string, { source: string | null, mutants: { status: string, location: { start: { line: number } } }[] }>} */
  const files = {};
  for (const r of reports) {
    for (const [path, f] of Object.entries(r?.files ?? {})) {
      const mutants = (/** @type {any} */ (f).mutants ?? [])
        .filter((/** @type {any} */ m) => VALID.has(m.status))
        .map((/** @type {any} */ m) => ({ status: m.status, location: { start: { line: m.location.start.line } } }));
      const was = files[path];
      files[path] = { source: was?.source ?? /** @type {any} */ (f).source ?? null, mutants: [...(was?.mutants ?? []), ...mutants] };
    }
  }
  return { version: 1, sha, date, files };
}

/**
 * The code map of a repository.
 * @param {{ ts: any, root: string, tsconfig?: string, include?: string[], exclude?: string[], coverage?: any, mutation?: any, repo: string, sha: string, date: string }} o
 */
export function codeMap({ ts, root, tsconfig = "tsconfig.json", include = ["src/"], exclude = [], coverage = null, mutation = null, repo, sha, date }) {
  const parsed = existsSync(resolve(root, tsconfig))
    ? ts.getParsedCommandLineOfConfigFile(resolve(root, tsconfig), {}, { ...ts.sys, onUnRecoverableConfigFileDiagnostic: (/** @type {any} */ d) => { throw new Error(ts.flattenDiagnosticMessageText(d.messageText, "\n")); } })
    : { fileNames: walk(root), options: { allowJs: true, module: ts.ModuleKind.NodeNext, moduleResolution: ts.ModuleResolutionKind.NodeNext, target: ts.ScriptTarget.ES2022 } };
  const rel = (/** @type {string} */ abs) => relative(root, abs).split(sep).join("/");
  // A package that ships hand-written .d.mts beside its .mjs: the compiler lists the declaration in the source's place.
  const files = [...new Set(parsed.fileNames.map(sourceOf))]
    .filter((f) => SOURCE.test(f) && !DECLARATION.test(f) && !TEST.test(f) && include.some((p) => p === "." || rel(f).startsWith(p)) && !exclude.some((p) => rel(f).startsWith(p)))
    .sort();
  const mapped = new Set(files.map(rel));
  const host = ts.createCompilerHost(parsed.options);
  /** @type {Map<string, string[]>} */
  const graph = new Map();
  const modules = files.map((abs) => {
    const path = rel(abs);
    const text = readFileSync(abs, "utf8");
    const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true);
    const imports = new Set();
    for (const spec of specifiersOf(ts, sf)) {
      const hit = ts.resolveModuleName(spec, abs, parsed.options, host).resolvedModule;
      const to = hit && rel(sourceOf(hit.resolvedFileName));
      if (to && mapped.has(to)) imports.add(to);
    }
    graph.set(path, [...imports].sort());
    /** @type {{ name: string, line: number, end: number, cc: number, statements: number, covered: number, mutants: number, detected: number, stale?: true }[]} */
    const fns = functionsOf(ts, sf).map((f) => ({ ...f, statements: 0, covered: 0, mutants: 0, detected: 0 }));

    const cov = coverage?.[abs] ?? coverage?.[path];
    if (cov) {
      for (const [id, s] of Object.entries(cov.statementMap ?? {})) {
        const f = innermost(fns, /** @type {any} */ (s).start.line);
        if (!f) continue;
        f.statements++;
        if ((cov.s?.[id] ?? 0) > 0) f.covered++;
      }
    }
    const mut = mutation?.files?.[path] ?? mutation?.files?.[abs];
    if (mut) {
      // Each mutant goes to the function it was made in, read from the file as the run saw it, so lines that moved since
      // still meet their function.
      const then = typeof mut.source === "string" ? functionsOf(ts, ts.createSourceFile(abs, mut.source, ts.ScriptTarget.Latest, true)).map((f) => ({ ...f, mutants: 0, detected: 0 })) : fns;
      for (const m of mut.mutants ?? []) {
        if (!VALID.has(m.status)) continue;
        const f = innermost(then, m.location.start.line);
        if (!f) continue;
        f.mutants++;
        if (DETECTED.has(m.status)) f.detected++;
      }
      if (then !== fns) {
        // A function keeps the run's numbers only when its lines are the same as then: the nth function of a name now
        // against the nth then.
        const linesThen = mut.source.split("\n");
        const linesNow = text.split("\n");
        const body = (/** @type {string[]} */ lines, /** @type {{ line: number, end: number }} */ f) => lines.slice(f.line - 1, f.end).join("\n");
        const keyed = (/** @type {{ name: string }[]} */ list) => {
          /** @type {Map<string, number>} */ const n = new Map();
          return list.map((f) => { const i = n.get(f.name) ?? 0; n.set(f.name, i + 1); return `${f.name}#${i}`; });
        };
        const keysThen = keyed(then);
        const byKey = new Map(then.map((f, i) => [keysThen[i], f]));
        keyed(fns).forEach((key, i) => {
          const was = byKey.get(key);
          if (was && body(linesThen, was) === body(linesNow, fns[i])) Object.assign(fns[i], { mutants: was.mutants, detected: was.detected });
          else Object.assign(fns[i], { stale: true });
        });
      }
    }

    return {
      path,
      loc: text.split("\n").filter((l) => l.trim() !== "").length,
      imports: graph.get(path),
      functions: fns.map(({ name, line, end, cc, statements, covered, mutants, detected, stale }) => {
        const c = cov ? (statements === 0 ? 1 : Math.round((covered / statements) * 1000) / 1000) : null;
        const measured = mut && !stale;
        return { name, line, end, cc, coverage: c, crap: crap(cc, c), mutants: measured ? mutants : null, score: measured && mutants > 0 ? Math.round((detected / mutants) * 1000) / 10 : null, ...(stale ? { stale } : {}) };
      }),
    };
  });
  const cycles = cyclesOf(graph);
  const inCycle = new Set(cycles.flat());
  for (const m of modules) if (inCycle.has(m.path)) Object.assign(m, { cycle: true });
  return {
    version: 1,
    repo,
    sha,
    date,
    tools: { typescript: ts.version, coverage: coverage ? "istanbul" : null, mutation: mutation ? "stryker" : null },
    ...(mutation?.sha && mutation.sha !== sha ? { mutated: { sha: mutation.sha, date: mutation.date } } : {}),
    modules,
    cycles,
  };
}

/** --name value pairs; a repeated name collects every value. @param {string[]} argv */
function args(argv) {
  /** @type {Record<string, string[]>} */
  const out = {};
  for (let i = 0; i < argv.length; i += 2) {
    if (!argv[i].startsWith("--") || argv[i + 1] === undefined) throw new Error(`expected --name value, got ${argv[i]}`);
    (out[argv[i].slice(2)] ??= []).push(argv[i + 1]);
  }
  return out;
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  const a = args(process.argv.slice(2));
  const root = process.cwd();
  const json = (/** @type {string | undefined} */ f) => (f && existsSync(f) ? JSON.parse(readFileSync(f, "utf8")) : null);
  for (const f of [a.coverage?.[0], ...(a.mutation ?? []), a["mutation-kept"]?.[0]]) if (f && !existsSync(f)) console.log(`::warning::${f} does not exist, so the map has no numbers from it`);
  const sha = a.sha?.[0] ?? process.env.GITHUB_SHA ?? execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim();
  const date = new Date().toISOString().slice(0, 10);
  // A fresh run's reports win over a kept one; with neither, the map has no mutation numbers.
  const reports = (a.mutation ?? []).map(json).filter(Boolean);
  const mutation = reports.length ? keptReport(reports, sha, date) : json(a["mutation-kept"]?.[0]);
  if (mutation && a["mutation-out"]?.[0]) writeFileSync(a["mutation-out"][0], `${JSON.stringify(mutation)}\n`);
  const map = codeMap({
    ts: loadTypeScript(root),
    root,
    tsconfig: a.tsconfig?.[0],
    include: a.include,
    exclude: a.exclude,
    coverage: a.coverage?.[0] && existsSync(a.coverage[0]) && !a.coverage[0].endsWith(".json") ? lcovCoverage(readFileSync(a.coverage[0], "utf8"), root) : json(a.coverage?.[0]),
    mutation,
    repo: a.repo?.[0] ?? process.env.GITHUB_REPOSITORY ?? "",
    sha,
    date,
  });
  writeFileSync(a.out?.[0] ?? "code-map.json", `${JSON.stringify(map, null, 2)}\n`);
  const fns = map.modules.reduce((n, m) => n + m.functions.length, 0);
  const stale = map.modules.reduce((n, m) => n + m.functions.filter((f) => f.stale).length, 0);
  console.log(`code map: ${map.modules.length} modules, ${fns} functions, ${map.cycles.length} cycles${map.mutated ? `; mutation from ${map.mutated.sha.slice(0, 7)} (${map.mutated.date}), ${stale} functions stale` : ""}`);
}