Caching
Zuke has two independent caches, and they solve different problems. Reach for whichever matches what you're trying to avoid re-doing. Both are file-backed, git-ignored, dependency-free, and best-effort: a missing or corrupt store is treated as empty (everything just rebuilds), so a broken cache never breaks a build.
Two caches
| Cache | What it skips | Where it lives | Opt in with |
|---|---|---|---|
| Incremental build cache | Re-running a target whose inputs are unchanged | <repo root>/.zuke/cache.json | .inputs() (and/or .cacheKey()) on a target |
| AI response cache | Re-paying a model for an identical review/fix call | <repo root>/.zuke/ai-cache/ | .cache(aiCache(...)) on a reviewer or fixer |
The build cache has a distributed sibling: a remote
cache that shares a target's built outputs across
machines, so a fresh checkout or a CI job can reuse work another machine
already did. And --affected
— the monorepo-scale complement to the cache — narrows a run to just the
targets a set of file changes can reach. Both build on the same input
fingerprint and are covered below.
Incremental build cache
A target that declares inputs becomes incremental.
Before running it, Zuke fingerprints the declared inputs; if the fingerprint
matches the last successful run and every declared output
still exists, the target is skipped and reported
cached. Otherwise it runs and its fingerprint is refreshed.
compile = target()
.inputs("src", "deno.json") // re-run only when these change…
.outputs("dist") // …or when dist is missing
.executes(async () => {
await DenoTasks.run((s) => s.script("build.ts"));
}); What "unchanged" means
The fingerprint is a SHA-256 computed with the built-in Web Crypto API — no dependency:
- A file hashes to the SHA-256 of its contents.
-
A directory hashes to the SHA-256 of its sorted
name:hashentries, recursively — so a change anywhere in the tree changes the result, but reordering the filesystem does not (entries are sorted first). - A missing path hashes to a sentinel, so a file's appearance or removal invalidates the cache just as an edit does. Renaming, adding, or deleting an input file all force a rebuild.
Inputs are combined in declaration order, so the same files always produce the same fingerprint — deterministic across machines and runs.
Outputs guard the cache
.outputs(...) lists the files or directories the target
produces. A cache hit also requires every declared output to
still exist — so deleting dist/ (or any declared output) forces a
rebuild even when the inputs are untouched. Outputs are optional; a target
with inputs but no outputs is cached purely on its input fingerprint.
Non-file inputs — .cacheKey()
Not every input is a file. .cacheKey(fn) folds an extra value — a
parameter, a tool version, a git commit — into the fingerprint, so the target
also rebuilds when that value changes. The function may be async and is
repeatable.
compile = target()
.inputs("src")
.cacheKey(() => this.configuration.value) // rebuild when the config flips
.cacheKey(() => Deno.version.deno) // …or the toolchain moves
.executes(/* … */);
A .cacheKey() on its own makes a target cacheable
— you don't need .inputs() for it to take effect. A target that
declares neither inputs nor cache keys is never cached and always runs.
The store
Fingerprints persist in <repo root>/.zuke/cache.json
(git-ignored). A few details worth knowing:
-
The file is created only when the build has at least one cacheable
target. A build with no
.inputs()/.cacheKey()anywhere never opens or writes it. -
A corrupt or hand-edited
cache.jsonis tolerated: if it can't be parsed, Zuke treats it as empty and everything rebuilds — it never errors on a bad store. - The store is only rewritten when a fingerprint actually changed, so an all-cached run touches no files.
Interaction with the build
- A cached (or condition-skipped) target counts as satisfied, so its dependents still run. Caching a target doesn't strand what depends on it.
- The fingerprint is recorded only after a successful run — a failed target is never marked up-to-date.
-
--no-cache(orexecute(..., { cache: false })) ignores the cache entirely and re-runs every target. -
--dry-runnever reads or writes the cache: it prints the plan without running any body, so it can't invalidate or refresh a fingerprint.
See Core concepts for the same feature in the context of the execution model, and the incremental cache example for a complete build file.
Remote cache
The incremental cache is local. A remote cache
shares a target's built .outputs() across machines: on a local
miss, Zuke restores the outputs from the store instead of
rebuilding them; after a successful run it uploads them for the
next machine. It applies to targets that declare both inputs and outputs, and is keyed by the same input
fingerprint the local cache uses — a restored target reports as
cached in the summary, just like a local hit. A store outage is
never fatal: Zuke logs a warning and falls back to a local rebuild.
import { Build, HttpCacheStore, parameter, target } from "jsr:@zuke/core";
class CI extends Build {
cacheToken = parameter("Cache auth token").secret().env("CACHE_TOKEN");
// Share built outputs across machines: a local miss restores them from the
// store instead of rebuilding; a successful run uploads them for the next.
override remoteCache() {
return new HttpCacheStore({
url: "https://cache.example.com",
token: this.cacheToken.value,
});
}
build = target().inputs("src").outputs("dist").executes(/* … */);
} Backends & configuration
Two dependency-free backends ship behind one RemoteCacheStore
interface — archives are the built-in tar/gzip:
| Backend | What it is | Where an entry lives |
|---|---|---|
FileSystemCacheStore | A shared or mounted directory — an NFS mount, a CI volume. | <dir>/<key>.tar.gz |
HttpCacheStore | Any object store or cache server behind a URL — an S3/GCS/R2 bucket, or a self-hosted endpoint — with an optional bearer token. | GET/PUT <url>/<key> |
Declare a store in code with a typed remoteCache() override (as
above), or configure it from the environment with no build-file change —
handy for CI:
# HTTP backend — any object store or cache server behind a URL
ZUKE_REMOTE_CACHE_URL=https://cache.example.com ZUKE_REMOTE_CACHE_TOKEN=… ./zuke ci
# Filesystem backend — a shared / mounted directory
ZUKE_REMOTE_CACHE_DIR=/mnt/zuke-cache ./zuke ci
./zuke ci --no-remote-cache # use the local cache only for this run
Precedence is: an explicit execute({ remoteCache })
option, then the build's remoteCache() override, then the
ZUKE_REMOTE_CACHE_* environment variables.
--no-remote-cache uses the local cache only for a run;
--no-cache disables both.
Archive entry names use the POSIX ustar format (a 100-byte path
limit), so extremely deep output paths are rejected with a clear error.
Security
The store URL and token are trusted configuration — outputs
are uploaded there and archives are extracted from it — so point them only at a
cache you control, and prefer a parameter().secret() or an
environment variable over a hard-coded value. On CI, restrict egress to the
cache host so a misconfigured or overridden URL can't exfiltrate artifacts.
Restore is hardened against a poisoned store: an archive entry with an absolute
path or one containing a .. segment is rejected before any file is
written, so nothing lands outside the workspace.
Affected targets — --affected
--affected restricts a run to the targets that a set of file
changes can reach — the monorepo-scale complement to the
incremental cache. Zuke asks git for the files changed since a base revision
and keeps only the affected targets; the rest are skipped
(their prior outputs are assumed current, so a skipped dependency still unblocks
its dependents).
zuke ci --affected # vs HEAD (uncommitted changes)
zuke ci --affected=origin/main # vs a base branch — the usual CI form
zuke ci --affected=main... # merge-base comparison
A target is affected when a changed file falls inside one of its declared
.inputs(), or when any of its dependencies is
affected (affectedness flows downstream along dependsOn and
triggers). A target that declares no inputs can't
be proven unaffected, so it is always run — declare
inputs on the targets you want --affected to be able
to skip.
The base defaults to HEAD (uncommitted changes); pass
--affected=<ref> for any git revision (origin/main,
a tag, or main... for a merge-base comparison). Programmatic
callers pass { affected: { base } } to
execute, optionally with a changedFiles seam in place
of git. Combine it with the remote cache and
CI fan-out for fast monorepo pipelines.
AI response cache
An AI review or
fix call is expensive: it spends tokens and
round-trips to a provider. Yet the same call often runs again
and again — a flaky CI retry, a re-pushed branch, a local loop over an
unchanged diff. aiCache(...) persists each provider response so an
identical call reuses the stored answer instead of paying for
the model again.
import { aiCache, securityReviewer } from "jsr:@zuke/ai";
review = target()
.validateBefore(
securityReviewer((r) =>
r.provider("openai").apiKey(this.key)
.cache(aiCache((c) => c.dir(".zuke/ai-cache").ttl(86_400)))
),
)
.executes(/* … */); The same cache attaches to a fixer with .cache(...):
test = target()
.executes(() => DenoTasks.test((s) => s.allowAll()))
.recoverWith(
aiFixer((f) =>
f.provider("openai").apiKey(this.key)
.cache(aiCache((c) => c.ttl(3_600)))
),
); How a call is keyed
An entry is keyed by a stable hash of the call's salient parts — the provider, the model, and the exact prompt (which, for a review, incorporates the diff). Change any of them and it's a different key, so a new diff or a swapped model is a natural miss. A cache hit costs nothing and does not draw down an AI budget.
Configuring aiCache
aiCache((c) => …) builds the cache inline. Every knob is
optional:
| Method | Effect |
|---|---|
.dir(path) | Directory for the default file store (default .zuke/ai-cache). |
.ttl(seconds) | Entries older than this are ignored. Default 604800 (7 days); 0 means never expire. |
.disable() | Turn the cache off programmatically — every read misses, every write is a no-op. |
.store(custom) | Inject a custom CacheStore instead of the file store. |
The cache is opt-in per reviewer/fixer — it does nothing
until you attach it with .cache(...). It is deliberately
best-effort: a missing file, a corrupt or truncated entry,
or a failed write is swallowed and treated as a miss, so a broken cache never
breaks the build it caches for.
Custom stores
The default backing store writes one JSON file per key under
.dir(). Any object implementing CacheStore —
get(key) / set(key, entry) — can replace it via
.store(...), which is how tests inject an in-memory store for a
single run:
import { type CacheEntry, type CacheStore } from "jsr:@zuke/ai";
const memory = new Map<string, CacheEntry>();
const inMemory: CacheStore = {
get: (k) => Promise.resolve(memory.get(k)),
set: (k, e) => {
memory.set(k, e);
return Promise.resolve();
},
};
securityReviewer((r) => r.provider("openai").cache(aiCache((c) => c.store(inMemory))));
Caching pairs naturally with the other AI
cost controls (budget,
maxDiffTokens, a cheaper model) — see the AI review
docs for the full picture.