Ctrl K
ComunidadGuías…

tests/helpers/importGraph.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// The import graph, read off the syntax tree — what the split tests assert on.
//
// A split is a move: a big module's body goes into files of its own and the
// module keeps its name and its exports, so not one importer changes. That
// claim has two halves a compiler only half checks. `tsc` proves every name an
// importer asks for still exists; it does not notice a part that exports more
// than anybody takes (a surface that grew in the move), nor an importer outside
// the family reaching past the facade into a part (a seam that stopped being
// one). `surfaceOf()` answers both from the source text alone: no build, no
// browser, no module is executed.

import { readdirSync, readFileSync, statSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import ts from "typescript";

export const ROOT = fileURLToPath(new URL("../..", import.meta.url));

const SCANNED = ["client", "server", "shared", "tests", "electron", "mobile/src", "build", "scripts"];
const SKIP_DIRS = new Set(["node_modules", "dist", "fixtures"]);

function walk(dir: string, out: string[]): void {
  let names: string[];
  try {
    names = readdirSync(dir);
  } catch {
    return;
  }
  for (const name of names) {
    if (SKIP_DIRS.has(name)) continue;
    const abs = path.join(dir, name);
    const st = statSync(abs);
    if (st.isDirectory()) walk(abs, out);
    else if (/\.(ts|tsx|mjs)$/.test(name) && !name.endsWith(".d.ts")) out.push(abs);
  }
}

let files: string[] | null = null;
function sourceFiles(): string[] {
  if (files) return files;
  files = [];
  for (const dir of SCANNED) walk(path.join(ROOT, dir), files);
  return files;
}

interface Edge {
  /** Repo-relative path of the importing file. */
  from: string;
  /** Repo-relative path of the imported module. */
  to: string;
  /** The names taken (`*` for a namespace or a star re-export, `default`). */
  names: string[];
  /** A re-export (`export { x } from`), not a use. */
  reexport: boolean;
}

interface Parsed {
  edges: Edge[];
  exports: Set<string>;
}

const cache = new Map<string, Parsed>();

function rel(abs: string): string {
  return path.relative(ROOT, abs).split(path.sep).join("/");
}

function parse(abs: string): Parsed {
  const hit = cache.get(abs);
  if (hit) return hit;
  const text = readFileSync(abs, "utf8");
  const kind = abs.endsWith(".tsx") ? ts.ScriptKind.TSX : abs.endsWith(".mjs") ? ts.ScriptKind.JS : ts.ScriptKind.TS;
  const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true, kind);
  const edges: Edge[] = [];
  const exports = new Set<string>();
  const target = (spec: string): string | null =>
    spec.startsWith(".") ? rel(path.resolve(path.dirname(abs), spec)) : null;
  const visitDynamic = (node: ts.Node): void => {
    if (
      ts.isCallExpression(node) &&
      node.expression.kind === ts.SyntaxKind.ImportKeyword &&
      node.arguments[0] &&
      ts.isStringLiteral(node.arguments[0])
    ) {
      const to = target(node.arguments[0].text);
      if (to) edges.push({ from: rel(abs), to, names: ["*"], reexport: false });
    }
    ts.forEachChild(node, visitDynamic);
  };
  for (const st of sf.statements) {
    if (ts.isImportDeclaration(st) && ts.isStringLiteral(st.moduleSpecifier)) {
      const to = target(st.moduleSpecifier.text);
      if (!to) continue;
      const names: string[] = [];
      const clause = st.importClause;
      if (clause?.name) names.push("default");
      const nb = clause?.namedBindings;
      if (nb && ts.isNamespaceImport(nb)) names.push("*");
      if (nb && ts.isNamedImports(nb)) for (const el of nb.elements) names.push((el.propertyName ?? el.name).text);
      edges.push({ from: rel(abs), to, names, reexport: false });
      continue;
    }
    if (ts.isExportDeclaration(st)) {
      const spec = st.moduleSpecifier && ts.isStringLiteral(st.moduleSpecifier) ? st.moduleSpecifier.text : null;
      const to = spec ? target(spec) : null;
      if (st.exportClause && ts.isNamedExports(st.exportClause)) {
        const names: string[] = [];
        for (const el of st.exportClause.elements) {
          exports.add(el.name.text);
          names.push((el.propertyName ?? el.name).text);
        }
        if (to) edges.push({ from: rel(abs), to, names, reexport: true });
      } else if (to) {
        edges.push({ from: rel(abs), to, names: ["*"], reexport: true });
      }
      continue;
    }
    const mods = ts.canHaveModifiers(st) ? (ts.getModifiers(st) ?? []) : [];
    if (!mods.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) continue;
    if (mods.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword)) exports.add("default");
    if (ts.isVariableStatement(st)) {
      for (const d of st.declarationList.declarations) if (ts.isIdentifier(d.name)) exports.add(d.name.text);
    } else if (
      (ts.isFunctionDeclaration(st) ||
        ts.isClassDeclaration(st) ||
        ts.isInterfaceDeclaration(st) ||
        ts.isTypeAliasDeclaration(st) ||
        ts.isEnumDeclaration(st)) &&
      st.name
    ) {
      exports.add(st.name.text);
    }
  }
  visitDynamic(sf);
  const out = { edges, exports };
  cache.set(abs, out);
  return out;
}

/** Every edge in the scanned tree that lands on `module` (repo-relative). */
export function importsOf(module: string): Edge[] {
  const out: Edge[] = [];
  for (const abs of sourceFiles()) for (const e of parse(abs).edges) if (e.to === module) out.push(e);
  return out;
}

/** What `module` exports by name (its own declarations and its re-exports). */
export function exportsOf(module: string): Set<string> {
  return parse(path.join(ROOT, module)).exports;
}

export interface Surface {
  /** Names some importer asks the facade for that it does not export. */
  missing: string[];
  /** A part's exports that nobody in the family imports — surface that grew. */
  unused: { part: string; name: string }[];
  /** Files outside the family that import a part directly. */
  strays: { from: string; part: string }[];
}

/**
 * The family is a facade (the module that kept its name) and its parts (the
 * files its body moved into). `outside` names extra importers a part may have
 * on purpose (a lazy boundary, say), which are then family too.
 */
export function surfaceOf(facade: string, parts: string[], outside: string[] = []): Surface {
  const family = new Set([facade, ...parts, ...outside]);
  const facadeExports = exportsOf(facade);
  const missing: string[] = [];
  for (const e of importsOf(facade)) {
    for (const n of e.names) if (n !== "*" && !facadeExports.has(n)) missing.push(`${e.from}: ${n}`);
  }
  const unused: { part: string; name: string }[] = [];
  const strays: { from: string; part: string }[] = [];
  for (const part of parts) {
    const taken = new Set<string>();
    let star = false;
    for (const e of importsOf(part)) {
      if (!family.has(e.from)) strays.push({ from: e.from, part });
      for (const n of e.names) (n === "*" ? (star = true) : taken.add(n));
    }
    if (star) continue;
    for (const name of exportsOf(part)) if (!taken.has(name)) unused.push({ part, name });
  }
  return { missing, unused, strays };
}