diffNamespaces

tcmts.cli.diff.diffNamespaces · #5t5wcggz · term · runs anywhere

import { substRefs } from "#codebase/tcmts/cli/diff";
import type { Change, NamespaceDiff, NameState } from "#codebase/tcmts/cli/diff";
import type { IR } from "#codebase/tcmts/core/hash";

export function diffNamespaces(
  before: NameState,
  after: NameState,
  irOf: (hash: string) => string,
  /** the text versions each side's definitions show, to tell which read differently */
  texts?: { before: ReadonlyMap<string, string>; after: ReadonlyMap<string, string> },
): NamespaceDiff {
  const d: NamespaceDiff = {
    edited: [],
    propagated: [],
    added: [],
    removed: [],
    renamed: [],
    aliased: [],
    conflicted: [],
    reworded: [],
  };
  const changed: Change[] = [];
  for (const path of [...new Set([...before.keys(), ...after.keys()])].sort()) {
    const o = before.get(path) ?? new Set<string>();
    const n = after.get(path) ?? new Set<string>();
    if (o.size === n.size && [...o].every((h) => n.has(h))) {
      const [h] = n;
      const [was, now] = [texts?.before.get(h!), texts?.after.get(h!)];
      if (n.size === 1 && was !== now)
        d.reworded.push({
          path,
          hash: h!,
          ...(was ? { old: was } : {}),
          ...(now ? { new: now } : {}),
        });
      continue;
    }
    if (n.size > 1) d.conflicted.push({ path, hashes: [...n] });
    else if (o.size === 0) d.added.push({ path, hash: [...n][0]! });
    else if (n.size === 0) for (const h of o) d.removed.push({ path, hash: h });
    else for (const h of o) changed.push({ path, old: h, new: [...n][0]! });
  }

  // a hash that left one name and arrived at another was renamed
  // (a copy: the loop takes from it)
  for (const a of d.added.slice()) {
    const r = d.removed.find((x) => x.hash === a.hash);
    if (!r) continue;
    d.renamed.push({ from: r.path, to: a.path, hash: a.hash });
    d.added.splice(d.added.indexOf(a), 1);
    d.removed.splice(d.removed.indexOf(r), 1);
  }
  // a hash that gained a name while keeping an old one was aliased
  const pathsBefore = new Map<string, string[]>();
  for (const [p, hs] of before)
    for (const h of hs) pathsBefore.set(h, [...(pathsBefore.get(h) ?? []), p]);
  // (a copy: the loop takes from it)
  for (const a of d.added.slice()) {
    const existing = (pathsBefore.get(a.hash) ?? []).filter((p) => after.get(p)?.has(a.hash));
    if (!existing.length) continue;
    d.aliased.push({ ...a, existing });
    d.added.splice(d.added.indexOf(a), 1);
  }

  // A definition is a pure propagation if rewriting its references through this diff's own
  // old → new pairs reproduces the new definition exactly.
  const remap = new Map(changed.map((c) => [c.old, c.new]));
  for (const c of changed) {
    const hit = new Set<string>();
    const rewritten = JSON.stringify(substRefs(JSON.parse(irOf(c.old)) as IR, remap, hit));
    if (hit.size && rewritten === irOf(c.new))
      d.propagated.push({ ...c, because: [...hit].map((h) => remap.get(h)!) });
    else d.edited.push(c);
  }
  return d;
}

uses

used by