canonicalizeSite

tcmts.core.canonicalize.canonicalizeSite · #kb5xxcbl · term · runs anywhere

import {
  declKey, IDENT_NAME, isAssignmentTarget, isCalled, isDeferred, isJsxTag, isList, isNamingPosition,
  isSkippedModifier, isStructuralName, isTaggedTemplatePart, isTypePosition, jsxText, kindName, meaningAt, REJECTED,
  slots,
} from "#codebase/tcmts/core/canonicalize";
import type { CanonContext, RawSpan, Resolved, SiteResult } from "#codebase/tcmts/core/canonicalize";
import { isAnonymousFunction, isInstantiated, TcmError } from "#codebase/tcmts/core/extract";
import type { DefSite } from "#codebase/tcmts/core/extract";
import type { IR } from "#codebase/tcmts/core/hash";
import { getModifiers, isOptionalChain } from "#codebase/tcmts/core/ts";
import type { ExtRef, RefPos } from "#codebase/tcmts/core/types";
import {
  isConstructorDeclaration as ts_isConstructorDeclaration, isHeritageClause as ts_isHeritageClause,
  isIdentifier as ts_isIdentifier, isImportSpecifier as ts_isImportSpecifier, isJsxText as ts_isJsxText,
  isMetaProperty as ts_isMetaProperty, isModuleBlock as ts_isModuleBlock,
  isModuleDeclaration as ts_isModuleDeclaration, isNamespaceImport as ts_isNamespaceImport,
  isNoSubstitutionTemplateLiteral as ts_isNoSubstitutionTemplateLiteral,
  isObjectBindingPattern as ts_isObjectBindingPattern, isParameterDeclaration as ts_isParameter,
  isPostfixUnaryExpression as ts_isPostfixUnaryExpression, isPrefixUnaryExpression as ts_isPrefixUnaryExpression,
  isPropertyAccessExpression as ts_isPropertyAccessExpression, isQualifiedName as ts_isQualifiedName,
  isSourceFile as ts_isSourceFile, isStringLiteral as ts_isStringLiteral, isTypeOperatorNode as ts_isTypeOperatorNode,
  isVariableDeclarationList as ts_isVariableDeclarationList, NodeFlags as ts_NodeFlags, SyntaxKind as ts_SyntaxKind,
  tokenToString as ts_tokenToString,
} from "typescript/ast";
import type {
  BigIntLiteral as ts_BigIntLiteral, BindingElement as ts_BindingElement, CallExpression as ts_CallExpression,
  ElementAccessExpression as ts_ElementAccessExpression, Identifier as ts_Identifier, ImportClause as ts_ImportClause,
  ImportDeclaration as ts_ImportDeclaration, ImportSpecifier as ts_ImportSpecifier, JsxText as ts_JsxText,
  ModuleDeclaration as ts_ModuleDeclaration, NamespaceImport as ts_NamespaceImport, Node as ts_Node,
  NoSubstitutionTemplateLiteral as ts_NoSubstitutionTemplateLiteral, NumericLiteral as ts_NumericLiteral,
  ParenthesizedExpression as ts_ParenthesizedExpression, ParenthesizedTypeNode as ts_ParenthesizedTypeNode,
  PrivateIdentifier as ts_PrivateIdentifier, QualifiedName as ts_QualifiedName,
  RegularExpressionLiteral as ts_RegularExpressionLiteral,
  ShorthandPropertyAssignment as ts_ShorthandPropertyAssignment, StringLiteral as ts_StringLiteral,
  TemplateLiteralLikeNode as ts_TemplateLiteralLikeNode,
} from "typescript/ast";
import { SymbolFlags as ts_SymbolFlags } from "typescript/sync";
import type { NodeHandle as ts_NodeHandle, Symbol as ts_Symbol } from "typescript/sync";

