Skip to content

Add hashmake, and the builtins a build description needs - #19

Open
janstrakowski wants to merge 8 commits into
mainfrom
claude/hashmake-cli-tool-0wqaei
Open

Add hashmake, and the builtins a build description needs#19
janstrakowski wants to merge 8 commits into
mainfrom
claude/hashmake-cli-tool-0wqaei

Conversation

@janstrakowski

@janstrakowski janstrakowski commented Sep 1, 2026

Copy link
Copy Markdown
Owner

hashmake is a build tool whose build files are HashedBuild programs. A hashmake.hb evaluates to a dependency graph; every node is an ordinary function from its prerequisites to the artifact it builds, and .needs maps a local alias to the target producing it, so a build function receives exactly { alias -> artifact } and never a path.

The tool itself is deliberately small — find the build file, evaluate it, order the graph, refuse cycles, call each node. It has no cache of its own. Incremental rebuilds come from the language: a node wraps its work in cached (§15), whose key is the code plus the values it reads, and a File is its content (§3). There are no timestamps anywhere in this.

odin build tools/hashmake -out:hashmake
cd examples/hashmake && ../../hashmake

That vendors cJSON as a pinned submodule, finds its three .c files by listing the directory rather than naming them, compiles each with clang, links them, and runs the result.

What this needed from the language

Four things were missing, and the first three were the reason it could not be written before:

  • exec { .cmd, .args, .inputs, .outputs, .stdin }{ .status, .stdout, .stderr, .outputs }. The command runs in a fresh scratch directory holding nothing but its inputs, and the declared outputs come back as values, never paths. That shape exists to make caching correct rather than as a convenience: §15's key excludes anything an expression reads at run time, so a thinner exec — one that ran a command in a directory and let the caller loadfile the result afterwards — would be silently wrong under cached, answering with the first run's bytes forever. A non-zero exit is not a failure; it is .status, so a build can check it and show .stderr. Gated by a new ctx.permissions.exec, not folded into io.
  • fold { .table, .init, .step } — the one general traversal. There are no loops and recursion cannot reach a Table's entries, so the language could not process a collection at all; map, filter and appending are now a line or two of HashedBuild each. It visits in ascending key order, not entry order, which is what makes a sequence fold by index and two Tables that compare equal fold to the same answer.
  • textlen / textslice — codepoints, not bytes. Deliberately the primitives: endswith, the thing a build actually wants, is four lines on top of them, and is how the C sources get filtered.
  • listdir <dirFile> — names only, sorted byte-wise. Resolves half of §16's own TODO.

Containment

exec being sandboxed while loadfile "…" still resolved anywhere was half a guarantee, so this closes it. ctx.dir is a handle to the directory a run is rooted at, and a path written without a handle is now governed by a permission:

permission loadfile "x" resolves
anypath anywhere, as before — granted at the root, so nothing existing changes
workdir contained to ctx.dir: .., an absolute path, an outward symlink and a .. written behind a . are all refused, and . resolves to ctx.dir rather than through it
neither refused — only the { .dir, .path } forms work

Both spellings of a handle-less path share one resolver, so the guarantee cannot hold for loadfile "x" and not for createfile { .path }. hashmake drops anypath before evaluating a build file, so a hashmake.hb cannot read the rest of the machine; --allow-any-path opts back out.

ctx.dir is its own type rather than a directory File, and this is forced rather than chosen. §15 puts the whole ctx into every cache key and §3 hashes a directory File over its contents — a File there would put every byte of the project tree into every entry, so editing any one file would invalidate all of them, which is the exact opposite of what a content-addressed build wants. It hashes as a bare tag, like ctx.cache. What is read through it are ordinary Files that still hash by content, so nothing is given up.

Measured, not asserted

On this checkout, building cJSON:

  • cold build compiles three sources;
  • warm run: 27 ms, nothing rebuilt;
  • editing one source adds exactly two cache entries — that object and the link, not the two untouched objects;
  • undoing the edit returns to a hit, at 27 ms, because the key is content and not a timestamp. A timestamp-based tool rebuilds here.

The graph rules are enforced rather than documented: a cycle is reported in full (a -> b -> c -> a) before anything is built, a .needs naming a target that does not exist is a clean error, and nothing may depend on a target that produced no artifact — which is what makes the run node's "produces no artifact" a rule rather than a convention.

