Codebase
tcmts.cli.codebase.Codebase · #bxhxbixc · class
import { checkerPool } from "#codebase/tcmts/cli/checkers";
import {
augmentationsIn, fileProblem, namedCodebase, registerTransformHook, sessionDirFor, setUpAgents, transformHookSource,
} from "#codebase/tcmts/cli/codebase";
import type { LockEntry, Rehashed, SaltFor } from "#codebase/tcmts/cli/codebase";
import { notFound } from "#codebase/tcmts/cli/help";
import { keepValues, keptValue } from "#codebase/tcmts/cli/kept";
import { librariesIn, libraryGuard, within } from "#codebase/tcmts/cli/libraries";
import { syncMirror } from "#codebase/tcmts/cli/mirror";
import type { Mirror } from "#codebase/tcmts/cli/mirror";
import { providePackages } from "#codebase/tcmts/cli/provided";
import { REACT_CACHE } from "#codebase/tcmts/cli/react_api";
import { codebaseRoles } from "#codebase/tcmts/cli/roles";
import { serverHostFiles } from "#codebase/tcmts/cli/server_api";
import { srcDir, viewOf, writeViews } from "#codebase/tcmts/cli/views";
import { analyzeFile, analyzeProject, saltOf, scratchNamespace } from "#codebase/tcmts/core/analyze";
import type { Analysis, AnalyzeHost, ProjectAnalysis, ProjectDef } from "#codebase/tcmts/core/analyze";
import { hashIR, IR_VERSION, rehashComponent, shortHash } from "#codebase/tcmts/core/hash";
import type { IR } from "#codebase/tcmts/core/hash";
import {
incrementalCompiler, NODE_TYPES_RANGE, projectCompilerOptions, tcmNodeTypes, transpileModule, TS_VERSION,
useNodeTypes,
} from "#codebase/tcmts/core/program";
import type { CompilerOptions } from "#codebase/tcmts/core/program";
import { compareVersions, satisfies as satisfies_ } from "#codebase/tcmts/core/semver";
import { isConflicted } from "#codebase/tcmts/core/types";
import type { DefRecord } from "#codebase/tcmts/core/types";
import {
augmentationRoot, externalsOf, layout, materialize, moduleFile, render, writeIfChanged, writeShim,
} from "#codebase/tcmts/loader";
import type { DefSource, Layout } from "#codebase/tcmts/loader";
import {
ensureExternals, lockAtLeast, npmAlias, parseModule, shippedPins, usePackageLock,
} from "#codebase/tcmts/loader/packages";
import { Store } from "#codebase/tcmts/store";
import { createHash } from "node:crypto";
import fs from "node:fs";
import path_1 from "node:path";
export class Codebase {
readonly lay: Layout;
readonly store: Store;
/** a session's own directory (`TCM_BRANCH`): its scratch files, `#codebase` shim and views */
readonly sessionDir: string | undefined;
/** scratch files are checked over and over (the watcher): unchanged files aren't parsed again */
private compile = incrementalCompiler();
private constructor(root: string) {
const session = process.env.TCM_BRANCH;
this.sessionDir = session ? sessionDirFor(root, session) : undefined;
this.lay = this.sessionDir
? { ...layout(root), shimDir: path_1.join(this.sessionDir, "codebase") }
: layout(root);
this.store = new Store(this.lay.dbFile);
// a library's names change only through `tcm lib`
this.store.guard = libraryGuard(this.store);
// a codebase `tcm init` made before the server library named #tcm/server in its imports: it's #tcm/host now (the
// server API is the server library, mounted with `tcm lib add tcm server`), which sessions copy
const rootPkgFile = path_1.join(root, "package.json");
try {
const pkg = JSON.parse(fs.readFileSync(rootPkgFile, "utf8")) as {
imports?: Record<string, string>;
};
if (pkg.imports?.["#tcm/server"] === "./.tcm/cache/tcm-server.js") {
delete pkg.imports["#tcm/server"];
pkg.imports["#tcm/host"] = "./.tcm/cache/tcm-host.js";
fs.writeFileSync(rootPkgFile, JSON.stringify(pkg, null, 2) + "\n");
}
} catch {
// (none, or one that isn't JSON: someone's own, left as it is)
}
// a session's scratch files resolve `#codebase` to its own shim, and the rest as the codebase's do
if (this.sessionDir) {
fs.mkdirSync(this.sessionDir, { recursive: true });
const rootPkg = path_1.join(root, "package.json");
const imports = fs.existsSync(rootPkg)
? ((JSON.parse(fs.readFileSync(rootPkg, "utf8")) as { imports?: Record<string, string> })
.imports ?? {})
: {};
const up = path_1.relative(this.sessionDir, root).split(path_1.sep).join("/");
// an import map names files of its own package only (`./…`): one of the codebase's (`#tcm/host`) is a module
// here that re-exports it, its types included
const stubs = path_1.join(this.sessionDir, "imports");
const local = Object.fromEntries(
Object.entries(imports).map(([k, v]) => {
if (!v.startsWith("./") || v.includes("*") || !/\.[cm]?js$/.test(v))
return [k, v.startsWith("./") ? `${up}/${v.slice(2)}` : v];
const from = path_1.relative(stubs, path_1.join(root, v)).split(path_1.sep).join("/");
const name = `${k.replace(/[^\w.-]+/g, "_")}${path_1.extname(v)}`;
fs.mkdirSync(stubs, { recursive: true });
writeIfChanged(path_1.join(stubs, name), `export * from ${JSON.stringify(from)};\n`);
writeIfChanged(
path_1.join(stubs, name.replace(/\.([cm]?)js$/, ".d.$1ts")),
`export * from ${JSON.stringify(from)};\n`,
);
return [k, `./imports/${name}`];
}),
);
const pkg = {
private: true,
type: "module",
imports: { ...local, "#codebase": "./codebase/index.ts", "#codebase/*": "./codebase/*.ts" },
};
writeIfChanged(
path_1.join(this.sessionDir, "package.json"),
JSON.stringify(pkg, null, 2) + "\n",
);
writeIfChanged(
path_1.join(this.sessionDir, "tsconfig.json"),
JSON.stringify({ extends: `${up}/tsconfig.json`, include: ["*.ts", "*.tsx"] }, null, 2) +
"\n",
);
}
// the versions generated modules run with
usePackageLock(this.lay, {
get: (name, range) => this.store.lockedVersion(name, range),
set: (name, range, version, integrity) =>
this.store.lockVersion(name, range, version, integrity),
integrity: (name, range) => this.store.lockedIntegrity(name, range),
all: () => this.store.lockedPackages(),
});
// and the ranges it chose for packages tcm ships (`tcm upgrade vitest@<range>`), which are installed, not tcm's
shippedPins.set(this.lay.cacheDir, (name) => this.store.getSetting(`tool.${name}`));
// tcm checks code against its own @types/node; running from a codebase with none installed near it, the
// codebase provides it like any package
if (!tcmNodeTypes()) {
const module = `npm:@types/node@${NODE_TYPES_RANGE}`;
ensureExternals(this.lay, [{ module, subpath: "", export: "*" }]);
useNodeTypes(path_1.join(this.lay.cacheDir, "node_modules", npmAlias(module)));
}
// for what runs from the codebase (tests, watch expressions), which may be far from tcm's own copy
writeIfChanged(
path_1.join(this.lay.cacheDir, "node-types.json"),
JSON.stringify(tcmNodeTypes()) + "\n",
);
// a codebase (or the tcm it was made with) that moved names a @types/node that isn't there any more, where
// `tcm init` wrote it: point that at this tcm's (type roots it doesn't recognize are left alone)
const tsconfig = path_1.join(root, "tsconfig.json");
const types = tcmNodeTypes();
if (types && fs.existsSync(tsconfig)) {
try {
const cfg = JSON.parse(fs.readFileSync(tsconfig, "utf8")) as {
compilerOptions?: { typeRoots?: string[]; types?: string[] };
};
const roots = cfg.compilerOptions?.typeRoots;
if (
cfg.compilerOptions &&
roots?.length &&
roots.every((r) => path_1.isAbsolute(r) && !fs.existsSync(r))
) {
cfg.compilerOptions.typeRoots = types.typeRoots;
cfg.compilerOptions.types = types.types;
fs.writeFileSync(tsconfig, JSON.stringify(cfg, null, 2) + "\n");
}
} catch {
// (one with comments in it is someone's own: left as it is)
}
}
// `tcm init` named tcm's own modules in package.json's imports: only the ones this tcm has (`#tcm/react` is the
// react library now, mounted with `tcm lib add tcm react`)
const pkgFile = path_1.join(root, "package.json");
try {
const pkg = JSON.parse(fs.readFileSync(pkgFile, "utf8")) as {
imports?: Record<string, string>;
};
if (pkg.imports?.["#tcm/react"] === "./.tcm/cache/tcm-react.js") {
delete pkg.imports["#tcm/react"];
fs.writeFileSync(pkgFile, JSON.stringify(pkg, null, 2) + "\n");
}
} catch {
// (none, or one that isn't JSON: someone's own, left as it is)
}
// the #tcm/host runtime every generated module and runner in this codebase shares
const host = serverHostFiles();
fs.mkdirSync(this.lay.cacheDir, { recursive: true });
writeIfChanged(path_1.join(this.lay.cacheDir, "tcm-host.js"), host.js);
writeIfChanged(path_1.join(this.lay.cacheDir, "tcm-host.d.ts"), host.dts);
// and React's cache and cacheSignal, as the server code tcm runs has them (`loader.render` points at it)
writeIfChanged(path_1.join(this.lay.cacheDir, "tcm-cache.js"), REACT_CACHE);
// imported code may use syntax Node can't strip: such modules are compiled on load, here and in runners
// the compilation (with its source maps) is tcm's own, loaded from where tcm runs
writeIfChanged(
path_1.join(this.lay.cacheDir, "tcm-transform.js"),
transformHookSource(transpileModule()),
);
registerTransformHook();
// hashes in another format aren't comparable with this tcm's: refuse rather than mix them
const versions = this.store.irVersions().filter((v) => v !== IR_VERSION);
if (versions.length) {
this.store.close();
throw new Error(
versions.some((v) => v > IR_VERSION)
? `${root} was written with hash format v${versions.join(", v")}, newer than this tcm's (v${IR_VERSION}): use a newer tcm`
: `${root} was written with hash format v${versions.join(", v")}; this tcm uses v${IR_VERSION}: \`tcm migrate\` moves it (history and all, keeping it as it was beside it)`,
);
}
}
static find(cwd: string): Codebase {
return new Codebase(Codebase.findRoot(cwd));
}
/** The codebase in `root`, exactly (not the one `TCM_CODEBASE` names): for code that says which it means. */
static open(root: string): Codebase {
if (!fs.existsSync(path_1.join(root, ".tcm", "codebase.sqlite")))
throw new Error(`no tcm codebase in ${root}`);
return new Codebase(root);
}
/** The nearest directory at or above `cwd` holding a codebase, or the one `TCM_CODEBASE` names. */
static findRoot(cwd: string): string {
if (namedCodebase) return namedCodebase;
for (let dir = cwd; ; dir = path_1.dirname(dir)) {
if (fs.existsSync(path_1.join(dir, ".tcm", "codebase.sqlite"))) return dir;
if (path_1.dirname(dir) === dir)
throw new Error("no tcm codebase here; run `tcm init` first");
}
}
/** The cache module of a component: `.tsx` when it uses JSX. */
modulePath(componentHash: string): string {
return path_1.join(
this.lay.cacheDir,
moduleFile(componentHash, { members: (c) => this.store.componentMembers(c) }),
);
}
static init(root: string): Codebase {
fs.mkdirSync(path_1.join(root, ".tcm", "cache"), { recursive: true });
fs.writeFileSync(
path_1.join(root, ".tcm", ".gitignore"),
"# derived from codebase.sqlite\ncache/\ncodebase/\nsrc/\nweb/\nbuild/\napps/\nexamples/\ntest/\nlint/\nfmt/\npack/\ncompiled/\npackages.json\n# app data and secrets: never commit these\ndata/\nsecret\ntls/\n",
);
const pkgFile = path_1.join(root, "package.json");
const pkg = fs.existsSync(pkgFile)
? JSON.parse(fs.readFileSync(pkgFile, "utf8"))
: {
name: path_1
.basename(root)
.toLowerCase()
.replace(/[^a-z0-9-]/g, "-"),
private: true,
};
pkg.type = "module";
pkg.imports = {
...pkg.imports,
"#codebase": "./.tcm/codebase/index.ts",
"#codebase/*": "./.tcm/codebase/*.ts",
"#tcm/host": "./.tcm/cache/tcm-host.js",
};
fs.writeFileSync(pkgFile, JSON.stringify(pkg, null, 2) + "\n");
// opened first: where @types/node is comes from it (see the constructor)
const cb = new Codebase(root);
const tsconfig = path_1.join(root, "tsconfig.json");
if (!fs.existsSync(tsconfig)) {
const options = {
target: "esnext",
// one codebase holds server and browser code; where each definition runs is inferred (see placement.ts)
lib: ["esnext", "dom"],
module: "nodenext",
moduleResolution: "nodenext",
strict: true,
noEmit: true,
erasableSyntaxOnly: true,
verbatimModuleSyntax: true,
allowImportingTsExtensions: true,
jsx: "react-jsx",
skipLibCheck: true,
...tcmNodeTypes(),
};
fs.writeFileSync(
tsconfig,
JSON.stringify({ compilerOptions: options, include: ["*.ts", "*.tsx"] }, null, 2) + "\n",
);
}
setUpAgents(root);
cb.refreshShim(true);
return cb;
}
/** Definitions from the store, overlaid with in-memory ones (scratch definitions not added yet). */
source(overlay: DefRecord[] = [], nameAt?: (hash: string) => string | undefined): DefSource {
const byHash = new Map(overlay.map((d) => [d.hash, d]));
const byComp = new Map<string, DefRecord[]>();
for (const d of overlay)
byComp.set(d.componentHash, [...(byComp.get(d.componentHash) ?? []), d]);
const views = new Map<string, { file: string; line: number; inserts?: number[] } | undefined>();
const src: DefSource = {
get: (h) => byHash.get(h) ?? this.store.getDef(h),
members: (c) =>
(byComp.get(c) ?? this.store.componentMembers(c)).sort(
(a, b) => a.componentIndex - b.componentIndex,
),
// (names as of another branch or history entry, if asked: a diff reads each side with its own)
nameOf: nameAt ?? ((h) => this.store.pathsOf(h)[0]),
// source maps lead here: the definition's view file, or the scratch file it's still in
viewOf: (h) => viewOf(this, byHash.get(h) ?? this.store.getDef(h), views),
views: srcDir(this),
// pure constants, evaluated once: what they're worth, rather than computed every time
kept: (h) => keptValue(this, byHash.get(h) ?? this.store.getDef(h)),
keep: (hs) => keepValues(this, hs, src),
};
return src;
}
/** whether name changes are written back to the mirror (not while the mirror itself is being imported) */
syncsMirror = true;
/**
* Import the mirror's files (`tcm import <dir> --update`): how a rename of a function or class is finished. Its
* name is part of its hash (code can see it as `.name`), so in a file the rename is a change to the code, and the
* codebase takes the renamed code. Set by the CLI, which owns importing.
*/
adoptMirror?: (m: Mirror) => boolean;
/** Keep `.tcm/codebase` (the `#codebase` import shim) and the mirror, if there is one, in sync with the branch's names. */
refreshShim(force = false) {
// another branch, only read (`--branch`): they're this process's branch's, and stay as they are
if (this.store.reading !== undefined) return;
const stamp = `${this.store.branch().name}:${this.store.logHead()}`;
// (each session has its own shim)
const key = this.sessionDir ? `shim_stamp:${this.store.branch().name}` : "shim_stamp";
const was = this.store.getSetting(key);
const there = fs.existsSync(path_1.join(this.lay.shimDir, "index.ts"));
if (!force && was === stamp && there) return;
// written on this branch before: only the namespaces whose names changed since are written again (what the name
// log says), not every one, with every module checked
const at = was?.lastIndexOf(":") ?? -1;
const since =
!force && there && was && was.slice(0, at) === this.store.branch().name
? Number(was.slice(at + 1))
: NaN;
const changed = Number.isInteger(since)
? new Set(this.store.pathsChangedSince(since))
: undefined;
const only =
changed &&
new Set([...changed].map((p) => (p.includes(".") ? p.slice(0, p.lastIndexOf(".")) : "")));
// code imports files too: they're values
const names = this.store.names(undefined, true);
writeShim(names, this.source(), this.lay, only);
// terms named now that came without a type shape (received: receiving leaves them), worked out, so their
// signatures name types as they're called now (`shownSignature`) and searches by type find them
this.fillShapesQuietly(names.filter((n) => !changed || changed.has(n.path)).map((n) => n.hash));
// and the views (definitions as files, for editors and debuggers): the branch's own, a session's in its directory
writeViews(this, force);
this.store.setSetting(key, stamp);
if (this.syncsMirror) syncMirror(this);
}
private optionsCache = new Map<string, CompilerOptions>();
private warned = new Set<string>();
/**
* Compiler options from the tsconfig.json nearest `dir` (the project root's by default), with the codebase's
* augmentations in `types`, all but those `without` leaves out (a scratch file holds new versions of them).
*/
compilerOptions(
dir = this.lay.root,
mode: "scratch" | "import" = "scratch",
without?: (path: string) => boolean,
): CompilerOptions {
let tsconfig: string | undefined;
for (let d = path_1.resolve(dir); ; d = path_1.dirname(d)) {
if (fs.existsSync(path_1.join(d, "tsconfig.json"))) {
tsconfig = path_1.join(d, "tsconfig.json");
break;
}
if (d === this.lay.root || path_1.dirname(d) === d) break;
}
const key = `${mode} ${tsconfig ?? ""}`;
if (!this.optionsCache.has(key)) {
const { options, warnings } = projectCompilerOptions(tsconfig, mode);
for (const w of warnings)
if (!this.warned.has(w)) {
this.warned.add(w);
console.error(`warning: ${w}`);
}
this.optionsCache.set(key, options);
}
const options = this.optionsCache.get(key)!;
// each augmentation is a type package of its own (see `writeShim`), so a check can leave some out
const root = augmentationRoot(this.lay);
const augmentations = fs.existsSync(root)
? fs.readdirSync(root).filter((p) => !without?.(p))
: [];
if (!augmentations.length) return options;
return {
...options,
typeRoots: [...(options.typeRoots ?? []), root],
// (a project listing no types gets every package in its type roots, these too)
...(options.types ? { types: [...options.types, ...augmentations] } : {}),
};
}
private host(
ignoreDiagnostics: number[],
saltFor: SaltFor | undefined,
compilerOptions = this.compilerOptions(),
wasTable?: (file: string, name: string) => boolean,
): AnalyzeHost {
return {
cacheDir: this.lay.cacheDir,
ignoreDiagnostics,
saltFor,
compilerOptions,
roleOf: (hash) => codebaseRoles(this).get(hash),
...(wasTable ? { wasTable } : {}),
resolveName: (name, meaning) => {
const hs = this.store.hashesAt(name, meaning === "type" ? "type" : "term");
if (hs.length === 0) throw new Error(`'${name}' is not in the codebase`);
if (hs.length > 1)
throw new Error(
`'${name}' is conflicted (${hs.length} definitions); resolve it first (\`tcm todo\`)`,
);
return hs[0]!;
},
};
}
/** `overlay`: unsaved text for files (an editor's buffers), used instead of what's on disk */
analyze(
file: string,
saltFor: SaltFor | undefined,
ignoreDiagnostics: number[] = [],
overlay?: ReadonlyMap<string, string>,
): Analysis {
// packages the file imports that the project doesn't install: tcm provides them
const text = overlay?.get(file) ?? fs.readFileSync(file, "utf8");
const provided = providePackages(this, file, text, this.compilerOptions());
// a provided package with no types anywhere (most ESLint plugins, say) is typed as any, with a warning: there's
// no @types to install, tcm already looked
const untyped = new Set<string>();
const redefined = augmentationsIn(text);
const a = analyzeFile(file, {
...this.host(
ignoreDiagnostics,
saltFor,
this.compilerOptions(this.lay.root, "scratch", (p) => redefined.has(p)),
),
compile: (files, options) => this.compile(files, options, overlay, provided.resolver),
packageDir: (name) => provided.dirs.get(name),
skipDiagnostic: (d) => {
if (d.code !== 7016) return false;
const m = /module '([^']+)'/.exec(d.text);
if (!m || !provided.dirs.has(m[1]!)) return false;
untyped.add(m[1]!);
return true;
},
});
for (const name of untyped)
a.warnings.push(`${name} has no types (neither its own nor @types), so it's typed as any`);
return a;
}
analyzeProject(
files: string[],
saltFor: SaltFor | undefined,
ignoreDiagnostics: number[] = [],
compilerOptions?: CompilerOptions,
typecheck = true,
shapes = typecheck,
wasTable?: (file: string, name: string) => boolean,
): ProjectAnalysis {
return analyzeProject(files, {
...this.host(ignoreDiagnostics, saltFor, compilerOptions, wasTable),
typecheck,
shapes,
});
}
/**
* The lineage salt for a definition about to be bound at `path`: whatever already lives there keeps its
* identity (so updates and re-adds reproduce hashes), and anything new is identified by where it was first added.
*/
lineageSalt(path: string): string {
for (const h of this.store.hashesAt(path)) {
const salt = saltOf(this.store.getDef(h)!.ir);
if (salt !== undefined) return salt;
}
return path;
}
/** Salts for a scratch file whose definitions go into `into` (or the file's namespace pragma). */
scratchSalts(into?: string): SaltFor {
return (file, name, targetPath) => {
if (targetPath) return this.lineageSalt(targetPath);
const ns = into ?? scratchNamespace(fs.readFileSync(file, "utf8")) ?? "";
return this.lineageSalt(ns ? `${ns}.${name}` : name);
};
}
/**
* Options for re-checking definitions: import-mode resolution (rendered modules import by hash, and may use syntax
* only imported code can) plus the type-checking profile the definitions were first checked under.
*/
recheckOptions(profile: Record<string, unknown> | undefined): CompilerOptions {
return {
...this.compilerOptions(this.lay.root, "import"),
...profile,
} as CompilerOptions;
}
/** Salts for re-analyzing rendered definitions: each keeps the salt it was stored with. */
private storedSalts(byFile: Map<string, Map<string, DefRecord>>): SaltFor {
return (file, name) => {
const rec = byFile.get(file)?.get(name);
return rec && saltOf(rec.ir);
};
}
/** For re-analyzing rendered definitions: each that was a table stays one (there are no names to read roles from). */
private storedTables(byFile: Map<string, Map<string, DefRecord>>) {
return (file: string, name: string) => !!byFile.get(file)?.get(name)?.table;
}
/**
* Re-render every live definition from the store and re-hash it. Rendering is supposed to be the inverse of
* hashing, so any mismatch means stored source, splicing, or canonicalization lost information.
*
* A component this same tcm verified already, rendered with the same names, isn't checked again (`fresh` checks
* it anyway): what it renders to and how that hashes follow from its hash, the names it's rendered with, and the
* code doing it, whose own hash covers everything it uses.
*/
verify(fresh = false): {
checked: number;
already: number;
mismatches: { name: string; stored: string; rehashed: string }[];
problems: string[];
} {
const src = this.source();
const live = this.liveHashes();
// a file kept as written hashes from its content, not as a module
const files = [...live].filter((h) => src.get(h)!.kind === "file");
const comps = new Set(
[...live].filter((h) => src.get(h)!.kind !== "file").map((h) => src.get(h)!.componentHash),
);
// (run from a scratch file rather than a build, the code doing this has no hash: everything is checked)
const verifier = (Codebase as unknown as Record<symbol, string | undefined>)[
Symbol.for("tcm.hash")
];
const keyOf = (c: string) => {
const members = src.members(c);
const names = members
.flatMap((m) => [m.hash, ...m.spans.flatMap((s) => (s.hash ? [s.hash] : []))])
.map((h) => `${h}=${src.nameOf(h) ?? ""}`);
return createHash("sha256")
.update(
JSON.stringify([
verifier,
process.version,
c,
members[0]?.checked ?? null,
names,
// (and the text it's rendered from, which can be another version on another branch)
members.map((m) => m.sourceText),
]),
)
.digest("hex")
.slice(0, 32);
};
const keys = new Map(verifier ? [...comps].map((c) => [c, keyOf(c)]) : []);
const done = new Set(
fresh ? [] : (JSON.parse(this.store.getSetting("verified") ?? "[]") as string[]),
);
const todo = new Set([...comps].filter((c) => !done.has(keys.get(c) ?? "")));
const r = todo.size
? this.rehash(src, todo)
: { defs: new Map<string, DefRecord>(), mismatches: [], problems: [] };
const mismatches = r.mismatches.map((m) => ({ name: this.displayRef(`#${m.stored}`), ...m }));
for (const h of files) {
const rehashed = hashIR(JSON.parse(src.get(h)!.ir) as IR);
if (rehashed !== h) mismatches.push({ name: this.displayRef(`#${h}`), stored: h, rehashed });
}
// what passed, for next time (only what's live now, so the list doesn't grow)
const passed =
r.problems.length || mismatches.length ? [...comps].filter((c) => !todo.has(c)) : [...comps];
if (verifier)
this.store.setSetting("verified", JSON.stringify(passed.map((c) => keys.get(c)!)));
const count = (cs: Iterable<string>) => [...cs].reduce((n, c) => n + src.members(c).length, 0);
return {
checked: count(comps) + files.length,
already: count(comps) - count(todo),
mismatches,
problems: r.problems,
};
}
/**
* Render components from `src` the way they will be materialized, then typecheck (unless not `typecheck`) and hash
* the result, working out type shapes if `shapes`. Returns the freshly analyzed definition for each stored hash.
*/
private rehash(
src: DefSource,
comps: Set<string>,
typecheck = true,
shapes = typecheck,
): Rehashed {
const job = this.rehashJob(src, comps, typecheck);
try {
return job.collect(
job.parts(1).map((p) => {
// untyped npm packages are fine: types were checked by whoever added the definition
const a = this.analyzeProject(
p.files,
job.saltFor,
[7016],
p.options,
typecheck,
shapes,
job.wasTable,
);
try {
return {
problems: [...a.errors, ...a.diagnostics.map((d) => d.formatted)],
defs: a.defs,
};
} finally {
// (its compilation goes now, not when the process ends: a sync checks one batch after another, each a
// compilation of thousands of modules)
a.dispose?.();
}
}),
);
} finally {
job.cleanup();
}
}
/**
* `rehash`, its analysis shared among checker processes ({@link checkerPool}) where there are some and enough to
* share: each gets a run of the components (in the order given, so what's used together goes together) to compile
* and analyze, at once. The same definitions come of it either way.
*/
private async rehashInParallel(
src: DefSource,
comps: Set<string>,
typecheck = true,
): Promise<Rehashed> {
const pool = comps.size >= 64 ? checkerPool() : undefined;
if (!pool) return this.rehash(src, comps, typecheck);
const job = this.rehashJob(src, comps, typecheck);
const parts = job.parts(pool.size);
try {
const found = job.collect(
await Promise.all(
parts.map((p) =>
pool.check({
files: p.files,
cacheDir: this.lay.cacheDir,
salts: job.saltsOf(p.files),
tables: job.tablesOf(p.files),
ignore: [7016],
options: p.options,
typecheck,
shapes: typecheck,
}),
),
),
);
return { ...found, checkers: Math.min(pool.size, parts.length) };
} finally {
job.cleanup();
}
}
/**
* What `rehash` analyzes: components rendered as modules (and what they use, materialized), in groups checked
* under the same options (`parts`, each cut into up to `n` runs); `collect` reads what analyzing them found against
* the hashes they're stored under. Not to `typecheck`, a module of what they use that's there already is taken as
* it is, and what it imports isn't visited: deriving their IR takes the modules they import, not what those use.
*/
private rehashJob(src: DefSource, comps: Set<string>, typecheck = true) {
materialize(
[...comps].flatMap((c) => src.members(c).map((m) => m.hash)),
src,
this.lay,
typecheck ? {} : { trustExisting: true },
);
const expected = new Map<string, Map<string, string>>();
const records = new Map<string, Map<string, DefRecord>>();
// definitions are re-checked under the options they were first checked with: group them by that profile
const groups = new Map<string, string[]>();
for (const c of comps) {
const members = src.members(c);
const r = render(members, src, this.lay, { style: "internal" });
// (this process's own: another verifying the same codebase at once, a session's, writes and removes its own)
const file = path_1.join(this.lay.cacheDir, `_verify_${process.pid}_${moduleFile(c, src)}`);
fs.writeFileSync(file, r.text);
expected.set(file, new Map([...r.memberNames].map(([h, n]) => [n, h])));
records.set(file, new Map([...r.memberNames].map(([h, n]) => [n, src.get(h)!])));
const key = JSON.stringify(members[0]?.checked ?? {});
groups.set(key, [...(groups.get(key) ?? []), file]);
}
return {
saltFor: this.storedSalts(records),
wasTable: this.storedTables(records),
tablesOf: (files: string[]) =>
Object.fromEntries(
files.map((f) => [
f,
[...(records.get(f) ?? [])].filter(([, rec]) => rec.table).map(([name]) => name),
]),
),
saltsOf: (files: string[]) =>
Object.fromEntries(
files.map((f) => [
f,
Object.fromEntries(
[...(records.get(f) ?? [])].flatMap(([name, rec]) => {
const salt = saltOf(rec.ir);
return salt === undefined ? [] : [[name, salt]];
}),
),
]),
),
parts: (n: number) =>
[...groups].flatMap(([profile, files]) => {
const options = this.recheckOptions(JSON.parse(profile));
const size = Math.ceil(files.length / Math.max(1, n));
return Array.from({ length: Math.ceil(files.length / size) }, (_, i) => ({
files: files.slice(i * size, i * size + size),
options,
}));
}),
collect: (found: { problems: string[]; defs: ProjectDef[] }[]): Rehashed => {
const problems: string[] = [];
const defs = new Map<string, DefRecord>();
const mismatches: { stored: string; rehashed: string }[] = [];
for (const a of found) {
problems.push(...a.problems);
for (const d of a.defs) {
const stored = expected.get(d.file)?.get(d.nameHint);
if (!stored) continue;
defs.set(stored, d);
if (stored !== d.hash) mismatches.push({ stored, rehashed: d.hash });
}
}
return { defs, mismatches, problems };
},
cleanup: () => {
// TCM_KEEP_VERIFY=1 leaves the rendered files in .tcm/cache, to debug a failing re-check
if (!process.env.TCM_KEEP_VERIFY)
for (const f of expected.keys()) fs.rmSync(f, { force: true });
},
};
}
/**
* Work out the type shapes of these terms where they have none (received ones: receiving leaves them, most of what's
* received being what only history names), and keep them: what search by type matches. A term with no shape to
* have is kept as having none, so it isn't worked out again.
*/
fillShapes(hashes: Iterable<string>) {
const lacking = [...new Set(hashes)].filter((h) => {
const d = this.store.getDef(h);
return d?.kind === "term" && d.shape === undefined;
});
if (!lacking.length) return;
const src = this.source();
const r = this.rehash(src, new Set(lacking.map((h) => src.get(h)!.componentHash)), false, true);
this.store.transaction(() => {
for (const h of lacking)
this.store.setMetadata(h, "shape", JSON.stringify(r.defs.get(h)?.shape ?? null));
});
}
/** `fillShapes`, where a shape not worked out is no matter: a search by type tries again */
fillShapesQuietly(hashes: Iterable<string>) {
try {
this.fillShapes(hashes);
} catch {
// (a shape is for searching and showing signatures: nothing depends on having it now)
}
}
/** Components needed to run or name `hashes`: their own plus everything they reference. */
closureComponents(hashes: Iterable<string>): Set<string> {
return this.store.closureComponents(hashes);
}
/**
* Accept definitions from another codebase. Nothing is taken on the sender's word: each component must be
* complete, every reference must resolve, and rendering and analyzing them locally must reproduce the claimed
* hashes; what's derived from them (IR, dependencies, signatures) is the local analysis's. Their types aren't
* judged here (they're the source's, which is checked to be what it claims): a contribution's are where it's merged.
*/
receive(records: DefRecord[]): { stored: number } {
const { firsts, versions } = this.receivedApart(records);
const r = this.receiving(firsts);
let found: Rehashed | Error | undefined;
try {
found = r.comps.size ? this.rehash(r.src, r.comps, false) : undefined;
} catch (e) {
found = e as Error;
}
const stored = r.store(found);
this.receiveTexts(versions);
return stored;
}
/**
* `receive`, its definitions analyzed by checker processes at once ({@link checkerPool}), where there are some. One
* at a time (a server's clients take turns): two at once would render the same components to the same files.
*/
receiveInParallel(records: DefRecord[]): Promise<{ stored: number; checkers: number }> {
const turn = this.receivingNow.then(() => this.receiveNow(records));
this.receivingNow = turn.catch(() => undefined);
return turn;
}
private receivingNow: Promise<unknown> = Promise.resolve();
private async receiveNow(records: DefRecord[]): Promise<{ stored: number; checkers: number }> {
const { firsts, versions } = this.receivedApart(records);
const r = this.receiving(firsts);
let found: Rehashed | Error | undefined;
try {
found = r.comps.size ? await this.rehashInParallel(r.src, r.comps, false) : undefined;
} catch (e) {
found = e as Error;
}
const stored = r.store(found);
this.receiveTexts(versions);
return { ...stored, checkers: (found instanceof Error ? undefined : found?.checkers) ?? 1 };
}
/** a definition this codebase has (or one that comes twice) can come with another text: its comments, layout or doc */
private receivedApart(records: DefRecord[]) {
const firsts = new Map<string, DefRecord>();
const versions: DefRecord[] = [];
for (const r of records)
if (this.store.hasDef(r.hash) || firsts.has(r.hash)) versions.push(r);
else firsts.set(r.hash, r);
return { firsts: [...firsts.values()], versions };
}
/**
* Accept definitions this codebase doesn't have yet (`receive`): what's checked before analyzing them, the
* components to analyze (`comps`, from `src`), and `store`, which checks what analyzing them found and keeps them.
*/
private receiving(all: DefRecord[]) {
// files kept as written are checked by their content: they aren't modules
const files = all.filter((r) => r.kind === "file");
const fresh = all.filter((r) => r.kind !== "file");
const checkedFiles = files.map((f) => {
const why = fileProblem(f);
if (why) throw new Error(`rejected definitions: ${shortHash(f.hash)} ${why}`);
return {
...f,
deps: [...new Set(f.spans.map((s) => s.hash!))].map((h) => ({
ref: `#${h}`,
pos: "value" as const,
strict: false,
})),
componentHash: f.hash,
componentIndex: 0,
componentSize: 1,
};
});
const byComp = new Map<string, DefRecord[]>();
for (const r of fresh) byComp.set(r.componentHash, [...(byComp.get(r.componentHash) ?? []), r]);
for (const [c, ms] of byComp) {
const indices = ms.map((m) => m.componentIndex).sort((a, b) => a - b);
if (ms.some((m) => m.componentSize !== ms.length) || indices.some((v, i) => v !== i)) {
throw new Error(`component ${shortHash(c)} is incomplete: got ${ms.length} member(s)`);
}
}
// (code is checked with the files it came with: it can import them; and none of it is run, not even a pure
// constant to keep its value: that's for when it's materialized to run)
const src: DefSource = { ...this.source([...fresh, ...checkedFiles]), keep: undefined };
const reject = (why: string): never => {
// cache modules rendered from rejected definitions must not linger under hashes they don't have
for (const c of byComp.keys())
for (const ext of [".ts", ".tsx"])
fs.rmSync(path_1.join(this.lay.cacheDir, `${c}${ext}`), { force: true });
throw new Error(`rejected definitions: ${why}`);
};
const store = (r: Rehashed | Error | undefined): { stored: number } => {
if (fresh.length) {
const found =
r instanceof Error ? reject(r.message) : (r ?? reject("they were not analyzed"));
if (found.problems.length)
reject(`they can't be read as code here:\n${found.problems.join("\n")}`);
const misplaced = fresh.filter((d) => {
const local = found.defs.get(d.hash);
return (
local &&
(local.componentHash !== d.componentHash || local.componentIndex !== d.componentIndex)
);
});
if (found.mismatches.length || misplaced.length || found.defs.size !== fresh.length) {
const bad = [
...found.mismatches.map(
(m) => `${shortHash(m.stored)} re-hashes to ${shortHash(m.rehashed)}`,
),
...misplaced.map((d) => `${shortHash(d.hash)} claims the wrong component`),
];
reject(
`their source does not match their hashes (${bad.join(", ") || "missing members"})`,
);
}
// what's derived from the source comes from the local analysis, never from the sender: the IR (the source is
// what was checked to hash so; IR sent beside it could say anything), dependencies, signatures, schemas, how
// it renders
this.putDefs(
fresh.map((d) => {
const local = found.defs.get(d.hash)!;
return {
...d,
ir: local.ir,
deps: local.deps,
signature: local.signature,
params: local.params,
shape: local.shape,
table: local.table,
globalNames: local.globalNames,
initKind: local.initKind,
nominal: local.nominal,
transform: local.transform,
jsx: local.jsx,
};
}),
);
}
this.putDefs(checkedFiles);
return { stored: fresh.length + checkedFiles.length };
};
return { comps: new Set(byComp.keys()), src, store };
}
/**
* Accept text versions of definitions this codebase has (`receive`): each must be the same code, checked as code is
* (rendered with the rest of its component, analyzed and hashed again), and is kept beside it.
*/
private receiveTexts(versions: DefRecord[]) {
for (const v of versions) {
const stored = this.store.defWithText(v.hash, undefined);
if (!stored || stored.kind === "file") continue;
const same = (t: string | undefined) => {
const d = this.store.defWithText(v.hash, t)!;
return d.sourceText === v.sourceText && d.doc === v.doc;
};
if ([undefined, ...this.store.textVersions(v.hash)].some(same)) continue;
const members = this.store
.componentMembers(stored.componentHash)
.map((m) =>
m.hash === v.hash
? { ...m, sourceText: v.sourceText, spans: v.spans, reservedNames: v.reservedNames }
: m,
);
let r: ReturnType<Codebase["rehash"]> | undefined;
try {
r = this.rehash(this.source(members), new Set([stored.componentHash]), false);
} catch (e) {
throw new Error(
`rejected definitions: a text of ${shortHash(v.hash)}: ${(e as Error).message}`,
);
}
if (r.problems.length || r.mismatches.length || !r.defs.has(v.hash))
throw new Error(
`rejected definitions: a text of ${shortHash(v.hash)} isn't its code (${r.problems[0] ?? "it hashes differently"})`,
);
this.store.addText(v.hash, v);
}
}
/**
* Record the package versions saved code was checked against: the lock moves up to them (within a compatible
* range, newer only adds), never down.
*/
lockPackages(packages: Record<string, string>) {
for (const [module, version] of Object.entries(packages)) {
const { name, version: range } = parseModule(module);
const was = lockAtLeast(this.lay, name, range, version);
if (was)
process.stderr.write(`${name} ${was} → ${version} (code was checked against ${version})\n`);
}
}
/**
* The lock entries for the packages `records` use (`name@range`, their @types included): what another codebase
* needs to run them on exactly these versions.
*/
packagesUsedBy(
records: readonly Pick<DefRecord, "deps">[],
from = this.store.lockedPackages(),
): LockEntry[] {
const used = new Set<string>();
for (const r of records) {
for (const d of r.deps) {
const m = /^npm:((?:@[^/@]+\/)?[^@/]+)@([^/#]+)/.exec(d.ref);
if (m) {
used.add(`${m[1]}@${m[2]}`);
used.add(`@types/${m[1]!.replace(/^@/, "").replace("/", "__")}@${m[2]}`);
}
}
}
return from.filter((l) => used.has(`${l.name}@${l.range}`));
}
/**
* Take lock entries from another codebase, for code received from it. A range not locked here is locked at its
* version; within a range, the newer version wins (semver: newer only adds), unless `keepLocks` (code from a
* library: what this codebase's own code runs on isn't another's to move). An entry whose version isn't in its
* range is no lock of that range, and isn't taken. The same version must be the same tarball on both sides.
*/
adoptPackages(entries: LockEntry[], o: { keepLocks?: boolean } = {}) {
for (const e of entries) {
if (!satisfies_(e.version, e.range)) continue;
const was = this.store.lockedVersion(e.name, e.range);
if (was === e.version) {
const mine = this.store.lockedIntegrity(e.name, e.range);
if (mine && e.integrity && mine !== e.integrity)
throw new Error(
`${e.name}@${e.version} is a different tarball there than here; refusing it`,
);
if (!mine && e.integrity) this.store.lockVersion(e.name, e.range, e.version, e.integrity);
} else if (!was || (!o.keepLocks && compareVersions(e.version, was) > 0))
this.store.lockVersion(e.name, e.range, e.version, e.integrity);
}
}
putDefs(defs: DefRecord[]) {
this.store.putDefs(defs, TS_VERSION, IR_VERSION);
}
/** Resolve `name` or `#hashprefix` to hashes. */
lookup(target: string): string[] {
if (target.startsWith("#")) return this.store.hashesWithPrefix(target.slice(1));
return this.store.hashesAt(target);
}
/** What to say for a name (or #hash) that isn't found: the names meant most likely, or what a namespace holds. */
notFound(target: string): string {
return notFound(
this.store.names(undefined, true).map((r) => r.path),
target,
);
}
/** Resolve to exactly one hash. A name holding a type and a value resolves to the `prefer`red one. */
lookupOne(target: string, prefer?: "type" | "value"): string {
let hs = this.lookup(target);
if (hs.length === 0) throw new Error(this.notFound(target));
if (hs.length > 1 && prefer) {
const kinds = hs.map((h) => this.store.getDef(h)!.kind);
if (!isConflicted(kinds))
hs = hs.filter((_, i) => (kinds[i] === "type") === (prefer === "type"));
}
if (hs.length > 1) {
const which = hs.map((h) => `${shortHash(h)} (${this.store.getDef(h)!.kind})`).join(", ");
throw new Error(`'${target}' names ${hs.length} definitions: ${which}; use a #hash`);
}
return hs[0]!;
}
displayRef(ref: string): string {
if (!ref.startsWith("#")) return ref;
const h = ref.slice(1);
return this.store.pathsOf(h)[0] ?? `#${h.slice(0, 8)}`;
}
/** Every hash reachable from a name on the current branch. */
liveHashes(): Set<string> {
return this.store.liveHashes();
}
/** Typecheck these definitions again, as stored (after the packages they use moved): the problems found. */
recheck(hashes: Iterable<string>): string[] {
const src = this.source();
const comps = new Set(
[...hashes].map((h) => src.get(h)?.componentHash).filter((c): c is string => !!c),
);
return comps.size ? this.rehash(src, comps).problems : [];
}
/**
* After names moved to new hashes (`patches`: old → new), re-derive every live dependent:
* re-render its stored source against the patched references, typecheck, and re-hash.
* Dependents that no longer typecheck go to `todo` and keep their old hash. That includes definitions just
* saved: one saved with a scratch file refers to what it uses as it was, and if that's re-derived too, so is it.
*
* `repin` re-derives some definitions against other package versions too (`tcm upgrade`): those definitions,
* rendered with their imports re-pinned, then everything that depends on them.
*/
propagate(
patches: Map<string, string>,
repin?: { hashes: Set<string>; module: (module: string) => string; command: string },
): { updated: string[]; failed: string[] } {
const { store, lay } = this;
const live = this.liveHashes();
// (a library is as it was added: what its code uses changing elsewhere, the same code named apart from it, leaves it
// as it is)
const libraries = librariesIn(store);
const outside = (p: string) => !libraries.some((l) => within(p, l.path));
const affected = new Set<string>(repin?.hashes ?? []);
const queue = [...patches.keys(), ...(repin?.hashes ?? [])];
// (named ones: an old version a stale definition keeps alive isn't re-derived, which would make a version of
// it nothing names and lose what replaced it; the stale definition catches up instead, when it's re-derived)
const reach = (dep: string) => {
if (
live.has(dep) &&
!patches.has(dep) &&
!affected.has(dep) &&
store.pathsOf(dep).some(outside)
) {
affected.add(dep);
queue.push(dep);
}
};
// a stale definition (using a version nothing names: one that couldn't follow a change, a merge's other side) is
// re-derived with what replaced that version: when it changes now, or is what changes now replace
const staleUsers = new Map<string, string[]>();
for (const u of store.unnamedUses()) {
const now = store.patched(u.uses);
if (now !== u.uses) staleUsers.set(now, [...(staleUsers.get(now) ?? []), u.hash]);
}
for (const h of [...patches.keys(), ...patches.values()])
for (const dep of staleUsers.get(h) ?? []) reach(dep);
while (queue.length) {
const h = queue.pop()!;
for (const dep of store.dependents(h)) reach(dep);
for (const dep of staleUsers.get(h) ?? []) reach(dep);
}
// files that name code by hash (a page's {@link}s) aren't checked as code: they're re-derived last, below
const files = [...affected].filter((h) => store.getDef(h)!.kind === "file");
for (const h of files) affected.delete(h);
const comps = new Map<string, DefRecord[]>();
for (const h of affected) {
const c = store.getDef(h)!.componentHash;
if (!comps.has(c)) comps.set(c, store.componentMembers(c));
}
const compOf = new Map<string, string>();
for (const [c, ms] of comps) for (const m of ms) compOf.set(m.hash, c);
// a dependency is what it was replaced by: one saved and then re-derived here too comes first, and so does one
// replaced before (a definition that couldn't follow a change then catches up now, or is to do again)
// (one replaced before only by what's named now: a reset can leave its replacement unnamed)
const viaThis = (h: string) => {
for (let i = 0; i < 1000 && patches.has(h); i++) h = patches.get(h)!;
return h;
};
// `now` replaces `old`. A version can come back, as the store has it: what replaced it then doesn't now (a merge
// that kept ours of what both sides only re-hashed points theirs at ours, and ours re-derived is theirs)
const replace = (old: string, now: string) => {
patches.delete(now);
patches.set(old, now);
store.addPatch(old, now);
};
const before = new Map<string, string>();
const replacedBefore = (h: string) => {
if (!before.has(h)) {
const later = store.patched(h);
before.set(h, later !== h && store.pathsOf(later).length ? later : h);
}
return before.get(h)!;
};
const resolve = (h: string) => {
const now = viaThis(h);
const later = replacedBefore(now);
return later === now ? now : viaThis(later);
};
const order: string[] = [];
const seen = new Set<string>();
const visit = (c: string) => {
if (seen.has(c)) return;
seen.add(c);
for (const m of comps.get(c)!)
for (const d of m.deps) {
const dc = d.ref.startsWith("#") ? compOf.get(resolve(d.ref.slice(1))) : undefined;
if (dc && dc !== c) visit(dc);
}
order.push(c);
};
[...comps.keys()].forEach(visit);
const updated: string[] = [];
const failed = new Set<string>();
// A dependent of definitions that kept their types is the same code with other hashes in it: it checks as it did,
// and its own type is what it was. So it's re-hashed as it is, not checked again (Unison's type-preserving
// update), and what depends on it can be too: a component all of whose dependencies that moved kept their types
// (a value's or function's type is its signature; a class's is more than that, so a changed class is checked
// below, as is what depends on anything checked there). A cycle is re-hashed as the analyzer would order it
// (`rehashComponent`); one whose members it can't tell apart is checked below.
const typeKept = new Set<string>();
// (a signature names types by name: `Record<Status, string>` reads the same when Status changed. So the types
// it refers to must be the same ones too)
const typesOf = (r: DefRecord) =>
JSON.stringify([...new Set(r.deps.filter((d) => d.pos === "type").map((d) => d.ref))].sort());
if (!repin) {
for (const [o, n] of patches) {
const was = store.getDef(o);
const now = store.getDef(n);
if (
was?.kind === "term" &&
now?.kind === "term" &&
was.signature === now.signature &&
!was.signature.startsWith("typeof ") &&
typesOf(was) === typesOf(now) &&
JSON.stringify(was.checked ?? null) === JSON.stringify(now.checked ?? null)
)
typeKept.add(o);
}
}
const rehashed = new Set<string>();
// (one transaction for all of them: a history entry is made once, not once a component)
if (typeKept.size)
store.transaction(() => {
for (const c of order) {
const members = comps.get(c)!;
const own = new Set(members.map((m) => m.hash));
const refs = members
.flatMap((m) => m.deps.filter((d) => d.ref.startsWith("#")).map((d) => d.ref.slice(1)))
.filter((h) => !own.has(h));
// (one depending on what's still to be checked waits for it, and is checked with it: what it uses, or what
// replaced that, as a merge's other side's code uses this side's replacement, itself re-derived later)
if (
refs.some((h) => {
const d = compOf.get(resolve(h));
return d !== undefined && d !== c && !rehashed.has(d);
})
)
continue;
// (what moved is what this change replaced, or what an earlier one did: that's checked)
const moved = refs.filter((h) => resolve(h) !== h);
if (!moved.length || !moved.every((h) => typeKept.has(h))) continue;
const r = rehashComponent(
members.map((m) => JSON.parse(m.ir) as IR),
resolve,
);
if (!r) continue;
const now = new Map(members.map((m, i) => [m.hash, r.hashes[r.index[i]!]!]));
// (a member's own name is a span too, of its own hash, as are the others' it uses: now their new ones)
const to = (h: string) => now.get(h) ?? resolve(h);
this.putDefs(
members.map((m, i) => ({
...m,
hash: now.get(m.hash)!,
componentHash: r.componentHash,
componentIndex: r.index[i]!,
componentSize: members.length,
ir: JSON.stringify(r.irs[r.index[i]!]),
spans: m.spans.map((s) => (s.hash ? { ...s, hash: to(s.hash) } : s)),
deps: m.deps.map((d) =>
d.ref.startsWith("#") ? { ...d, ref: `#${to(d.ref.slice(1))}` } : d,
),
...(m.docRefs
? { docRefs: m.docRefs.map((x) => (x.hash ? { ...x, hash: to(x.hash) } : x)) }
: {}),
})),
);
store.transaction(() => {
for (const m of members) {
const hash = now.get(m.hash)!;
store.clearTodo(m.hash);
if (hash === m.hash) continue;
replace(m.hash, hash);
for (const p of store.pathsOf(m.hash).filter(outside)) {
store.replaceName(p, m.hash, hash, m.kind, "update (propagated)");
updated.push(p);
}
}
});
for (const m of members) typeKept.add(m.hash);
rehashed.add(c);
}
});
const toCheck = order.filter((c) => !rehashed.has(c));
// components whose dependencies are all re-hashed are re-checked together, in one program: a wave per level
// of the dependency graph, rather than a program per component (each of which checks much of the codebase again)
const level = new Map<string, number>();
for (const c of toCheck) {
let l = 0;
for (const m of comps.get(c)!)
for (const d of m.deps) {
const dc = d.ref.startsWith("#") ? compOf.get(resolve(d.ref.slice(1))) : undefined;
if (dc && dc !== c) l = Math.max(l, level.get(dc)! + 1);
}
level.set(c, l);
}
const waves: string[][] = [];
for (const c of toCheck) (waves[level.get(c)!] ??= []).push(c);
type Item = {
c: string;
members: DefRecord[];
rendered: ReturnType<typeof render>;
tmp: string;
};
for (const wave of waves) {
const src = this.source();
const items: Item[] = [];
// what the wave's components import, written to the cache once for all of them (their closures overlap)
const imported = new Set<string>();
for (const c of wave) {
const members = comps.get(c)!;
const memberSet = new Set(members.map((m) => m.hash));
// (what it uses, or what that was replaced by: a dependency taken from a merge's other side that failed)
const blocker = members
.flatMap((m) => m.deps)
.find(
(d) =>
d.ref.startsWith("#") &&
(failed.has(d.ref.slice(1)) || failed.has(resolve(d.ref.slice(1)))),
);
if (blocker) {
for (const m of members) {
failed.add(m.hash);
store.setTodo(
m.hash,
`depends on ${this.displayRef(`#${resolve(blocker.ref.slice(1))}`)}, which needs fixing`,
);
}
continue;
}
for (const m of members)
for (const s of m.spans)
if (s.hash && !memberSet.has(s.hash)) imported.add(resolve(s.hash));
ensureExternals(lay, externalsOf(members));
const repinned = !!repin && members.some((m) => repin.hashes.has(m.hash));
if (repinned)
ensureExternals(
lay,
externalsOf(members).map((e) => ({ ...e, module: repin.module(e.module) })),
);
const rendered = render(members, src, lay, {
style: "internal",
remap: resolve,
...(repinned ? { remapPackage: repin.module } : {}),
});
const tmp = path_1.join(lay.cacheDir, `_update_${moduleFile(c, src)}`);
fs.writeFileSync(tmp, rendered.text);
items.push({ c, members, rendered, tmp });
}
// the re-check only needs the modules to exist: names and source maps are brought up to date afterwards
materialize([...imported], src, lay, { keepExisting: true });
// checked under the profile each was first checked with; untyped npm packages are fine here (the definitions
// were already checked against their types when added)
const check = (group: Item[]) => {
const byFile = new Map(
group.map((x) => [
x.tmp,
new Map([...x.rendered.memberNames].map(([h, n]) => [n, src.get(h)!])),
]),
);
return this.analyzeProject(
group.map((x) => x.tmp),
this.storedSalts(byFile),
[7016],
this.recheckOptions(group[0]!.members[0]?.checked),
true,
true,
this.storedTables(byFile),
);
};
const outcome = new Map<string, ProjectDef[] | string>();
const byProfile = new Map<string, Item[]>();
for (const x of items) {
const k = JSON.stringify(x.members[0]?.checked ?? null);
byProfile.set(k, [...(byProfile.get(k) ?? []), x]);
}
for (const group of byProfile.values()) {
const a = check(group);
const problem = (r: typeof a) => r.errors[0] ?? r.diagnostics[0]?.message;
if (!problem(a))
for (const x of group)
outcome.set(
x.c,
a.defs.filter((d) => path_1.resolve(d.file) === path_1.resolve(x.tmp)),
);
// a problem somewhere in the group: check each on its own, to say which
else
for (const x of group) {
const b = group.length === 1 ? a : check([x]);
outcome.set(x.c, problem(b) ?? b.defs);
}
}
for (const x of items) fs.rmSync(x.tmp, { force: true });
// (the wave's results in one transaction: a history entry is made once, not once a component)
store.transaction(() => {
for (const { c, members, rendered } of items) {
const result = outcome.get(c)!;
if (typeof result === "string") {
for (const m of members) {
failed.add(m.hash);
store.setTodo(m.hash, result);
}
continue;
}
const oldByName = new Map([...rendered.memberNames].map(([h, n]) => [n, h]));
// a re-derived definition keeps its doc (rendered code has no comments); the doc's references are by hash
// and follow patches when rendered
this.putDefs(
result.map((d) => {
const was = src.get(oldByName.get(d.nameHint) ?? "");
return was?.doc
? { ...d, doc: was.doc, ...(was.docRefs ? { docRefs: was.docRefs } : {}) }
: d;
}),
);
store.transaction(() => {
for (const d of result) {
const old = oldByName.get(d.nameHint);
if (!old) continue;
store.clearTodo(old);
if (d.hash === old) continue;
replace(old, d.hash);
for (const p of store.pathsOf(old).filter(outside)) {
// only this hash moves: a conflicted name keeps its other definitions
store.replaceName(p, old, d.hash, d.kind, repin?.command ?? "update (propagated)");
updated.push(p);
}
}
});
}
});
}
// a file names what replaced what it names; nothing else of it changes
const named = (ir: IR): IR =>
Array.isArray(ir)
? ir[0] === "Ref" && typeof ir[1] === "string"
? ["Ref", resolve(ir[1])]
: ir.map(named)
: ir;
for (const h of files) {
const rec = store.getDef(h)!;
const ir = named(JSON.parse(rec.ir) as IR);
const hash = hashIR(ir);
if (hash === h) continue;
const spans = rec.spans.map((sp) => (sp.hash ? { ...sp, hash: resolve(sp.hash) } : sp));
this.putDefs([
{
...rec,
hash,
componentHash: hash,
ir: JSON.stringify(ir),
spans,
deps: [...new Set(spans.flatMap((sp) => (sp.hash ? [sp.hash] : [])))].map((d) => ({
ref: `#${d}`,
pos: "value" as const,
strict: false,
})),
},
]);
store.transaction(() => {
replace(h, hash);
for (const p of store.pathsOf(h).filter(outside)) {
store.replaceName(p, h, hash, "file", repin?.command ?? "update (propagated)");
updated.push(p);
}
});
}
return { updated, failed: [...failed] };
}
}
uses
used by