export function canonicalizeSite(site: DefSite, ctx: CanonContext): SiteResult {
  const { checker } = ctx;
  const sf = site.sf;
  const locals = new Map<ts_Symbol, number>();
  const spans: RawSpan[] = [];
  const reserved = new Set<string>();
  const edges = new Map<number, boolean>();
  const writes = new Set<number>();
  let transform = false;
  let jsx: string | undefined;
  const globals = new Set<string>();
  /** a module augmentation's specifier, and the name it declares: the definition they augment (see `augmenting`) */
  const augmentSpecs = new Map<ts_Node, Resolved>();
  const augmentNames = new Map<ts_Node, Resolved>();
  const ACCESSIBILITY = new Set([
    ts_SyntaxKind.PublicKeyword,
    ts_SyntaxKind.PrivateKeyword,
    ts_SyntaxKind.ProtectedKeyword,
    ts_SyntaxKind.ReadonlyKeyword,
    ts_SyntaxKind.OverrideKeyword,
  ]);

  /**
   * If `left` is a namespace import of a project module, what `left.right` names: one of its definitions, or what
   * it re-exports from a package.
   */
  const namespaceMember = (left: ts_Identifier, right: ts_Identifier): Resolved | undefined => {
    const sym = checker.getSymbolAtLocation(left);
    if (!sym || !(sym.flags & ts_SymbolFlags.Alias)) return undefined;
    const target = checker.getAliasedSymbol(sym);
    const decl = target.declarations?.[0];
    if (!decl || decl.kind !== ts_SyntaxKind.SourceFile || !ctx.projectFiles.has(decl.path))
      return undefined;
    let m = checker.getSymbolAtLocation(right);
    if (m && m.flags & ts_SymbolFlags.Alias) m = checker.getAliasedSymbol(m);
    const d = m?.declarations?.length ? pickDef(m.declarations, right) : undefined;
    if (d !== undefined) return { kind: "def", def: d };
    const ext = reexport(decl, right.text, right);
    if (ext) return { kind: "ext", ext };
    throw new TcmError(
      `'${left.text}.${right.text}' is not a top-level definition of ${ctx.fileName(decl.path)}`,
      right,
    );
  };

  /** What project module `file` (a source file's declaration) re-exports from a package as `name`. */
  const reexport = (file: ts_NodeHandle, name: string, at: ts_Node): ExtRef | undefined => {
    const sf = ctx.nodeOf(file);
    return sf && ts_isSourceFile(sf) ? ctx.classifyReexport(sf, name, at) : undefined;
  };

  /** A package's export, imported by `imp` through a project module that re-exports it. */
  const importedReexport = (
    imp: ts_ImportSpecifier | ts_ImportClause | ts_NamespaceImport,
    at: ts_Node,
  ): ExtRef | undefined => {
    if (ts_isNamespaceImport(imp)) return undefined;
    const decl = (
      ts_isImportSpecifier(imp) ? imp.parent.parent.parent : imp.parent
    ) as ts_ImportDeclaration;
    const mod = checker.getSymbolAtLocation(decl.moduleSpecifier)?.declarations?.[0];
    if (mod?.kind !== ts_SyntaxKind.SourceFile || !ctx.projectFiles.has(mod.path)) return undefined;
    return reexport(
      mod,
      ts_isImportSpecifier(imp) ? (imp.propertyName ?? imp.name).text : "default",
      at,
    );
  };

  /**
   * The package namespace `e` is, through a project module's `export * as ns from "pkg"`: `ns` imported from the
   * module, or `m.ns` through `import * as m` of it (in a type, too).
   */
  const reexportedNamespace = (e: ts_Node): ExtRef | undefined => {
    let ext: ExtRef | undefined;
    if (ts_isIdentifier(e)) {
      const d0 = checker.getSymbolAtLocation(e)?.declarations?.[0];
      const imp = d0?.kind === ts_SyntaxKind.ImportSpecifier ? ctx.nodeOf(d0) : undefined;
      if (imp && ts_isImportSpecifier(imp)) ext = importedReexport(imp, e);
    } else {
      const [l, r] = ts_isPropertyAccessExpression(e)
        ? [e.expression, e.name]
        : e.kind === ts_SyntaxKind.QualifiedName
          ? [(e as ts_QualifiedName).left, (e as ts_QualifiedName).right]
          : [];
      const m =
        l && r && ts_isIdentifier(l) && ts_isIdentifier(r) ? namespaceMember(l, r) : undefined;
      if (m?.kind === "ext") ext = m.ext;
    }
    return ext?.export === "*" ? ext : undefined;
  };

  /** The definition a symbol denotes here. A type and a value sharing a name are one merged symbol. */
  const pickDef = (decls: readonly ts_NodeHandle[], at: ts_Node): number | undefined => {
    const defs = [
      ...new Set(decls.map((d) => ctx.declToDef.get(declKey(d))).filter((d) => d !== undefined)),
    ];
    if (defs.length <= 1) return defs[0];
    const wantType = meaningAt(at) === "type";
    return defs.find((d) => (ctx.kindOf(d) === "type") === wantType) ?? defs[0];
  };

  /**
   * Whether one of a symbol's declarations is in this definition: in a module augmentation, one merging with the
   * augmented definition's (`interface Augment`) is declared first there, in its file or its generated module, and here
   * too.
   */
  const declaredHere = (decls: readonly ts_NodeHandle[]) =>
    decls.some((d) => {
      const node = d.path === sf.path ? ctx.nodeOf(d) : undefined;
      return !!node && site.nodes.some((n) => node.pos >= n.pos && node.end <= n.end);
    });

  const classifySymbol = (sym: ts_Symbol | undefined, at: ts_Node, text: string): Resolved => {
    if (!sym) return { kind: "raw", name: text };
    const decls = sym.declarations ?? [];
    const d0 = decls[0];
    if (!d0) return { kind: "global", name: text };
    if (
      d0.kind === ts_SyntaxKind.ImportSpecifier ||
      d0.kind === ts_SyntaxKind.ImportClause ||
      d0.kind === ts_SyntaxKind.NamespaceImport
    ) {
      // a name both imported and declared here (`import type { Stream }` beside `const Stream`) is what's declared
      // here, for the meaning that has
      const own = decls.filter(
        (d) =>
          d.kind !== ts_SyntaxKind.ImportSpecifier &&
          d.kind !== ts_SyntaxKind.ImportClause &&
          d.kind !== ts_SyntaxKind.NamespaceImport,
      );
      const ownDef = own.length ? pickDef(own, at) : undefined;
      if (ownDef !== undefined) {
        const k = ctx.kindOf(ownDef);
        if (k === "class" || (k === "type") === (meaningAt(at) === "type"))
          return { kind: "def", def: ownDef };
      }
      // imports between project files (directly or through re-exports) name a definition in the graph
      const target = checker.getAliasedSymbol(sym);
      const td = target.declarations?.[0];
      if (td && ctx.projectFiles.has(td.path)) {
        const def = pickDef(target.declarations!, at);
        if (def !== undefined) return { kind: "def", def };
        // a namespace import has no type: in a type, the name is the file's own type of that name (`interface Scope`
        // beside `import type * as Scope`), or else the global one (`Iterable<T>` beside `import * as Iterable`)
        if (
          td.kind === ts_SyntaxKind.SourceFile &&
          meaningAt(at) === "type" &&
          !(at.parent && ts_isQualifiedName(at.parent) && at.parent.left === at)
        ) {
          const own = decls.filter(
            (d) =>
              d.kind !== ts_SyntaxKind.ImportSpecifier &&
              d.kind !== ts_SyntaxKind.ImportClause &&
              d.kind !== ts_SyntaxKind.NamespaceImport,
          );
          if (!own.length) return { kind: "global", name: text };
          const ownDef = pickDef(own, at);
          if (ownDef !== undefined) return { kind: "def", def: ownDef };
        }
        throw new TcmError(
          td.kind === ts_SyntaxKind.SourceFile
            ? `'${text}' is a namespace import of a project module; import the definitions you use by name`
            : `'${text}' is not a top-level definition of ${ctx.fileName(td.path)}`,
          at,
        );
      }
      const imp = ctx.nodeOf(d0) as ts_ImportSpecifier | ts_ImportClause | ts_NamespaceImport;
      const ext = importedReexport(imp, at);
      if (ext) return { kind: "ext", ext };
      return ctx.classifyImport(imp, meaningAt(at));
    }
    if (ctx.projectFiles.has(d0.path)) {
      const def = pickDef(decls, at);
      if (def !== undefined) return { kind: "def", def };
      if (declaredHere(decls)) return { kind: "local", symbol: sym };
      throw new TcmError(`cannot resolve '${text}'`, at);
    }
    if (/\.d\.[cm]?ts$/.test(d0.path)) return { kind: "global", name: text };
    if (declaredHere(decls)) return { kind: "local", symbol: sym };
    throw new TcmError(
      `'${text}' refers to a declaration in ${d0.path}; import codebase definitions from '#codebase/...' instead of local files`,
      at,
    );
  };

  const refIR = (r: Resolved, at: ts_Node, text: string, shorthand?: string): IR => {
    switch (r.kind) {
      case "local": {
        let n = locals.get(r.symbol);
        if (n === undefined) {
          n = locals.size;
          locals.set(r.symbol, n);
        }
        reserved.add(text);
        return ["L", String(n)];
      }
      case "global":
        reserved.add(r.name);
        globals.add(r.name);
        return ["G", r.name];
      case "raw":
        reserved.add(r.name);
        globals.add(r.name);
        return ["U", r.name];
    }
    const pos: RefPos = site.kind === "type" || isTypePosition(at) ? "type" : "value";
    const strict = pos === "value" && !isDeferred(at, site.nodes);
    const base = {
      start: at.getStart(sf),
      end: at.end,
      pos,
      strict,
      ...(pos === "value" && isCalled(at) ? { called: true } : {}),
      shorthand,
      ...(isJsxTag(at) ? { element: true } : {}),
      ...(augmentSpecs.has(at) ? { literal: true } : {}),
      ...(augmentNames.has(at) ? { augmented: true } : {}),
    };
    switch (r.kind) {
      case "def":
        spans.push({ ...base, target: { def: r.def } });
        if (r.def !== site.id) edges.set(r.def, (edges.get(r.def) ?? false) || strict);
        if (r.def !== site.id && ctx.isLet(r.def) && isAssignmentTarget(at)) writes.add(r.def);
        return ["@def", String(r.def)];
      case "hash":
        spans.push({ ...base, target: { hash: r.hash } });
        return ["Ref", r.hash];
      case "ext":
        spans.push({ ...base, target: { ext: r.ext } });
        return ["Ext", r.ext.module, r.ext.subpath, r.ext.export];
    }
  };

  const atRoot = (node: ts_Node) => site.nodes.includes(node);
  const children = (node: ts_Node, out: IR[]) => {
    for (const [slot, c] of slots(node)) {
      if (!isList(c)) {
        if (!(atRoot(node) && isSkippedModifier(c))) out.push([slot, visit(c)]);
        continue;
      }
      // only the definition's own `export` is dropped: inside a namespace, `export` changes what the code means
      // and indentation between JSX tags, which React drops: `<a><b /></a>` hashes like its multi-line layout
      const kids = [...c].filter(
        (k) =>
          !(atRoot(node) && isSkippedModifier(k)) && !(ts_isJsxText(k) && jsxText(k.text) === ""),
      );
      // `export function f` and `function f` must hash the same: drop a modifier list emptied by stripping
      if (kids.length === 0 && slot === "modifiers") continue;
      out.push([slot, ["[", ...kids.map(visit)]]);
    }
  };

  /**
   * What a module augmentation (`declare module "./Schema" { namespace Annotations … }`) augments: the definition its
   * one declaration names, by the checker (in imported files, which merge them) or by its codebase path (in a scratch
   * file, `#codebase/effect/Schema`, whose re-export keeps it from merging). Its specifier and that name are references
   * to it, so neither hashes as written.
   */
  const augmenting = (md: ts_ModuleDeclaration) => {
    const spec = (md.name as ts_StringLiteral).text;
    const decls = md.body && ts_isModuleBlock(md.body) ? md.body.statements : [];
    const named = decls[0] as (ts_Node & { name?: ts_Node }) | undefined;
    if (decls.length !== 1 || !named?.name || !ts_isIdentifier(named.name))
      throw new TcmError("a `declare module` augments one definition: write one for each", md);
    const id = named.name;
    // a generated module's (`declare module "./<module>.ts" { namespace h_<hash> … }`, as tcm renders it): the hash
    const generated = /^\.\/[a-z2-7]+\.tsx?$/.test(spec) ? /^h_([a-z2-7]+)$/.exec(id.text) : null;
    let r: Resolved = generated
      ? { kind: "hash", hash: generated[1]! }
      : classifySymbol(checker.getSymbolAtLocation(id), id, id.text);
    if (r.kind !== "def" && r.kind !== "hash" && spec.startsWith("#codebase")) {
      const ns = spec.slice("#codebase".length).replace(/^\//, "").replaceAll("/", ".");
      const hash = ctx.resolvePath?.(ns ? `${ns}.${id.text}` : id.text);
      if (hash) r = { kind: "hash", hash };
    }
    if (r.kind !== "def" && r.kind !== "hash")
      throw new TcmError(
        `can't tell which definition \`declare module "${spec}"\` augments: '${id.text}' isn't one of that module's`,
        id,
      );
    augmentNames.set(id, r);
    augmentSpecs.set(md.name, r);
  };

  const visit = (node: ts_Node): IR => {
    const k = node.kind;
    if (
      k === ts_SyntaxKind.ModuleDeclaration &&
      site.tag === "Augment" &&
      ts_isStringLiteral((node as ts_ModuleDeclaration).name)
    )
      augmenting(node as ts_ModuleDeclaration);
    const transformable =
      k === ts_SyntaxKind.EnumDeclaration || k === ts_SyntaxKind.ModuleDeclaration;
    // a namespace holding only types is erasable, in a scratch file too
    const rejected =
      k === ts_SyntaxKind.ModuleDeclaration && !isInstantiated(node as ts_ModuleDeclaration)
        ? undefined
        : REJECTED[k];
    if (rejected && !(transformable && ctx.allowTransformed)) throw new TcmError(rejected, node);
    if (transformable) transform = true;
    if (
      ts_isParameter(node) &&
      ts_isConstructorDeclaration(node.parent) &&
      (getModifiers(node) ?? []).some((m) => ACCESSIBILITY.has(m.kind))
    )
      transform = true;

    switch (k) {
      case ts_SyntaxKind.Identifier: {
        const id = node as ts_Identifier;
        const augmented = augmentNames.get(id);
        if (augmented) return refIR(augmented, id, id.text);
        if (isStructuralName(id)) return ["N", id.text];
        // a namespace's own name is this definition's, though the symbol it shares with a value of that name (`function
        // Struct` beside `interface Struct` and `namespace Struct`) reads as the value where a declaration names it
        const own =
          !!id.parent &&
          ts_isModuleDeclaration(id.parent) &&
          id.parent.name === id &&
          site.declarations.includes(id.parent);
        const ref = refIR(
          own
            ? { kind: "def", def: site.id }
            : classifySymbol(checker.getSymbolAtLocation(id), id, id.text),
          id,
          id.text,
        );
        return isNamingPosition(id) ? ["Named", id.text, ref] : ref;
      }
      case ts_SyntaxKind.JsxText:
        return ["JsxText", jsxText((node as ts_JsxText).text)];
      case ts_SyntaxKind.JsxElement:
      case ts_SyntaxKind.JsxSelfClosingElement:
      case ts_SyntaxKind.JsxFragment:
        // elements compile to calls into a runtime package: pinned (and installed) like an import of it
        if (jsx === undefined) {
          const e = ctx.jsxRuntime(node);
          jsx = e.module;
        }
        break;
      case ts_SyntaxKind.PrivateIdentifier:
        return ["PN", (node as ts_PrivateIdentifier).text];
      case ts_SyntaxKind.StringLiteral: {
        const augmented = augmentSpecs.get(node);
        if (augmented)
          return ["AugmentOf", refIR(augmented, node, (node as ts_StringLiteral).text)];
        return ["Str", (node as ts_StringLiteral).text];
      }
      case ts_SyntaxKind.NoSubstitutionTemplateLiteral: {
        const t = node as ts_NoSubstitutionTemplateLiteral;
        return isTaggedTemplatePart(t) ? ["TStr", t.text, t.getText(sf)] : ["Str", t.text];
      }
      case ts_SyntaxKind.TemplateHead:
      case ts_SyntaxKind.TemplateMiddle:
      case ts_SyntaxKind.TemplateTail: {
        const t = node as ts_TemplateLiteralLikeNode & ts_Node;
        return isTaggedTemplatePart(t)
          ? [kindName[k]!, t.text, t.getText(sf)]
          : [kindName[k]!, t.text];
      }
      case ts_SyntaxKind.NumericLiteral:
        return ["Num", String(Number((node as ts_NumericLiteral).text.replaceAll("_", "")))];
      case ts_SyntaxKind.BigIntLiteral:
        return [
          "BigInt",
          BigInt((node as ts_BigIntLiteral).text.slice(0, -1).replaceAll("_", "")).toString(),
        ];
      case ts_SyntaxKind.RegularExpressionLiteral:
        return ["Re", (node as ts_RegularExpressionLiteral).text];
      case ts_SyntaxKind.ParenthesizedExpression: {
        // `(a?.b).c` differs from `a?.b.c`: parentheses around an optional chain count where the chain goes on after
        // them (format 2; `tcmts.core.migrate.dropUncontinuedParens`); every other paren is just grouping already
        // captured by the tree
        const inner = (node as ts_ParenthesizedExpression).expression;
        const parent = node.parent as
          | (ts_Node & { expression?: ts_Node; tag?: ts_Node })
          | undefined;
        const continued =
          !!parent &&
          (parent.expression === node || parent.tag === node) &&
          [
            ts_SyntaxKind.PropertyAccessExpression,
            ts_SyntaxKind.ElementAccessExpression,
            ts_SyntaxKind.CallExpression,
            ts_SyntaxKind.TaggedTemplateExpression,
            ts_SyntaxKind.NonNullExpression,
            ts_SyntaxKind.NewExpression,
          ].includes(parent.kind);
        if (!isOptionalChain(inner) || !continued) return visit(inner);
        break;
      }
      case ts_SyntaxKind.ParenthesizedType:
        return visit((node as ts_ParenthesizedTypeNode).type);
      case ts_SyntaxKind.ShorthandPropertyAssignment: {
        // `{ x }` hashes exactly like `{ x: x }` (and `{ x = d }` like `{ x: x = d }`): renaming what `x` refers to
        // expands the shorthand, and the expanded text must hash the same
        const s = node as ts_ShorthandPropertyAssignment;
        // a shorthand's name is always an identifier
        const name = s.name as ts_Identifier;
        const sym = checker.getShorthandAssignmentValueSymbol(s);
        const ref = refIR(classifySymbol(sym, name, name.text), name, name.text, name.text);
        const init = s.objectAssignmentInitializer;
        const value: IR = init
          ? [
              "BinaryExpression",
              ["left", isAnonymousFunction(init) ? ["Named", name.text, ref] : ref],
              ["operatorToken", ["EqualsToken"]],
              ["right", visit(init)],
            ]
          : ref;
        return ["PropertyAssignment", ["name", ["N", name.text]], ["initializer", value]];
      }
      case ts_SyntaxKind.BindingElement: {
        const b = node as ts_BindingElement;
        // `const { a } = o` binds a local *and* reads property `a`: keep the property name structural
        if (
          ts_isObjectBindingPattern(b.parent) &&
          !b.propertyName &&
          !b.dotDotDotToken &&
          b.name &&
          ts_isIdentifier(b.name)
        ) {
          // `{ a }` hashes exactly like `{ a: a }`; a top-level binding can be renamed, and the span remembers the
          // property so the splice writes `{ a: renamed }`
          const ref = refIR(
            classifySymbol(checker.getSymbolAtLocation(b.name), b.name, b.name.text),
            b.name,
            b.name.text,
            b.name.text,
          );
          const out: IR[] = [
            "BindingElement",
            ["propertyName", ["N", b.name.text]],
            ["name", isNamingPosition(b.name) ? ["Named", b.name.text, ref] : ref],
          ];
          if (b.initializer) out.push(["initializer", visit(b.initializer)]);
          return out;
        }
        break;
      }
      case ts_SyntaxKind.ElementAccessExpression: {
        const e = node as ts_ElementAccessExpression;
        const arg = e.argumentExpression;
        if (
          (ts_isStringLiteral(arg) || ts_isNoSubstitutionTemplateLiteral(arg)) &&
          IDENT_NAME.test(arg.text)
        ) {
          // same IR as the PropertyAccessExpression `a.b` would produce
          const out: IR[] = ["PropertyAccessExpression", ["expression", visit(e.expression)]];
          if (e.questionDotToken) out.push(["questionDotToken", ["QuestionDotToken"]]);
          out.push(["name", ["N", arg.text]]);
          return out;
        }
        break;
      }
      case ts_SyntaxKind.PropertyAccessExpression:
      case ts_SyntaxKind.QualifiedName: {
        // `ns.member` through `import * as ns from "./mod"` (or a re-exported `export * as ns`) is a reference to
        // that member: it hashes, and is spliced, exactly as if `member` had been imported by name
        const left = ts_isPropertyAccessExpression(node)
          ? node.expression
          : (node as ts_QualifiedName).left;
        const right = ts_isPropertyAccessExpression(node)
          ? node.name
          : (node as ts_QualifiedName).right;
        const member =
          ts_isIdentifier(left) && ts_isIdentifier(right)
            ? namespaceMember(left, right)
            : undefined;
        if (member) return refIR(member, node, right.text);
        // `ns.x` through a re-exported `export * as ns from "pkg"` is a reference to `pkg`'s `x`
        const ns = ts_isIdentifier(right) ? reexportedNamespace(left) : undefined;
        if (ns) return refIR({ kind: "ext", ext: { ...ns, export: right.text } }, node, right.text);
        break;
      }
      case ts_SyntaxKind.CallExpression: {
        const call = node as ts_CallExpression;
        if (call.expression.kind === ts_SyntaxKind.ImportKeyword) {
          const arg = call.arguments[0];
          if (
            call.arguments.length !== 1 ||
            !arg ||
            !(ts_isStringLiteral(arg) || ts_isNoSubstitutionTemplateLiteral(arg))
          ) {
            // a computed specifier loads code the codebase doesn't track (like `import.meta`, it depends on where
            // the module runs): allowed, hashed as written, and flagged
            ctx.warn(
              "`import()` with a computed specifier loads code the codebase doesn't track",
              node,
            );
            break;
          }
          // pinned like a static import; the generated code imports the pinned version
          const ext = ctx.classifyDynamicImport(arg.text, arg);
          spans.push({
            start: arg.getStart(sf),
            end: arg.end,
            pos: "value",
            strict: false,
            target: { ext },
            literal: true,
          });
          return ["DynImport", ext.module, ext.subpath];
        }
        break;
      }
    }

    const out: IR[] = [kindName[k] ?? `Kind${k}`];
    if (ts_isPrefixUnaryExpression(node) || ts_isPostfixUnaryExpression(node))
      out.push(ts_tokenToString(node.operator)!);
    else if (ts_isTypeOperatorNode(node)) out.push(ts_tokenToString(node.operator)!);
    else if (ts_isHeritageClause(node)) out.push(ts_tokenToString(node.token)!);
    else if (ts_isMetaProperty(node)) {
      out.push(ts_tokenToString(node.keywordToken)!);
      if (node.keywordToken === ts_SyntaxKind.ImportKeyword) {
        ctx.warn(
          `'${node.getText(sf)}' depends on where the module lives; in the codebase it is evaluated inside .tcm/cache`,
          node,
        );
      }
    } else if (ts_isVariableDeclarationList(node)) {
      const scope = node.flags & ts_NodeFlags.BlockScoped;
      out.push(
        scope === ts_NodeFlags.AwaitUsing
          ? "await using"
          : scope === ts_NodeFlags.Using
            ? "using"
            : scope === ts_NodeFlags.Const
              ? "const"
              : scope === ts_NodeFlags.Let
                ? "let"
                : "var",
      );
    }
    children(node, out);
    return out;
  };

  // the salt gives separately declared definitions separate identities, even when their code is identical
  const head: IR[] =
    site.salt === undefined ? ["Def", site.tag] : ["Def", site.tag, ["Salt", site.salt]];
  const body = site.nodes.map(visit);
  const ir: IR = [...head, ...(jsx ? [["Jsx", jsx] as IR] : []), ...body];
  if (site.tag === "Enum" || site.tag === "Namespace") transform = true;
  return { ir, spans, reserved, edges, transform, globals, writes, ...(jsx ? { jsx } : {}) };
}

uses

used by