Verification

  • odin test src253 passing, up from 231.
  • scripts/wasi_smoke.sh31 examples agree native-vs-WASI. WASI cannot start a process, and core:os's backend answers .Unsupported, so exec there reports "running a program is not available on this target" rather than a missing program — no fs_*-style three-way split was needed.
  • scripts/editor_keys_test.py (pty), and scripts/playground_browser_test.py against a real Chromium. The playground manifest now skips the vendored checkout: a submodule is one gitlink entry in git ls-files, not its contents, so reading it as a file failed outright.
  • The hashmake end-to-end build, plus new CI steps asserting a second build reuses every entry (by counting entries, not wall-clock) and that a cycle is refused.

Five examples with asserted values, and unit tests for what an example structurally cannot show — a denied or malformed call is fatal (§8/§16), so an example that tripped one would end rather than evaluate to anything.

CI is green on all five jobs, Windows included. That was the one target I could not exercise locally, and it found a real problem on the first run: a test of mine asserted ctx.dir's hashing by comparing against sha256 (loadfile "."), which hashes the entire checkout — .git and the submodule's internals included — and the Windows runner cannot open everything in there. It now asserts the property directly, on two constructed handles that must hash alike, which is both the right test and one that reads nothing. The same run caught running-a-program.hb missing from playground_browser_test.py's skip list, which is separate from wasi_smoke.sh's; both now carry a comment pointing at the other.

Worth a second opinion

  • ctx.dir as its own type is the largest design decision here (rationale above).
  • fold's key ordering, and that it cannot stop early.
  • File gained is_executable — not hashed (§3 hashes no permission bit), but it is a change to a core value type. Without it a linked binary stops being runnable after a round trip through the cache, which is exactly what the run node does.
  • The root context now names exec and anypath explicitly; behaviour is unchanged, but context-permissions.hb's documented value grew accordingly.
  • Smaller calls: exec's .outputs takes regular files only; its scratch lives under the cache directory; a .cmd containing / resolves against that scratch.

import is still unimplemented, so a build description cannot yet span files.

🤖 Generated with Claude Code

https://claude.ai/code/session_01R8FUURkLC3b5axnRKvKUfM

Four builtins a build description needs, and the containment that makes a
sandboxed build mean something.

`fold { .table, .init, .step }` is the one general traversal primitive - the
language had no loops and no way to enumerate a Table's entries, so `map`,
`filter` and sequence append are now writable in HashedBuild rather than each
needing a builtin. It visits in ascending key order rather than entry order,
which buys two properties: a sequence folds in index order, and two Tables
that compare equal (§6 ignores entry order) fold to the same answer.

`textlen`/`textslice` count codepoints, not bytes, since the type is Utf8 and
a byte index could cut a character in half. They are the primitives; `endswith`
is a one-liner on top, which is how the C sources get filtered.

`listdir <dirFile>` resolves the open half of §16's TODO: names only, sorted
byte-wise for the same reason the directory hash sorts - readdir order is the
filesystem's business, and a build whose argument order varied by machine
would cache differently on each.

`ctx.dir` is a handle to the directory a run is rooted at, and handle-less
path resolution becomes three states chosen by permission: `anypath` (as
before), `workdir` (contained to ctx.dir - ".." , an absolute path and a
symlink are all refused, and "." resolves to ctx.dir rather than through it),
or denied. Both spellings of a handle-less path share one resolver, so the
guarantee cannot hold for `loadfile "x"` and not for `createfile { .path }`.

ctx.dir is its own type rather than a directory File for one specific reason:
§15 puts the whole ctx into every `cached` key, and a directory File hashes
over its contents, so a File here would make every cache entry depend on every
byte of the project tree. Like ctx.cache it hashes as a bare tag; what is read
*through* it are ordinary Files that still hash by content.

The root context grants io, exec and anypath, so existing behaviour is
unchanged - context-permissions.hb's documented value grew accordingly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R8FUURkLC3b5axnRKvKUfM
The language could not start a process at all. This adds the one builtin that
can, in the shape the rest of the design needs rather than the thinnest one
that would work.

  exec { .cmd, .args, .inputs, .outputs, .stdin }
    -> { .status, .stdout, .stderr, .outputs = { name -> File } }

The command runs in a fresh scratch directory holding nothing but the inputs
it was handed, and what comes back are the declared outputs as File values,
never paths. That is what makes `cached exec { … }` correct: §15's key
excludes anything an expression reads at run time, so a thinner exec that
wrote into a directory and let the caller loadfile the result afterwards would
answer with the first run's bytes forever. Here an input is a File, a File is
its content (§3), so inputs are in the key and a changed source invalidates
exactly the steps that consumed it - verified end to end: a warm run hits, and
editing a source produces a new digest and a second entry.

