fix(codegen): carry copy-output provenance in a .hephgen beside the file - #415
Open
raphaelvigee wants to merge 1 commit into
Open
fix(codegen): carry copy-output provenance in a .hephgen beside the file#415raphaelvigee wants to merge 1 commit into
raphaelvigee wants to merge 1 commit into
Conversation
A `codegen = "copy"` output is a build artifact living in the source tree, and
heph has to be able to tell it apart from raw source — otherwise a `glob()`
picks it up and double-sources the generated content, or folds a target's own
output back into its input hash. That answer was a `user.heph.codegen` xattr on
each generated file, and `has_codegen_xattr` treats every failure as "not
stamped", so losing it does not fail the build: it silently turns a build output
back into a build input.
Losing it is easy. `git checkout`, `tar` (so any `actions/cache` round trip),
`rsync` without `-X`, `cp` without `-p` and an editor's atomic save all keep a
file's bytes and drop its metadata, as does any filesystem without user xattr
support — which `stamp_codegen_xattr` had to hard-fail on, making `copy` silently
non-portable.
The defect is that the record's lifetime was decoupled from the file's. So put
the record in the same directory as the files it describes: a `.hephgen` naming
each generated file, its owning target, and a hash of the bytes heph wrote. It
moves under `cp -r`, `mv`, `tar` and docker `COPY`, and dies with `git clean -xd`
— always in step with what it describes. The write-back maintains it from the
target's own declared outputs, so nothing walks the graph and nothing has to be
run by hand.
Two invariants make it race-free rather than well-timed:
- **Register before publish.** The entry is written before the bytes are, so a
net-new file is registered before it exists (an entry naming nothing is inert),
and a rewrite keeps the outgoing hash as `prev=` until the rename lands — every
instant of the swap has the on-disk content matching some accepted hash.
Pruning what a target no longer emits waits for the end of the write-back,
when what it emits is fully known.
- **The directory mtime does the invalidation.** The registry is published by
rename and parsed into the mtime-validated `DirListing`, so it cannot go stale
relative to its directory and there is no second coherence path to get wrong.
Concurrent write-backs into one directory serialize on an flock in the heph
home (a lock beside the registry would die with the inode the rename replaces).
The hash is what makes a stale record safe: content heph did not write reads as
source again, whatever the registry says — so a branch switch that checks a real
file in over a generated path, or a hand-edited generated file, is not silently
dropped from the build. That is also new behaviour the xattr never had.
Registering a path changes what a later glob sources, so a registry change now
folds into the write-back's `wrote` and drives the fixpoint recompute; the xattr
never did, which meant the run that repaired a stripped stamp skipped it.
Generated files are also now published by rename rather than written in place, so
a consumer never reads one half-written. A rewrite therefore resets the file's
mode to the umask default plus the artifact's exec bit instead of preserving the
old mode — deterministic, and (content, exec-bit) is what the fs hash covers
anyway.
Decisions worth naming:
- **`.git/info/exclude`.** `.hephgen` is untracked, and requiring `heph tool
gen-gitignore` to keep `git status` clean would put the same "remember to run
it" tax on correctness-adjacent machinery. So on first creating a registry heph
appends `**/.hephgen` there, once, idempotently — nothing committed, no diff,
no review. It resolves the gitdir without the `git` binary, following a linked
worktree's `gitdir:` and `commondir` to the shared directory. On by default;
`codegen: { gitExclude: false }` opts out. This is the one part that writes
under `.git/`.
- **The xattr stays**, read as a per-file fallback (a directory can hold both a
registered file and a legacy-stamped one whose target has not re-run) and still
written, but best-effort: the hard failure on an xattr-less filesystem is gone,
and a mixed fleet stays readable both ways. Its cost is that the `getxattr` per
unregistered file survives, so the read-path win waits for `heph tool
migrate-codegen` and the removal one release later.
- **Orphans are not reclaimed yet.** A record whose owning target was deleted
still hides its file — the behaviour the xattr had, and the safe direction,
since an unreconciled orphan must never be promoted back into a compiled input.
Detection and reclamation land with the end-of-run reconciliation in a
follow-up. See docs/CODEGEN_PROVENANCE.md §6.
`DIR_LISTING_VERSION` goes to 2: a row written before this carries no registry,
and serving one would report every generated file in the directory as source.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ALT9b7iKzgtHxsFWGD2EEU
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
A
codegen = "copy"output is a build artifact that lives in the source tree, so heph has to tell it apart from raw source — otherwise aglob()double-sources the generated content, or a target folds its own output back into its input hash.That answer was a
user.heph.codegenxattr on each generated file, andhas_codegen_xattrtreats every failure as "not stamped". So losing the xattr doesn't fail the build — it silently turns a build output back into a build input.Losing it is easy:
git checkout,tar(so anyactions/cacheround trip),rsyncwithout-X,cpwithout-p, an editor's atomic save, and any filesystem without user xattr support — whichstamp_codegen_xattrhad to hard-fail on, makingcopysilently non-portable.The defect is that the record's lifetime was decoupled from the file's.
The change
Put the record in the same directory as the files it describes. Each directory receiving
copyoutputs gets a.hephgen:It moves under
cp -r,mv,tarand dockerCOPY, and dies withgit clean -xd— always in step with what it describes. The write-back maintains it from the target's own declared outputs, so nothing walks the graph and nothing has to be run by hand.Column 1 is fixed as the entry name in every future version, so an older binary reading a newer file degrades to names-only rather than failing open.
Race-free by construction, not by timing
prev=until the rename lands — every instant of the swap has the on-disk content matching some accepted hash. Pruning what a target no longer emits waits until the end of the write-back, when what it emits is fully known.DirListing, so it can't go stale relative to its own directory and there's no second coherence path to get wrong. Concurrent write-backs into one directory serialize on an flock in the heph home (a lock beside the registry would die with the inode the rename replaces).The hash earns its place
A record says "heph wrote this name"; the hash says "and these are still the bytes it wrote". Content heph didn't write reads as source again whatever the registry says — so a branch switch that checks a real file in over a generated path, or a hand-edited generated file, isn't silently dropped from the build. The xattr never had that.
It's free:
CachedWalker::file_hashalready computes and caches exactly this hash, and it's only consulted when there's a record to check.Two fixes that fall out
wroteand drives the fixpoint recompute. The xattr never did — meaning the run that repaired a stripped stamp skipped it.(content, exec-bit)is what the fs hash covers anyway.Decisions worth flagging
.git/info/exclude, on by default..hephgenis untracked, and requiringheph tool gen-gitignoreto keepgit statusclean would put a "remember to run it" tax right next to correctness. So on first creating a registry, heph appends**/.hephgenthere once, idempotently — nothing committed, no diff, nothing to review. It resolves the gitdir without thegitbinary, following a linked worktree'sgitdir:→commondirto the shared directory.codegen: { gitExclude: false }opts out. This is the one part that writes under.git/.getxattrper unregistered file survives, so the read-path win waits for a migration sweep and the removal a release later.Compatibility
DIR_LISTING_VERSION1 → 2: a row written before this carries no registry, and serving one would report every generated file in the directory as source. One-time re-read.codegen:config section with one optional key; absent means the previous behaviour plus the exclude line.Testing
crates/walk: format round-trip incl.prev=and symlink records, future-version degradation, lenient parsing,retain_owned, and thathashout_bytesagrees withfile_hashouton the same file (if those ever diverge, every generated file reads as foreign content).crates/engine: register-before-publish ordering, mid-write-back non-pruning, a second target's rows preserved, no rewrite in the steady state,.git/info/excludeidempotence + worktree resolution.in_placetarget won't clobber a copy-owned file with no xattr present; foreign content at a registered path flips back to source; a deleted registry is restored by the next run of its owner; the registry file is never globbed as source.lintclean; fulltstgreen.Docs
docs/CODEGEN_PROVENANCE.mdcarries the design, the alternative that lost (one sqlite table per repo — it reintroduces the same decoupling in a narrower, correlated form), and what's deferred.example/fmt/updated.🤖 Generated with Claude Code
https://claude.ai/code/session_01ALT9b7iKzgtHxsFWGD2EEU