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

CacheWhat it skipsWhere it livesOpt 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:hash entries, 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.json is 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 (or execute(..., { cache: false })) ignores the cache entirely and re-runs every target.
  • --dry-run never 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:

BackendWhat it isWhere 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:

MethodEffect
.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 CacheStoreget(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.