A non-zero exit is deliberately not a failure - it comes back as .status so a
build can `check` it and show .stderr, which is the useful thing to do with a
compiler that rejected its input. A command that cannot be started, an input
that cannot be written and a declared output that is not there are all fatal
like any other builtin (§16).

Gated by a new ctx.permissions.exec rather than riding on `io`: running an
arbitrary program is strictly more authority than reading a file. WASI has no
process spawn, and core:os's backend answers .Unsupported, so a wasm build
says so rather than reporting a missing program - no per-target split needed.

Six helpers in cache_store.odin become package-visible: materialising a File
into a directory, reading one back out, and removing a tree are exactly what
`cached` already does, and sharing them is what keeps a File round-tripping
identically through a build step and through the cache.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R8FUURkLC3b5axnRKvKUfM
hashmake reads a hashmake.hb, evaluates it to a dependency graph, orders it,
refuses cycles, and calls each node with what it asked for. That is all it
does: it has no cache of its own, because a node opts in by writing `cached`
around its own work and §15 then gives correct incremental rebuilds - an input
is a File and a File is its content, so there are no timestamps anywhere in
this program.

The demo builds DaveGamble/cJSON, vendored as a submodule and pinned. Nothing
in hashmake.hb names a C file: the sources are found by listing the checkout
and filtering on a suffix with `endswith`, itself written in HashedBuild on
top of textlen/textslice. That yields one compile node per source, a link node
fanning the three objects in, and a run node that produces no artifact.

Measured on this checkout: a cold build compiles three sources; a warm run is
27ms with nothing rebuilt; editing one source adds exactly two cache entries -
that object and the link, not the two untouched objects; and *restoring* the
file returns to the original entries at 27ms, which a timestamp-based tool
could not do.

The graph rules are enforced rather than documented: a cycle is reported in
full (a -> b -> c -> a) before anything is built, a .needs naming a target
that does not exist is a clean error, and nothing may depend on a target that
produced no artifact. By default the build file is contained to its own
directory (ctx.dir plus `workdir`), so it cannot read the rest of the machine;
--allow-any-path opts back out.

Three supporting changes in src:

- eval_source_file is factored into eval_source_file_run, which hands the
  value to a callback while the AST is still alive. A Function value points
  into the AST, so returning one past ast_destroy would return a dangling
  reference - and calling functions out of the graph is hashmake's whole job.
  eval_source_file is now a thin wrapper, so every existing caller is
  unchanged.
- chperm's edit is split from its Function wrapper, so a host can narrow a
  context by the same means a program would.
- A regular File carries its executable bit. §3 hashes no permission bit, so
  no digest changes; it is carried because a linked binary that stopped being
  executable by passing through the cache could not then be run, which is
  exactly what the run node does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R8FUURkLC3b5axnRKvKUfM
Five examples, in the house style with their documented values asserted by the
suite, plus the unit tests for what an example structurally cannot show: a
denied or malformed call is a fatal failure (§8/§16), so an example that
tripped one would end rather than evaluate to anything.

folding-a-table, text-slicing and listing-a-directory are ordinary
EXAMPLE_CASES rows. workdir-containment documents the three ambient path modes
and shows the two it can - the refusals are left as a by-hand invitation in
its header, for the reason above. running-a-program drives clang, so it gets
its own test that skips with a logged reason where clang is absent, exactly as
files-symlink.hb does for symlinks.

The unit tests cover the promises rather than the happy path: that `fold`
visits in ascending key order and so agrees on two Tables that compare equal;
that textlen/textslice count codepoints and refuse to run past the end; that
`workdir` refuses "..", an absolute path, and - the one that looks like it
stays put - a ".." written behind a "."; that neither permission denies a
handle-less path outright; that exec has its own permission and requires the
outputs it declared; and that ctx.dir does not hash as its directory's
contents, which is the property the whole incremental story rests on.

examples/listing/ is a small fixture with stable contents, so listdir's
example asserts a fixed answer rather than one that changes whenever an
example is added.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R8FUURkLC3b5axnRKvKUfM
The submodule is checked out on every job - a partial checkout is a confusing
failure mode, and only one job actually needs it - and the Linux job now runs
hashmake end to end: it discovers cJSON's sources by listing the submodule,
compiles each with clang, links them and runs the result. hashmake lives
outside src/, so `odin test src` never builds it; without this it would be the
one part of this work nothing checked.

