Definitions and hashing — tcm-ts

38. Definitions and hashing

38.1 What a definition is

Definitions are top-level functions (overloads included), consts, classes, types and interfaces.

  • Destructuring. const { a, b: [c] } = init() binds one definition per name. The bindings share the statement, so it still runs once.
  • A type and a value can share a name, like TypeScript's own type and value spaces (const User = z.object(…) with type User = z.infer<typeof User>). Each half is its own definition, updated, diffed and merged independently. The #codebase shim exports both under the one name.
  • Declarations that exist only in types merge with a type of their name, as TypeScript merges them: an interface Effect<A> with a declare namespace Effect { type Success<T> = … } of types beside it, a declare const s: unique symbol with type s = typeof s, a class with a namespace of types. They're one definition, written together (Effect<number> and Effect.Success<…> are both uses of it).
  • A statement setting up a constant goes with it. A top-level statement that writes into one constant declared above it, and nothing else, finishes setting it up: filling a table (for (…) codes[c] = i), a constructor's prototype (Impl.prototype = Proto), Object.freeze(types), Object.defineProperty(Proto, …). It's part of that constant's definition, run and hashed with it. Writing means assigning into it (x[i] =, x.p =, x.n++, delete x.p) or handing it to one of Object's or Reflect's functions that change what they're given; what a function in the statement does later doesn't count, nor does a method call (server.listen()). Any other statement is refused.
  • An augmentation is a definition of its own. A file's declare global { … } (several are one) is the definition <file's namespace>.global, and its declare module "./schema" { namespace Annotations … } (adding to another module's definition, one per block) is <file's namespace>.augments, which names the definition it adds to by hash, so it follows renames; in a scratch file it's written declare module "#codebase/lib/schema". It has no name to import: every check in the codebase includes it (scratch files, re-checks, imports), as TypeScript includes it in its whole program. Code relying on what it adds (e.hint on an Error) doesn't name it, so its hash doesn't include it, as with @types packages.
  • Classes hash as one unit. Methods aren't split out: this, #private, super and initialization order make per-method hashing meaningless.
  • Rejected in scratch files, with a reason: top-level var/using, enums, namespaces, decorators, declare, and top-level statements other than watch expressions and statements setting up a constant (an expression writing into one is that, not a watch expression). tcm import accepts most of these, because existing code uses them.
  • Dynamic import("pkg") of a package or Node builtin is pinned like a static import, so it's still content-addressed. Lazy imports of local files or codebase modules are rejected. A computed specifier (import(url)) is allowed with a warning, because it loads code the codebase doesn't track.

38.2 How a definition is hashed

tcmts.core.canonicalize lowers every definition to an S-expression IR built from syntax-kind names, not numbers, with every child labelled by its slot (name, body, …). Unknown syntax is an error, never skipped, so two different programs can't collide.

  • Comments, formatting and export are dropped.
  • Literals are normalized: 1_000 = 0x3e8, and 'a' = "a". Shorthand properties hash like their explicit form.
  • Locals become de Bruijn levels, so renaming a local doesn't change the hash.
  • Every identifier is resolved through the type checker. A reference to another definition becomes Ref <hash>, an npm import becomes Ext npm:<pkg>@<compatible range> (npm:lodash@4; packages), and a global becomes G name.
  • Property names stay structural, and so do JSX tags and attributes (<div className>); a component tag (<Counter />) is a reference like any other.
  • JSX text is normalized as React reads it: indentation and line breaks between tags don't count, spaces within a line do. A definition with JSX also hashes the JSX runtime it compiles to (npm:react@<version>).
  • Type annotations are part of the hash.
  • Mutually recursive definitions are hashed as a cycle (Tarjan SCC + Unison-style ordering), including cycles that span files.

38.3 Identity

  • Lineage salts. In JavaScript every declaration has its own identity: new Map() twice is two maps, and two identical classes fail each other's instanceof. So every function, class and value is hashed with a lineage salt, the codebase path where it was first added. Updates and renames reuse the existing salt, so renames stay free and re-adding the same file reproduces the same hashes. Identical code added at two paths is two definitions.
  • Types are unsalted, since they have no runtime identity, so identical types deduplicate. The exception is an ambient declare const x: unique symbol, which is salted: each one is a distinct symbol.
  • Names are hashed exactly where JavaScript can observe them (.name): function and class declarations, const f = () => …, and local functions or bindings that name an anonymous function (function helper() {}, (cb = () => {}) =>). Generated code keeps these names, so tcm mv changes the codebase path but not .name. All other locals and parameters stay alpha-renamable.

38.4 The hash format

  • The format is versioned (tcm --version says which: v2). tcmts.core.test.golden pins the hashes of a corpus that covers most syntax, so a change to them is deliberate. A codebase written with another format is refused rather than mixed with this one's hashes, and says to migrate it.
  • tcm migrate moves a codebase to this tcm's format, history and all. Each format comes with a step that turns the last one's IR into its own (tcmts.core.migrate.IR_STEPS; v1 to v2 drops parentheses around an optional chain that nothing goes on from, which a formatter adds). Every definition, dependencies first, is taken through the steps, its references moved to their new hashes, and hashed again; then its history, from the bottom up (namespaces and history entries, with their times and commands); and everything keyed by a hash follows: names, branches, releases, patches, test results. It's a pure function of the codebase, so a clone and its server, each migrating, get the same hashes and go on syncing (tcm serve's server migrates when it starts). It's safe: the database as it was is kept beside it (.tcm/codebase.v1.sqlite), and the migrated one is used only if this tcm reads every live definition back to its hash (tcm verify); otherwise the codebase is left as it was.
  • A codebase whose tables an older tcm laid out differently is refused too, and says to migrate it: tcm migrate lays them out as this tcm does first, taking each layout's step in turn (tcmts.store.LAYOUT_STEPS; layout 8f648138 to 85655ca2 moves patches kept whole for each branch into sets branches share), and keeps the database as it was beside it (.tcm/codebase.layout-<layout>.sqlite). Hashes don't move. A codebase copied from a package an older tcm packed (the web UI's, the registry's, its libraries) is migrated as it's copied. A layout with no step from it (a newer tcm's, or one from before there were steps) is made again (imported from its files, or pulled).
  • Hashes don't depend on the compiler or its version. The IR visits each node's children in an order that's tcm's own (tcmts.core.slots, generated from TypeScript 7.1's), so a TypeScript release that adds or reorders children changes no hash until that table does. pnpm compare-compilers hashes this repo and the corpus with TypeScript 7 and with tsrs: the hashes are identical.
  • tcm verify re-renders every live definition from the store and re-hashes it. Rendering is supposed to be the inverse of hashing, so any mismatch means stored source, splicing or canonicalization lost information. TCM_KEEP_VERIFY=1 leaves the rendered files in .tcm/cache for debugging a failing re-check. A component it verified already isn't checked again while the names it renders with stay the same and the tcm verifying is the same build: what it renders to and how that hashes follow from those, and the verifying code's own hash covers everything it uses (a change to hashing or rendering re-checks everything). tcm verify --fresh checks every definition again.

built from c482a14e