Two properties get their own steps rather than being taken on trust. That a
second build reuses every entry the first wrote, asserted by counting cache
entries rather than by wall-clock, which is not a thing a CI runner can
promise. And that a cycle is refused, since a build tool that quietly looped
would be worse than one that never ran.

running-a-program.hb joins the WASI skip list: WASI cannot start a process at
all, so there is no answer for the two targets to agree on. Verified rather
than assumed - the wasm build still links with `exec` in it, `exec` under
wasmtime reports "running a program is not available on this target", listdir
works there, and the smoke test has 31 examples agreeing across the two.

The playground manifest skips the vendored checkout: a submodule is one
gitlink entry in `git ls-files`, not its contents, so reading it as a file
failed outright - and a browser terminal has no use for a C library it cannot
compile. hashmake.hb itself is still included.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R8FUURkLC3b5axnRKvKUfM
CLAUDE.md makes docs part of a feature rather than follow-up work, and this is
that half.

SPEC.md gains the design: §9 grows ctx.dir and the three permissions (exec,
and the workdir/anypath pair that decide what a handle-less path may reach),
including why ctx.dir has to be its own type rather than a directory File -
§15 puts the whole ctx into every cache key and §3 hashes a directory File
over its contents, so a File there would put the whole project tree into every
entry. §16 gains listdir - which resolves half of that section's own TODO, and
says why names-only - plus exec, fold and the text builtins, and a new TODO
for what exec still cannot do.

LANGUAGE.md gains an entry each, with snippets that were run before they were
committed, and its "what isn't built yet" list is corrected: directory listing
and Table traversal have landed, there are still no loops, and two limits that
would otherwise be discovered the hard way are stated - exec collects regular
files only, and createfile is still exclusive.

GETTING_STARTED.md documents the hashmake CLI, which is where CLAUDE.md routes
a tool that isn't demonstrated by a runnable file. tools/hashmake/README.md
specifies what a hashmake.hb must evaluate to and what the tool enforces;
examples/hashmake/README.md is the worked project, with four things to try -
including undoing an edit and watching it come back as a cache hit, which is
the property that distinguishes this from a timestamp-based tool.

README.md gets the headline: the first thing built *on* the language now runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R8FUURkLC3b5axnRKvKUfM
The example declares its linked output as "greet", and Windows produces
greet.exe - a declared output that is not there is fatal (§16), by design.
GitHub's windows-latest image ships clang, so the existing "skip if clang is
missing" guard would not have caught this; the suite would have gone red on a
runner nobody was looking at.

Teaching the example to pick an extension per platform would put the platform,
rather than exec, at the centre of what it demonstrates, so it stays a Linux
check and both the test and the example's header say why.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R8FUURkLC3b5axnRKvKUfM
Both were mine, and neither was a flake.

**Windows.** test_ctx_dir_does_not_hash_as_the_directorys_contents asserted the
right property the wrong way: it compared against `sha256 (loadfile ".")`,
which hashes the entire checkout - .git and the submodule's internals included
- and the runner cannot open everything in there, so it failed with "could not
read a directory's entries (Access)". Walking a large tree to prove that
nothing about a directory reaches ctx.dir's digest was backwards anyway.

It is now asserted directly, on two constructed handles rooted at different
paths, which must hash alike: that is the property §15 needs, since the whole
ctx goes into every cache key. The other half - that a directory File *does*
still hash by content, so reading through ctx.dir is unaffected - is a second
test against the three-file fixture rather than the checkout root.

**Playground.** running-a-program.hb was added to scripts/wasi_smoke.sh's skip
list but not to the one inside scripts/playground_browser_test.py, which I had
not noticed was separate. A browser cannot start a process, so `exec` reported
exactly that and there was no answer for the two sides to agree on - the same
reason, and now the same skip, with a comment on each pointing at the other.

Verified rather than assumed: the playground failure was reproduced in a real
Chromium and now reports "all playground checks passed". The Windows one
cannot be reproduced here, so the fix removes the failing expression outright
rather than trying to make it survive - nothing in either replacement reads
more than three files. Suite is 253 green, WASI still agrees on 31 examples,
and the cJSON build still runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R8FUURkLC3b5axnRKvKUfM
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants