qk
qk, short for quick, is a standalone task runner written in Rust. It reads Nx-compatible workspace configuration and executes tasks with dependency ordering, affected selection and a cache shared by Git worktrees.
Start with Getting started to inspect and run the self-contained example workspace.
This book documents the current implementation. The design and implementation plan describes the longer-term plan; planned behavior should not be treated as supported functionality.
Read Running tasks for execution and dependency ordering, Affected selection for change detection, and Local cache for inputs and output restoration.
qk also implements S3-compatible remote caching, warm state and run history.
Use Workspace configuration, Target configuration, and the CLI reference for options and defaults. Nx compatibility explains the supported subset.
Getting started
Install a current stable Rust toolchain, then run from this repository. On
Windows, building also needs clang-cl, from LLVM, as the C compiler for the
MSVC target: set CC_x86_64_pc_windows_msvc=clang-cl.
cargo run -p qk-cli -- --workspace examples/basic show projects
cargo run -p qk-cli -- --workspace examples/basic show project web --json
cargo run -p qk-cli -- --workspace examples/basic show projects --projects 'tag:scope:*' --json
cargo run -p qk-cli -- --workspace examples/basic graph --file -
cargo run -p qk-cli -- --workspace examples/basic run codegen:smoke
Inspection and the codegen:smoke task are self-contained; no Node
installation, package installation, Nx process is required. Other example targets demonstrate package scripts
and require pnpm when executed.
To install the binary locally:
cargo install --path crates/qk-cli --locked
qk --help
qk --workspace /path/to/your/workspace show projects --json
Inside a workspace, omit --workspace. qk searches ancestors for nx.json,
pnpm-workspace.yaml or a root package.json with workspaces. A standalone
project.json, or a package with an nx object, is also supported when no
parent workspace marker exists.
Running tasks
Inspect the normalized project before running it:
qk show project web
qk run web:build --dry-run
qk run web:build
qk run-many -t build,test -p 'web,tag:library' --parallel 4
The dry run exposes dependency expansion without starting a process.
Use qk --help or qk <command> --help for command details.
Selecting tasks and run options
run requires an existing target; run-many skips projects without the
requested targets and errors when nothing matches. run, run-many and
affected take Nx’s run options:
-c/--configuration, and--prodfor-c production.--parallel: a number, a percentage of the cores such as50%, orfalsefor one task at a time. Without it,NX_PARALLEL, then nx.jsonparallel, then 3;--parallelalone meansNX_PARALLEL, else 3, as in Nx.--skip-nx-cache(--skip-cache,--disable-nx-cache, orNX_SKIP_NX_CACHE=true), and--skip-remote-cache(--disable-remote-cache,NX_SKIP_REMOTE_CACHE=trueorNX_DISABLE_REMOTE_CACHE=true) to keep to the local cache. Tasks seeNX_SKIP_NX_CACHE=truewhen the cache is skipped.--nx-bail(orNX_BAIL=true, which--nx-bail=falseoverrides): after a task fails, nothing else starts; what is running finishes.--exclude-task-dependencies: only the requested tasks run.--nx-ignore-cycles(orNX_IGNORE_CYCLES=true): a task dependency cycle is broken, as Nx does, by dropping the dependency that closes it, with a warning; otherwise it is an error.--graph=<file>or--graph=stdout(--graphalone prints too, where Nx opens its viewer): writes the project graph and the task graph in the shape Nx’s--graphwrites, without running anything. Nx’staskPlans, its hashing plan, is left out.--verbosesetsNX_VERBOSE_LOGGING=truefor tasks; without it they seeNX_VERBOSE_LOGGING=false, unless it is alreadytrue.--output-styleand--dry-run, which prints qk’s own task graph.- Any other option, as in Nx, is forwarded to the requested tasks; see Forwarding arguments.
- Nx’s own options (
--runner,--batch,--skip-sync,--cloud,--dte,--agents,--tui,--tui-auto-exit) are accepted and have no effect.
--output-style takes Nx’s names, and qk’s own quiet:
dynamic,tuianddynamic-legacyshow a live panel on stderr: how many tasks are done, cached, running, queued and failed, and each running task with its time. Successful tasks’ output stays hidden; a failed task’s output is printed above the panel. Forrun, the panel counts the requested task’s dependencies, prints warnings as they come, and once they are done gives way to one line saying how they went and the task’s own output, streamed under> qk run <task>; continuous dependencies stream behind their project prefix. A task without dependencies streams without the line or the header. Without a terminal, underNX_TASKS_RUNNER_DYNAMIC_OUTPUT=falseor with verbose logging they fall back tostatic, as in Nx.quietprints nothing while tasks run except a failed task’s output, under✖ qk run <task> failed, with escape sequences removed when the output is not a terminal andFORCE_COLORis not set. It is meant for agents and scripts.streamprefixes each non-empty line with the task’s project, in Nx’s colour for it;stream-without-prefixespasses output through untouched.staticholds each task’s output until it ends and prints it under> qk run <task>, as Nx does under> nx run <task>, with the target in quotes when its name has a colon and the task’s arguments after it, marked[local cache]for a hit, and in GitHub Actions folds each task into a log group unlessNX_SKIP_LOG_GROUPING=true. As in Nx, the run opens with a banner naming what it runs and ends with one saying whether it succeeded, listing failed tasks and those not run because of them. Forrun,staticfollowsnx run: the requested task streams under its header as it runs, and its dependencies are held, with only the header shown for a cache hit.static-failures-onlyisstatic, except that a task that did not fail is one line,✔ qk run <task>, and the run ends saying how many tasks’ output was left out. Forrun, the tasks of the requested task’s project still print in full. With--verbose, every task prints in full, as in Nx.summaryprints no task output. The run ends with how many tasks succeeded, came from the cache, failed and were skipped, then each failed task with the file holding its output, kept in the worktree’s state directory untilqk reset, and how to see the logs inline.
Without the option, NX_DEFAULT_OUTPUT_STYLE applies when it names a style;
otherwise every command uses static in CI; every command uses the live
panel on a terminal, except a run whose output is piped; otherwise run
passes output through, and run-many and affected use static on a terminal and
quiet otherwise. These
defaults are qk’s; Nx’s is static-failures-only where it does not use its
terminal UI. stream and stream-without-prefixes print qk: status lines
on stderr; static shows each task’s status in its header instead, and the
panel and quiet collect warnings for the summary. Continuous tasks stream
under the static styles, since their output would otherwise never appear. As in Nx, when no static style was named, as with
the CI default, a long-running task streams too: one that runs with
--watch, or whose target is serve, dev, start or ends in -watch
or :watch. NX_STREAM_OUTPUT=true streams every task and
NX_STREAM_OUTPUT=false none but continuous ones. As in Nx, output streamed
under a style other than stream carries the project prefix only with
NX_PREFIX_OUTPUT=true. Colour follows picocolors: off
with NO_COLOR or FORCE_COLOR=0, on with FORCE_COLOR, in CI or on a
terminal.
Runs of several tasks, and any run under static, the panel or quiet,
end with a summary on stderr: how many tasks succeeded and came from cache, or which
failed and which were skipped because of them; the critical path with its
three longest tasks, in the order they ran; the most memory the tasks used
together and the tasks that used the most; and whether --parallel held the
run back. For that, qk records how long each task waited for a free slot
after its dependencies finished, and samples the machine’s CPU use through
the run. When tasks on the critical path waited while the machine had
capacity to spare, it says how much sooner a higher --parallel could
finish; when the machine was busy while they waited, it says more
parallelism would not help.
Of the tasks ready to start, those with the longest expected path to the end of the run start first: how long the task took in its recent runs that executed it, rather than restoring it from the cache, and the longest chain of tasks depending on it. A task without such runs is expected to take no time, so a workspace’s first run starts tasks in the order of their ids.
Terminals and input
As in Nx, when qk’s stdout is a terminal, on Unix, a command whose output qk
captures runs under a pseudo-terminal, so tools that check for a terminal
draw as they would on one. It is the command’s controlling terminal, as large
as qk’s, and both its stdout and stderr, so the output is recorded and
replayed as one stream on stdout. Commands keep pipes for a target with
usePty: false, for a command entry with a prefix, for several commands
run side by side, and for every nx:run-commands target under
NX_NATIVE_COMMAND_RUNNER=false.
Output qk passes straight through already reaches the terminal.
Only the task a run is for reads qk’s stdin, when it has a single command
whose output is shown as it runs; other tasks read nothing. Run directly, it
has the terminal itself. Under a pseudo-terminal, it reads input that is not
a terminal, such as a pipe, directly; from a terminal, qk copies its input in
and puts the terminal in raw input mode while the task runs, so each key arrives
as typed and Ctrl-C interrupts the task; tty: false keeps the terminal’s own
line editing. What the task echoes of the input is part of its output.
Waiting for memory
Tasks that each fit in memory can exhaust it together, and a machine out of
memory freezes rather than failing one task. So a task starts only once the
machine has the memory it is expected to use free. Free memory is the
machine’s, so other runs, worktrees and programs count. What is free, less a
tenth of the machine’s memory kept for everything else and what the run’s
running tasks are still expected to grow by, must cover what the task is
expected to use. A task that waits leaves its slot to the next task that
fits, and starts regardless once nothing else of the run is running, so a
run cannot stall. The summary names the tasks that waited, and does not
count their wait for memory as a wait for a --parallel slot.
A task is expected to use the most memory it used in its recent runs that executed it, as run history records. A task without such runs is expected to use the median of the run’s other tasks of its target, so a type-check of a new project is expected to use what the others do. A task of a target none of which has been measured starts without waiting.
Dependencies and configurations
The planner expands dependsOn before execution: local targets, ^target
on project dependencies, project:target, and objects with target,
projects, dependencies, params and options. Target names may contain
colons: a:b is project a’s target b when a is a project with that
target, and otherwise the task’s own target a:b. A target may be a glob,
such as lint-* or ^build-*: as in Nx, it stands for every target name in
the workspace it matches. options: "forward" passes the target’s options,
its configuration’s included, to the dependency as its own options, which
they override; as in Nx that includes a run-commands target’s own command,
so it suits targets of the same executor. params: "forward" passes the
run’s own arguments, however the dependency is reached. projects takes
project selectors. As in Nx 24, projects: "self" and
projects: "dependencies" fail: leave projects out for the task’s own
project, or set dependencies: true. As in Nx, a project dependency without the
target is looked through: the task’s dependsOn applies again from that
project, so ^tsc reaches the nearest dependencies that have tsc. Other
missing dependency targets are skipped; unknown explicit projects and task cycles fail.
Shared tasks run once. When different requested configurations reach the same
task, its dependencies include the selections from every request.
Requested configurations propagate to dependencies that define the same name;
otherwise the dependency’s default configuration applies. The requested name
continues through that dependency to its dependencies. Without a requested
configuration, each task chooses its own default. run-many -c treats the
targets it selects the same way. As in Nx, a shared task reached with
different forwarded arguments or options keeps those it was first reached
with.
Executors
See Executors for supported executors and options.
Environment
See Environment variables for precedence, dotenv loading and task variables.
Forwarding arguments
Forward task arguments after --, or, as in Nx, give options qk does not
know directly:
qk run app:build -- --mode=production 'two words'
qk run app:package -c release -- --arch=arm64
qk test app --coverage --max-workers 2
The options qk and Nx know, such as --configuration or --nx-bail, are
read as theirs; everything else, and any positional after the task, reaches
the requested tasks in the order given, before what follows --.
A run-commands command, as Nx’s, puts the task’s arguments in one of three ways:
{args.name}stands for one value: a--name=valueor--name valueargument, a flag without a value astrue,--no-nameasfalse, a repeated flag as its values joined with commas, a name theargsoption sets (also in camel case), or another option of the target. The value goes in as it is, unquoted, for the shell to read, so quote the placeholder where the command needs one word:echo "{args.message}". A missing value interpolates as nothing. As in Nx 24,??lists fallbacks, and the first that is set and not empty applies: anotherargs.name,env.NAMEfrom the task’s environment, or a final literal, as in{args.port ?? env.PORT ?? 4200}.0andfalseare values.{args}stands for everything: the options run-commands does not know, as--name=value, the arguments, and then theargsoption.- Without either, the same are appended, unless
forwardAllArgsisfalse.
A command cannot use both {args} and {args.name}. Forwarded options and
arguments the shell would split or read are put in double quotes, as Nx
puts them, so $NAME in one still expands. Arguments that set a
run-commands option, such as --cwd=dir, are not forwarded.
Path tokens such as {projectRoot} in options resolve as the workspace
loads; see options. Dependencies
receive no arguments unless their dependency object sets
params: "forward".
Failures and cancellation
All selected tasks are prepared before any command starts, so unsupported
executors and options fail before dependencies run. A failed task skips its
dependents while independent tasks continue. As in Nx, the CLI exits with 1
when any task failed or was skipped, whatever the task’s own exit code.
Ctrl-C and, on Unix, SIGTERM and SIGHUP cancel the run, which exits with 128
plus the signal’s number, as Nx does: 130, 143 or 129. Cancelled commands
receive SIGTERM on Unix and are killed with their process group if they
have not exited within NX_PROCESS_KILL_GRACE_PERIOD milliseconds, five
seconds by default; on Windows they are killed directly. Parallel commands
within a failed task are terminated immediately, and as in Nx the task then
fails with exit code 1; commands run in sequence stop at the first failure.
Under --nx-bail, a run that left tasks unstarted exits with 1, where Nx
exits with 130 as if interrupted. Commands must not detach themselves into
separate sessions or launch external services; those processes are outside
the managed group.
As Nx does, a run refuses to run a task that one of the runs it was started
from is running with the same arguments, such as a task whose command runs
qk run for itself, and names the chain of tasks.
Continuous tasks and readiness
Targets with continuous: true, such as dev servers and watchers, run until
they exit or are stopped. A task that depends on a continuous task starts as
soon as that task has started, not when it finishes, unless its dependsOn
entry sets waitFor: "ready". Continuous tasks do not count towards
--parallel. A requested continuous task runs until it exits or the run is
cancelled. A continuous task that only other tasks depend on is stopped, like
a cancelled command, once all of them have finished; that stop counts as
success. A continuous task that exits by itself reports its exit status as
usual. Continuous tasks are never cached. As in Nx, a cacheable task depending
on one is keyed with the continuous task’s inputs as its own, unless every
dependsOn entry naming it sets inputs: false.
With readyWhen, a string or an array of strings, a task is ready once every
string has appeared in its output, on stdout or stderr: as printed, or as a
terminal shows it, so text that terminal control sequences break up, such as
a port printed in bold, matches too. Its dependents start
then, while its commands keep running, and qk stops it, as a success, once
nothing still to run needs it. A requested task with readyWhen is done once
ready, as in Nx, unless it is also continuous. Such a task holds a
--parallel slot until it is ready, and a continuous one uses readyWhen to
hold its dependents back until then. A task whose commands exit before it is
ready ends with their status, and fails, as in Nx 24.0.2, when they all
succeed without printing it. With commands, readyWhen requires
parallel. These tasks are not cached, since their commands outlive any
result.
A continuous target’s own readyWhen,
an object, is a check for the tasks that depend on it with
waitFor: "ready", as in Nx 24: they start once a url answers, a port
accepts a connection, a command succeeds, text appears in the output or the
task signals that it is ready. A task that waited gets the address of the
one server it waited for that reported one, in NX_READY_URL, and its own
check, resolved once those servers are ready, can read it too.
Waiting holds no --parallel slot. qk checks only while a task in the run
waits. A check that times out fails the task, and the tasks waiting for it
are skipped. A task that exits before its check passes keeps its own status,
and the tasks waiting for it fail, naming it, as in Nx. An invalid check
fails the task only when something waits for it; otherwise qk warns and runs
it unchecked. A port of 0 or auto is one the server picks, so there is
nothing to check and the task is ready once started. A request to this
machine skips proxies and accepts a self-signed certificate.
A request to another host goes through the proxy that HTTP_PROXY or
HTTPS_PROXY, by the url’s scheme, or else ALL_PROXY names, unless
NO_PROXY covers the host. As in Nx 24.0.3, a NO_PROXY entry is * for
every host, a domain that also covers its subdomains (example.com or
.example.com), *.example.com for the subdomains only, an IP address, or a
CIDR range such as 10.0.0.0/8. Any entry except a range can end in a port,
such as staging.internal:8080 or [fd00::1]:8080, to match only that port.
The request trusts the certificates in the operating system’s store, which
SSL_CERT_FILE or SSL_CERT_DIR replaces, and in the file that
NODE_EXTRA_CA_CERTS names. A file qk can’t read adds nothing.
A target with parallelism: false runs alone, as in Nx: it waits until no
other task runs, continuous ones included, and nothing starts while it runs.
Like Nx, qk rejects a graph where such a task depends on a continuous task,
or where a continuous task other tasks depend on has it.
--dry-run only plans and prints the task graph: it does not load dotenv,
validate executor support, execute runtime inputs, or run commands.
Running affected tasks
For an opt-in consumer-specific comparison of shared sources, see Project change projections, and for leaving out projects whose entry points do not import a change, see Import reachability. Ordinary selection and task cache keys retain the behavior described here.
qk show projects --affected --base main
qk show affected web --base main
qk affected -t build,test --base main
qk affected -t build,test --granularity project --base main
As in Nx, changed files are those between the merge base of --base and
--head, or with no head, between the merge base and the working tree,
including uncommitted and untracked files. The base defaults to NX_BASE,
then nx.json defaultBase, then main; the head to NX_HEAD. --files,
--uncommitted and --untracked replace the comparison. A file named with
--files counts as changed whole: its revisions are not compared, so a JSON
or lockfile change is not narrowed to what changed inside it. Files matching
the root .gitignore or .nxignore are left out. Untracked files are those
Git lists as untracked and not ignored, so an .nxignore negation does not
bring back one .gitignore excludes, though it does for a file named with
--files. As in Nx, a base or head starting with - is refused, since git
would read it as an option.
Unlike Nx, when the base is a branch with an upstream, such as main
following origin/main, and the head left the upstream later than the local
branch, the base is that later point. Otherwise a local main that has not
been updated would count everything that landed on origin/main since as
changes. When the compared commits still include some already on the default
branch’s upstream, as with an explicit --base older than where the head
branched, affected and show affected warn. On the default branch itself
the range is its own history, and nothing is flagged.
Tasks or projects
As in Nx 24, qk affected selects the tasks a change reaches, by the rules
in Selecting affected tasks by inputs,
and runs them with the tasks they depend on. Project selection, Nx’s
behavior before 24, runs the targets of every affected project instead, by
the rules in How changes reach projects. The
first of these that is set decides:
--granularity taskor--granularity project;NX_LEGACY_AFFECTED:trueselects projects andfalsetasks; as in Nx, other values are ignored;- nx.json
affected.legacy:trueselects projects; - otherwise, tasks are selected.
To keep selecting projects, as Nx’s migration to 24 does for an existing
workspace, set affected.legacy in nx.json:
{
"affected": { "legacy": true }
}
show projects --affected -t <targets> decides the same way, as in Nx 24:
under task selection it lists the projects owning an affected task of the
targets, and under project selection the affected projects with one of the
targets. Without -t it lists the affected projects, and with -p, unless
given --granularity task, it selects projects, as Nx does.
Change projections apply to project selection only (see
Project change projections). Under an affected
profile with projections, qk affected and show projects --affected -t
select projects where they would select tasks, and say so on stderr; with
--granularity task they fail instead.
How changes reach projects
A changed file touches the project whose root most specifically contains it.
A file named by a {workspaceRoot} input touches the projects declaring it;
an input without glob syntax, such as {workspaceRoot}/generated, names that
file or everything under it.
A change to nx.json, the file it extends or nx.local.json touches
projects by what it changes. qk reads the configuration at both revisions,
merged as qk loads it, and compares the values, so a change to comments or
formatting touches nothing. A change to a setting every task’s cache key
holds, such as plugins, pluginsConfig or workspaceLayout, touches every
project (see What changes the key). A change
to targetDefaults or namedInputs touches the projects whose targets or
named inputs it resolves differently: qk resolves every project with the base
revision’s configuration and the head’s manifests, and compares. A change
only to settings that change no task’s work, such as defaultBase,
parallel or release, touches nothing. Where a revision cannot be read or
parsed, or the files were named with --files, every project is touched.
A changed project.json or project.local.json that a project has at both
revisions touches its project as any file does when an input of the
project’s targets selects it, as default does unless the workspace leaves
the files out of it (see What changes the key).
It also touches projects by what it changes. qk reads the changed manifests
at the base revision, resolves the workspace with them and the head’s other
files, and compares every project’s configuration, since a tag or a name
decides what other projects’ inputs and dependencies select. A project is
touched where its targets, named inputs or other keys differ, where its
dependencies do, or where an input of it naming projects, such as
tag:shared, selects others. Where no input selects the file, a change to
comments or formatting touches nothing. A manifest the change added, or one
that cannot be read at the base revision or was named with --files,
touches its project as any other file does.
A deleted project.json or package.json that removes a project touches the
projects that depended on it. Nx touches every project instead, having no way
of knowing what depended on it; qk reads the removed project’s manifests at
the base revision and looks for what still declares it. qk’s graph is made of
manifests alone, and a project whose files the change leaves alone has the
same manifests at both revisions, so what depended on the removed project is
what the change touches already and what still names it: a package.json
dependency that linked to it, or an implicitDependencies entry or pattern
selecting it. Their dependents follow as for any other touched project. A
deletion can remove a package and leave its project, when a project.json
still makes it, or move a project under the same name. Either way a project
whose dependency on the package the head graph no longer has is touched, as
when a file: path names where the package was; one whose link survives,
such as a workspace: range to the moved package, depends on it as before,
and one whose implicitDependencies exclude it was never linked to it. Every project
is still touched when qk cannot tell: for a deleted root lockfile, for files
named with --files, which are not compared with a base revision, or when the
removed project’s manifests cannot be read there.
Path mappings changed in the root tsconfig touch the projects they point into, and any other change to it every project.
A change to the root lockfile (pnpm-lock.yaml, pnpm-lock.yml,
package-lock.json, yarn.lock, bun.lock or bun.lockb) touches projects
by nx.json’s pluginsConfig["@nx/js"].projectsAffectedByDependencyUpdates:
every project under "all", Nx’s default, or the projects a list of
selectors names. Under "auto", a pnpm-lock.yaml change touches the
projects whose importer installs something different, by the same installed
sets the cache keys use; another lockfile, or one qk cannot compare, touches
every project.
In the root package.json, a changed dependency touches the projects
installing that package, or the workspace project it names, and an
@types/ package also the package it types. A changed override, under
overrides, resolutions or pnpm.overrides, touches the projects
installing the package its selector names: the last key, past a pnpm
parent> and without a @range. A removed dependency, a change to a global
package (nx and the plugins nx.json loads), an override naming no installed
package, or a file that cannot be compared touches every project. Installed
packages are read from pnpm-lock.yaml; without it, a changed dependency
touches every project. Every project depending on a touched project is
affected too.
Some rules deliberately differ from Nx:
- Under
"auto", the installed sets leave out projects that install exactly what they did before, which Nx reports, and catch transitive changes such as a dependency’s dependency moving version, which Nx can miss. - A list of selectors touches its projects only when a lockfile changed. Nx touches them on every change, lockfile or not, which reads as a bug; its task selection checks for a lockfile change, as qk does.
- A change to nx.json touches only what it changes, by the rule above. Nx hashes nx.json whole into every task, so any change to it, even a comment, touches every project.
- A change to the file nx.json
extendscounts as a change to nx.json does. Nx does not look at that file, although it configures the workspace as much as nx.json does. - A change to a project’s
project.jsonthat no input of the project selects touches what it changes, by the rule above. Nx touches the project whatever changed.
show projects --affected prints projects in graph order; Nx prints its
traversal order, so compare the two as sets.
Explaining the result
qk show affected takes the same change options and explains the result.
Without a project it lists every affected project with the first reason it
is touched, or the dependency it is affected through. With a project it
prints a shortest dependency path to a touched project, the project’s other
affected dependencies, and every reason the touched project is touched. For
a lockfile reason it lists what the importer installs differently, by
package: versions that moved first, then packages added or removed, then
those that kept their version but resolved different peers or patch, then
those whose dependencies changed as a consequence. After what was compared,
it says which granularity qk affected selects by and what decided it (see
Tasks or projects).
$ qk show affected web --base HEAD^ --head HEAD
2 changed files between 3f1c0a9e2b7d and HEAD.
The base is where HEAD left HEAD^, 1 commit back.
Granularity: task (the default).
web is affected because it depends on ui:
web -> ui (static)
ui is touched:
what packages/ui installs changed in pnpm-lock.yaml (3 packages)
react (direct): 19.0.0 -> 19.1.0
react-dom (direct): 19.0.0: peers or patch changed
scheduler: dependencies or integrity changed
Selecting affected tasks by inputs
Under task selection, a task is affected when Nx 24 would select it, or when
its cache key changed, except that qk narrows a lockfile change, as below,
and a deleted manifest, an nx.json change and a project.json change, as in
How changes reach projects. As in Nx, that is
when:
- a changed file is one of its resolved inputs, a declared output included: a tracked output is a file like any other;
- a file projects are created from changed in a project whose inputs it
reads, its own or one its
^andprojectsinputs reach, or such a file was deleted and removed a project that one of those depended on, by the rule above, which falls back on every task where it does. Aproject.jsonorproject.local.jsoncompared at both revisions counts by its inputs and the rule below instead; - the root tsconfig changed outside
compilerOptions.paths, or, withselectivelyHashTsConfig, in path mappings into a project whose inputs it reads (see What changes the key); - the root lockfile changed and
projectsAffectedByDependencyUpdatesis"all", Nx’s default, and the task hashes installed packages: it declares noexternalDependencies, or names some. Under a list of selectors, the tasks of the projects it names are affected; - the root
package.jsonmoved an installed package the task hashes, by the rules above, or named the task’s project as a dependency.
Where Nx selects every task on any nx.json change, qk selects a task when the
change moves its key: when it changed a setting every key holds, or when,
resolved with the base revision’s targetDefaults and namedInputs, the
task’s definition differs, or what its inputs select does, its own or a
dependency’s through ^ inputs. A named input that selects the same files
leaves the task alone. A task declaring {workspaceRoot}/nx.json as an input
is affected by any change to the file, as its key holds it whole.
A changed project.json or project.local.json selects every task whose
inputs select it, as any input does, its dependents’ through ^ inputs
included; with default holding {projectRoot}/**/*, that is every task
reading the project’s files, as in Nx. For a task whose inputs leave it out,
it counts as nx.json does: the task is selected when, resolved with the
manifests the base revision had, its definition differs, or what its key
holds of its project’s configuration, such as its root and output paths,
or what its inputs select, or the dependency manifests and importers qk adds
for it. Editing one target then selects that target’s tasks, and a tag the
tasks whose inputs select projects by it.
Beyond those, qk’s key also reads what its lockfile importers or
externalDependencies install, pnpm-workspace.yaml outside its resolution
keys, and each dependency project’s package.json in the fields its key
reads, so a task is affected when any of these changed, or a dependency’s
version moved into or out of the range the task’s project declares for it.
Under "auto", these installed sets are what a lockfile change is judged by.
Nx instead selects, on any lockfile change, every task it keys by the whole
lockfile, such as those of a project that depends on no package, and every
task that hashes a package whose lockfile entry moved, even where what the
task’s importers install stayed the same. qk keys neither, so it selects
neither.
A task is also affected when it reads the declared outputs of an affected
task, through dependentTasksOutputFiles or a fileset that selects them. As
in Nx, depending on a task without reading its outputs carries nothing, so
a task ordered after a build but reading only sources is not affected by the
build’s changes. A file the task’s inputs select counts by any change to it,
as in its key. An unaffected task would therefore be a cache hit, except that
env and runtime inputs count as unchanged, since the base revision’s
environment is unknowable. An affected one can still be a hit where Nx
selects more than qk keys, such as for a changed declared output or a
lockfile change under "all". A test-only change then reaches the test tasks
and not the builds whose production inputs exclude tests.
A continuous task a task depends on, such as the dev server an e2e task runs
against, counts its inputs as the task’s own instead, and so do those of
the continuous tasks it depends on in turn. A dependsOn entry with
inputs: false leaves them out, unless another entry names the same task
without it.
qk show tasks -t <targets> --affected lists the affected tasks with their
first reason, or the whole analysis with --json, whatever the granularity.
--json gives the whole analysis, or for one project its path with the
typed reasons, including the snapshot keys a lockfile change added,
removed or changed. Both carry granularity, task or project, and
granularityFrom: --granularity, NX_LEGACY_AFFECTED, affected.legacy,
projections or default.
Project selectors
Project selectors are read as Nx’s findMatchingProjects reads them,
wherever Nx reads them: --projects and --exclude, implicitDependencies,
dependsOn and inputs projects, targetDefaults filter.projects and a
projectsAffectedByDependencyUpdates array.
- A project name selects that project. A glob selects the names it matches;
globs are minimatch globs, so
*and?do not cross a/(@scope/*, not@scope*, selects@scope/ui) and**does. tag:<pattern>selects by tag,directory:<glob>by project root andname:<pattern>by name only. A pattern without a label is tried as a name, then as a root when no name matched, solibs/*selects the projects directly underlibs.- A plain pattern that is no project name and no glob selects the names
containing it as a word, ignoring case:
uiselectsui,@scope/uiandui_kit, but notui-e2e, as-and@join words. - Patterns apply in order, each adding what it matches or, prefixed with
!, removing it:'!tag:e2e,app-e2e'keepsapp-e2e. When the first pattern excludes, selection starts with every project. --excluderemoves what its patterns match among the projects already selected.- Empty patterns and
nx-cloud:references to other workspaces are skipped. A selection matching nothing is empty. As in Nx,show projecttakes a name that is no project as a pattern, which must match exactly one.
The projects of an input are read by Nx’s native hasher, which differs:
a first ! pattern starts from nothing, and a plain name matches only that
name.
Quote globs so the shell does not expand them. Commas delimit CLI selectors, so brace globs containing commas are not supported on the command line.
Project change projections
A schema or another shared source can change without changing what its consumers use. An affected profile lets a workspace adapter compare a consumer-specific representation at two revisions. qk replaces the declared source changes with changed artifact paths before selecting projects and following their dependencies.
For example, a runtime profile can compare generated JavaScript rather than all generated TypeScript. A type-only change can then leave preview deployments unselected while ordinary affected selection still selects type checking. Changes to runtime values, such as generated operations or possible-type maps, still count when the adapter includes them in its snapshot.
Profiles are opt-in and apply to project selection. They do not change task cache keys, cached results, or task-level affected selection.
qk show projects --affected --affected-profile runtime --base main --head HEAD --json
qk show affected web --affected-profile runtime --base main --head HEAD
--profile remains the performance-timing flag. --affected-profile selects
a named change projection.
Configure a profile
Add qk:affectedProfiles to nx.json.
For example:
{
"qk:affectedProfiles": {
"runtime": {
"projections": [
{
"name": "generated-runtime",
"command": ["node", "tools/runtime-snapshot.mjs"],
"sources": ["schemas/**", "operations/**", "generator.config.json"],
"outputs": ["apps/*/generated/**", "packages/*/generated/**"],
"timeoutSeconds": 60
}
]
}
}
}
Each projection has these settings:
| Setting | Meaning |
|---|---|
name | Nonempty name, unique within the profile; used in explanations |
command | Nonempty executable-and-arguments array; qk does not invoke a shell |
sources | Nonempty array of workspace-relative globs naming source changes this adapter may replace |
outputs | Nonempty array of workspace-relative globs permitting artifact paths in the manifest |
timeoutSeconds | Per adapter invocation and per worktree checkout, integer from 1 to 3600; defaults to 60 |
Patterns use /, with * confined to one path segment and ** spanning
segments. Absolute paths, backslashes, . and .. segments are rejected.
Colons and negation patterns are rejected too. {workspaceRoot} tokens are not supported here.
Unknown settings and overlapping ownership of a changed source are configuration
errors. They stop selection rather than silently applying a different profile.
List every source whose change needs this interpretation, including operations and generator configuration. Sources not covered by a projection keep their normal affected behavior. A projection is skipped when none of its sources changed. When it runs, it must return the complete artifact manifest, not only artifacts it believes changed.
Adapter protocol, version 1
qk resolves base/head to commits and runs head’s adapter for both snapshots.
Each invocation gets a fresh detached head worktree for adapter code and a
separate fresh worktree for revision data. The command runs in the head
adapter’s workspace directory. Read inputs and generate outputs under
revisionRoot, rather than assuming the current directory contains revision data.
Changing the adapter without changing nx.json therefore still uses the same
committed adapter to interpret both revisions. The original
checkout, branch, dirty files and generated output directories are left alone.
Worktree registration is temporary; qk removes the worktrees after comparison
or failure. Each projection gets fresh checkouts, so another adapter’s writes
do not become its inputs.
The command receives one JSON object on stdin:
{
"version": 1,
"workspaceRoot": "/workspace/project",
"revisionRoot": "/tmp/qk-revision/checkout",
"adapterRoot": "/tmp/qk-adapter/checkout",
"revision": "0123456789abcdef0123456789abcdef01234567"
}
workspaceRoot is the original workspace, useful for locating a pinned tool
installation. revisionRoot is the isolated workspace containing data at the
requested commit. adapterRoot is the fresh head workspace and the command’s
working directory. For a workspace nested inside a Git repository, all three
roots name the workspace subdirectory. Relative executable paths and arguments
are interpreted from adapterRoot; bare executable names are resolved through
PATH. An adapter imported by a Node script also comes from head.
qk expands {workspaceRoot}, {revisionRoot} and {adapterRoot} inside each
command argument, without shell parsing. For example, a wrapper can use
["node", "tools/snapshot.mjs", "--input-root", "{revisionRoot}"].
Use {adapterRoot} for committed adapter code and {workspaceRoot} for tools
installed in the original workspace. Pointing adapter code at {workspaceRoot}
explicitly opts into the original checkout’s code, including dirty edits.
These tokens apply to command, not sources or outputs.
qk supplies no task metadata or task sandbox; the adapter inherits the runner
environment and permissions. Git repository-local environment variables,
such as GIT_DIR, GIT_INDEX_FILE and per-repository config overrides, are
removed from qk’s Git and adapter subprocesses so hook invocation cannot redirect
them to another checkout.
Write exactly one manifest to stdout. Write progress and diagnostics to stderr, which qk forwards without mixing them into selection JSON:
{
"version": 1,
"artifacts": {
"apps/web/generated/operations.js": "sha256:035ad...",
"packages/client/generated/possible-types.js": "sha256:cb900..."
}
}
Artifact names are normalized workspace-relative paths. They must match
outputs and cannot name workspace or project metadata such as nx.json,
package.json, project.json or lockfiles. Empty segments, . or ..,
backslashes, colons and NUL characters are rejected. Fingerprints are opaque,
nonempty strings of at most 1024 bytes; a content digest is a good choice. Fingerprints must be deterministic across
checkouts: exclude timestamps and absolute temporary paths, and normalize
ordering when it has no meaning in the representation.
Duplicate artifact names, unknown response fields, unsupported protocol
versions, and manifests larger than 1 MiB are rejected.
Artifacts can be virtual: the named file need not exist in the original checkout, and it can be Git-ignored generated output. qk maps each changed artifact path to the most specific containing project, then propagates through normal project dependencies. Choose artifact names in the consumer’s project when that consumer is the unit of change. Naming a shared package artifact instead affects that package and its dependents.
qk compares the union of both manifests. A changed fingerprint, newly present artifact, or removed artifact contributes a changed path. The adapter must include shared runtime artifacts as well as per-consumer artifacts; leaving an artifact out of both snapshots cannot establish that it is unchanged.
Tools and generated code
Disposable checkouts contain revision files and Git metadata. qk does not
install packages, initialize submodules, copy node_modules, or run checkout
hooks. Put external tools on PATH or let the adapter explicitly locate a
pinned installation through workspaceRoot. For a Node adapter, dependencies
can be resolved with createRequire:
import { createRequire } from 'node:module'
import { join } from 'node:path'
const requireTool = createRequire(join(request.workspaceRoot, 'package.json'))
const ts = requireTool('typescript')
Use the same chosen tool installation to interpret both revisions. qk does not reproduce historical installations. Installation metadata changing in the comparison causes conservative fallback, and there is no persisted projection cache in this version.
For a generator adapter:
- Read the revision’s generator configuration from
revisionRoot. - Run the pinned generator there. Send its stdout to stderr so it cannot corrupt the manifest. Clean its declared generated output directories first if tracked generated files could otherwise survive a deletion.
- Read every relevant generated output. For a JavaScript-runtime profile, erase TypeScript types using the chosen compiler and fingerprint the resulting JavaScript; keep non-TypeScript runtime files in the manifest too.
- Emit workspace-relative output names and fingerprints.
The representation defines what the profile promises. Equal generated JavaScript is not proof that types remain valid, that validation passes, or that a complete bundle is equivalent. Keep validation and type checking on ordinary affected selection. A broader deployment profile must account for all other deployment inputs through normal changes or additional projections.
Adapters are trusted executable workspace tooling. Selecting a profile can
execute the adapter code committed at head with runner permissions.
Use an appropriate CI execution environment when reviewing untrusted changes.
The adapter must write generated files only in revisionRoot; qk’s disposable
worktrees are isolation of files, not a permission sandbox.
Conservative fallback and explanations
qk retains all original changes for the profile if any active adapter fails,
times out, emits invalid output, conflicts with another adapter’s artifacts,
or emits an artifact overlapping a replaced source. This prevents a partial
successful projection from hiding changes after another projection failed.
Adapter process groups are stopped on exit and on failure, including timeout;
worktrees are then removed. Each worktree checkout also has its own
timeoutSeconds deadline; a stalled checkout is a comparison fallback, and
its process group and any registered worktree are cleaned up. Each active
projection creates four full checkouts (base and head data, plus fresh head
code for each invocation), so checkout cost can be significant in large
repositories. Adapter roots are writable and are not reused between runs.
An adapter that deliberately escapes its process
group is outside this cleanup contract.
Projection also falls back when:
- The comparison has no explicit committed head (
--headorNX_HEAD). --files,--stdin,--uncommittedor--untrackedreplaces the revision diff.- Workspace configuration or root installation metadata differs between commits.
- The current workspace metadata differs from the committed head.
- A declared source change would replace protected workspace/project metadata.
- A revision cannot be checked out, including an absent workspace subdirectory.
An untracked or Git-ignored nx.local.json selects the same merged profile for
both snapshots and is exempt from the metadata check. It is not copied into
revision worktrees. If an adapter needs local settings, read the common
original workspace’s override through workspaceRoot. Tracked overrides
remain protected: changes between commits or dirty edits trigger fallback.
Fallback retains all original changes and prints a warning to stderr by default.
For preview CI, use --fail-on-projection-fallback=adapter to fail only when an
adapter exits unsuccessfully, cannot start, times out, emits invalid output or
undeclared artifacts, or conflicts with another adapter’s artifacts:
qk show projects --affected --affected-profile runtime --base main --head HEAD \
--fail-on-projection-fallback=adapter --json
Configuration or installation changes remain successful conservative
comparisons in this mode, including a commit first introducing a profile.
They print a one-line notice to stderr naming the projection and the reason,
so the job log explains why conservative selection built more previews.
Explanations also retain the reason. This lets normal dependency updates create
previews while output drift, such as a new generated project outside outputs,
fails CI.
The bare --fail-on-projection-fallback flag and
--fail-on-projection-fallback=all fail on every fallback condition,
including configuration changes and checkout failures.
Both modes require --affected-profile and a committed head via --head or
NX_HEAD. Missing head, --files, --stdin, --uncommitted or --untracked
are invocation errors in either strict mode, even when no declared sources
changed. Default mode keeps conservative fallback with a diagnostic for
unsupported comparisons. A valid comparison with no changed sources still
skips the adapter successfully. Invalid profile configuration always fails
selection.
qk show affected --json includes
projections with each adapter’s status (applied, skipped or fallback),
replaced source paths, changed artifact paths and any fallback detail.
Fallback reports also include fallbackKind: adapter for adapter execution
or manifest failures, and comparison for configuration, installation,
unsupported comparison or checkout conditions. Text
explanations show both the original changed-file count (after ignore rules)
and the count after projection, followed by the changed artifacts. Explanation
JSON includes originalFiles and files when a profile is selected, including
when explaining a single project. files contains the paths used for selection.
Project-list JSON stays a plain array.
A profile with projections is rejected by task-level affected selection:
qk affected --granularity task and show tasks --affected. Without
--granularity, where qk affected would select tasks, as it does by
default, it selects projects under such a profile and says so on stderr (see
Tasks or projects). Task dependency
propagation and cache inputs keep their ordinary meanings.
Try the example
The examples/affected-projections workspace uses only Node’s standard library. Its adapter fingerprints each consumer’s
runtime object in a JSON contract and ignores its types object. It does
not run a real code generator; the small representation makes the protocol
and project propagation visible.
Copy examples/affected-projections outside the qk repository, then:
git init -b main
git add .
git commit -m 'Initial contract'
Edit schemas/contract.json, change only types.id, and commit it. Ordinary
selection includes schema and web; the runtime profile returns an empty
array:
qk show projects --affected --base HEAD^ --head HEAD --json
qk show projects --affected --affected-profile runtime --base HEAD^ --head HEAD --json
Next change runtime.operation and commit it. The runtime profile includes
web, because apps/web/generated/contract.js has a different fingerprint.
qk show affected web with the same profile and revisions explains why.
Import reachability
A task is affected when one of its inputs changes, whether or not its code
uses what changed. An affected profile with reachability leaves out a task
whose entry points do not import the change, so a change to a corner of a
shared package that only one app uses does not select every app’s builds.
Imports are followed by fallout.
qk show projects --affected --affected-profile reach --base main --head HEAD
qk show tasks -t build test --affected --affected-profile reach --base main --head HEAD
The profile is opt-in. It decides tasks, and a project by its tasks: a project is left out only when every task of it a change affects is. A target can also name cases it runs separately. Ordinary selection and cache keys do not change. Use it where a wrongly skipped task is recovered cheaply, such as deciding which app builds a pull request runs, and keep ordinary selection for checks and for the default branch.
Configure it
Name the settings tasks are decided by under qk:reachability in nx.json,
and enable reachability in a profile. A profile can also contain
projections, which are applied first.
{
"qk:reachability": {
"app": {
"anchors": ["{projectRoot}/src/main.tsx", "{projectRoot}/vite.config.ts"],
"sources": ["{projectRoot}/src/**/*.{ts,tsx}", "{workspaceRoot}/packages/*/src/**/*.{ts,tsx}"]
},
"unit": {
"anchors": ["{projectRoot}/test/setup.ts"],
"cases": ["{projectRoot}/src/**/*.test.{ts,tsx}"],
"sources": ["{projectRoot}/src/**/*.{ts,tsx}", "{projectRoot}/test/**/*.ts", "{workspaceRoot}/packages/*/src/**/*.{ts,tsx}"]
}
},
"qk:affectedProfiles": {
"reach": { "reachability": { "default": "app" } }
}
}
A target takes part through its qk:reachability, in its project’s
configuration or from targetDefaults:
| Value | Meaning |
|---|---|
| absent | The profile’s default config, or none without one |
| a name | That config from nx.json |
false | None, even with a default: ordinary selection decides it |
| an object | Settings of its own, as a named config declares them |
{
"name": "app",
"targets": {
"build": {},
"test": { "qk:reachability": "unit" },
"lint": { "qk:reachability": false }
}
}
"reachability": true enables the profile without a default, so only
targets that name a config or declare one take part.
| Setting | Meaning |
|---|---|
anchors | Globs naming the files every run of the task depends on through their imports: entry points, build configuration, a test harness |
cases | Globs naming files the task runs separately, each decided on its own. See Cases |
sources | Nonempty globs naming the files whose changes matter only through imports |
A config needs anchors, cases or both, and sources. Paths take
{projectRoot} and {workspaceRoot}, expanded for the task’s project, so
one config serves every project laid out alike. They take ! exclusions: a
file matches when an inclusion matches it and no exclusion does, in any
order. A package.json is never a source, since it decides what imports
resolve to.
Anchor what each task runs, not only what the app ships. An app’s build reads its entry points; its unit tests also read their harness and helpers, which the app never imports. A test target narrowed by the app’s anchors would be left out when a helper changes.
List code in sources, not every file under a directory. A GraphQL schema,
a translation catalogue or a tool’s configuration reaches a build through a
generator or a tool rather than an import, so as sources they would let a
change that matters leave the task out. Leave test files in: a changed test
reaches the task only if an anchor or case imports it, while a file left out
of sources keeps the task whenever it changes.
"sources": [
"{projectRoot}/src/**/*.{ts,tsx,js,jsx}",
"{workspaceRoot}/packages/**/*.{ts,tsx,js,jsx}",
"!{workspaceRoot}/packages/**/*.config.{ts,js}"
]
An end-to-end target that drives an app rather than importing it can name
the app’s anchors with {workspaceRoot}, so the two are decided alike. Every
target’s settings are read whenever the profile is selected, so a mistake is
reported before a change needs it. qk:reachability on a project rather than
a target is refused.
When a task is left out
Under the profile, a task with settings that ordinary task selection affects is decided by its changed inputs:
- A changed input that is not a source runs the whole task, as do lockfile
installs, a lockfile or root
package.jsonchange Nx selects the task for, a changed root tsconfig, a deleted manifest and an affected task whose outputs it reads. A continuous task it depends on is not one of these: its inputs count as the task’s own, so the profile decides them by imports like any other changed input. That holds whatever else touches the task, and for a dependency the profile keeps for any reason, such as an anchor importing a change; one it leaves out does not count. The task’s ordinary inputs are what make this sound: declare its harness, its workflow and anything else that changes how it runs as inputs, outsidesources. A dependency’spackage.jsonwhose fields dependents read are unchanged, such as one with only a newversionorscripts, is the exception: it does not count, though it changes a project configuration Nx reads. A new or moved dependency does. - An anchor that imports a change runs the whole task. fallout searches from each anchor through the imports, at file granularity, comparing each changed file with its base version. A change made only of TypeScript types does not count, since it does not change what a bundler emits.
- An import the search cannot place runs the whole task. If fallout cannot resolve an import the repository answers for, such as a relative path or a workspace package that is not linked, the change may be behind it.
- Otherwise the task is left out, and with it any task affected only through it, or narrowed to its cases that import a change.
Anything that prevents an answer runs the whole task and says why: anchors or
cases that match no file, a fallout.toml that cannot be read, or a head
revision that is not the checkout.
Project selection
Under the profile, project selection decides each affected project by its tasks: every target of it, with the tasks they depend on. A project is left out when the profile left out every task of it that a change affects. One with a task that still runs is kept, as is one none of whose affected tasks the profile decides. Its dependents are decided by their own tasks.
A project with many targets is left out rarely, since any of its tasks without settings keeps it. To ask about the targets a job runs, such as which apps’ builds a pull request needs, select projects by those targets instead:
qk show projects --affected -t build,export --affected-profile reach --json
Under task selection, the default, this lists the projects where a task of
one of the targets is affected, by its inputs and then by the profile.
--affected-targets in place of -t does so under project selection too.
A profile with projections selects projects instead (see
Tasks or projects), and cannot be used with
--affected-targets, since task selection reads declared inputs.
Cases
A target can name cases: files it runs separately, such as the cases of a
visual regression suite or a project’s test files, each decided on its own.
{
"targets": {
"visual": {
"inputs": ["default", "^default", "{workspaceRoot}/.github/workflows/visual.yml"],
"qk:reachability": {
"anchors": ["{projectRoot}/visual/shell.tsx"],
"cases": ["{projectRoot}/visual/cases/**/*.tsx"],
"sources": ["{projectRoot}/src/**/*", "{projectRoot}/visual/**/*.tsx", "{workspaceRoot}/packages/*/src/**/*"]
}
}
}
}
Anchors are what every case runs inside, such as a suite’s shell, so an anchor that imports a change runs every case. Otherwise the cases that import a change, or a changed case itself, are selected. With none, the task is left out.
qk show tasks -t <targets> --affected --affected-profile <name> lists each
selected case with the change it imports, and --json adds a reachability
object with each task’s decision.
Running only the selected cases
qk affected -t <targets> --affected-profile <name> runs the affected tasks,
under task selection, the default. A task narrowed to some of its cases receives
QK_AFFECTED_CASES, the absolute path of a temporary file naming those cases
one per line, each relative to the workspace root. Read the file at that path
as it is, from any working directory. The variable is absent when the whole task
runs, so a task that does not read it runs every case, which is always safe.
if [ -n "$QK_AFFECTED_CASES" ]; then
suite --only "$(paste -sd, "$QK_AFFECTED_CASES")"
else
suite
fi
Such a task runs without the cache, reads and writes both: its result covers the selected cases alone, so it must not stand for a run of every case. A suite’s own rules, such as running the cases that show translated copy when a translation catalogue changes, stay in the suite, which can add cases to the list but should not drop them.
Requirements
- The head must be the checkout. fallout reads files from disk, so with
--headnaming another commit, or with uncommitted changes to tracked files, every task runs. Run selection in the checkout of the revision being tested, as CI does. - Install workspace packages first. Imports between workspace packages
resolve through
node_modules. Without them, those imports are gaps and nothing is left out: the profile is safe and saves nothing. - Only static imports are followed. What a bundler adds through its
configuration, native code and requests built at run time are not seen,
which is why only changes to
sourcescan leave a task out. - Bundler settings come from
fallout.toml. Whichexportsconditions andpackage.jsonfields an app’s bundler reads, and its import aliases, are declared in afallout.tomlbeside the app. See fallout’s documentation.
Explaining the result
qk show affected lists the projects the profile left out after the affected
ones, and notes for each kept project with a narrowed task which task runs
and why: the anchor that imports a change, the import that could not be
resolved, or the change imports do not carry. With a project, it shows what
the profile decided for each of its affected tasks, or the chain of imports
that reached one. --json adds a reachability object with each decision.
qk show tasks --affected does the same for tasks.
$ qk show affected --affected-profile reach --base main --head HEAD
1 changed file between 3f1c0a9e2b7d and HEAD.
Granularity: task (the default).
other depends on lib
lib touched: libs/lib/src/unused.ts changed
Left out by import reachability:
app-e2e left out: none of its 1 affected task runs
app left out: none of its 1 affected task runs
A workspace can record this beside ordinary selection without acting on it, and compare what it would have left out with what later broke on the default branch, before letting it skip anything.
Local task cache
qk cache path
qk run web:build
qk run web:build
qk run web:build --skip-cache
qk cache prune --max-size 1GB
With a cacheable target and unchanged inputs, the second run restores outputs and replays logs. Run history explains why a key changed.
Targets with cache: true are cached locally. Inside a Git repository the
cache lives by default in <git common dir>/qk/cache/v1, so every linked worktree of the
repository shares one cache. What belongs to one worktree (file digests,
records of the outputs it holds, restores in progress) lives in its own git
directory, $(git rev-parse --git-dir)/qk, so nothing of qk’s appears in the
working tree. Outside Git both live in .qk at the workspace root, the cache
in .qk/cache/v1. qk cache path prints the cache’s location. --skip-cache (also
--skip-nx-cache and --skipNxCache) bypasses result-cache reads and writes.
Otherwise cacheable executions still record their keys and bounded stdout/stderr
in run history, so repeated forced runs can reveal flaky outcomes.
What changes the key
A task’s key covers its ID, forwarded arguments, resolved target definition,
declared inputs, the package manager, qk’s version and the platform. As in
Nx, a dependency task counts only through the outputs the task reads: files
its dependentTasksOutputFiles inputs select from the dependency’s declared
outputs, or that its filesets select. A run that reproduces those outputs
leaves the task cached however the dependency’s own inputs changed, and a
task that reads none of them is keyed without them. A task that reads a
dependency’s outputs without declaring it can therefore be restored stale;
declare such reads as inputs. Files are keyed
by workspace-relative path, content and mode. Branch names and checkout
locations are not part of the key, so identical sources in two worktrees
share entries.
A key has two kinds of input. What the task’s inputs select counts as they
say: a file by its whole content and a json input by its fields, whatever
the file is. Beside them, qk adds workspace configuration and package-manager
state to every task’s key on its own, each by the rule below. Where an input
selects a whole file qk would add, the file counts whole, as the input says. A
json input on such a file adds its fields beside qk’s copy rather than
narrowing it.
Root workspace files (.gitignore, .nxignore,
pnpm-workspace.yaml, lockfiles) and the package.json of the task’s
project are always included, its scripts and dependency declarations with
it. The root tsconfig
(tsconfig.base.json, or else tsconfig.json) is included as Nx hashes it:
without compilerOptions.paths, so adding or moving a path mapping leaves
every key as it was. With selectivelyHashTsConfig: true in the options of
nx.json’s tasksRunnerOptions runner (the one --runner names, else the one
NX_TASKS_RUNNER names, else default), the key also holds the mappings whose targets lie in the projects
whose inputs the task reads: its own, and those its ^ and projects inputs
or a continuous task serving it reach. A root tsconfig that cannot be read as
JSON with comments is keyed whole. The root package.json is not, as in Nx:
what the root importer installs counts for every task through the lockfile, as
below, and the rest of it is the root project’s own manifest, so editing a root
script misses only the root project’s tasks. Dotenv files are not, as in Nx: they hold
per-machine values and credentials, so keying them would keep machines from
sharing entries; env inputs key the variables a task declares.
nx.json counts through what it changes rather than by its content, unlike
in Nx, which hashes it into every task. Merged with the file it extends and
nx.local.json, it reaches a task through the task’s resolved
configuration, which the key holds already: targetDefaults through its
definition, and namedInputs through what its inputs select, the files, env
and runtime inputs, externalDependencies and dependency outputs they name,
a dependency’s through ^ inputs included. Beside that, the key holds the
merged settings without targetDefaults and namedInputs, and without those
that change no task’s work: $schema, extends, defaultBase, affected,
parallel, tui, cli other than cli.packageManager, generators,
release, sync, s3, nxCloudId, nxCloudAccessToken, nxCloudUrl,
nxCloudEncryptionKey, neverConnectToCloud, defaultProject,
cacheDirectory, maxCacheSize, qk:storeServer, qk:affectedProfiles,
qk:reachability, and the
workspace-wide implicitDependencies, which Nx no longer supports. Every
other setting, such as plugins, pluginsConfig, workspaceLayout,
tasksRunnerOptions, which can set selectivelyHashTsConfig, or one qk does
not know, changes every task’s key. Comments and formatting change none, and
neither does a named input edited to select the same files. A tool that reads
nx.json itself declares {workspaceRoot}/nx.json as an input, which keys
the file whole.
A project’s project.json and project.local.json are not added to its
tasks’ keys either. An input that selects one, such as {projectRoot}/**/*,
which default holds unless the workspace names it otherwise, keys it as
the input says, as for any file and as in Nx: whole for a fileset, by its
fields for a json input. Where no input selects them, they count through
what they change. Their targets reach a task through its definition, and
their named inputs through what its inputs select. Tags, the project’s name
and implicitDependencies reach a task through which projects its inputs
and dependencies select, and sourceRoot through the output paths
{project.sourceRoot} names: the key holds each task’s resolved output
paths. Beside those, a task’s key holds what is left of its own project’s
configuration without name, targets, namedInputs, tags,
implicitDependencies, sourceRoot, metadata, projectType, $schema,
generators, release and qk:reachability, such as its root, and all
of it when a forwarded argument holds a {project.*} token.
To have tasks keyed by their resolved configuration rather than by the
files, leave the files out of the inputs that select them, as default
does here:
{
"namedInputs": {
"default": [
"{projectRoot}/**/*",
"!{projectRoot}/project.json",
"!{projectRoot}/project.local.json"
],
"production": ["default", "!{projectRoot}/**/*.spec.ts"]
}
}
A comment, or an edit to one target, then misses only the tasks whose keys
it changes. The negations hold for every named input built on default, such
as production, and for a dependency’s files read through ^production. As
in Nx, a negation leaves the file out of all of the task’s filesets, so a
target that also names {projectRoot}/project.json does not read it back;
one whose inputs do not build on default names it to key it whole.
The package.json of each project the task’s project depends on, directly or
not, counts by what it decides for its dependents, such as where their imports
of it resolve, even when the task’s inputs leave the file out. Its version,
scripts, description, keywords, author, contributors, maintainers,
license, homepage, repository, bugs, funding, private and
publishConfig are left out, since none of them changes what a dependent reads
or runs. A version that moves into or out of the range a dependent declares for
the package adds or removes that dependency instead, which changes the
dependent’s key through what it depends on. With a pnpm lockfile qk can read,
its dependencies, devDependencies, peerDependencies,
peerDependenciesMeta, optionalDependencies and dependenciesMeta are left
out too: what they install is keyed through the lockfile, as below. A manifest
the task’s inputs select, as ^default selects every file of each dependency,
counts whole. A dependency
releasing a new version within its dependents’ ranges therefore leaves them
cached, while a change to its exports reaches them.
A pnpm v9 pnpm-lock.yaml is keyed by what it installs rather than by its
content: for the root importer and the importers of the task’s project and
its transitive project dependencies, every package installation they reach,
by snapshot key (version, peers and patch), integrity and dependencies. The
root importer counts for every task because its packages resolve from every
package, unless the task names its external dependencies: as in Nx, a task
with an externalDependencies input, even an empty one, hashes the packages
it names in place of every installed one, so only the root project’s own
tasks then count the root importer. Name in it every root-installed tool the
task runs; the sandbox’s audit and
input analysis report a package a task reads but its
key leaves out. A lockfile change therefore invalidates only the tasks whose
projects install something that changed, plus every task when the lockfile
version, pnpm’s settings or the lock of pnpm itself changes. A lockfile qk
cannot read, or one the task’s inputs select, is keyed by its whole content.
With such a lockfile, pnpm-workspace.yaml is keyed without the keys that
configure resolution (catalog, catalogs, overrides,
patchedDependencies, peerDependencyRules and the like): their effect lands
in the lockfile, so a catalog bump invalidates only the tasks whose installs
change. A task whose inputs select the file keys it whole, as they say. That
includes a root project’s own files and a task that reads the catalog itself,
such as a catalog-consistency lint. A task that reads only packages or other
keys outside resolution need not select it: the copy qk adds already covers
them, while selecting it reruns the task on every catalog change. A task that
reads only some resolution keys, such as catalog, can add them with a json
input, which reads the file as YAML. Affected selection applies the same
rules.
qk show hash <task> prints a task’s key and what it is computed from
without running it, and --against compares it with another task’s key or
with the task’s key in a recorded run; see
Key inspection.
Inputs
Candidate input files are tracked and untracked-but-not-ignored files, minus
planned tasks’ declared outputs. Nx keys a tracked declared output like any
other file; qk leaves it out, so that a task regenerating a tracked file does
not change keys, its own included. Source filesets also select other tasks’ declared
outputs after dependencies complete, including newly generated files, while
respecting source ignore rules and excluding the consumer’s own outputs. These
files are keyed independently of selective dependentTasksOutputFiles inputs.
The source list is taken once per run, so a file that a task creates without
declaring it as an output is seen from the next run. File contents are re-read whenever their metadata changes; their
digests persist between runs in the worktree’s state, keyed by
path, size, modification time and, on Unix, change time, inode and mode, so a
warm run reads only files whose metadata changed. Without
inputs, a task uses default and ^default; default is
{projectRoot}/**/* unless a named input overrides it. As in Nx, a
project’s filesets select only the files it owns, leaving out those of
projects nested in its root.
Every input declaration of Nx 24 is supported, always read as absent:
- filesets, as strings or
{"fileset": "…"}, with!exclusions; a path with no glob pattern names a file or a whole directory, and\escapes a glob character. With"includeIgnored": truea fileset reads the disk, gitignored files included, and a file it reads is keyed by the content it points to, wherever that is; - named inputs, also as
{"input": "name"}; ^nameand{"input": "name", "dependencies": true}(or"projects": "dependencies"), which as in Nx cover every project the project depends on, directly or not, and^{projectRoot}/…,^{workspaceRoot}/…or{"fileset": "…", "dependencies": true}, a fileset of each of them;{"input": "name", "projects": […]}, the named input of the projects the project selectors select;env,runtime,dependentTasksOutputFileswithtransitive, andexternalDependencies, which adds every installation of the named packages in the pnpm lockfile, whichever importer installs them;{"workingDirectory": "relative" | "absolute"}, the directory qk was run from;{"json": "path", "fields": […], "excludeFields": […]}, only the selected dotted fields of a JSON file, or of each file a glob selects. Affected selection counts any change to the file, even when source ignore rules exclude its path.
As in Nx, a named input cannot use dependencies or projects. With a
package manager other than pnpm, lockfiles are always keyed by content.
Generated artifacts stay out of source filesets. A consumer’s filesets can
include outputs from its dependency tasks, including transitive dependencies.
Outputs in git-ignored directories are read only by dependentTasksOutputFiles
and by filesets with includeIgnored. Outputs from downstream or unrelated
tasks do not feed back into its cache key.
runtime commands share one evaluation per run when their effective runtime
environments match, including when tasks request them concurrently. That
environment includes dotenv, explicit settings and local binary lookup.
As in Nx, runtime inputs are evaluated before task metadata such as
NX_TASK_TARGET_TARGET is injected. Runner color defaults apply to task
execution; an explicitly configured FORCE_COLOR remains visible to runtime
commands.
dependentTasksOutputFiles keys the selected artifacts directly; qk checks
their listings and contents again after execution before saving a result.
. and .. segments in
paths are resolved within the workspace. A symlinked input is keyed by its
target text and the content it resolves to, including the files below a
linked directory, even when source ignore rules exclude the target. Nested links
are followed; directory cycles and links outside the workspace run uncached.
Task-level affected selection follows changes to symlink targets, including
deletions, even when the target lies outside the source glob that selected the link.
See Inputs and outputs for glob expansion rules and the affected-selection differences from Nx for bare parenthesized extension groups.
Outputs
Outputs resolve as in Nx 24. {projectRoot} and {projectName} name the
project, {options.name} reads the target’s options, with the task’s
--name=value arguments applied, and the legacy {project.<path>} reads
the project’s configuration, such as {project.sourceRoot}. A placeholder
can fall back, left to right, to others and finally to a literal:
{options.outputPath ?? options.outDir ?? {projectRoot}/dist}. A value is
written as JavaScript writes it, so false is false, 0 is 0 and an
array is its items joined with ,; only a missing option, null and ""
have no value. Option values have {projectRoot}, {projectName} and a
leading {workspaceRoot} resolved first, as Nx does when it builds the
project graph. Braces that do not start with a token are glob text, so
{projectRoot}/dist/**/*.{js,map} is one output, and so is {env.NAME}:
environment variables are no placeholder in outputs.
As in Nx 24, every output starts with a token, after its ! if negated:
{projectRoot}/dist or {workspaceRoot}/dist, not dist, which nx repair
rewrites. An output whose {options.…} placeholder, or a {projectRoot} or
{workspaceRoot} left as text, has no value is left out. {workspaceRoot}
may only start an output, and an output from it must name a directory
before any pattern: {workspaceRoot}/dist/**, not {workspaceRoot}/**.
Building the task graph fails on an output that breaks either rule. An
absolute output from an option is read relative to the workspace, and one
outside the workspace is an error. ! negates an output:
what it matches is neither cached nor removed when an entry is restored. A
target without outputs takes options.outputPath, and a build or
prepare target dist/<root>, <root>/dist, <root>/build and
<root>/public. Those defaults are cached and restored, but because they are
guesses they are not declared outputs: they stay source files that other
tasks’ filesets can select, and dependentTasksOutputFiles selects none of
them.
A negated group such as src/**/!(*.test).ts or dist/!(cache)/** expands
as Nx expands it, into a glob with the group widened and one naming the
group’s items that excludes, so both tools select the same files, including
where Nx’s expansion is surprising: *.!(ts) keeps only files ending in .,
as in Nx. Nx applies such exclusions to every pattern of a project at once;
qk applies each to the pattern it comes from, which can only add inputs.
A task with any other input declaration, or an output without a fixed directory prefix, runs uncached and reports why.
Saving and restoring results
On a miss, the task runs with stdout and stderr streamed through a pipe while
they are recorded, or, when qk’s output is a terminal, through a
pseudo-terminal, which records them
as one stream that replays on stdout. Runtime inputs always use pipes, so a
key does not depend on where qk ran. The entry is
saved only when the task succeeds and its inputs are unchanged afterwards.
Declared dependency outputs are rechecked too. Cache keys use a new schema so
entries saved before these artifact checks are not reused.
On a hit, qk removes existing files matching the declared outputs, copies the
cached outputs into place and replays the recorded stdout and stderr. As in
Nx, outputs a worktree already holds for the key are left as they are: after
each restore or save qk records, in the worktree’s state, every output
path with its size, times, inode and mode, and a hit that finds exactly those
still in place only replays the log, marked
[existing outputs match the cache, left as is]. Restores
are staged in the worktree’s state. Saving and restoring copy files,
which clones them on copy-on-write filesystems such as APFS when the cache and
the checkout share a volume. Files are never hardlinked, so editing a restored
file never changes the cache. Every file is verified
against its content hash; an unreadable or corrupt entry is a miss.
A per-key lock makes concurrent runs of the same task, including runs in
different worktrees, wait for each other and reuse the result.
Restored files take the time their restore started as their modification
time. A task restores only after its dependencies finish, and its sources
were written before the run, so restored outputs are newer than the sources
they were built from and newer than the outputs of the tasks they depend on.
Tools that decide what to rebuild by comparing modification times, such as
tsc --build, then find restored outputs up to date. Outputs a worktree
already holds are stamped again when a dependency’s outputs changed in the
run, so they stay the newer. Symbolic links and directories keep their
times.
Entries unused for a week are evicted after each run, as in Nx, and the cache stays under a size limit. See remote caching for size limits and cache directory overrides.
Remote cache and storage limits
nx.json’s s3 key, as @nx/s3-cache reads it, adds a remote store on
S3-compatible storage: bucket, region, endpoint, forcePathStyle,
cacheKeyPrefix, accessKeyId and secretAccessKey. Credentials otherwise
come from AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and
AWS_SESSION_TOKEN, including from the workspace’s .env files. localMode
applies outside CI and ciMode when CI is set; each is read-write (the
default), read (also spelled read-only) or no-cache, and
NX_POWERPACK_CACHE_MODE overrides both. encryptionKey and SSO profiles are
not supported; a store that cannot be used is reported and left out, and the
local cache carries on.
Entries are stored under <cacheKeyPrefix>qk/v4/, so a bucket shared with Nx
never mixes the two. Result entries and portable warm state use streaming
Zstandard level 3 compression with a frame checksum. Each entry is one object
holding its manifest and its small outputs, so that a lookup or restore of an
entry whose outputs are all small is one request however many files the task
wrote. A local miss fetches it, keeps the
outputs the local cache lacks, verifying each against its hash, and shows
[remote cache]; any failure is a miss. The path sets of tasks with
qk:ultracache are stored as pathsets/<weak>. A store
server takes at most 16 MiB of them for one task, so a larger record is
shared without its oldest sets; one whose newest set alone is larger is not
shared, and its upload fails.
What tasks are expected to take, which orders them and sizes their
shares of the cores, is stored as expectations/<name>, one
record per branch and platform. It holds each task’s last five runs that
executed it: their time, the cores they held and the memory they used. A run
that executed tasks adds them to the record of its branch, starting from the
default branch’s when its branch has none, and saves it for its branch; the
newest save replaces the last, and a task no run has executed for 30 days is
dropped. A run whose local history lacks a task reads the record of its
branch, else of the default branch, so that a machine without history, such
as a CI job on a fresh clone, still knows which tasks take long. Local history
wins for a task it has. A record that cannot be read leaves the run without
it, and is not replaced. The record is fetched while the run gets ready to
start its tasks, and waited for only when local history lacks one of them.
Expectations are read and written as the store’s mode allows.
QK_REMOTE_EXPECTATIONS narrows that: read (also spelled read-only) reads
them without adding to them, off neither reads nor writes them, and
read-write, the default, changes nothing.
An output whose compressed size reaches 1 MiB is stored apart, as
blobs/<hash>, uploaded once for every entry that has it and only when the
store lacks it. A machine fetches it only when its local cache lacks it, so a
machine that already holds a build’s large files receives little more than the
manifest. The index names each entry’s large outputs, so a machine lacking
them fetches them beside the entry, a few at a time, rather than after it.
QK_REMOTE_BLOB_THRESHOLD sets another size, such as 256KB, or off to keep
every output in its entry. Large outputs are shared between entries, so do not
expire blobs/ with a storage lifecycle rule: an entry whose output is gone is
a miss, never a wrong result, but every entry citing it misses until it runs
again. After a task is saved it is uploaded in the background, and a run waits
for its uploads before it ends. Uploads report their failures without failing
the run; an entry larger than 5 GiB, the most one S3 upload may hold, is not
uploaded.
A request may take ten minutes, which allows for the largest transfers, but one that receives nothing from the store for 30 seconds fails: a store that stops answering costs a lookup a miss and an upload its failure, rather than holding every task that asks it. Once the store has stalled twice, the run asks it nothing more: each later lookup misses without asking, each later upload fails, and qk says so once. Until then, when the connection fails an upload, by a reset, a broken pipe or a stall, qk sends it twice more, after two and then four seconds, and stops trying once the store is left out. An upload the store refuses, or one that runs out its ten minutes, fails at once.
For local runs, qk can hand queued uploads to a detached process after local
cache saves finish. Set s3.uploadMode to "background" to opt in:
{
"s3": {
"bucket": "task-cache",
"region": "us-east-1",
"uploadMode": "background"
}
}
"wait" is the default. QK_REMOTE_UPLOAD_MODE=wait overrides the setting
for a run; use it in CI so uploads finish before the runner is torn down.
It waits for that run’s uploads, not workers started by earlier runs.
Background mode starts uploads at the end of the run, whereas the default
mode overlaps uploads with task execution and waits for them at the end.
The detached process receives a private snapshot of result manifests and warm records, with hard links to immutable cache blobs (copies when hard links are unavailable). It can finish after qk exits, even if the local cache is pruned or reset. Snapshots and private failure logs live beside the cache directory; qk prints the log path on handoff. Credentials go through a pipe, not command arguments or a request file. If handoff fails, qk waits for the uploads instead.
The worker removes its snapshot when it finishes and writes a completion line with a failure count to the log. This is best-effort background work, not a durable retry queue: shutdown, worker termination, or CI job cleanup can interrupt it. Later background runs reclaim snapshots older than one day when no worker holds their lease, and logs older than one day. Pending snapshots can retain disk space outside the cache size limit until cleanup.
The remote format uses a clean cutover from qk/v3: new clients neither fetch
nor overwrite older remote objects. Upgrading may cause one-time remote
misses while the compressed cache fills. Existing local entries are unchanged,
and older clients can continue using their own remote namespace. There is no
legacy lookup after a miss. Downloads limit the decoder window to 128 MiB and
the expanded pack to 5 GiB; malformed, truncated or oversized packs are misses.
The local content-addressed blob store remains uncompressed.
Every request to the store waits a round trip, so qk keeps an index of the
entries it holds and does not ask for one the index leaves out. Each run
that writes to the store adds a listing under index/ of the entries it
found or put there. Each entry upload also writes a listing before
publishing the entry, so an interrupted run or a failed final listing cannot
leave a stored entry undiscoverable. After the final listing succeeds, the
run removes its replaced announcements in batches. If that first listing
fails, the entry is not uploaded; if the entry upload fails, its listed key
simply returns a miss. A run lists index/ as it starts, reads the listings it
has not seen and keeps them in the local cache. If that fails, including a
listing removed by concurrent compaction, it looks up each entry as it needs
it. Paginated indexes also use direct lookups because pages cannot establish
a complete snapshot during concurrent compaction. Listing payloads are bounded
at 8 MiB each and 32 MiB across a synchronization; exceeding either limit also
uses direct lookups without retaining an incomplete index. An entry uploaded
after a run started is not found by that run. Once there are
16 or more listings, a writing run merges them into its own and deletes
them, so the store’s credentials need permission to delete objects. Entries
whose timestamps have not been renewed for 30 days drop out of the index.
Read-write local cache hits renew entries an available remote index already
lists; unuploaded local results are not advertised.
A listing also records its run: the entries it used, in the order it used
them. Merging keeps the most recent runs, at most 32, none older than 14 days.
A cached chain of tasks otherwise fetches one entry per level, each waiting for
the level below. Once a task’s entry is a remote hit, qk fetches the other
entries of the recent runs that used it in the background, nearest to it
first, so a dependent usually finds its entry already local; a task whose
entry is still being fetched waits for that fetch. Prefetching stops when
fewer than half of the run’s entries match the runs it follows, after a
gigabyte or 5,000 entries, or when the run ends, without waiting for it. A
prefetched entry is shown as [remote cache], and listed, only once a task
uses it. QK_REMOTE_PREFETCH=off turns it off.
Store server
nx.json’s qk:storeServer key sends the remote cache to a store server
instead of S3, with its token in QK_STORE_SERVER_TOKEN:
{
"qk:storeServer": { "url": "https://cache.example.com" }
}
A store server keeps the same entries, but each run first tells it which
outputs the local cache holds, and it leaves those out of every entry it sends.
A run whose request to tell it fails says why, and receives entries whole,
as does, silently, one whose local cache holds no outputs or more than two
million. It keeps the index itself and commits each entry once, so runs
neither write announcements nor merge listings. localMode, ciMode, uploadMode and
NX_POWERPACK_CACHE_MODE apply as they do for s3. A read token fetches; a
write token also uploads.
Nx does not read qk:storeServer, so a workspace that still runs some tasks
with Nx can keep s3 for Nx’s cache plugin: qk uses the store server and
ignores s3.
tools/store-server in qk’s repository is a reference server, a Cloudflare
Worker over R2, and documents the protocol in its PROTOCOL.md.
Storage limits
The cache stays under a size limit: NX_MAX_CACHE_SIZE, else nx.json
maxCacheSize, else a tenth of the disk holding it (100GB when the disk’s
size cannot be read), as in Nx’s code; its documentation’s 10GB cap is not
in the code. Sizes are a number of bytes with an optional KB, MB or GB,
in powers of 1024; 0 means unlimited. After each run, and on
qk cache prune [--max-size <size>], qk evicts the entries unused for a
week, whatever the limit, and when the cache is over the limit the least
recently used entries until it holds at most 90% of it, as Nx does. A hit
counts as a use. Outputs shared between entries are stored once and deleted only
with the last entry citing them. Stored outputs no entry cites, scratch files
and locks of evicted entries are removed once they are an hour old, since a
concurrent run may still be writing them; outputs a remote fetch in progress
relies on are kept whatever their age. A run under the limit only sums the
cache’s size; the full pass runs at most hourly unless the cache is over its
limit.
NX_CACHE_DIRECTORY, else nx.json cacheDirectory, moves the cache, as in
Nx: a path relative to the workspace root, with qk’s entries in qk/v1
inside it so they never mix with Nx’s, and so CI steps that save and restore
that directory keep working. A relative path gives each worktree its own
cache; the run history and each worktree’s state stay in the git directory.
Reusing warm state
A target can keep scratch state that makes it faster to rerun, without that
state ever being part of a result, with a qk:warm key at target level
(beside inputs and outputs, not in options; Nx ignores it):
"tsc": { "qk:warm": { "outputs": true } },
"build-android": {
"qk:warm": {
"paths": ["{projectRoot}/node_modules/.cache/babel"],
"env": { "METRO_CACHE_DIR": "{warm}/metro" },
"maxSize": "2GB"
}
}
outputs: truerestores the task’s previous outputs before it runs, so an incremental tool finds its last build, such astsc --builditstsbuildinfo.pathsare workspace scratch paths, kept beside the task’s entries. They are never inputs. Like outputs, they may be globs with a fixed directory prefix, and a path starting with!excludes what it matches from the saves, such as packaged artifacts that every build rewrites; excluded files are not inputs either.envsets variables for the task, with absolute directory tokens{projectRoot},{workspaceRoot}and{warm}, a directory qk keeps for the task in the worktree’s state, outside the working tree. Under Nx these variables are not set, so tools that only cache when told to keep their default behaviour there.
Warm state is restored before the task runs, on a cache miss and for
targets that are not cacheable, and never on a hit. A group already present
on disk is left alone, since it is the newest for that checkout; otherwise
it comes from the worktree’s own save. The task’s paths count as one group
unless they are restored one by one. With
portable: true, it can also come from the store linked worktrees share or
from the remote store, and with portable: "path" from the remote store when
saved in a checkout at the same path. Each worktree
keeps its own save, and a restore takes the worktree’s own before the most
recent other worktree’s; the eight most recent saves of a task are kept.
With sharing enabled and no suitable local save, it comes from the remote store: the current branch’s
save, else the default branch’s (nx.json defaultBase, else main).
A local save made at a commit HEAD does not descend from, as on another
branch, counts as suitable only when the remote store has none: a CI runner
that builds every branch in one directory keeps one save there, the last
branch’s, which is the least like the checkout it is restored into. The
branch comes from GITHUB_HEAD_REF or GITHUB_REF_NAME in CI, else from
git; a checkout with no branch reads the default branch’s state but saves
none remotely. remote: false keeps a target’s warm state local. It is
saved after successful runs only; files unchanged since the last save or
restore are recognised by their metadata and not read again.
New warm files are copied with bounded concurrency. Cache writes are left
to the operating system to flush to disk, for both warm state and result
entries. A power loss may discard or damage a cache entry; blobs are
verified before restoration, invalid result entries are misses, and a failed
warm restore does not prevent the target from running.
The timestamp and path policies below distinguish reusable tool state from
verified result outputs.
Replacing a bad save
A save holding state a tool cannot recover from is restored by every run
that misses, saved again once the run succeeds, and shared through the remote
store with every machine on the branch, or on every branch that falls back
to the default branch’s. Removing the warm paths by hand, or qk reset,
clears only this machine’s copy, and the next run restores the save again.
--cold, or QK_COLD=true, runs each task from scratch: it restores no
result, empties the task’s warm paths rather than restore them, and saves
what the task gives as usual. Its save replaces the bad one, here and in the
remote store, for the branch it ran on, so a bad save on the default branch
is replaced by a cold run on that branch:
qk run app:build --cold
A group tasks share is emptied once a run, so a task sharing it with one that ran before it takes what that one left. A group qk cannot empty, as when a file in it cannot be removed, is not saved, so the run cannot keep what it was to replace; qk says so.
A task’s result is saved too, replacing the local entry under its key, and,
for a task with Ultracache recordings, the entries under the
keys its recordings give, which a later run looks up first; the remote store
keeps an entry it already holds. --cold cannot be combined
with --skip-cache, which saves nothing; QK_COLD gives way to a run that
skips the cache, as an input analysis does.
Result hits and warm state
A result-cache hit means qk has already verified the task’s inputs and can
restore its outputs without running it. Restored result files receive the
time restoration started, after the dependencies finished. This lets tools
such as tsc --build see valid outputs newer than their inputs. Outputs
already held unchanged on disk retain their timestamps unless dependency
outputs were renewed.
Warm state helps when the task must run. It can belong to an older input
set, so qk dates restored files to the Unix epoch by default and lets the
tool validate them. mtimes: "preserve" applies only to the worktree that
saved the state. Another worktree’s or a remote save still receives epoch
timestamps; this setting never promises an up-to-date build in a different
checkout. Preserving times also preserves the tool’s timestamp assumptions:
Make 3.81 can miss a rapid source edit within its output timestamp’s
one-second resolution. Validate source changes as well as unchanged builds
before using preserved times for that tool.
Choosing state for a tool
Separate final deliverables from the tool’s build history. Restoring an executable or library does not restore Cargo’s fingerprints, Ninja’s log, or Xcode’s build database. Those tools can rebuild even when the final artifact has a fresh timestamp.
The following guidance comes from local restoration and relocation checks with TypeScript 6.0, Make 3.81, Ninja 1.10, CMake 4.4, Xcode 27, Cargo 1.98, Metro 0.84, Gradle 9.3, Jest 30.5 with babel-jest, and Vitest 5.0.2. Tool versions, plugins, generated rules and compiler flags can change whether state relocates. The configurations are starting points to verify on your project.
| Tool and state | Same-worktree configuration | Different checkout path |
|---|---|---|
TypeScript build outputs and .tsbuildinfo | outputs: true; keep default epoch times; opt into portable: true after relocation checks | Relative build-info paths relocated and sources were revalidated. Custom generators or absolute paths need their own checks. |
| Make objects | Complete object/output paths, mtimes: "preserve", portable: false | Relative rules rebuilt correctly, but epoch objects did not skip compilation. |
| Ninja objects and logs | Complete build directory, mtimes: "preserve", portable: false | Relative rules rebuilt correctly. Absolute paths in generated rules can bind the state to the old checkout. |
| CMake build directory | Complete build directory, mtimes: "preserve", portable: false | CMakeCache.txt retained the original source and build paths; relocation failed. Configure a new build directory. |
| Xcode build intermediates | Explicit intermediate/DerivedData paths, mtimes: "preserve", portable: false | A relative-source project rebuilt correctly but compiled again. CMake-generated projects also retained CMake’s absolute paths. |
| Cargo target directory | Actual target directory, mtimes: "preserve", portable: false | A simple crate rebuilt correctly, but was Dirty rather than Fresh. Build scripts and generated absolute paths need separate checks. |
| Metro transform cache | portable: true after validation; default epoch times; configure the tool’s cache store | Reused transforms at a different project root with the same transformer/configuration. Its file-map cache is separate. |
| Jest transform cache | Configure cacheDirectory; default epoch times worked; portable: false | babel-jest transformed modules again because the configuration/cache identity included checkout paths. |
| Vitest filesystem module cache | Enable fsModuleCache, configure fsModuleCachePath; default epoch times worked; portable: false | Module-cache keys included absolute module IDs and the Vite root; transforms ran again. |
| Gradle project history and build outputs | Complete local state, mtimes: "preserve", portable: false | A Java project rebuilt correctly; copied history alone did not make compilation up to date. Native/CMake state can impose stricter path requirements. |
Jest and Vitest
Warm transform caches avoid repeating compilation while the test runner still executes tests. In controlled two-module fixtures, Jest/babel-jest and Vitest with filesystem module caching each performed two transforms on a cold run, zero after restoring the same checkout’s cache, and one after changing a source module. Both epoch and preserved timestamps worked; preserving times provided no extra transform hits. Every run executed the test and asserted the current source value.
Linked-worktree and remote restores at a different checkout path each
performed both transforms again. These caches therefore use
portable: false in the recipes below. Remote checks used an isolated local
S3-protocol store on the same platform with empty local cache directories;
they do not establish portability across machines or operating systems.
For Jest, configure its cache directory explicitly rather than trying to capture the operating system’s temporary directory:
// jest.config.cjs
module.exports = {
cacheDirectory: '<rootDir>/.cache/jest',
};
"test": {
"qk:warm": {
"paths": ["{projectRoot}/.cache/jest"],
"portable": false
}
}
For Vitest, a Vite cache directory alone did not persist source transforms
between processes in the tested installation. Enable the
filesystem module cache
explicitly; defaults and option locations vary by version. Vitest 5 uses
test.fsModuleCache and test.fsModuleCachePath; versions that expose them
under test.experimental need that nesting instead.
// vitest.config.mjs
export default {
test: {
fsModuleCache: true,
fsModuleCachePath: '.cache/vitest-modules',
},
};
"test": {
"qk:warm": {
"paths": ["{projectRoot}/.cache/vitest-modules"],
"portable": false
}
}
Ignore these scratch directories in Git. In multi-project configurations, use the actual Jest root or Vitest cache root when declaring the warm path. Vitest plugins that read external files or options may need a cache key generator or an opt-out to invalidate transforms correctly. Include those inputs in qk’s task inputs too: task-result invalidation and transform-cache invalidation each need to cover them.
For example, keep Cargo’s local target directory with the worktree:
"compile-rust": {
"executor": "nx:run-commands",
"options": { "command": "cargo build --target-dir target" },
"qk:warm": {
"paths": ["{workspaceRoot}/target"],
"mtimes": "preserve",
"portable": false,
"key": ["{workspaceRoot}/rust-toolchain.toml", "{workspaceRoot}/Cargo.lock"]
}
}
Use the directory the command actually writes; for a task with a project
working directory, target may need {projectRoot} instead. Include every
intermediate and history file needed by the tool. Excluding final
artifacts from warm paths is reasonable only when the tool can regenerate
them while reusing the remaining state.
survive keeps local state across a dependency that deletes its directory,
without resetting its timestamps. Combine it with local-only state for
native prebuilds. Use a key for the relevant toolchain and configuration;
do not use restoreKeys to relax a compatibility boundary that the tool
cannot validate.
Sharing state across worktrees and CI
portable: false is the default: warm state is saved and restored only in
its own worktree. Set portable: true to permit another worktree’s save and,
when remote is enabled, remote exchange. It does not rewrite absolute paths
inside files or make incompatible tools accept them. remote: false disables
remote exchange while still permitting other worktrees when portable is true.
State that records where it was made, as a CMake build directory, Xcode’s
intermediates or Gradle’s project state do, cannot follow a checkout to
another path, but suits another machine whose checkout is at the same path,
as CI runners checking out in one place are. portable: "path" exchanges it
through the remote store, and restores a save only when the checkout that
made it was at the same path, resolved through links: one runner’s save
reaches the next runner, while a developer’s checkout elsewhere keeps its own
state, as with false. Saves from each path are stored apart, so a checkout
elsewhere that can write to the store never replaces the save the runners
restore. Linked worktrees, each at a path of its own, do not
share it. What else such state depends on, as the toolchain’s location or
version, qk does not check: keep it the same where the path is, or name it
in key.
Migration: configurations that previously relied on implicit cross-worktree
or remote warm-state sharing must now set portable: true explicitly. Validate
the tool’s relocation behavior before opting in. Same-worktree warm restores
and local/remote task-result caching keep their existing behavior.
Prefer a tool’s content-addressed compiler/transform/task cache when the
full build directory is tied to a checkout. ccache and Gradle’s task-output
cache are separate from the local build history in the table above.
A group can keep a shared compiler cache, but cannot also declare
paths or outputs. When one task needs both, with different portability,
list them as several entries.
Before enabling shared warm state:
- Build in one worktree and remove the intended warm paths.
- Verify an own-worktree restore, including which compiler/transform steps the tool actually runs rather than qk’s replayed log.
- Restore into a worktree at another absolute path. Change a source there and verify the resulting artifact uses that source, not the first checkout’s files.
- Repeat with an empty local qk store and remote warm state. Match the operating system, architecture, toolchain and relevant configuration, then vary whichever dimensions the cache is intended to support.
Controlled same-platform S3-protocol checks restored TypeScript build information, Metro transforms and Cargo state to another path. Metro reused its transforms; TypeScript revalidated sources and Cargo compiled again. Separate ccache and Gradle task-cache checks produced compiler/task cache hits after both linked-worktree and remote restores. The Gradle task-cache check used an isolated cache directory, so it did not rely on a preexisting user-wide cache.
Transporting state successfully is not evidence of an incremental hit or of cross-platform portability. A production store, credentials and tool configuration still need their own verification.
Fetching remote state ahead
A task restores its warm state once its result has missed, so fetching a
remote save adds a round trip to the store, and the time to receive what the
local store lacks, before the task starts. prefetch: true starts that
fetch as the run starts instead, while the task’s dependencies run, and the
restore takes what it fetched:
"qk:warm": {
"portable": true,
"prefetch": true,
"env": {"TOOL_CACHE": "{warm}"}
}
Nothing is fetched ahead when the warm paths are already on disk, when a
save already in the local store would be restored first, or in a
cold run, which restores nothing. Whether the task’s
result misses is not known until its dependencies are done, so a task whose
result comes from the cache never uses the save fetched for it: set
prefetch on a target that usually runs, as one whose inputs change in most
pull requests.
Two saves are fetched at a time, those of the tasks with the fewest dependencies to wait for first, and a group shared by several tasks is fetched once. A task that starts while its save is still waiting its turn fetches it itself; one that starts while it is being fetched waits for that fetch. A save fetched for a key the task no longer has when it runs, as when a dependency rewrites a file the key names, is not used, and the task fetches its own without waiting. Once the run’s tasks are done, saves still waiting their turn are not fetched, and those being fetched are waited for, unless the run was cancelled.
Portable transform and task caches
For Metro, configure its transform cache explicitly:
"bundle": {
"qk:warm": {
"portable": true,
"env": { "METRO_CACHE_DIR": "{warm}/metro-transform" },
"maxSize": "1GB"
}
}
Metro does not automatically read that variable. In the Metro configuration,
pass it to FileStore, retaining a default when running without qk:
const os = require('node:os')
const path = require('node:path')
const { FileStore } = require('metro-cache')
const cacheStores = [
new FileStore({
root: process.env.METRO_CACHE_DIR ?? path.join(os.tmpdir(), 'metro-cache'),
}),
]
Use cacheStores in the existing Metro configuration. File-map state is a
separate cache; sharing transform results does not promise that Metro will
skip discovery in a new checkout. Match the transformer, configuration and
dependencies, and test plugins that use absolute source paths.
For Gradle’s task-output cache, keep its content-addressed objects in a
shared group rather than copying the project’s whole .gradle directory:
"compile-java": {
"executor": "nx:run-commands",
"options": { "command": "./gradlew compileJava --build-cache" },
"qk:warm": {
"group": "gradle-task-cache",
"portable": true,
"env": { "GRADLE_BUILD_CACHE_DIR": "{warm}/task-cache" },
"maxSize": "1GB"
}
}
The Gradle settings must consume this variable. For example, in
settings.gradle:
def warmBuildCache = System.getenv('GRADLE_BUILD_CACHE_DIR')
buildCache {
local {
if (warmBuildCache != null) {
directory = file(warmBuildCache)
}
}
}
A task must support Gradle caching and declare its inputs correctly. Its path-sensitivity rules and toolchain decide whether another checkout can reuse the entry. This recipe does not make the project’s execution history, configuration cache, or native CMake directories portable.
Finding local state
Native builds write to places that are hard to guess. qk warm suggest <project:target> runs the task, and what it depends on, in the sandbox’s
audit mode and lists the directories it wrote outside its declared outputs,
with how much each holds. Each written path is shown under its topmost
directory that holds no source file, tracked or not ignored, since that is
a directory the build keeps apart from the checkout; writes beside sources
are left out. It takes the options qk run does and needs macOS, where the
sandbox can report rather than refuse.
Keeping state across destructive dependencies
A step that regenerates a directory can take build state with it, as
expo prebuild --clean deletes an Android project together with Gradle’s
build directories. survive names such dependencies:
"android": {
"dependsOn": ["prebuild-android"],
"qk:warm": {
"paths": [
"{projectRoot}/android/.gradle",
"{projectRoot}/android/build",
"{projectRoot}/android/app/.cxx",
"{projectRoot}/android/app/build"
],
"survive": ["prebuild-android"],
"mtimes": "preserve",
"portable": false
}
}
Before a named dependency runs, or is restored from the cache, qk moves the
task’s paths into the worktree’s state, and after it finishes, whether it
succeeded or not, moves them back over whatever it left there. They keep
their files and modification times without being stored or copied.
Dependencies named this way that run at once share the move: the first
moves the paths, the last moves them back. A path is only moved back where
its parents are directories inside the workspace; when the dependency put a
link or a file in their place, the path stays aside, and the next run moves
it back after that run’s dependency, keeping the task’s other paths across
that dependency as usual. A run that is stopped leaves its paths
aside the same way. An entry names a target in the task’s project, or
project:target, and applies to the dependency in any configuration. What
the dependency writes beside the paths stays.
Stash ownership records are replaced atomically after syncing the new file. Unreadable, invalid or missing metadata leaves saved contents in place and reports a recovery warning. This protects stopped-process recovery; it does not promise durability across power loss.
Restoring paths one by one
All of a task’s paths are restored together, and only when none of them is
on disk. Then one directory left behind, such as a native library’s CMake
state in node_modules, keeps the rest from coming back when another is
deleted. restore: "each-path" restores every path that is missing, from the
save, and leaves those on disk as they are:
"android": {
"qk:warm": {
"paths": [
"{projectRoot}/android/app/build",
"{workspaceRoot}/node_modules/.pnpm/*/node_modules/*/android/.cxx"
],
"restore": "each-path"
}
}
A restore can then put an older save beside newer state on disk. Use it only
when every path is state a tool validates on its own, as Gradle and CMake
check their outputs against their inputs. Keep state that one tool writes and
reads together, such as an index and the files it describes, under one path,
or leave the default, "whole".
Several entries
qk:warm can be an array of entries, each restored, saved and shared under
its own settings. An Android build can then share Metro’s transform cache,
which relocates, while keeping Gradle’s build history to the worktree that
wrote it:
"android": {
"dependsOn": ["prebuild-android"],
"qk:warm": [
{
"group": "metro",
"portable": true,
"env": { "METRO_CACHE_DIR": "{warm}/metro" }
},
{
"paths": ["{projectRoot}/android/.gradle", "{projectRoot}/android/app/build"],
"survive": ["prebuild-android"],
"mtimes": "preserve",
"portable": false
}
]
}
At most one entry goes without a group: it is the task’s own and the only
one that can hold outputs and paths. No group may be named twice, and no
variable set by two entries. Each entry’s portable, remote, key,
maxSize, mtimes and save apply to that entry alone, and each restore
is reported with the group it belongs to.
Matching toolchains and configurations
A key restricts restores to saves that suit the checkout, such as those
built with the same toolchain:
"qk:warm": {
"paths": ["{projectRoot}/android/app/.cxx"],
"key": ["{workspaceRoot}/pnpm-lock.yaml", { "env": "ANDROID_NDK_VERSION" }],
"restoreKeys": 1
}
Each part is a file, by its content, or an environment variable, by its
value as the task receives it, including the target’s options.env. A save is restored when its key matches whole, or, with restoreKeys,
when it matches in that many leading parts; one that matches whole is
preferred. Among saves that suit equally, the worktree’s own comes first,
then, with portable: true, the one made at the commit nearest behind
HEAD in Git history, then the newest, so a worktree cut from main starts
from main’s state rather
than the last feature branch’s.
Shared compiler caches
A group shares one {warm} directory, and its saves, between every target
that names it, as one compiler cache serves several builds:
"qk:warm": {
"group": "ccache",
"portable": true,
"env": { "CCACHE_DIR": "{warm}/ccache", "CCACHE_BASEDIR": "{workspaceRoot}" }
}
The ccache example supplies absolute directories. Relative compiler inputs
and CCACHE_BASEDIR allowed cache hits across tested checkout paths. Debug
information and flags containing absolute paths may need compiler path
remapping; test that the reused object has the intended paths and contents.
A group keeps no outputs or paths, which belong to one task. Its
targets may run at once and use the directory together; restoring and
saving it are serialized, and a save holds everything the directory held.
save: "background" saves after the task has reported, so its dependents
start without waiting for a large group to be stored; the run waits for the
save before it ends. Other tasks may change the paths meanwhile, so the save
can hold files from after the task ran, and files removed before it reads
them are left out.
Groups over their maxSize are not saved. Warm state counts toward the
cache’s size limit and is evicted with it. --skip-cache neither restores
nor saves it, and leaves the variables unset, so it cannot replace a bad save
either: see Replacing a bad save. Two tasks in one run cannot
keep the same path. A tool must validate its own cache, as Metro, tsc and
Next do: qk only guarantees that warm state never changes a key or a hit.
Keying tasks by what they read
{
"targetDefaults": {
"build": { "cache": true, "qk:ultracache": {} }
}
}
qk run-many -t build --record # on Linux, in CI
qk run-many -t build # anywhere, later
A target’s declared inputs are usually wider than what its task reads, so an
edit the task never reads still misses the cache, or narrower, so a hit can be
stale. With qk:ultracache, a task is keyed by what it actually read the last
time it was recorded, as Nx keys tasks from Nx Cloud’s Ultracache recordings.
qk records tasks itself, and keeps the recordings in its cache.
Recording
A run with --record, or with QK_RECORD=true, records each task with
qk:ultracache that it executes. Recording needs Linux, with eBPF or strace
as Recorders describes. On other systems, and on Linux where
no recorder works, --record records nothing and says why, and the run goes
on: a recording only keys what a later run may take, so CI can set
QK_RECORD on any runner. A QK_RECORDER naming no recorder, or naming one
that cannot record here, fails the run.
A task taken from the cache is not recorded, since nothing runs.
A recording keeps a path set: what the task did to each path in the workspace.
- A file it read is keyed by its content.
- A directory it listed is keyed by its entries’ names and kinds, so a new
file there, such as another
.svgin a directory a build bundles whole, changes the key even though no recording read it. - A path it looked up is keyed by whether it exists, and as what, and a
file it found by its content: a tool that compares timestamps, as
tsc --builddoes, decides by files it never reads. A file created where a resolver once found nothing changes the key; so does deleting or changing one it found.
Left out are paths outside the workspace, node_modules, which the lockfile
keys, .git, qk’s own state, the task’s outputs and warm paths, files it wrote
before reading them, its
scratch directories, and
what its ignoreReads names, which the sandbox’s audit leaves out too. A
glob there starting with ^ names files in the projects the task’s project
depends on, so which projects those are is part of its weak key. A
directory’s listing leaves out the task’s outputs and what it wrote there, which are there after
it runs and need not be before, and the declared outputs of every other
target in the workspace, which other tasks write whether or not this one
reads them, with the directories between an output and its project’s root,
which hold it: .cache for an output in .cache/build. The outputs of the
targets it depends on are its inputs, and stay in its listings. A directory
listings leave out has nothing in them to key.
Keying
A task with qk:ultracache has a weak key: its key without its declared file
inputs. It keeps the command, arguments, environment and runtime inputs,
externalDependencies, the lockfile and what qk keys for every task, such
as the nx.json settings and its project’s configuration and package.json,
and its inputs marked
"always": true, which count whether it read them or not. Up to eight path
sets are kept for a weak key, the newest first. A task that reads differently
from run to run keeps a set for each way.
For each path set, qk keys the paths as they are now, and restores a result
stored under that key. When none has one, a task whose declared inputs cover
every one of its path sets is looked up by its declared key, as without
qk:ultracache; any other runs. Recorded, its new path set and result are
kept; not recorded, its result is kept under its declared key only, which the
next run finds when its declared inputs cover its path sets.
The declared inputs cover a path set when the declared key changes with everything the task could notice through it:
- each file it read or found is keyed, by the declared inputs or by what qk keys for every task, or a directory it found holds a keyed file;
- a file created where it looked for one and found none would be selected,
and is neither gitignored nor a declared output, so that, say, a missing
.env.localkeeps it on its recordings; - every entry a directory it listed could gain or lose would be selected,
gitignored entries aside: a fileset takes all of what is under the
directory, as
{projectRoot}/**/*takes a project’s directories, no exclusion of that fileset reaches into it, and the directory is no declared output.
A file qk keys by part of its content, such as a dependency’s package.json
or a json input, counts as keyed, as it does for every task’s key: a result
taken by the declared key is as sound as one without qk:ultracache.
Otherwise its declared inputs are what a recording found wanting, and the task is keyed by its path sets alone. Coverage is judged when the task is looked up, by the declared inputs it is looked up with, so that a declaration changed since a path set was recorded, such as a narrowed named input, is what counts.
A task without path sets is keyed by its declared inputs, as without
qk:ultracache, until a run records it. Its results are also kept under its
declared key when recorded, so runs without its recordings find them.
The run summary names the tasks taken from the cache by what they read, those
taken by their declared key in place of their recordings, those recorded,
those that missed what they read when recorded, and those with no recording
yet. --report writes which of these each task’s recordings did as
recorded, and the run history keeps it, so qk show run --json
prints it too.
Recorders
qk records a task on Linux in one of two ways, and a recording is kept or refused alike whichever made it.
- eBPF, by default. A program in the kernel notes the file calls of the
task’s processes and passes them to qk, so a recorded task runs at close to
its usual speed. Loading the program takes root, so qk starts a helper,
qk __trace-helper: itself when it runs as root, otherwise throughsudo -n, which never asks for a password. It needs Linux 5.8 or later built with BTF, as distributions build it, on x86-64 or arm64; in a container, it also needs theCAP_BPFandCAP_PERFMONcapabilities. - strace, on
PATH, where eBPF cannot load. strace stops the task at each call it records, so a task that opens many files takes noticeably longer. It filters the calls it records in the kernel with seccomp where it can, which strace 5.3 and later do, so that other calls run at full speed.
QK_RECORDER picks one: auto, the default, records with eBPF where it
loads and with strace otherwise; ebpf or strace records only with that
one, and a run that cannot record with it fails.
What a recording cannot see
- Reads by processes outside the task, such as a build daemon started before
it or shared between tasks: keep such a target
off. - Environment variables, which are no file reads: declare them as inputs.
- A compiled program in the workspace that the task runs directly. A script it runs is read by its interpreter, and so recorded.
- Files opened before the task started, and reads through
io_uring. - A recording a task’s trace was too large for, that could not be decoded wholly, or that lost calls the recorder had no room for, is not kept, and the summary says so. Neither is one that names no path in the workspace, which would match any workspace, nor one whose task changed a file it read or found while it ran: keyed by the file as the task left it, it would be keyed by the task’s own write. Nor is one where an entry in a directory it listed was added, renamed or removed while it ran, other than what its listings leave out, and the summary names the entry that changed where it can be told; what another task writes inside one of its entries, or a file it rewrites in place, leaves the listing as it was. Where the task wrote into the directory, any file there that changed counts, rewritten or not, and a subdirectory counts if it was created while the task ran, which a file system that keeps no creation time cannot tell. An entry another process removed there, or a subdirectory it moved in, cannot be told from the task’s own writes, and goes unnoticed.
Sharing recordings
With a remote cache, recordings are shared. A recorded run
uploads a task’s path sets for its weak key, as pathsets/<weak>, and its
result under the key of what it read, beside its result under its declared
key. A task whose local path sets all miss fetches the path sets the store
holds for its weak key, adds the new ones to its own, and fetches a result for
any of their keys the store’s index lists. A machine that never records, such
as one on macOS, is keyed by the recordings CI made.
A local hit makes no request. A local miss makes one for the path sets, and fetches only results the store holds. The newest upload of a weak key’s path sets replaces the last, after merging what the store held when the task was looked up; two machines recording one weak key at once can lose one’s path set, which costs a miss, never a wrong result.
Trying it out
"qk:ultracache": { "mode": "record" }
With "record", a task is keyed by its declared inputs, as without the field,
while a run with --record records it and keeps and shares its recordings as
with "on". Every run also looks the task up by its recordings without
restoring anything, and says what they would have done: --report writes it
as shadow, wouldHit with the key that had a result, wouldMiss, or
unrecorded without path sets, and the run history keeps it, so runs can be
counted afterwards: a task its declared inputs missed with wouldHit is a run
its recordings would have saved. The run summary names the tasks where the two
disagree:
- a task its declared inputs missed where its recordings had a result: a
change it never read, which
"on"would have taken from the cache; - a task its declared inputs took from the cache where its recordings had none: a change to what it read that its declared inputs leave out, a hit that may be stale, or a recorded result never shared.
Two more things tell whether "on" would be right:
- Whether a recorded result is the one a run gives. A task that runs
where its recordings had a result is compared with that result:
shadowcarriescheck, withmatchedtrue when the task succeeded with the same output files, and otherwise thedifferences, such asfailed (exit 1)ordist/main.js (differs). Its terminal output is not compared, as timings and the like change from run to run. The run summary names each task that gave another result: a recording that left out something the task read, or a task that does not give the same result twice. - What changed that the declared inputs did not see. Where the declared
inputs took a task from the cache and its recordings had no result,
shadowcarrieschanged: the paths of its newest path set that are not as they were when it was recorded, with what the task did to each, such aspackage.json (read), anddeclaredwhere the declared inputs cover the path. A change they do not cover either matters, so their hits may be stale, or does not, andignoreReadskeeps the recording from keying it. For a path set recorded by a qk that did not keep how its paths were,uncoverednames instead what it holds that the declared inputs do not cover, one of which changed.
The recordings also show what a task read beyond its declared inputs at
all: QK_RECORD=audit holds each task that runs against its declarations,
as --sandbox does, from the recording it already made. See Auditing
recorded runs.
Looking a task up by its recordings costs the request for its path sets
whenever it has none of its own, even when its declared inputs hit the local
cache: that is where a stale hit would show. Switching to "on" keys it by them.
Running the tests
tools/ultracache/fixture is a workspace whose build reads, lists and looks
for files, and crates/qk-cli/tests/ultracache.rs follows it through edits. The
tests need Linux and strace; tools/ultracache/podman.sh runs them, or any
other cargo test arguments, in a Linux container from another system. With
QK_RECORDER=ebpf it runs them with the eBPF recorder, in a container with the
capabilities it needs.
The kernel program is crates/qk-trace/bpf/trace.bpf.c, and the objects built
from it are committed beside it, so that building qk needs no BPF toolchain.
After changing it, rebuild them with tools/trace/build.sh, which needs
Podman; CI checks that they match.
Sharing cores between tasks
--parallel bounds how many tasks run; tools such as Vitest and Jest then
start a worker per core each, so tasks running side by side oversubscribe
the machine. qk also keeps a core budget, --cores (or QK_CORES, nx.json
qk:cores, else the cores available), and a target with qk:threads is
given a share of it:
"build": {
"qk:threads": {
"env": { "CARGO_BUILD_JOBS": "{threads}", "RAYON_NUM_THREADS": "{threads}" },
"min": 2,
"max": 8
}
},
"test": { "qk:threads": true }
The task gets its share as QK_THREADS and through each env variable
naming {threads}, times its scale if it has one (see below); "qk:threads": true sets only QK_THREADS. A tool
without such a variable reads it in its configuration, as Vitest’s
maxWorkers: Number(process.env.QK_THREADS) || undefined does. Under Nx
neither is set, so the tool keeps its default there. Every other task holds
one core. Continuous tasks are outside the core budget: qk:threads does
not allocate a share or set these environment variables for them.
A share is fixed when the task starts, since a tool cannot give up workers it has started. Threaded tasks without an expectation, from earlier runs, split the free cores evenly, after a core for each task slot other pending tasks could take, so two get half each and three a third, and one starting alone leaves room for what follows.
A task’s expectation is its time in its recent runs, those that executed it, on the cores it held then: from local history, which lives beside the cache, or for a task local history lacks, from the remote store, which shares what other machines ran. A task with an expectation is given the part of the cores that its work is of the work that can run beside it: its own, and the largest of what the other task slots can hold of the tasks still to finish, leaving out those that depend on it, which wait for it to end. A running task counts what it has still to do, its expected core-milliseconds less what it has done on the cores it holds. A short suite thus takes a few cores, finishes, and leaves the rest free for a long one still waiting for its dependencies, rather than holding an even split of them when the long one starts; and a long task is given most of the cores, so that it does not keep running once the rest is done. Either share leaves a core for each other task slot, including tasks without history, and the minimum of other ready threaded tasks when the budget can accommodate them.
Cores come back as tasks finish and go to the next to start. A
share stays within min (default 1) and max (default the budget); a task
waits until min cores are free, unless nothing else runs. The thread count
is not part of the cache key, and neither is qk:threads. The summary lists
what each threaded task was given, and qk show task records it. When none of
the threaded tasks that ran had an expectation, the summary says so below
them: every share was then an even split, as on a CI job with neither local
history nor a remote store to read one from.
Threads that wait
A share counts cores, one thread to each. Test runners’ workers spend much of
their time waiting, on the file system, a database or timers, so one worker
per core leaves the cores idle. scale tells the task to start more threads
than the cores it holds:
"test": {
"qk:threads": {
"scale": 2,
"env": { "VITEST_MAX_WORKERS": "{threads}" }
}
}
The task holds its share of the budget as without scale, and is told
ceil(share × scale) threads: a task holding 3 cores starts 6 workers, and one
running alone on 16 starts 32. Rounding up adds less than one thread per
task, so the threads every task is told stay within scale times the
budget plus one for each task running: eight tasks holding a core each with
scale: 1.1 are told 2 threads each. A test’s wall-clock budgets, such as a
waitFor or a test timeout, are no less trustworthy than that bound makes
them, however many tasks run at once. Keep scale to targets whose threads wait; for a
compiler, whose threads keep their cores busy, it only adds contention.
Vitest reads VITEST_MAX_WORKERS itself, and a variable a script sets for
Nx keeps applying there, since qk sets nothing under Nx.
How busy a task kept the cores
While a task runs, qk samples the CPU time of its processes, as it samples
their memory. The summary follows each threaded task’s threads with the
cores it kept busy on average, app:test 6 on 3 (2.4 busy), and
qk show task says it, with the cores it held, for a task that ran for two
seconds or more and whose processes were sampled using CPU time: one whose
processes all started and exited between samples has no figure. A task busy on fewer cores than it held is waiting, and
can take a higher scale; one busy on more is starting threads it was not
told about, such as a runtime’s own, and holds less than it uses. A process
that starts and exits between two samples, a quarter of a second apart, is
not counted, so the figure is a lower bound, close for a task whose work runs
in processes that last.
Checking inputs and outputs in a sandbox
--sandbox checks that each task declares what it reads and writes in the
workspace. On macOS it runs the task under Seatbelt, through sandbox-exec.
On Linux, audit records the task, as Ultracache
does, and enforce runs it under Landlock. qk is not a hermetic build system: outside the workspace
everything stays open, including system files, the home directory,
temporary files and tool caches.
Inside the workspace, a task may:
- read the files its cache key holds whole, such as its declared inputs, its
project’s
package.jsonand the root.gitignore, anynode_modules, its own outputs, its dependencies’ outputs and its warm paths, though audit reports reading an installed package its key leaves out; - list any directory;
- write its outputs and warm paths.
A file the key holds only by what it changes or resolves to is not one of
them, unless the task’s inputs select it: nx.json, the file it extends
and nx.local.json, the task’s project’s project.json and
project.local.json, a pnpm lockfile qk reads, pnpm-workspace.yaml, and
the root package.json outside the root project. A tool that reads one,
such as a package manager running a script or a nested qk, could act on
what the key leaves out, so the task declares the file: as a file input,
which keys it whole, or as a json input selecting the fields the tool
reads. default’s {projectRoot}/**/* selects the project’s own
project.json and project.local.json, and a root project’s nx.json.
Structured JSON inputs permit reading their declared JSON file, including its symlink target. Declared directory symlinks permit reading the whole linked tree and its nested link targets, matching the content in the cache key. These read permissions do not allow writing input files.
A directory whose every tracked file is an input is allowed whole, so
untracked files in it can be read too, unless it holds one of the
configuration files above that the task does not declare, such as an
ignored project.local.json: Landlock cannot allow a directory less a file
in it, so its inputs are then allowed one by one.
Audit
--sandbox (or --sandbox=audit) lets a task do everything, and reports
what went beyond its declarations:
With a build target that declares src as input and dist as output,
but also reads other/notes.txt and .env and writes stray.txt:
$ qk run-many -t build,clean --sandbox
…
Sandbox audit: 1 of 2 tasks went beyond what they declare
app:build
1 read outside its inputs:
other/notes.txt
1 written outside its outputs:
stray.txt
read, which the sandbox allows, but not in their keys: .env (1 task)
For each task it lists the files read outside its inputs, the TypeScript
configuration (tsconfig*.json) it read outside them, which is likely an
input, and what it wrote outside its outputs. The count of tasks that went
beyond what they declare leaves out what enforce allows: the .git and
dotenv files tasks read, what they wrote in .git, and the installed
packages their keys leave out, listed below. The .git and dotenv files are
not listed for each task either: the summary ends with each, once, and how
many tasks read it, then how many wrote in .git, and --sandbox-report
lists them for each task, as unkeyedReads and among strayWrites. A task
that reads one its inputs do not declare can be restored from the cache
unchanged when it differs, as one stamping the commit it was built from. A file it writes there and reads, such as
a version stamp it keeps, is listed once, as written: declaring it an output
covers both. Creating the directory that holds a declared output is not
listed: a tool writing dist/app.js creates dist. Another file written
there, such as dist/other.txt, still is. The directories an output lies in
that do not exist yet are created as the task starts, in audit as in
enforce, and removed again if the task leaves them empty: a tool that makes
one by renaming a directory it built beside it, as cargo makes target for
an output in target/release, then finds it there rather than writing
outside its outputs. The output itself is not created, as its path may name
a file, except by enforce on Linux, which takes a missing output without an
extension for a directory (see Linux). Reads of workspace packages’
package.json, which package managers and bundlers make to resolve them,
and of version and package managers’ configuration, such as
.tool-versions, are counted once at the top rather than listed for each
task; --sandbox-report lists them as manifestReads and toolchainReads.
Other paths read by many tasks, at least three and at least a quarter of
those that went beyond what they declare, are listed once at the top, under
read by many tasks, likely by the tools their commands start through.
Files read in a project the task’s project depends on, directly or not, are
grouped by that project, as dependencyReads in --sandbox-report, with how
many each in the summary. They are what running a dependency’s code, or a
tool following imports into it, reads. Inputs prefixed with ^, such as
^production, key them. Whether one changes the task’s result depends on the
tool: a test running a dependency’s code depends on it, while a lint rule
that only follows imports to look for a cycle through the linted file cannot
find one through a project that cannot import back.
A task whose dependencies’ files cannot change its result names them in
qk:ultracache.ignoreReads (see
ignored reads) with a leading ^,
the glob after it matched from the root of each project its project depends
on, directly or not: ^** matches every file in them, ^src/** their
sources. A file in a project nested in a dependency is that project’s, and
matches only if the task depends on it too.
Installed packages outside the key
With a pnpm v9 lockfile qk reads, a task’s key counts the packages its
importers install, and those its externalDependencies name. A task that
names its external dependencies, even none, leaves the root importer out (see
the cache), so a tool only the root package.json installs, such
as a linter, is in its key only if the task names it. Audit reports reading
an installed package the key leaves out, by name:
$ qk run-many -t lint --sandbox
…
Sandbox audit: 3 tasks kept to what they declare
app:lint
1 installed package read, which the sandbox allows, but not in its key; name it in its externalDependencies:
fallow
Enforce allows the read, so it does not count the task as going beyond what
it declares, but no key holds the package: name it in the task’s
externalDependencies input, beside any it names already; its installations
and everything they reach then count. A
package read by many tasks, as for paths, is listed once at the top with how
many tasks read it.
A task without externalDependencies keys every package its importers
install and everything those depend on. A package it still reads outside
that set is no dependency of any package its importers install: some package
imports it without declaring it, and finds it through pnpm’s hoisting, which links every package
installed anywhere in the workspace where any of them can resolve it. What the
task reads then changes with what another project installs. Audit lists such
a package apart, as hoistedPackages in --sandbox-report:
app:build
1 installed package read that nothing its importers install depends on, which the sandbox allows, found through pnpm's hoisting; declare it as a dependency of the package importing it:
phantom
Naming it in externalDependencies would key it, but in place of every other
installed package. Declaring it as a dependency of the package importing it,
with pnpm’s packageExtensions, keys it with the rest. Narrowing pnpm’s
hoist-pattern instead makes the import fail rather than find it, which shows
where it is missing but keys nothing.
Audit finds a package by a read of its package.json, which Node and
bundlers make for every package they load, rather than by each of the
thousands of files a tool may read: reports of those would crowd the system
log, which drops reports when many arrive at once. A tool reading a package without its
package.json goes unreported; input analysis, which
records every read, finds it.
A read is placed by the directory of pnpm’s store it lies in,
node_modules/.pnpm/<directory>, which pnpm names after the installation’s
snapshot key in the lockfile; a read through an importer’s
node_modules/<name> link counts as a read of the directory it points to.
What lies elsewhere in node_modules, such as .bin, .modules.yaml or a
tool’s .cache, and a store directory no snapshot in the lockfile names,
such as one left from an earlier install, belong to no package and are never
reported. Without a pnpm lockfile qk reads, with one a task’s inputs select,
or with pnpm’s node-linker=hoisted, which has no such store, nothing is
reported.
Enforce
--sandbox=enforce refuses those reads instead, so a task that depends on
what it does not declare fails, and only its declared outputs can change in
the workspace. A write outside them is refused as it happens on macOS, and
on Linux is discarded when the task ends, and listed (see Linux). Reading and writing .git and reading dotenv files
stay allowed, and so do reading and writing qk’s own state for the
worktree, where warm state lives.
Reading an installed package the key leaves out stays allowed too, and is
not listed: Landlock grants a directory with everything beneath it, so on
Linux a store directory could not be refused while node_modules stays open
for what tools create there as they run, and enforce would refuse on macOS
what it lets through on Linux. Audit, or
input analysis, finds such reads.
Scratch directories and ignored reads
A tool may keep a cache in the project that nothing declares, as a linter’s results by file. Declared as warm state, qk keeps and restores it, which is safe only for a tool that checks each entry against what it read. For any other, name the directory as scratch on its target:
"lint": { "qk:sandbox": { "scratch": ["{projectRoot}/.tool-cache"] } }
qk empties a scratch directory before the task runs, with or without
--sandbox, so nothing an earlier run left there can change a result,
whatever the tool does with it. The cache neither keeps nor restores it, a
recording for Ultracache leaves it out, and the sandbox lets
the task read and write it, in audit and enforce alike. A scratch directory
lies in the workspace, outside .git, .qk and node_modules however their
names are cased, holds no project and neither qk’s cache nor its state, and
neither is nor lies under a symbolic link, which emptying it could follow out
of the workspace; qk doctor reports one that breaks these rules. The
links are checked again as each directory is emptied, in case an earlier
task made one. Two tasks of a run cannot share a scratch directory, nor
have one inside the other’s, since each empties it before it runs: such a
run fails before any task starts.
A file a task reads that cannot change its result, as a tool reading the
tsconfig only to print a hint, is named in its qk:ultracache.ignoreReads,
whether or not the task is recorded. That it cannot is a claim qk cannot
check, which a scratch directory does not need. The globs that leave it out
of recordings also leave it out of the audit’s findings, and enforce lets
the task read the files they match, Git-ignored ones included.
Landlock and Seatbelt hold paths, so enforce allows the files that exist as
the run starts, found by walking each glob from the directory its leading
literal parts name. A glob ending in /**, as **/.cache/**, allows each
directory it names whole, so a profile stays small however many files the
directories hold, and a file made in one during the run may be read too.
Any other glob, or one with a negated group such as !(private), which can
leave out a path deeper down, allows the files it matches one by one. A path
named whole, with no pattern in it, is allowed as the task starts though it
did not exist as the run started, so a file a task it depends on makes, as
code it generates, may be read. The
walk never enters .git, .qk or node_modules, and a directory holding
one of them is not allowed whole: the directories beside it are. A symbolic
link is left out, since allowing it would allow whatever it leads to. Seatbelt
matches the path a link resolves to, so a link put in place of one of these
during the run reaches only what is allowed anyway. Landlock follows links
as it allows a path, so on Linux each is allowed only while no link stands
anywhere on the way to it from the workspace root: one put there by an
earlier task of the run gets no rule.
A glob that covers a nested workspace, a directory with a pnpm-lock.yaml
importer of its own such as a project’s end-to-end tests, covers what it
installs too. Reads of those packages reach their pnpm store directory, not
a path under the ignored directory, so the audit leaves out a read of a
package that the nested workspace installs, directly or not, unless the
task’s key covers it. The same goes for the workspace projects it links to,
and what they install, unless the task’s key counts them: enforce lets the
task read their manifests, and the audit leaves those reads out. The root
importer, and an importer the task’s key counts, is never covered this way.
Notes
- A sandboxed run keeps the cache. A task restored from it did not run, so
it is not checked; the summary counts it apart, and
--sandbox-reportlists none of its findings.--skip-cacheruns every task to check it. Enforce caches what a task produced held to its declarations, which a run without the sandbox then restores, as it restores any result of the same key. --sandbox-report <path>writes every finding as JSON.- On macOS, qk reads the sandbox’s reports from the system log while the run lasts; each report is tagged with its task. The log can drop reports when many arrive at once, so audit lists what was reported rather than proving nothing else happened. A task that makes more than 100000 reports, as a package manager installing thousands of files outside its outputs, is listed as not audited, with how many it made, and the other tasks keep theirs. On Linux, audit’s reports come from each task’s recording, and a recording that lost events or could not be decoded leaves its task listed as not audited.
- A task the sandbox cannot be built for, as one whose inputs cannot be
resolved, one whose profile Seatbelt rejects on macOS, or one whose rules
Landlock cannot take on Linux, runs unsandboxed under audit and is listed
under
unsandboxedwith the reason. Enforce does not run it unconfined: it fails, is listed underrefusedin--sandbox-report, and says to set"qk:sandbox": falseon its target to run it unsandboxed. A task taken from the cache runs nothing, so it is not refused. - On macOS, Seatbelt caps the paths a profile with reports may name at 64 KiB
together. A task’s profile names the shorter list of pnpm’s store
directories, those to report or those not to, which fits stores of a few
thousand directories. A task whose profile still does not fit is retried
without reporting installed packages: if Seatbelt takes that profile, the
task runs sandboxed and is listed under
packagesUncheckedin--sandbox-report; if not, it is handled as above. Only audit profiles carry reports, so the cap is an audit’s: an enforce profile can name far more. Only tasks that started are listed. - On macOS, Seatbelt refuses to apply a sandbox inside one that restricts anything, as
every task’s profile does. A task that applies its own, as Xcode does to
run Swift macros, fails with
sandbox_apply: Operation not permitted. Audit runs it again unsandboxed and lists it with the reason, without the findings of its first attempt, which ended early; enforce leaves it failed."qk:sandbox": falseon its target runs it unsandboxed from the start, in either mode. For Swift,-disable-sandboxin its Swift flags instead leaves out only the compiler’s own sandbox, so the task can be checked. - On Windows, qk has no sandbox.
Linux
Audit records each task with the recorder Ultracache
uses, eBPF where it loads and strace otherwise, as QK_RECORDER picks, and
reports what the recording shows the task did beyond what it declares. A
read is a file the task opened for reading, and a write one it opened for
writing, created, renamed or removed. A task whose recording is incomplete
is listed as unaudited, with why, rather than with part of its findings.
Enforce runs each of a task’s commands in a user and mount namespace of its
own, over an overlay of the workspace, as
ADR 0002
lays out. What a command writes in the workspace lands in an upper layer of
the overlay, which qk reads as the task ends: what lies within its outputs,
warm paths and scratch directories, written, created or removed, is applied
to the workspace before the cache stores its outputs and before any task
depending on it starts; anything else is listed as written outside its
outputs and not kept. Nothing is applied through a symbolic link in the
workspace: a directory a command made where another left a link replaces
the link. Writes are judged by
what the task leaves, not as they happen: a tool may write a temporary file
anywhere, or make a directory beside its output and rename it into place.
Commands run one after another share an upper layer, and ones run side by
side each have their own. A continuous task’s writes are applied when it
stops. .git, qk’s state, every node_modules and the
task’s scratch directories are bound through the overlay, so what a task
writes there reaches the workspace at once, as without enforce. Reads are
held by Landlock, built in each command’s namespace once the overlay is
mounted; a refused read fails the task with Permission denied.
The overlay needs unprivileged user namespaces, which Linux 5.11 and later
offer to any process where the system allows them; some distributions keep
them from unprivileged processes, as Ubuntu does through AppArmor from
24.04. Where a command cannot run over an overlay, enforce holds tasks by
Landlock alone, in Linux 5.13 and later, and the summary says why; so does
QK_SANDBOX_OVERLAY=0. Landlock refuses a write outside the outputs as it
happens, failing the task.
An overlay keeps a task from seeing what another task running beside it
writes until that task ends. A directory another task replaces whole while
it runs, as a restore from the cache does, may still look to it as it was if
it looked at it before; a task does not depend on what a task running beside
it writes, so this matters only for what it reads undeclared. Renaming a
directory that was there as the task started, rather than one it made, fails
with Invalid cross-device link under an unprivileged overlay; mv copies
instead, and a tool calling rename must too.
Auditing recorded runs
With --record, --sandbox audits a task the cache records from that
recording rather than recording it twice. QK_RECORD=audit does the same
as --record --sandbox, for CI, which sets it once for every run:
$ QK_RECORD=audit qk run-many -t build
…
Sandbox audit: 1 of 4 tasks went beyond what they declare
app:build
1 read outside its inputs:
other/notes.txt
12 tasks restored from the cache, so not audited
Where no recorder works, such a run goes on unrecorded and unaudited, and
says so, when QK_RECORDER is unset or auto. A QK_RECORDER naming a
recorder that cannot record here, or naming none, fails the run.
A task without qk:ultracache is recorded for the audit
alone, as without --record. Only --record saves what a recording
read for the cache: without it, an audit’s recordings save nothing, though
recordings saved before still key results. An audit costs reading each recorded task’s
trace a second time, as the audit reads installed packages’ manifests that a
path set leaves out. --report gives
each task audited its findings as audit, by the kinds --sandbox-report
names, those with nothing left out: {} for a task that kept to its
declarations. A task not audited has no audit, and the run history keeps
it with the rest of the task.
Enforce cannot be combined with --record yet.
Landlock holds paths that exist, so each task’s rules are built as it starts, after its dependencies have written their outputs. Held by Landlock alone, an output that does not exist yet and has no extension is taken for a directory and created then too, and removed again if the task leaves it empty, and a file output that does not exist yet cannot be created. A task may write beneath its outputs, but not remove or rename an output directory itself, which would need rights over its parent: a tool should clear an output directory rather than delete and recreate it. Outside the workspace, Landlock allows what exists when the task starts; an entry created later directly in a directory above the workspace, such as a new file in the home directory when the workspace is below it, is refused.
Recording task inputs
qk inputs analyze web:build --report inputs.json --suggestions inputs-review.json
qk inputs analyze web:build --report next.json --previous inputs.json
qk inputs analyze web:build -c production --report production.json -- --verbose
inputs analyze executes a finite task and its task dependencies, records
file accesses by their commands and descendants, and compares those accesses
with qk’s effective file-input coverage. The terminal summary goes to stderr;
task output keeps its usual streams. --report writes a versioned input-analysis
JSON report instead of the normal run report. Without it, analysis prints only
the summary. Paths to reports are relative to the invocation directory.
Analysis bypasses result-cache reads and writes, remote caching, and warm-state restoration/publication for every task in the graph. Existing output and warm files remain on disk: this is not a clean build. Use a clean checkout when you want to observe a tool’s cold behavior. Normal run history still records the run.
Reading the report
Each task retains its arguments, configuration, outcome, backend and coverage
limitations. Events contain a process ID, operation, path, result and category.
Workspace paths are relative; recorded external paths are absolute. Accesses
retain descriptor targets as resolvedPath when available. Declared
symlinks captured before execution can identify a covering input as keyedPath,
including nested links in declared directory inputs and structured JSON links.
Target definitions are represented by a digest, not their environment values. Reports
contain filenames and forwarded arguments, so review them before sharing.
Categories distinguish keyed source files, mandatory workspace/package configuration, structured JSON inputs, dependency outputs, own outputs, warm state, installed packages, resolution metadata, Git state, discovery and external accesses. Discovery includes directory accesses and missing-path checks; these deserve review independently of content inputs.
A content read in node_modules is unkeyedPackage, with the package named as
package, when it lies in an installation the task’s key leaves out, as the
sandbox places it: a task
naming its externalDependencies, even none, keys only what its projects’
importers install and the packages it names, so a tool only the root installs
must be named. unkeyedPackages lists each such package with how many of its
files were read, and the terminal names them with the fix: add each to the
target’s externalDependencies. A task without an externalDependencies
input keys every package its importers install and what those depend on, so a
package it still leaves out is one nothing they install depends on, found
through pnpm’s hoisting; namesExternalDependencies tells the two apart, and
the terminal says to declare such a package as a dependency of the package
importing it. Other node_modules accesses are
installedPackage: in an installation the key covers, outside pnpm’s store,
or in a store directory no lockfile snapshot names.
uncoveredAccesses lists workspace paths accessed outside those categories.
observedInputs lists keyed file inputs accessed in this execution. Successful
recordings contribute to successfulObservations; unobservedInputs lists
current declared files absent from that union. Mandatory configuration files
are excluded from narrowing suggestions because qk keys them regardless of the
target’s filesets.
candidateFilesets contains exact observed file patterns. Suggestions replace
only broad {projectRoot}/**/* and {projectRoot}/** filesets. Existing specific
filesets and named references that do not contain broad project filesets stay
intact, even when some of their files were not observed. A task already using
specific inputs receives a note that no replacement is suggested.
For broad project filesets, globSuggestions groups active top-level source trees
into recursive patterns such as {projectRoot}/src/**/*.{ts,tsx}. The extension
set includes all currently declared file types in that tree, retaining files that
a partial trace missed. Successful directory enumeration retains whole trees,
such as {projectRoot}/public/**/*, so new assets and nested directories stay
covered. Directory evidence is unioned across compatible successful recordings.
Root files remain literal or are grouped by extension. Each glob reports its
observed count, additional declared files retained, and the reason for the scope.
These are review suggestions; discovery at the project root, empty directories,
absent-path probes and new file types outside enumerated trees need further review.
configurationFragment is a review-only project.json fragment replacing the
target’s inputs. Only named inputs containing broad project filesets are
expanded. Specific file patterns, other named references, exclusions, environment,
runtime, structured JSON, working-directory, package and dependency declarations
remain in their original order. Mandatory configuration files stay keyed by qk.
No fragment is produced without usable successful observations or when the
existing declarations need no replacement. Earlier version 1 reports remain
compatible; their successful directory events can still contribute evidence.
accessReview separates uncovered potential content reads, workspace package
manifest reads, toolchain configuration reads, TypeScript configuration reads
(tsconfig*.json, likely inputs), and metadata-only checks. The sandbox’s
audit groups reads the same way. These
groups help distinguish likely source dependencies from package-manager scans;
none proves an access is harmless. Raw events and uncoveredAccesses remain
available in full. The terminal shows content reads first and limits examples
from the other groups.
The terminal prints the fragment. --suggestions inputs-review.json exports
fragments grouped by task ID, with a reviews entry for every task, including
no-change notes, uncovered-access groups and unkeyed packages; this wrapper is not itself a
project configuration. Copy a reviewed fragment into the corresponding project’s
configuration. Suggestions apply to the whole target, even when the recording
used -c production: validate every configuration before accepting them. Expanding
broad named inputs also means future edits to those expanded definitions will no
longer flow through the copied declaration. Specific named references stay live. Review uncovered accesses and directory
or missing-file discovery separately; they are not automatically added. Evaluated
environment values are excluded, but input declarations can contain runtime
commands and reports retain arguments and paths, so review exports before sharing.
No configuration or cache key is changed automatically.
Not observed does not mean unnecessary. A tool can use other inputs with different arguments, environment values, configurations, file existence or warm state. Recording coverage is partial on both platforms. Validate proposed changes across representative runs and clean builds.
Combining recordings
Repeat --previous <path> to union observations from several reports. Only
compatible task definitions, effective input configuration, arguments and
platforms are merged. Run IDs prevent counting an observation twice. Failed,
cancelled, skipped or interrupted recordings do not add evidence for narrowing;
their access events remain useful for diagnosing missing declarations. A union
can itself be saved and passed to a later run.
Backends and limits
On macOS, qk uses Seatbelt through sandbox-exec and reads its reports from
the system log. It probes the log before starting tasks. Workspace file-access
authorization checks are observed, including subprocesses; external files and
syscall results are not. Failed lookups may never produce an event. The log can
coalesce or lose events, and directory reads do not establish an exact listing.
Directory type is inspected after execution, so renamed or removed directories
can be ambiguous. A machine that does not expose these logs refuses analysis
before task execution.
Symlink accesses can be reported by target path rather than link path.
On Linux, qk records with eBPF where it can load its recorder and with
strace on PATH otherwise, as Ultracache’s recorders
describes, and QK_RECORDER picks one. qk checks it can record before starting
tasks. The backend records path operations and directory
enumeration, follows descendants, tracks working-directory changes, and records
syscall results such as ENOENT. Readable opens are potential content dependencies;
write-capable opens do not prove a write, and the recorder does not inspect read
buffers. Symlink targets, inherited descriptors,
io_uring, external services and processes outside the task tree are not fully
reconstructed. Undecodable or truncated records are reported as collection issues.
Neither backend traces qk’s own command preparation, dotenv loading or runtime input evaluation. Raw Linux trace files are temporary and removed after collection. Read/write buffers and exec argument/environment arrays are excluded from tracing.
--dry-run, --graph, --sandbox, continuous tasks and tasks with readyWhen
are not supported by analysis. A run with a failed task exits with 1, as any
run does, and still writes a report. A cancelled run exits as the signal that
cancelled it, 130 for Ctrl-C. Collection
issues appear in the report and prevent that execution contributing narrowing
evidence. Windows has no recorder backend.
Run history and reports
qk show runs
qk show run --json
qk show task web:build
qk run web:build --report run.json
Every run is recorded in a SQLite database beside the cache
(<git common dir>/qk/history.db, or .qk/history.db outside Git), so linked
worktrees share it. Each run records the worktree it ran in: qk show runs,
qk show run without an id, and qk show flaky read the current worktree’s
runs, and --all-worktrees reads every worktree’s, naming each run’s. Runs
recorded before qk kept the worktree appear only with --all-worktrees.
Each task’s record holds its status, cache result
(local-hit, remote-hit, miss or uncached), key, timing and cause: what
differs from the previous key recorded for the task, grouped as files (with
the paths added, removed and changed), configuration (the files qk keys for
every task, such as .gitignore and the task’s own package.json, listed
with files, the nx.json settings every key holds, by key, what the key
holds of the task’s project’s configuration, such as its root and output
paths, and dependency
manifests and pnpm-workspace.yaml as dependents read them), env, runtime, dependencies (the dependency outputs
dependentTasksOutputFiles reads, by task and pattern),
lockfile (with the importers and packages), inputs, definition (with each field that differs and both of its
values), args and tooling, or first, unchanged and
unknown when the previous inputs are no longer kept. For targets with warm
state it also holds, for each entry that restored any, its group when it
has one, where that state came from (local, worktree <root> or
remote <branch>) and how many files and bytes were restored; and, once for
the task, which groups were on disk already and how long saving took, or that
saving happened in the background. The run summary names the tasks that
started from warm state, and qk show task compares how long the task took from warm state
with how long it took without, over its successful runs that executed. A task
that executed also records the most memory it used: that of its commands and
every process under them, summed, sampled every 250 ms while it runs, so a
task too brief to be sampled records none. On macOS a process’s memory is
its physical footprint, the figure Activity Monitor shows, and a task’s peak
is at least the most its largest process used in its life, which the system
keeps, so a spike between samples is not missed; elsewhere it is its
resident memory. The scheduler waits for memory
from these peaks. The run
summary gives the most the run’s tasks used together and the three tasks
that used the most, and qk show task what the task used in each run. The
newest 200 runs are kept. The schema is versioned in schema_version.
qk show hash <task> --against <run-id> compares the key a task would have
now with its key in a recorded run, without running it; see
Key inspection.
--report <path> (or NX_RUN_REPORT) on run, run-many and affected
writes the run as JSON: the command, commit, exit code, each task with its
record, and the critical path, the dependency chain with the longest total
duration. qk show run --json prints the same for a recorded run.
$ qk show task web:build
web:build, most recent first:
run 1790687500772-75771 2 min ago success miss 4.1s
key changed since run 1790687500464-74260: dependencies, files
changed packages/ui/src/index.ts
dependency ui:build:**/*.d.ts
used 2.1 GB of memory at most
Execution logs and mixed outcomes
qk show flaky
qk show flaky web:test --json
qk show log <run-id> web:test
Successful and failed cacheable executions retain stdout and stderr separately,
with their observed chunk order preserved. show log replays each chunk to its
original stream. Cache hits replay a previous success and do not create another
execution log. Otherwise cacheable tasks run with --skip-nx-cache (or its
aliases or NX_SKIP_NX_CACHE=true) retain their keys and execution logs while
always executing, without restoring or publishing results or warm state.
Finite tasks that run without the cache, under inputs analyze, or with the
cases qk affected narrowed them to, retain their execution logs too,
cacheable or not, within the same budget, as do uncacheable tasks run under
--sandbox. Their
inputs are not rechecked, so show flaky does not count them. Otherwise,
uncacheable tasks and tasks whose inputs cannot be keyed do not retain
execution logs. Failed executions never publish reusable
cache entries.
show flaky [task] lists task keys with both successful and failed executions
in retained history. --limit defaults to 20 groups; --json includes the exact
run IDs, outcomes and whether each log is still available. It excludes cache
hits, cancelled executions and executions whose declared inputs changed while
running. These are mixed outcomes for identical declared inputs, which can
also indicate undeclared inputs, network dependencies or changing external
state. To collect more actual executions while keeping existing results, run
the task repeatedly with --skip-nx-cache, then use qk show flaky and
qk show log to inspect the outcomes. qk reset --only-cache also clears
result entries while keeping history.
Each log retains at most its first 4 MiB of framed output and reports truncation
when replayed. History keeps at most 64 MiB of log payload across linked
worktrees, evicting older payloads while keeping their outcome observations.
The normal 200-run retention also removes those runs’ logs and observations.
These limits apply to retained payload, not SQLite metadata, free pages or
result-cache logs. Logs stay local in history.db; remote result caching is
unchanged. Logs can contain anything the command prints, including credentials.
Schema version 4 adds execution observations and logs. Older histories upgrade in place; existing runs remain readable but have no execution logs and are not included in mixed-outcome detection.
Schema version 5 adds the memory each task used. Older histories upgrade in place, and runs recorded before have none.
Schema version 6 adds the worktree each run ran in. Older histories upgrade in place, and runs recorded before have none. Linked worktrees share the history, so once one worktree’s qk upgrades it, a qk that reads only an earlier version cannot read or record runs in any of them.
CLI reference
| Command | Result |
|---|---|
qk inputs analyze <task> [--report <path>] [--previous <path>] [--suggestions <path>] | Execute tasks without caching, record file accesses and review input coverage; macOS, or Linux with eBPF or strace. See Recording task inputs |
qk show projects [--json] | Project names in Nx graph order, one per line or as a compact JSON array |
qk show projects -p 'web,tag:library' --exclude 'experimental-*' | Matching names, globs, tags and roots, minus exclusions |
qk show projects --with-target e2e --type app [--sep ,] | Projects with one of the targets, of a type (app, lib or e2e, as Nx types them), joined by a separator |
qk show projects --affected -t build,export | Projects owning an affected task of one of the targets, or under project selection the affected projects with one of them, as qk affected selects them |
qk show projects --affected --affected-targets build,export | Affected projects where a task of one of the targets is affected, as task selection decides it |
qk show project [name] [--json] | A project’s graph node, as Nx’s summary or one line of JSON; the current directory’s project by default, and a name that is no project matches as a pattern, as in Nx |
qk graph --file <path>.json | Workspace project graph in a { "graph": { "nodes": ..., "dependencies": ... } } envelope |
qk graph, qk graph --file stdout or --file - | The same graph on stdout |
qk graph --external | The graph with the packages the pnpm lockfile installs as externalNodes |
qk graph --focus web --exclude 'tag:e2e' | Only web, what it depends on and what depends on it, minus exclusions |
qk run <project>:<target>[:<configuration>], qk run -p <project> -t <target> | Execute a task and its dependencies |
qk <target> [project], qk <project>:<target>, qk <project> | Nx-style shorthand for qk run |
qk run-many -t build,test -p 'web,core' --parallel 4 | Execute matching targets with bounded task concurrency |
qk exec -p web -- <command> | Run a command in projects, or as a package script’s target, as nx exec does |
qk run web:build --dry-run | Prepare every planned task without running it, and print the task graph as JSON |
qk show projects --affected [--base <rev>] [--head <rev>] | Projects affected by the changes, in graph order |
qk affected -t build,test [--base <rev>] [--head <rev>] | Execute the affected tasks of the targets, and the tasks they depend on |
qk show affected [project] [--json] | Why projects are affected, or why one project is, and the granularity qk affected selects by |
qk affected -t build --granularity project | Execute the targets of the affected projects, as affected.legacy does |
qk show tasks -t build,test --affected [--json] | The affected tasks and why |
qk show runs, qk show run [id] | Recent runs; one run’s tasks, cache results and critical path |
qk show flaky [task] | Mixed successful/failed executions for identical declared inputs |
qk show log <run> <task> | Replay retained stdout/stderr from an actual cacheable execution |
qk show task <project:target> | A task’s recent runs and why its cache key changed |
qk show hash <project:target> [--against <task|run>] | A task’s cache key and what it covers, or where it differs from another key, without running the task |
qk cache path | Print the local cache directory without creating it |
qk cache prune [--max-size 1GB] | Evict entries unused for a week, then least recently used entries until the cache fits |
qk warm suggest <project:target> | Run a task in the sandbox and list directories it wrote outside its outputs, as candidate warm paths (macOS) |
qk reset [--only-cache | --only-workspace-data] | Remove the cache and this worktree’s state, as nx reset does |
As in Nx, qk build web and qk web:build mean qk run web:build, and take the
same options. qk run reads its task as Nx’s run does: a
project:target[:configuration], else -t/--target (for a target whose name
has a colon) with -p/--project or the positional naming the project, else
a project with a run target, which qk tool and qk run tool run, else a
target. The project may be a name or a pattern matching exactly one project;
a pattern matching several fails with Nx’s “Multiple projects matched”. As
in Nx, b, e, l, s and t name the build, e2e, lint, serve
and test targets. Without a project, qk build and qk run build use the project
whose root most specifically contains the current directory, as Nx does:
in the root project, NX_DEFAULT_PROJECT picks another, and where no
project contains the directory, NX_DEFAULT_PROJECT, then nx.json
cli.defaultProjectName, then defaultProject. As in Nx, --base, --head,
--files, --uncommitted and --untracked imply --affected for
show projects. qk’s own
subcommands, and Nx commands qk does not implement such as format and
release, are never read as targets; use qk run for a target
with one of those names.
As in nx graph, --focus keeps the project and every project connected to
it through dependencies in either direction, and --exclude takes
project selectors. The kept projects’ edges are unchanged, so they can name projects
left out. --print and --file stdout write to stdout. qk has no browser
view, so nx graph’s --affected, --view and server options do not apply;
for a task graph, use qk run-many -t <targets> --graph.
Options are read as Nx’s yargs configuration reads them. They may be
spelled in camelCase as well as kebab-case, so Nx’s --outputStyle works as
--output-style. A boolean option takes a value or a negation:
--nx-bail=false, --skip-nx-cache true and --no-nx-bail all work, and
any value other than true means false. An explicit --nx-bail=false or
--verbose=false overrides NX_BAIL and NX_VERBOSE_LOGGING.
On the commands that run tasks, run and its shorthand, run-many and
affected, an option qk does not know, and a positional beyond the
command’s own, is forwarded to the requested tasks, as Nx’s
unknown-options-as-args forwards it: qk test web --coverage runs
web:test with --coverage. They are forwarded in order and as spelled,
followed by any arguments after --, which are forwarded unchanged. Nx
drops the unknown options when a -- is also given; qk keeps them. The
other commands reject unknown options.
As in Nx, the hidden affected:build, affected:test, affected:lint and
affected:e2e run affected for that target, and affected:graph and
print-affected, which Nx removed, fail with Nx’s message.
--workspace <path> works before or after the subcommand. As in Nx, a
graph --file path is relative to the workspace root, its directories are
created, and it must end in .json; qk has no HTML graph, so a .html file
fails. JSON output has no progress messages mixed into stdout. Errors go to
stderr and return a nonzero exit code. Unknown commands fail explicitly.
exec
qk exec -- <command> runs a command as nx exec does. Each argument is
wrapped in double quotes and the result runs in the shell tasks use, so
$NAME expands; NX_PROJECT_NAME and NX_PROJECT_ROOT_PATH name the project.
- Inside a task, where
NX_TASK_TARGET_PROJECTis set, the command runs in the current directory for that project. - From a package script, where the package manager sets
npm_lifecycle_event, the current directory’s project runs the script’s target instead, with the run options and any arguments beyond the script’s own. That task runs the script again, which then takes the first case, so a script such as"build": "qk exec -- tsc"is cached. Nx runsnx runfor it without the run options, and reads the extra arguments as its own options; qk passes the options to the run and the arguments to the task. - Otherwise it runs in each project
-pand--excludeselect, all by default, and in every project they depend on, dependencies first. The first failure stops it.--exclude-task-dependenciesruns only the selected projects.
The command is what follows --; as in Nx, other options exec does not
know are ignored. A failing command exits with 1, as in Nx. Only the package-script case
runs a task, so the others refuse --sandbox, --dry-run and --graph
rather than run the command without them.
Shared run options
These options apply to run, run-many and affected:
| Option | Behavior / default |
|---|---|
-c, --configuration <name> | Select named options; otherwise defaultConfiguration |
--prod | Select production |
--parallel [value] | Number, percentage, or false; NX_PARALLEL, then workspace parallel, then 3 |
--cores <count> | Positive core budget; QK_CORES, then qk:cores, then available cores |
--skip-cache | Bypass all result caching and warm state |
--cold | Run each task from scratch: restore no result and empty its warm paths rather than restore them, then save both as usual; QK_COLD=true too. See Replacing a bad save |
--skip-remote-cache | Keep local caching only |
--nx-bail | Stop starting tasks after the first failure; NX_BAIL=true too |
--nx-ignore-cycles | Drop cycle-closing dependency edges with a warning |
--exclude-task-dependencies | Run only requested tasks |
--graph[=<file>] | Write Nx-shaped project/task graph without execution; default stdout |
--dry-run | Print qk’s task graph; no dotenv, runtime inputs or executor preflight |
--output-style <style> | Select output behavior; see Running tasks |
--verbose | Set NX_VERBOSE_LOGGING=true for tasks, which otherwise see false unless it is set to true; print every task in full under static-failures-only |
--sandbox[=audit|enforce] | Report (audit) or refuse (enforce) what tasks read and write in the workspace beyond their declarations. A task restored from the cache is not checked; add --skip-cache to check every one. See Checking inputs in a sandbox |
--sandbox-report <path> | Write every sandbox finding as JSON |
--record | Record what each task with qk:ultracache reads when it executes, and key its result by that; Linux only. With --sandbox, audit what it records. See Keying tasks by what they read and Auditing recorded runs |
--report <path> | Save JSON run report; defaults to NX_RUN_REPORT when set |
--profile | Print cache input and restore phase timings to stderr; see Profiling cache operations |
-- <args> | Forward arguments to requested tasks, after any unknown options |
--parallel alone uses NX_PARALLEL, otherwise 3. Percentages are
rounded down with a minimum of one task. --skip-nx-cache and
--disable-nx-cache alias --skip-cache, as NX_SKIP_NX_CACHE=true and
NX_DISABLE_NX_CACHE=true do; --disable-remote-cache aliases
--skip-remote-cache. The corresponding environment flags are documented in
Environment variables.
Profiling cache operations
Run qk run web:build --profile (or set QK_PROFILE_CACHE=1) to print lines
such as qk profile: task=web:build stage=local_restore ms=3.197.
Profiling is disabled by default and reads no clocks while disabled.
Timings cover dependency inputs, declared inputs, runtime commands, remote
fetches, local restore phases, restoring and saving
warm state, the command itself (execute), the
input recheck after it (inputs_recheck) and storing its result
(publish). Input resolution also times listing the workspace’s files
(inventory_list), waiting for another task’s listing (inventory_wait) and
parsing pnpm-lock.yaml (lockfile_parse); each is charged to the task that
needed it. A remote fetch also times waiting for the store’s index
(remote_index_wait), waiting for the store to respond (remote_response) and
receiving the entry (remote_body). From a store server that sends a
Server-Timing header, as qk’s does, remote_response adds it as server:
the part of the wait the server spent, the rest being the network. The last adds bytes, what was received,
held_bytes, how much of the files it carried the local cache held already,
apart_bytes, what was received for outputs stored apart, and
skipped_bytes, the outputs stored apart that were not fetched because the
local cache held them. Fetching outputs stored apart is remote_blobs, with
their count, and waiting for another fetch of the same entry is
remote_flight_wait. Fetches made ahead of the tasks that need them are
timed under the task prefetch, and a run that prefetched ends with
task=run stage=remote_prefetch, counting the entries fetched ahead, those
claimed by a task, and the bytes received. Runtime lines identify the command; other lines identify the task.
Spans can overlap: runtime evaluation and the inventory are part of input
resolution, restore phases are part of local restoration, and storing new warm
files is part of saving warm state. Do not sum them as exclusive costs. Shared runtime commands are timed only when
evaluated, so a task reusing a result has no separate runtime line.
The flag leaves task stdout and cache results unchanged. Timings are diagnostic stderr output, available with every output style, and are not task warnings. Compare repeated runs with the same cache state; a remote fetch, a local restore into missing outputs and a hit with outputs already present do different work.
Change selection
affected, show affected, show tasks, and show projects accept:
| Option | Behavior |
|---|---|
--base <revision> | Base: NX_BASE, then defaultBase, then main |
--head <revision> | Head: NX_HEAD, otherwise working tree |
--affected-profile <name> | Opt-in refinement of selection from qk:affectedProfiles: change projections, which apply to project selection and require a committed comparison, and import reachability, which applies to project and task selection and requires the head to be the checkout |
--fail-on-projection-fallback[=all|adapter] | Fail on every fallback (bare flag or all), or adapter failures (adapter) with notices for conservative comparisons; requires --affected-profile and a committed head, rejects explicit/working-tree file selection |
--files <paths> | Replace Git comparison with named workspace-relative paths |
--stdin | Read changed workspace-relative paths from stdin, one per line; an empty list means no changes |
--uncommitted | Only uncommitted changes |
--untracked | Only untracked files |
show projects change options imply --affected. As in Nx, --head needs
--base; --files, --stdin, --uncommitted and --untracked exclude
--base, --head and each other, except that the paths --stdin reads are
added to --files. --stdin preserves spaces in paths. For show tasks, use
--affected to filter the planned tasks. affected --granularity task
selects tasks by their inputs and --granularity project the targets of
affected projects; without it, NX_LEGACY_AFFECTED, then nx.json
affected.legacy, decide, and tasks are the default, as in Nx 24. See
Tasks or projects.
See Affected selection for merge-base behavior.
Selection and inspection
run-many, affected and show tasks accept -t/--targets
(--target too), -p/--projects and --exclude; lists accept commas,
spaces or repeated flags. run-many accepts Nx’s deprecated --all, the
default; --all=false without --projects selects nothing, as in Nx.
show projects accepts -p/--projects, --exclude, -t/--with-target,
--type app|lib|e2e, and --sep. --sep conflicts with --json. With
--affected and -t, it lists the projects owning an affected task of the
targets under task selection, which --granularity decides as for
affected, except that, as in Nx, -p selects projects unless given
--granularity task. --affected-targets lists the projects where a task
of one of the targets is affected by its inputs, narrowed by
--affected-profile, whatever the granularity; it has no Nx equivalent.
Quote name/tag globs so the shell does not expand them.
show runs --limit defaults to 20, and show task <id> --limit to 10.
show run without an ID uses the most recent run. All support --json.
show flaky [task] --limit defaults to 20 task/key groups and supports --json.
show log <run> <task> replays output to its original stdout/stderr streams.
See Run history for retention limits and interpretation.
Cache administration
cache path prints the location without creating it. cache prune uses
--max-size, else NX_MAX_CACHE_SIZE, else maxCacheSize, else the default
disk fraction. See Storage limits.
reset, or its alias clear-cache, removes the local cache and this
worktree’s state: file digests, output records and warm directories. The
cache is shared by the repository’s worktrees, so resetting clears it for
all of them. Run history and the remote cache are kept. --only-cache and
--only-workspace-data (or --onlyCache and --onlyWorkspaceData) limit it
to one of the two; --only-daemon and --only-cloud are accepted without
effect.
Accepted Nx options
--runner, --batch, --skip-sync, --cloud, --no-cloud, --dte,
--no-dte, --agents, --tui, --no-tui, and --tui-auto-exit, and
--verbose on show projects, are accepted without
effect. They do not enable Nx plugins, distributed execution or Nx Cloud.
As in Nx, --runner must name default or a runner in nx.json
tasksRunnerOptions.
Target inspection
qk show target <project:target[:configuration]> --json shows the resolved
executor, options, caching and concurrency settings, task dependencies,
readyWhen with the timeout its check gets, or null where it turns one off,
and qk’s own fields, such as qk:warm and qk:threads, as configured.
-c <configuration> selects a configuration. A target name without a project
uses the current project, as run does.
qk show target inputs <target> --json lists files and declared environment,
runtime and dependency-output inputs. qk show target outputs <target> --json
lists resolved output patterns, existing output paths and unresolved templates.
Without --json, inputs and outputs print one entry per line. Inspection does
not load dotenv files, execute runtime inputs or require a supported executor.
The listed inputs include qk’s mandatory workspace and package configuration
files; these can differ from Nx’s hash plan.
Both input and output commands accept --check <values...>. File and directory
queries use workspace-relative paths. Inputs also accept declared environment
variable names and runtime commands. The command exits with 1 if any query does
not match. --json returns each query and its membership result.
Key inspection
qk show hash <project:target[:configuration]> prints the key qk run would
cache the task under now, without running the task, and a summary of what the
key covers: the declared inputs, file count, env and runtime values,
dependencies and tooling. --json prints the key and everything it is computed
from, with set env values shown as "<redacted>" and unset ones as null. Arguments after -- are
forwarded as qk run forwards them, to both tasks with --against <task>. A target name without a project uses the
current project.
Computing a key executes the task’s runtime inputs. Dependencies are not run: each counts as having succeeded, with its outputs as they are on disk, so a dependency that has not run yet, or whose outputs are stale, keys differently here than after it runs.
--against <task> keys another task the same way, a target name alone
naming one of the current project’s targets, and lists where the two keys
differ: the definition fields with both values, the files added, removed or
changed, and the values and dependencies that differ. --against <run-id>
compares with this task’s key in a recorded run, as qk show runs lists them,
while that run’s inputs are kept. Differences read from the --against key to
this one. Two different tasks always differ in id. With --json, the report
holds both keys and their inputs, same, and differences in the shape
qk show task reports causes.
$ qk show hash web:build --against web:check
web:build 55a02f0c…
web:check 963b6121…
keys differ in: definition, files
field id: "web:check" -> "web:build"
field definition.inputs[0]: "{projectRoot}/src/**/*" -> "{projectRoot}/**/*"
field definition.inputs[1]: {"runtime":"node -v"} -> {"env":"MODE"}
field definition.inputs[2]: {"env":"MODE"} -> {"runtime":"node -v"}
added apps/web/README.md
The target definition is part of the key as written, so reordering inputs
changes the key even when the files and values it selects do not; such a
change shows as definition.inputs reordered.
Project watch
qk watch -p web --includeDependencies -- <shell command> watches the selected
projects and, optionally, their project dependencies. --all watches all projects,
including projects created after watch starts. --initialRun runs once before
waiting for edits. -g/--includeGlobalWorkspaceFiles also reacts to files
that belong to no project, such as root configuration; as in Nx, a command
naming NX_PROJECT_NAME is refused with it. The kebab-case aliases also work.
Callbacks run from the invocation directory with NX_PROJECT_NAME and
NX_FILE_CHANGES (workspace-relative paths joined with spaces). If the command
mentions NX_PROJECT_NAME, a batch runs concurrently once per changed project;
otherwise it runs once for the batch with an empty project name. Initial --all
also runs once with an empty project name, matching Nx. Quote the shell command
so variables expand in the callback rather than in the invoking shell.
Native file events are debounced and changes arriving during a callback are
queued for the next batch. A directory event or backend rescan can include other
eligible source files in the reported batch. Callback failures are reported;
watch continues.
Edits to sources known to be regular files reuse the current project view. Configuration
and ignore-policy changes, file creation/deletion, directories, symlinks and
backend rescans reload discovery and selection before the next callback.
Workspaces using package presets or presets outside the workspace retain a reload
on every source batch, because preset resolution has inputs outside the source set.
Ctrl+C cancels callbacks and their process groups and exits with 130. Ignored
source files, node_modules, Git/qk state, configured caches, explicit target
outputs and warm paths do not trigger callbacks. Files outside any selected
project do not trigger callbacks, unless -g is given and they belong to no
project at all. Watch uses no daemon or Nx plugins.
Compatibility diagnostics
qk doctor reports unsupported executors, configured Nx plugins, sync
generators and fields in qk:warm or qk:threads that this qk does not know,
without executing commands, loading dotenv files or running plugins.
It also reports dependentTasksOutputFiles globs that select nothing for any
task of a target, which therefore key nothing: none of those tasks depends on
a task declaring outputs, or the outputs on disk hold no file the glob
matches. Outputs not built yet are taken to match. Targets sharing a set of
such globs, as through a named input, are one finding.
It also lists Nx CLI options accepted without effect. --json returns a
versioned report with stable finding codes, locations and error/warning counts.
Unsupported executors cause exit code 1; --strict also fails on warnings.
This checks normalized explicit target definitions. A clean report does not establish that commands can execute or that plugin-inferred targets exist.
Workspace configuration
qk reads nx.json as JSON with comments, trailing commas and "//" comment
keys. Unknown metadata is retained, but that does not imply qk executes it.
Use Nx compatibility to check the supported surface.
{
"defaultBase": "main",
"parallel": 3,
"qk:cores": 8,
"namedInputs": {
"default": ["{projectRoot}/**/*"],
"production": ["default", "!{projectRoot}/**/*.test.ts"]
},
"targetDefaults": {
"build": {
"cache": true,
"inputs": ["production", "^production"],
"dependsOn": ["^build"]
}
}
}
extends
A file whose settings nx.json starts from, resolved as Nx resolves it with
Node’s require.resolve from the workspace root: a path beginning ./ or
../, or a package subpath such as nx/presets/npm.json, found in
node_modules through the package’s exports, whose conditions apply in the
order they are declared. A bare package name without exports resolves to
its main, else index. As in Nx, each setting
nx.json declares replaces the extended file’s whole, and the extended file’s
own extends is not followed. nx.local.json merges over the result. A
change to the extended file counts as a change to nx.json does: through what
it changes, by the merged settings (see
What changes the key).
namedInputs
An object mapping names to arrays of input declarations.
Defaults to an empty object. Without an explicit definition, default means
{projectRoot}/**/*. Projects override workspace definitions by name.
A named input cannot itself declare dependencies or projects.
targetDefaults
An object of target defaults; defaults to empty. Matching keys are tried in
this order: executor, target name, then matching target-name globs, longest
first. A key is a glob when it holds one of *, |, {, }, (, ) or
[, and it matches as minimatch matches: * does not cross a /. The first
key with a matching entry wins; keys do not all accumulate. As in Nx 24, the
selected defaults lie beneath the project’s files, which merge over them as
described in Merging, spreads included. Beneath a
default there is only the earlier matching entries of its key, so a spread in
the first adds nothing.
Each value may be a target object or an array of target objects. Array entries
may have filter.projects, an array of
project selectors, and
filter.executor, and other filter keys are ignored. filter.plugin names
the plugin in nx.json plugins that wrote the target; targets from
project.json and package.json have none, so an entry with filter.plugin
never applies. Matching entries merge in array order, later ones winning.
An entry naming an executor different from the target’s own is left out,
although its key still wins.
As in Nx, a default takes the executor of the target it applies to, and a run-commands target’s command, so that the target merges over it, unless it names its own command; a default naming another command or script is replaced by the target whole. A default whose target the files replace along the way still lies beneath the result; see Nx compatibility.
defaultBase
A revision string for affected selection and remote warm state’s default
branch. The fallback is main. Affected selection resolves --base, then
NX_BASE, then this setting. See Affected selection.
affected
affected.legacy: true makes affected selection select projects rather
than tasks, as in Nx 24. NX_LEGACY_AFFECTED and --granularity override
it. See Tasks or projects.
qk:affectedProfiles
Named, opt-in refinements of affected project selection. Each profile
contains a projections array of adapters with name, command, sources,
outputs and optional timeoutSeconds, reachability, or both.
reachability is true, or {"default": "<name>"} naming the
qk:reachability config for targets that declare none.
Select a profile with --affected-profile <name>.
See Project change projections for the
protocol, configuration example, tool setup and conservative fallback rules,
and Import reachability for
reachability.
qk:reachability
Named import reachability configs, each an object with anchors, cases
and sources arrays of globs. A target’s qk:reachability names one, is
false, or declares its own settings; without one, it takes its profile’s
default. See Import reachability.
qk:environments and qk:defaultEnv
qk:environments names environments a target can run in, each an array of
entries, as qk:env reads them. qk:defaultEnv is the
environment of every target without its own qk:env, the same array, or
false. Without it, a target with no qk:env inherits every variable, as
qk’s own process holds them. [] lets no inherited variable through to any
task that does not name the ones it needs. See
Declared environments.
parallel
A positive integer giving the default number of concurrently running tasks.
Defaults to 3; --parallel and NX_PARALLEL take precedence. This is
separate from options.parallel, which runs commands within one task.
qk:cores
A positive integer giving the core budget for a run. Defaults to the cores
available to qk. --cores, then QK_CORES, take precedence. Targets opt in to
sharing this budget with qk:threads.
cli.packageManager
Selects the package manager for nx:run-script. Lockfile detection is used
when absent; see Executors for the full order.
cli.defaultProjectName and defaultProject
Fallback project names for a target invoked without a project. The project
whose root most specifically contains the current directory normally wins.
In the root project, NX_DEFAULT_PROJECT can pick another. Where no project
contains the directory, qk tries NX_DEFAULT_PROJECT, then
cli.defaultProjectName, then defaultProject.
workspaceLayout
An object with appsDir and libsDir string prefixes used to infer package
project types. Matching is by plain string prefix. Explicit project.json
configuration can override the inferred type.
pluginsConfig["@nx/js"]
The settings Nx’s JavaScript support reads, which qk reads too:
analyzePackageJson: anything buttruestops package.json dependencies making graph edges.analyzeLockfile:falseleaves the installed packages out of the graph, sograph --externaladds none and a lockfile change underprojectsAffectedByDependencyUpdates: "auto"affects every project, where Nx affects none (see Compatibility). It defaults to on, unless alerna.jsonstands without annx.json. Cache keys andexternalDependenciesinputs read the lockfile either way.projectsAffectedByDependencyUpdates: see Affected selection.
cacheDirectory
A path relative to the workspace root, or an absolute path, overriding the
default cache location. NX_CACHE_DIRECTORY takes precedence; without either,
tasksRunnerOptions.default.options.cacheDirectory is read, as in Nx. qk stores its
entries in qk/v1 beneath this directory so it can coexist with Nx.
History and worktree state keep their default locations. A relative override
gives each worktree its own cache.
Without an override, the cache lives in <git common dir>/qk/cache/v1, or
.qk/cache/v1 outside Git. Use qk cache path to inspect it.
tasksRunnerOptions
Nx’s task runners. qk runs tasks itself, so it reads only what Nx’s default
runner reads from here: default.options.cacheDirectory, under
cacheDirectory. As in Nx, --runner must name a runner
configured here, or default. A runner’s parallel option counts in neither
Nx nor qk: Nx’s own parallel setting replaces it.
maxCacheSize
A byte count or size such as "2GB". Units KB, MB and GB use powers of
1024; 0 means unlimited. NX_MAX_CACHE_SIZE takes precedence. The default
is a tenth of the disk holding the cache. Entries unused for a week are
evicted whatever the limit. See Storage limits.
s3
An optional object configuring S3-compatible remote storage. Without it qk uses only the local cache. Put credentials in the environment or local dotenv files rather than checked-in configuration.
{
"s3": {
"bucket": "my-task-cache",
"region": "eu-north-1",
"cacheKeyPrefix": "tasks/",
"localMode": "read",
"ciMode": "read-write"
}
}
| Field | Type | Default / behavior |
|---|---|---|
bucket | string | Required |
region | string | Required |
endpoint | URL string | https://s3.<region>.amazonaws.com |
forcePathStyle | boolean | false; use true for path-style storage |
cacheKeyPrefix | string | Empty; qk appends qk/v4/ |
accessKeyId | string | Falls back to AWS_ACCESS_KEY_ID |
secretAccessKey | string | Falls back to AWS_SECRET_ACCESS_KEY |
localMode | string | read-write outside CI |
ciMode | string | read-write in CI |
uploadMode | string | wait; qk can detach uploads with background |
Modes are read-write, read (or read-only), and no-cache.
NX_POWERPACK_CACHE_MODE overrides the selected mode.
uploadMode is "wait" (the default) or "background";
QK_REMOTE_UPLOAD_MODE overrides it. See Remote caching
for detached uploads, logs and lifecycle behavior.
AWS_SESSION_TOKEN supplies a temporary credential token.
--skip-remote-cache, NX_SKIP_REMOTE_CACHE=true and
NX_DISABLE_REMOTE_CACHE=true disable the remote store.
encryptionKey and SSO profiles are unsupported. Unusable remote stores
are reported; local caching continues. See Remote caching.
qk:storeServer
An optional object sending the remote cache to a
store server instead of S3. Nx does
not read it, so when it is set qk uses it and ignores s3, which can stay
for Nx’s own cache plugin. The token comes from QK_STORE_SERVER_TOKEN.
{
"qk:storeServer": { "url": "https://cache.example.com" }
}
| Field | Type | Default / behavior |
|---|---|---|
url | URL string | Required; QK_STORE_SERVER_URL overrides it |
localMode | string | read-write outside CI |
ciMode | string | read-write in CI |
uploadMode | string | wait; qk can detach uploads with background |
Modes and upload modes, and the variables that override them, are as for s3.
nx.local.json
A machine-specific overlay on nx.json. targetDefaults merge per entry,
namedInputs merge by name, and other settings replace their checked-in
values. Add the file to your workspace’s .gitignore. Nx does not read it.
Every run reports the overlay, and every task’s key includes what it changes,
as for nx.json.
Projects and discovery
qk finds the workspace root as Nx does: NX_WORKSPACE_ROOT_PATH when set,
else the nearest ancestor with an nx.json or an nx or nx.bat wrapper
script, else the topmost ancestor with Nx installed in node_modules. For a
workspace without Nx, it takes the nearest ancestor with
pnpm-workspace.yaml or a package.json with workspaces; a standalone
project.json, or package with an nx object, also works when no parent
workspace marker exists. --workspace <path> selects a root explicitly.
Workspace membership
qk discovers project.json files, including nested projects, and package
manifests the package manager’s workspaces take in, as Nx’s package.json
plugin does. The workspace patterns are those of the root package.json
workspaces (an array, or an object’s packages), pnpm-workspace.yaml
packages and lerna.json packages together; a lerna.json without
packages means packages/*. A pattern names directories, or package.json
files when it ends in /package.json. Patterns are minimatch globs without
the dot option, so * and ** pass over directories starting with ..
! patterns exclude, and with exclusions alone every package.json not
excluded is taken in. An unreadable pnpm-workspace.yaml or lerna.json warns and
adds none.
A package.json beside a project.json is part of that project whether the
workspaces take it in or not. The root package is a project when it has an
nx object or an adjacent project.json. With
NX_INFER_ALL_PACKAGE_JSONS=true, a workspace without a root package.json
takes in every package.json, as in Nx.
Discovery reads .gitignore and .nxignore files with Git’s rules, in the
workspace and, when the workspace is inside a Git repository, the
repository’s .gitignore files above it, as Nx does. It does not follow
symbolic links, and leaves out node_modules, .git, .nx/cache,
.nx/workspace-data, .yarn/cache and qk’s .qk wherever they are. Other
hidden directories remain discoverable. Unlike Nx, qk does not read the
machine’s global Git excludes, or .gitignore files above a workspace that is
not in a Git repository.
Configuration precedence
Package scripts become nx:run-script targets, except a script that an
adjacent project.json runs another way: with a command, another
executor, or run-script running another script. Package nx configuration
is merged over these; a target there with an executor or command
replaces the script’s. Adjacent project.json merges over that, then
project.local.json. Workspace targetDefaults lie beneath them all.
Targets merge as described in Merging. As in Nx, tags
merge as an ordered union, implicitDependencies concatenate, named inputs
merge by name, generators merge by generator and then option, and
metadata merges by field, arrays concatenating one level down; other
project fields are replaced.
A package’s metadata is what Nx reads from it, replacing any in its nx
object: targetGroups listing its script targets under NPM Scripts, its
description, and under js its name, version, exports and main and
whether the package manager’s workspaces take it in. Each script target’s
metadata holds the script and the package manager’s command for it. Neither
is part of a cache key.
{
"name": "web",
"projectType": "application",
"sourceRoot": "apps/web/src",
"tags": ["scope:web"],
"implicitDependencies": ["codegen"],
"targets": {
"build": {
"command": "echo build web",
"outputs": ["{projectRoot}/dist"]
}
}
}
name
A string. An explicit name wins over the package name; otherwise, as in Nx,
a project is named after its directory, in lower case: libs/data/Access
is access. For the workspace root qk uses the workspace directory’s name.
An unnamed root package.json cannot be a project, as in Nx: name it, or
name the project in its nx object. Duplicate names fail with both roots in
the error.
root
The workspace-relative project directory, with forward slashes (. for the
workspace root). It defaults to the configuration’s directory; as in Nx, a
root given in project.json or the package’s nx object is used as written,
an empty string meaning the workspace root. It must stay inside the
workspace, and two projects cannot share a root. As in Nx, the project’s
package dependencies, scripts and lockfile entry then come from the
package.json at that root, not the one beside the configuration.
sourceRoot
An optional string retained in the normalized project configuration. Set it to the source directory to describe the project.
projectType
An optional type string. qk show projects --type accepts app, lib and
e2e and applies Nx’s project typing rules. Package entry points and
workspaceLayout also contribute to inferred types.
tags
An array of strings, empty by default. Select them with tag:<glob>.
Packages also gain npm:private or npm:public and npm:<keyword> tags.
implicitDependencies
An array of project selectors,
empty by default. What the selectors match, less the project itself, gains an
implicit graph edge; a ! selector removes every edge to the projects it
matches, package dependencies included. As in Nx, a selector matching no
project is an error, and the graph’s node lists the expanded names, each
exclusion as !name. Package dependency sections also create graph edges when
the range identifies a workspace package; see Nx compatibility.
namedInputs
An object of named input arrays, overriding workspace definitions by name. See Inputs and outputs.
targets
An object mapping target names to target definitions.
Inspect the merged result with qk show project web --json.
Unknown executor names are preserved during inspection; execution rejects
unsupported executors before commands start.
includedScripts
An array under the package’s nx object restricting which package scripts
become targets. By default all scripts do. An empty array disables script
target creation. Listed names initially create targets even if the script
is absent, allowing explicit definitions to replace them; missing scripts
fail in execution preflight, not inspection.
qk:reachability
A qk extension: the anchors and sources an affected profile with
reachability uses to leave the project out when its anchors do not import
what changed. See Import reachability.
project.local.json
A machine-specific overlay beside project.json or package.json. It can
change options or add targets, but cannot set name or root. Add it to
your workspace’s .gitignore. Nx does not read it.
{
"targets": {
"serve": { "options": { "port": 4300 } }
}
}
qk reports each overlay used. What it changes is included in cache keys, as
for project.json, so overridden tasks do not reuse the checked-in
definition’s cache entries, and the project’s other tasks keep theirs.
Target configuration
Targets appear in a project’s targets object or workspace targetDefaults.
The example uses explicit command execution and output declarations:
{
"targets": {
"build": {
"executor": "nx:run-commands",
"options": { "command": "pnpm exec tsc --build", "cwd": "{projectRoot}" },
"configurations": { "release": { "args": "--verbose" } },
"dependsOn": ["^build"],
"inputs": ["default", "^default"],
"outputs": ["{projectRoot}/dist"],
"cache": true
}
}
}
Merging
A target can be defined in several places: target defaults, package.json
and project.json (see Configuration precedence).
As in Nx 24, each definition merges over the one beneath it field by field:
a field it sets replaces the one beneath, except that options merge by
option name and configurations by configuration name and then by option
name. Arrays and nested option values are replaced.
A definition that runs the target differently replaces the one beneath it
whole, every field included: it names another executor, or for
nx:run-commands another command, or for nx:run-script another script.
A definition without an executor, command or script merges over any.
The spread token "..." merges instead of replacing. In an array, such as
inputs, outputs or dependsOn, the item "..." stands for the items
beneath it, in its place: "inputs": ["mine", "..."]. In an object, the
key "...": true stands for the fields beneath it: a field written after
it wins over the one beneath, and one written before it yields to it. It
works on the target itself, on options and each option’s value, on
configurations, on each configuration and each of its options’ values.
Beneath that, a spread is an ordinary value. With nothing beneath it, a
spread adds nothing. An object with "..." cannot also have a key that is
a whole number, such as "1": Nx cannot tell where it was written.
A target key that is a glob, such as "e2e-ci--*", merges onto every target
defined beneath it whose name it matches, and is a target of its own when it
matches none. Keys are globs and match as targetDefaults keys do (see
Workspace): * does not cross a /.
executor
A string naming nx:run-commands, nx:run-script, or nx:noop.
Unsupported names remain inspectable but fail execution preflight.
See Executors for their options. A target with neither an
executor nor a command runs nx:noop when it has dependsOn, and is left
out otherwise, as in Nx.
command
A shorthand command normalized into nx:run-commands options. Prefer an
explicit executor with options.commands for multiple commands. A target
cannot have both command and executor.
options
An object, empty by default, of executor-specific options. Selected
configuration values merge over these by key, followed by task argument
overrides. Nested objects, including env, are replaced rather than recursively
merged. A configuration with env: {} removes the base option’s overrides;
process and dotenv values still apply.
As in Nx, the tokens {projectRoot} and {projectName} are replaced in
every string of the options and configurations, nested ones included, as the
workspace loads, and qk show project shows the result. {workspaceRoot}
may only start a string, and is removed with the slash after it, leaving a
path relative to the workspace root: "{workspaceRoot}/dist" becomes
"dist". Anywhere else it is an error.
configurations
An object mapping configuration names to option objects, empty by default.
Select with -c release or project:target:release. --prod selects
production. Requested configuration names propagate through dependencies.
A dependency without that configuration uses its own default, while its
dependencies still receive the requested name. A configuration’s keys are
all options: outputs in a configuration is an option named outputs, not
the target’s outputs, as in Nx.
defaultConfiguration
An optional string selecting a configuration when none is requested. Without a requested configuration, each task chooses its own default; a parent’s default does not propagate to its dependencies.
dependsOn
An array of dependency declarations. Without it, a target has no declared task dependencies. Shared dependencies run once.
| Declaration | Meaning |
|---|---|
"build" | Target in the same project |
"^build" | Target on project dependencies, looking through projects without it |
"core:build" | Target in an explicit project |
"lint-*" or "^build-*" | Matching target names |
{ "target": "build", "projects": ["tag:library"] } | Target on selected projects |
{ "target": "build", "dependencies": true } | Target on project dependencies |
Target names may contain colons. As in Nx, "a:b" names target b of
project a when a is a project with that target, and otherwise target
a:b of the same project; "^a:b" and { "target": "a:b" } name target
a:b.
Object declarations also accept params: "forward" and options: "forward";
any other value, or none, does not forward. params passes the run’s own
arguments; options passes the target’s options, its configuration’s
included, as the dependency’s options, overriding its own, lists and
objects included. With both projects and dependencies: true, projects
applies. Other fields are ignored, as in Nx. As in Nx 24, the Nx 15
spellings projects: "self" and projects: "dependencies" fail.
An object declaration naming a continuous target also accepts inputs and
waitFor. inputs: false keeps the continuous task’s inputs from counting
for the dependent; see Affected selection.
waitFor is "started", the default, or "ready", which starts the
dependent once the target’s readyWhen check passes. When several
declarations name the same task, "ready" wins, and its inputs count unless
every declaration sets inputs: false. Both have no effect on a dependency
that is not continuous.
Other missing dependency targets are skipped. Unknown explicit projects
and task cycles fail unless --nx-ignore-cycles is used. A shared task
reached with different forwarded arguments or options keeps those it was
first reached with, as in Nx.
See Running tasks.
inputs
An array of input declarations. Without it, qk uses
default and ^default. Inputs determine both cache keys and task-level
affected selection.
outputs
An array of output paths or globs with fixed directory prefixes. On a cache hit these are restored and recorded logs replayed. Explicit outputs also let dependent keys reflect output content instead of the dependency’s entire key. See Inputs and outputs.
cache
A boolean; absent or false means the target does not opt in to result
caching. true enables caching when supported by the task’s inputs and
outputs. Continuous and readiness tasks run uncached. As in Nx, a target
with both cache and continuous set to true is an error.
See Local cache.
continuous
A boolean, default false. Dependents may start when this task starts,
or, with waitFor: "ready", when its readyWhen check passes. Continuous tasks do not count towards
--parallel; they run until exit or cancellation. A continuous task used
only as a dependency is stopped successfully once its consumers finish.
See Continuous tasks.
readyWhen
How a continuous target tells that it is ready, for tasks depending on it
with waitFor: "ready", as in Nx 24. It is an object with exactly one of:
| Key | Ready when |
|---|---|
url | An HTTP or HTTPS GET gets a status from 200 to 403. Redirects are not followed, and a 404 at the root path is tried again at /index.html. |
port | A TCP connection succeeds, to 127.0.0.1 or ::1 unless host names the address. |
command | The command, run through the shell at the workspace root with the environment the target’s commands run with, exits with 0. It reads the options they run with, as JSON, from NX_TASK_OPTIONS. |
logMatches | Every one of a string or an array of strings has appeared in the output, terminal control sequences left out. |
signal | true: the task reports that it is ready, as @nx/devkit’s signalTaskReady does, through the socket qk names in NX_READY_SIGNAL_SOCKET. |
timeout, 600000 (ten minutes) by default, is how many milliseconds the check may take, and
interval, 250 by default, how long a url, port or command check waits
between tries. Strings may use {workspaceRoot}, {projectRoot},
{projectName}, {options.name}, {env.NAME} and ?? fallbacks, read from
the options, arguments and environment the task runs with. null turns off
a check set by a target default. See
Continuous tasks.
A signal takes no interval. The address a signalling task reports is
passed on in NX_READY_URL to a task that waited for it, when it is the only
server that task waited for to report one. A task that sets
NX_USE_V8_SERIALIZER=true signals in Node’s own serialization, which qk
cannot read.
As in Nx 24.0.1, a task resolves its own readyWhen once the servers it
waits for are ready, so {env.NX_READY_URL} and a command check’s
environment have the address. Only a signal check reports one: behind a
server with another check, {env.NX_READY_URL} has no value. Behind a
signalling server, a port read from NX_READY_URL gets the address rather
than a ?? fallback, so it is not an integer, and the check is invalid.
This is unrelated to the run-commands readyWhen option, which marks the
task itself as ready.
parallelism
A boolean, default true. false runs the task alone, waiting for all
other tasks, including continuous ones. No other task starts while it runs.
Graphs requiring it to overlap a continuous dependency are rejected.
env
A target-level object of environment variables, for every executor. This is
a qk extension; Nx ignores it. Executor options.env and selected
configuration env can override it. See
Environment precedence.
qk:env
The environment the task inherits: an array of entries, each the name of an
environment in nx.json’s qk:environments,
whose entries it stands for, or an object with one of these fields:
| Field | Type | What it does |
|---|---|---|
inherit | array of variable names | Passes the variables through and keys their values |
pass | array of variable names | Passes the variables through without keying their values, as for credentials and proxies |
set | object of string values | Sets the variables, keyed as written |
bin | array of command names, or of objects with name and key | Puts the commands, as found on the PATH qk inherited, on the task’s PATH, keyed by the files they resolve to, or with "key": "content" by their content |
dirs | array of paths, with {projectRoot} and {workspaceRoot} | Adds the directories to the task’s PATH, after the commands, keyed as written; relative to the workspace root |
from | object with command and, optionally, inputs, an array of paths | Sets the variables the command prints as JSON, as nix print-dev-env --json, direnv export json or mise env --json print them; see Declared environments |
A name can contain *, which matches any run of characters, as LC_*. A
variable both inherited and passed fails the task, as does a command bin
names that is not on PATH. An environment with bin or dirs entries
builds PATH, so it cannot also inherit, pass or set it. false inherits every
variable, as without the field, which overrides nx.json’s qk:defaultEnv.
See Declared environments.
qk:threads
true, or an object containing:
| Field | Type | Default |
|---|---|---|
env | object of string values | Empty; {threads} expands to the threads the task is told to start: its share of cores, times scale, rounded up |
min | positive integer | 1 |
max | positive integer, at least min | Run’s core budget |
scale | number above 0, at most 8 | 1; threads the task is told to start per core it holds, rounded up |
Opted-in tasks receive QK_THREADS; true sets only that variable.
The share is fixed when the task starts. min and max bound the cores a
task holds; scale only changes how many threads it is told to start on them.
Without this key a task holds one core. The setting and allocated count do not affect cache keys.
Continuous tasks do not take a core share; this setting does not allocate
threads or set these environment variables for them.
false is not a supported value; omit the key to disable it.
Other fields are ignored, as Nx ignores keys it does not know, so a
configuration can use fields a newer qk added; qk doctor reports them.
See Sharing cores for allocation examples.
qk:sandbox
false runs the task unsandboxed under --sandbox, listed with the reason,
for a task that applies a sandbox of its own, which cannot run inside qk’s,
such as an Xcode build running Swift macros. true, the default, sandboxes
it. An object sandboxes it too, and names its scratch directories:
| Field | Type | Default |
|---|---|---|
scratch | array of paths, with {projectRoot} and {workspaceRoot} | Empty; directories emptied before each run of the task, with or without --sandbox, which the task may read and write and which nothing keeps |
A scratch directory lies in the workspace, outside .git, .qk and
node_modules in any case, holds no project and neither qk’s cache nor its
state, and neither is nor lies under a symbolic link; a task naming another fails, and so does a run in which two
tasks share one or have one inside the other’s.
qk doctor reports one, any other value, and fields it does not know. See
Scratch directories and ignored reads.
qk:ultracache
An object: the task is keyed by what it read when a run with --record last
recorded it, rather than by its declared file inputs, once it has been
recorded. See Keying tasks by what they read.
| Field | Type | Default |
|---|---|---|
mode | "on", "record" or "off" | "on"; "record" records the task and reports what its recordings would have done, but keys it by its declared inputs; "off" keys it by its declared inputs, as without the field |
ignoreReads | array of workspace globs | Empty; files left out of recordings, whose changes cannot change the result; the sandbox’s audit leaves them out too, and enforce lets the task read them. A glob starting with ^ is matched from the root of each project the task’s project depends on, directly or not |
Nx’s "warn" and "error" modes are not supported yet. Other fields are
ignored, as in qk:threads, and qk doctor reports them. The field does not
change the task’s key; ignoreReads changes what its recordings key.
qk:warm
An object describing reusable scratch state, or an array of them restored and
saved separately. In an array, at most one entry omits group, no group is
named twice and no variable is set by two entries. See
Several entries. Each object has:
| Field | Type | Default |
|---|---|---|
outputs | boolean | false; restore previous outputs before execution |
paths | array of workspace paths and globs | Empty; ! excludes; {warm} is not allowed here |
env | object of strings | Empty; {warm}, {workspaceRoot}, {projectRoot} expand to absolute directories |
maxSize | bytes or size string | No per-group limit |
remote | boolean | true; permit remote exchange when portable is true or "path" |
portable | true, false or "path" | false; true permits another worktree’s save and remote exchange; "path" permits remote exchange, restoring a save only when made in a checkout at the same path |
mtimes | "epoch" or "preserve" | "epoch"; "preserve" keeps a worktree’s own save’s modification times |
key | array of workspace paths and {"env": name} objects | Empty; a save is restored only where the key matches |
restoreKeys | count | None; how many leading key parts a save must match when none matches whole |
group | string | None; share {warm} and its saves with every target naming the group; an entry with a group cannot set outputs, paths or survive |
survive | array of targets, target or project:target | Empty; keep paths across these dependencies |
save | "wait" or "background" | "wait"; "background" saves after the task has reported |
restore | "whole" or "each-path" | "whole"; "each-path" restores each missing path from paths and leaves those on disk; see Restoring paths one by one |
prefetch | boolean | false; fetch the remote save as the run starts, while the task’s dependencies run; see Fetching remote state ahead |
Other fields are ignored, as in qk:threads, and qk doctor reports them.
Put it beside inputs and outputs, rather than in executor options.
Environment values retain their other text unchanged; their directory tokens
are absolute even when the task runs from a project subdirectory.
Warm state is restored on misses and uncached runs, saved after success,
and never changes a key or a hit. --skip-cache disables it.
See Warm state for tool configurations, portability checks
and lifecycle details.
Executors
qk executes commands through /bin/sh -c on Unix and cmd.exe /D /S /C
on Windows. Task PATH starts with node_modules/.bin directories from cwd
through every ancestor to the filesystem root, then the selected Node runtime’s
directory, then the task’s environment PATH. Node is selected from qk’s inherited
PATH in the workspace root before task environment or cwd overrides, as in Nx.
Version-manager proxies are probed when a command first starts, with a two-second limit.
Cache hits do not probe Node. A failed probe of the first executable Node match
does not promote a later installation from the inherited PATH; executable
lookup continues through the task’s PATH without adding a runtime directory.
Unsupported executors fail before tasks start.
nx:run-commands
{
"executor": "nx:run-commands",
"options": {
"commands": ["echo first", "echo second"],
"parallel": false,
"cwd": "{projectRoot}"
}
}
command and commands
Use command for a string (or an array joined with spaces) or commands for
an array of strings or command objects; command wins when both are set. An
empty commands succeeds without running a process. Each object can have
command, forwardAllArgs, description, prefix, prefixColor, color,
and bgColor. Decorations apply when commands run in parallel.
parallel
A boolean, default true, controlling concurrency within the task.
false runs commands sequentially and stops at the first failure. It is
separate from the run’s --parallel task limit. With command, as in Nx,
it is ignored: the command runs in parallel exactly when readyWhen is set.
cwd
A directory relative to the workspace root, which is the default, or an
absolute one. Like every option, it may use {projectRoot} and
{projectName}, and start with {workspaceRoot}, which resolve as the
workspace loads; see options.
env, envFile, and color
env is an object of environment variables. envFile is a dotenv path
relative to the workspace root, loading only variables not already set, or
set but empty, as in Nx 24.
color: true sets FORCE_COLOR=true. NX_LOAD_DOT_ENV_FILES=false disables
all dotenv loading, including envFile. See Environment.
args and forwardAllArgs
args supplies extra arguments as shell text or an array joined with spaces.
They are forwarded after other options and before the task’s own arguments.
forwardAllArgs defaults to true. Argument placeholders select which
values to interpolate. See Forwarding arguments.
readyWhen
A string or array of strings. The task is ready once all strings appear on
stdout or stderr, as printed or with terminal control sequences left out. If
every command exits first, the task fails, naming the strings that did not
appear. With commands, it requires parallel: true.
It keeps running while dependents need it, and is not cached.
See Readiness.
Other options and task overrides
Other scalar options are forwarded as --name=value and exposed as
{args.name}; lists, objects and nulls are not forwarded, and {args.name}
writes a list’s items joined with commas. Task arguments override values
of the same name. An argument naming an executor option sets that option,
for example -- --args='--watch', --cwd=dir, --no-parallel, or
--env.NAME=value. As in Nx, the tokens {projectRoot}, {projectName},
{project.<field>} and a leading {workspaceRoot} resolve in such a value
for the task’s project.
usePty: false runs the target’s commands with pipes rather than a
pseudo-terminal, and tty: false leaves the terminal out of raw mode while
the task a run is for reads it. streamOutput and verbose are accepted
without effect. See Terminals and input.
nx:run-script
options.script names a package script. As in Nx, it is the only option
read: qk runs the script in the project directory, whatever cwd says, with
the task’s environment, without options.env or envFile, using the
detected package manager:
| Manager | Invocation |
|---|---|
| npm | npm run <script> -- <args> |
| pnpm | pnpm run <script> <args> |
| yarn | yarn <script> <args> |
| bun | bun run <script> -- <args> |
Detection order: nx.json’s cli.packageManager, then the lockfile
(bun.lockb or bun.lock, yarn.lock, pnpm-lock.yaml, package-lock.json),
then root packageManager or pnpm-workspace.yaml, then the invoking
manager’s npm_config_user_agent, else npm. Nx does not use the root
packageManager/workspace-file fallback.
Missing scripts fail in execution preflight after explicit overrides apply.
The task’s arguments are each quoted for the shell; Nx joins them as they
are.
nx:noop
Succeeds after its dependencies without launching a process. No executor options are needed.
Inputs and outputs
Inputs describe what changes a task’s result. Outputs describe files to save and restore. Local caching explains the complete key, including the root workspace files and manifests always included.
inputs
Without an explicit array, a task uses default and ^default.
Unless overridden by a named input, default means {projectRoot}/**/*.
{
"inputs": [
"production",
"^production",
{ "env": "NODE_ENV" },
{ "runtime": "node --version" }
],
"outputs": ["{projectRoot}/dist"]
}
Supported declarations:
- filesets, as strings or
{"fileset": "…"}, with!exclusions, and{"fileset": "…", "includeIgnored": true}, which reads files on disk that ignore rules leave out; - named inputs, also as
{"input": "name"}; ^nameand{"input": "name", "dependencies": true}(or"projects": "dependencies"), which as in Nx cover every project the project depends on, directly or not, and^{projectRoot}/…,^{workspaceRoot}/…or{"fileset": "…", "dependencies": true}, a fileset of each of them;{"input": "name", "projects": […]}, the named input of the projects the project selectors select, and with"dependencies": truetheir^nameinstead, as in Nx 24: every project they depend on, but not themselves;env,runtime,dependentTasksOutputFileswithtransitive, andexternalDependencies, which adds every installation of the named packages in the pnpm lockfile, whichever importer installs them;{"workingDirectory": "relative" | "absolute"}, the directory qk was run from;{"json": "path", "fields": […], "excludeFields": […]}, only the selected dotted fields of a JSON or YAML file, or of each file a glob selects.
As in Nx, a string names a named input when one of that name exists, and is
a fileset otherwise. A fileset, like a json path, starts from
{projectRoot} or {workspaceRoot}. {projectRoot} and {projectName}
are replaced anywhere in it; {workspaceRoot} only at the start, and
anywhere else it is glob text, as in Nx. A fileset starting from
{projectRoot}/ selects the project’s files, and one from
{workspaceRoot}/ the workspace’s. Write globs with / on every platform:
\ escapes the character after it, so {projectRoot}/app/\\(marketing\\)/**/*
in JSON selects the app/(marketing) directory. A path with no glob
pattern names that file, or that directory and everything under it, so
{projectRoot}/src, {projectRoot}/src/ and {projectRoot}/src/**/*
select the same files.
As in Nx, a named input cannot use dependencies or projects, an input
with "always": true and a projects pattern that matches no project is an
error, and so is an externalDependencies entry naming a workspace project
or, with a pnpm lockfile qk can read, a package nothing installs. With a
package manager other than pnpm, lockfiles are always keyed by content.
The runtime input environment determines which tasks can
share a runtime evaluation.
dependentTasksOutputFiles selects matching files from completed dependency
outputs rather than their full output fingerprints. . and .. segments in
paths are resolved within the workspace. A symlinked input is keyed by its
target text and the content it resolves to, including the files below a
linked directory; links that resolve outside the workspace are not supported.
As in Nx, a project’s filesets select only the files the project owns, each
file belonging to the project whose root most specifically contains it. A
project nested in another’s root owns its files, so they reach neither the
outer project’s {projectRoot}/**/* nor, for a root project at ., its
default; select files outside a project’s own with a {workspaceRoot}
fileset.
File exclusions apply to every inclusion in the same input scope, regardless of
declaration order. Workspace filesets and each project’s filesets have separate
scopes: an exclusion in ^production cannot remove a file selected explicitly
by a {workspaceRoot} fileset. Generated files selected by source filesets are
keyed after dependencies complete, independently of dependentTasksOutputFiles;
a task’s own declared outputs stay out of its source inputs.
Extended globs (?(…), *(…), +(…), @(…), !(…), (a|b) and {,…}) are expanded
into plain globs. Some expansions follow Nx 23’s approximations: +(a|b)
matches one occurrence, and an omitted group in a directory segment widens
that segment to *.
Bare parenthesized alternation differs for affected-project selection. qk
selects projects with **/*.(svg|png) inputs when SVG or PNG files change,
while Nx 23.1.0’s affected-project matcher treats that group as literal text.
Nx’s native cache hasher does expand the group. Use **/*.{svg,png} for
consistent hashing and affected selection in both tools.
includeIgnored
A fileset only sees files that .gitignore and .nxignore do not exclude.
With "includeIgnored": true it reads the disk instead, whether Git tracks a
file or not, as in Nx 24:
{
"inputs": [
{ "fileset": "{projectRoot}/generated/**/*.json", "includeIgnored": true },
{ "fileset": "!{projectRoot}/generated/**/*.local.json", "includeIgnored": true },
{ "fileset": "{workspaceRoot}/openapi.generated.json", "includeIgnored": true }
]
}
Each glob starts from {projectRoot} or {workspaceRoot}; one that climbs
out with .., has a . segment or names an absolute path is an error. A
glob is read from its leading directory: with no pattern after it, it names
that file, or that directory and everything under it, and a path that names
a missing file changes the key when the file appears. A glob read from
inside a project matches only that project’s files, not those of a project
nested below it, and one read from outside every project matches all it
names. A walk does not enter node_modules, .git, .nx/cache,
.nx/workspace-data, .yarn/cache or .qk unless it starts inside one,
skips the timestamp files Vite and Vitest write beside their config, and
does not enter a linked directory it meets. A linked file, or a directory
named exactly, is read wherever it points.
A ! negation with includeIgnored filters every includeIgnored glob
resolved with it: the project’s own, or, with "dependencies": true, the
globs carrying that flag, applied together to each project the project
depends on, directly or not. A negation with nothing to filter is an error.
Such a fileset reading another task’s declared outputs makes that task’s
changes carry to this one in affected selection, as dependentTasksOutputFiles
does. Affected selection matches a changed path against the globs, so a
deleted file counts. Unlike Nx, the task’s own declared outputs stay out of
these inputs, as they do for every fileset.
env and runtime
{"env": "NAME"} keys the declared variable’s value. Dotenv file content
is not automatically keyed. {"runtime": "command"} keys command output;
commands run once per run for each environment.
Task-level affected selection treats both as unchanged because it cannot
recover the base revision’s environment.
externalDependencies
{"externalDependencies": ["typescript"]} adds every installation of the
named packages in the pnpm lockfile, whichever importer installs them.
The task’s root/project/transitive importer installations are already keyed.
dependentTasksOutputFiles
{"dependentTasksOutputFiles": "**/*.d.ts", "transitive": true} is
selects matching files from declared outputs of direct task dependencies.
transitive: true also includes outputs of their dependencies. As in Nx, a
dependency without declared outputs offers none, and other artifacts do not
change this input’s key. Multiple patterns are combined. This and filesets
or json globs selecting a dependency’s outputs are the only ways a
dependency task keys a task, and the only ways a change to it makes the task
affected.
workingDirectory
{"workingDirectory": "relative"} or "absolute" includes the directory
qk was invoked from in the key.
json
{"json": "path", "fields": ["compilerOptions"], "excludeFields": ["scripts"]}
selects dotted fields of a JSON or JSONC file. A path with no glob pattern is
read even when source ignore rules exclude it, which Nx does not do. A glob
selects, as in Nx, among the files a fileset from the same root would: the
project’s own, or the workspace’s from {workspaceRoot}/, a dependency’s
declared outputs among them once it has written them. A file named .yaml
or .yml is read as YAML, which Nx does not do: Nx keys such a file by its
whole content, so a key qk leaves unchanged can still change Nx’s. As in Nx, a file qk cannot read is keyed by
its whole content, without the field filters.
Affected task selection compares the selected fields at both revisions, so a
change to fields the input leaves out does not affect the task. A change it
cannot compare counts, such as an added or deleted file, or one it cannot read
at either revision. Project selection, as in Nx, does not read json
inputs: a changed file touches the project that contains it, if any.
outputs
Outputs resolve as in Nx 24. {projectRoot} and {projectName} name the
project, {options.name} reads the target’s options, with the task’s
--name=value arguments applied, and the legacy {project.<path>} reads
the project’s configuration, such as {project.sourceRoot}. A placeholder
can fall back, left to right, to others and finally to a literal:
{options.outputPath ?? options.outDir ?? {projectRoot}/dist}. A value is
written as JavaScript writes it, so false is false, 0 is 0 and an
array is its items joined with ,; only a missing option, null and ""
have no value. Option values have {projectRoot}, {projectName} and a
leading {workspaceRoot} resolved first, as Nx does when it builds the
project graph. Braces that do not start with a token are glob text, so
{projectRoot}/dist/**/*.{js,map} is one output, and so is {env.NAME}:
environment variables are no placeholder in outputs. As in inputs, \
escapes a glob character, so {projectRoot}/app/\\(group\\) in JSON names
the app/(group) directory. The project’s root and name go in as the
paths they are, so {projectRoot}/dist of a project at apps/(group) names
apps/(group)/dist, which Nx does not do; an option’s value goes in as
written, a pattern if it holds one.
As in Nx 24, every output starts with a token, after its ! if negated:
{projectRoot}/dist or {workspaceRoot}/dist, not dist, which nx repair
rewrites. An output whose {options.…} placeholder, or a {projectRoot} or
{workspaceRoot} left as text, has no value is left out. {workspaceRoot}
may only start an output, and an output from it must name a directory
before any pattern: {workspaceRoot}/dist/**, not {workspaceRoot}/**.
Building the task graph fails on an output that breaks either rule. An
absolute output from an option is read relative to the workspace, and one
outside the workspace is an error. ! negates an output:
what it matches is neither cached nor removed when an entry is restored. A
target without outputs takes options.outputPath, and a build or
prepare target dist/<root>, <root>/dist, <root>/build and
<root>/public. Those defaults are cached and restored, but because they are
guesses they are not declared outputs: they stay source files that other
tasks’ filesets can select, and dependentTasksOutputFiles selects none of
them.
A negated group such as src/**/!(*.test).ts or dist/!(cache)/** expands
as Nx expands it, into a glob with the group widened and one naming the
group’s items that excludes, so both tools select the same files, including
where Nx’s expansion is surprising: *.!(ts) keeps only files ending in .,
as in Nx. Nx applies such exclusions to every pattern of a project at once;
qk applies each to the pattern it comes from, which can only add inputs.
A task with any other input declaration, or an output without a fixed directory prefix, runs uncached and reports why.
Use explicit output declarations for build artifacts. A cache hit removes existing matching outputs before restoring the saved result; negated outputs are preserved. An output pattern without a fixed directory prefix disables caching, with a reported reason, and so does an output naming a project’s root or a directory that holds a project, which a restore would replace.
Root .gitignore and .nxignore rules exclude files from source input discovery,
including tracked files. Negations use Git ignore semantics. Watch uses the same
rules, and project affected selection also applies the root ignore files. qk still hashes
mandatory workspace/project configuration independently of source filesets,
including the root .gitignore and .nxignore files themselves.
Task-level affected selection follows these cache inputs, including explicitly
declared JSON inputs and mandatory configuration hidden by source ignore rules.
Environment variables
Environment precedence
Environment precedence, highest first: FORCE_COLOR from color: true,
configuration env, options.env, target-level env (a qk extension), the variables Nx sets
for every task, the inherited process environment, the task’s dotenv files
in Nx’s order: in the
project root and then the workspace root, .env.<target>.<configuration>,
.env.<configuration>, .env.<target>, then .env.local, .local.env and
.env, each also as .env.<name>.local, .<name>.local.env and
.<name>.env, then envFile. color, env and envFile are
run-commands options; run-script reads none of them. A target that a plugin
atomized, named in its project’s metadata.targetGroups with a
metadata.nonAtomizedTarget, also reads the files of the target it was split
from, after its own. Each atomized target reads its own files, where Nx gives
every target of the group the files of its first atomized one. The
task variables are NX_TASK_TARGET_PROJECT, NX_TASK_TARGET_TARGET,
NX_TASK_TARGET_CONFIGURATION, NX_WORKSPACE_ROOT, LERNA_PACKAGE_NAME,
NX_TUI=false, NX_INVOCATION_ANCESTOR_PIDS, NX_TERMINAL_CAPTURE_STDERR=true
for a test target, FORCE_COLOR, which is true unless already set, and
NX_TASK_HASH, the task’s cache key, which every task is given, as in Nx,
whether or not the run uses the cache, except under --sandbox and input
analysis.
Declared environments
A target with qk:env, or with nx.json’s
qk:defaultEnv when it
names none, inherits only the variables its environment lets through. Its
environment applies with or without --sandbox. What the precedence above
calls the inherited process environment holds then only the variables the
environment inherits or passes, and the ones it sets. The rest of the
task’s environment is built as before: its dotenv files, envFile, its
env settings and the variables Nx sets apply on top, since its
configuration declares them.
PATH is either a variable like any other, which the environment passes or
inherits, or built from its bin and dirs entries. A built PATH holds a
directory linking each command bin names, found on the PATH qk inherited,
and nothing else from that directory, then the directories dirs names. The
directory of links is in the user’s cache directory, under
~/Library/Caches/qk/bin on macOS and $XDG_CACHE_HOME/qk/bin or
~/.cache/qk/bin elsewhere, named by the commands it links, so tasks
linking the same commands share one. On Windows, where making a link takes a
privilege a user may not hold, PATH holds the directory of each command
instead. Either way, qk adds the workspace’s node_modules/.bin directories
and Node’s directory to it, as for every task.
A from entry runs its command in the workspace root, with every variable
qk inherited, and sets the variables it prints as JSON: either Nix’s
{"variables": {NAME: {"type": …, "value": …}}}, of which the exported
variables are taken, less those nix develop also leaves as they are, such
as HOME and TMPDIR, or a flat {NAME: value}, in which null leaves a
variable unset. What it sets takes the place of an inherited variable, and
the environment’s set entries take the place of what it sets. Its PATH
ends a PATH built from bin and dirs entries, or is the task’s PATH
without them; the environment cannot also inherit or pass PATH then. The
command runs once per run for every task sharing it. With inputs, files
relative to the workspace root, such as flake.nix and flake.lock, its
output is kept in the user’s cache directory, readable by its owner alone,
until they change, and the task’s key holds the command and their content
rather than the output, which can hold paths that differ from machine to
machine. Without them, the key
holds a digest of each value it printed, which changes with the value without
keeping it, as it can be a credential. A command that fails, or prints something else, fails
the task.
The task’s key holds its environment’s entries, with named environments
replaced by theirs, so a change to an environment changes the key of every
task running in it, the values of the variables it inherits, and the file
each command bin names resolves to, whatever links lead to it. A command
found somewhere else changes the key; the rest of the inherited PATH, and
where the links are, do not. A command written as
{"name": "clang", "key": "content"} is keyed by its content instead: the
same command installed elsewhere keys alike, and one replaced in place, as
some system toolchains update, changes the key. Its digest is kept in the
user’s cache directory until the file’s size or modification time changes.
A version manager’s shim, as mise and asdf install, resolves to the version
manager itself, whichever version it starts, so neither its path nor its
content changes with that version. Name such a tool’s directory with dirs,
or set the version manager’s own environment with a from entry, such as
mise env --json, rather than naming its shim in bin. The values
of the variables it passes are not keyed. Without a declared environment,
keys are as before.
A process reads its environment from its own memory, so the sandbox’s audit cannot see which variables a task reads: a variable a task needs and its environment leaves out shows as the task failing. When a task with a declared environment fails, qk names the inherited variables its environment left out that its dotenv files and settings do not set again, by name only, as values can be secrets.
Dotenv files
Dotenv files are read as Nx reads them, with npm’s dotenv and
dotenv-expand. A task’s files are read together: the first to set a
variable wins, a variable the environment already holds keeps its value
unless it is empty, and then every value is expanded once, across all the
files: $NAME, ${NAME}, ${NAME:-default}, ${NAME-default},
${NAME:+alternate} and ${NAME+alternate} read the environment, then the
values the files set before. A reference preceded by \ is left as $, and
a value that refers back to itself expands to its default. Values in single
quotes expand too, as dotenv-expand does not know about quotes. Lines are
read as dotenv reads them: export is allowed, KEY: value works, quoted
values may span lines, \n and \r are line breaks inside double quotes,
an unquoted value ends at #, and a line dotenv cannot read is skipped
rather than failing the run. envFile is read the same way, and so fills a
variable set but empty, as in Nx 24.
As Nx does at startup, qk loads the workspace’s root .local.env,
.env.local and .env, in that order of precedence, into its own
environment before it reads its command line, so the run controls below can
be set there; remote cache credentials arrive the same way. For each task,
a variable whose value is one of those files’ is then removed again, so the
task’s own files decide it, as in Nx. NX_LOAD_DOT_ENV_FILES=false turns
every dotenv file off, envFile included, as in Nx; qk then removes nothing,
where Nx still removes a shell variable whose value a root file repeats. For envFile, the
task’s environment decides, so a task’s dotenv file can turn it off.
A task does not see NX_SET_CLI, NX_ORIGINAL_FORCE_COLOR,
NX_TERMINAL_OUTPUT_PATH, NX_STREAM_OUTPUT or NX_PREFIX_OUTPUT from an
enclosing run, as in Nx. It sees QK_TASK_INVOCATIONS, the tasks it runs
inside, which a run it starts reads to refuse running one of them again.
dotenv-expand’s use of JavaScript’s String.replace, which reads $& and
similar patterns in a value it substitutes, is not reproduced.
Run controls
| Variable | Behavior |
|---|---|
NX_DEFAULT_PROJECT | Fallback project for shorthand commands |
NX_WORKSPACE_ROOT_PATH | Workspace root, unless --workspace names one |
NX_INFER_ALL_PACKAGE_JSONS=true | Every package.json is a project, in a workspace without a root package.json |
NX_BASE, NX_HEAD | Affected comparison revisions; CLI overrides them |
NX_LEGACY_AFFECTED | true makes affected selection select projects, false tasks; overrides nx.json affected.legacy, and --granularity overrides it |
NX_PARALLEL | Task concurrency, overridden by --parallel |
QK_CORES | Core budget, overridden by --cores |
QK_COLD=true | Run each task from scratch and save what it gives, as --cold does; ignored by a run that skips the cache |
QK_RECORD=true | Record tasks with qk:ultracache that execute, as --record does |
QK_RECORD=audit | Record them and audit what they did beyond their declarations, as --record --sandbox does |
QK_SANDBOX_OVERLAY | 0 holds Linux enforce by Landlock alone, rather than running each task over an overlay that keeps what it writes outside its outputs from the workspace |
QK_RECORDER | How Linux records tasks: auto (the default) uses eBPF where it loads and strace otherwise; ebpf or strace uses only that one |
NX_BAIL=true | Stop starting tasks after a failure, unless --nx-bail=false |
NX_IGNORE_CYCLES=true | Break dependency cycles with a warning |
NX_DEFAULT_OUTPUT_STYLE | Default output style, overridden by --output-style; an unknown name is ignored, as in Nx |
NX_TASKS_RUNNER_DYNAMIC_OUTPUT=false | No live panel; static instead |
NX_STREAM_OUTPUT | true streams every task’s output rather than holding it; false streams none but continuous tasks |
NX_PREFIX_OUTPUT=true | Under the static styles, streamed output carries the project prefix |
NX_PROCESS_KILL_GRACE_PERIOD | Milliseconds a cancelled or stopped command has to exit after SIGTERM before it is killed; 5000 by default |
NX_LOAD_DOT_ENV_FILES=false | Load no dotenv file |
NX_RUN_REPORT | JSON report path, overridden by --report |
NX_VERBOSE_LOGGING | Task verbosity; tasks see true or false, and --verbose or --verbose=false sets it; true also turns the live panel off |
NX_SKIP_LOG_GROUPING=true | Disable static output groups in GitHub Actions |
NO_COLOR, FORCE_COLOR | Output color controls; FORCE_COLOR=0 or false keeps qk’s own output plain, and tasks receive it as set |
NX_NATIVE_COMMAND_RUNNER=false | Run nx:run-commands targets with pipes rather than a pseudo-terminal |
CI | CI output and remote cache mode selection; as Nx’s isCI, any value but false (0 included), or a CI provider’s variable such as GITHUB_ACTIONS=true, GITLAB_CI, BUILDKITE=true or JENKINS_URL |
Cache controls
| Variable | Behavior |
|---|---|
NX_SKIP_NX_CACHE=true, NX_DISABLE_NX_CACHE=true | Bypass cache reads, writes and warm state |
NX_SKIP_REMOTE_CACHE=true, NX_DISABLE_REMOTE_CACHE=true | Local cache only |
NX_CACHE_DIRECTORY | Override nx.json’s cacheDirectory |
NX_MAX_CACHE_SIZE | Override nx.json’s maxCacheSize |
NX_POWERPACK_CACHE_MODE | Override the remote store’s localMode/ciMode |
QK_REMOTE_UPLOAD_MODE | Override the remote store’s uploadMode: wait or background |
QK_REMOTE_BLOB_THRESHOLD | The compressed size from which an output is stored apart, such as 256KB, or off; 1 MiB by default |
QK_REMOTE_PREFETCH=off | Do not fetch entries ahead of the tasks that need them |
QK_REMOTE_EXPECTATIONS | What a run does with the task expectations the remote store holds: read-write (the default, as the store’s mode allows), read or off |
QK_STORE_SERVER_TOKEN | The store server’s token |
QK_STORE_SERVER_URL | Override qk:storeServer.url |
QK_PROFILE_CACHE=1 | Enable cache phase timings, as --profile does |
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN | Remote credentials |
GITHUB_HEAD_REF, GITHUB_REF_NAME | CI branch for remote warm state |
See Workspace configuration for defaults and Remote caching for failure behavior.
Variables supplied to tasks
NX_TASK_TARGET_PROJECT, NX_TASK_TARGET_TARGET,
NX_TASK_TARGET_CONFIGURATION, NX_WORKSPACE_ROOT, LERNA_PACKAGE_NAME,
NX_TUI=false, and FORCE_COLOR describe the running task.
NX_TASK_HASH holds its cache key. NX_VERBOSE_LOGGING is true or
false, NX_INVOCATION_ANCESTOR_PIDS lists the qk and Nx processes it runs
inside, outermost first, and a test target sees
NX_TERMINAL_CAPTURE_STDERR=true, as in Nx.
Targets using qk:threads additionally receive QK_THREADS and their
configured thread variables. Targets using qk:warm receive their expanded
warm environment when warm state is enabled. A task an affected profile with
reachability narrowed to some of its cases receives QK_AFFECTED_CASES, the
absolute path of a file naming those cases one per line, each relative to the
workspace root; see
Import reachability.
Nx compatibility
qk reads a bounded subset of Nx 24 configuration. It runs without invoking Nx or loading Nx plugins. The implementation, fixtures and parity tests establish supported behavior; preserving unknown metadata during inspection does not mean qk executes it.
Supported executors
nx:run-commands, nx:run-script and nx:noop are executable.
Other executor names remain inspectable but fail execution preflight.
Project dependencies
Package dependencies create workspace graph edges when their ranges are
workspace:…, *, a file: path to the package, or a semver range the
package’s version satisfies (including prereleases by npm’s rules).
Ranges such as catalog:, npm aliases, or ranges outside the workspace
version do not create an edge. Names are looked up by dependency key, so
"ui": "workspace:@scope/ui@*" does not link to @scope/ui.
When a name occurs in multiple sections, precedence is dependencies,
devDependencies, peerDependencies, then optionalDependencies. When two
packages share a name, the name means the one whose package.json path sorts
last, as in Nx. pluginsConfig["@nx/js"].analyzePackageJson turns these
edges off.
implicitDependencies adds selected project edges.
Deliberate differences
- A pnpm lockfile change under
projectsAffectedByDependencyUpdates: "auto"follows installed sets, avoiding projects whose installations are unchanged and including transitive installation changes Nx can miss. Task selection judges such a change by the same installed sets. projectsAffectedByDependencyUpdatesas a list of selectors touches its projects only when a lockfile changed; Nx’s project selection touches them on every change, which reads as a bug, since its task selection checks.- nx.json counts through what it changes. Nx hashes it whole into every task,
so any change to it, even a comment, misses every task’s cache and affects
every project and task. qk keys each task by its resolved configuration,
which
targetDefaultsandnamedInputsreach it through, and by the remaining settings that can change a task’s work. A change affects every project and task only where it changes those settings; one totargetDefaultsornamedInputsaffects the projects whose configuration it changes and the tasks whose key it changes; and one to comments, formatting or settings such asdefaultBaseaffects nothing. A tool that reads nx.json declares it as an input. See What changes the key. - A change to the file nx.json
extendscounts as a change to nx.json does; Nx does not look at it. - Where no input selects a project’s
project.jsonorproject.local.json, they count through what they change. Nx hashes the whole configuration of every project whose inputs a task reads, so editing one target misses and affects every task of the project and of its dependents reading it through^inputs. qk keys each task by its resolved configuration and what remains of its own project’s, such as its root and output paths, so the edit misses and affects the tasks of that target. An input selecting the file, as{projectRoot}/**/*does, keys it as in Nx; a workspace opts in by leaving the files out ofdefault. See What changes the key. - Task selection adds to what Nx selects every task whose qk key changed, by
the parts of the key Nx does not hash (see below). Nx selects some tasks
whose qk key is unchanged, such as for a changed tracked output or a
lockfile change under
"all"; qk selects them too, and they are cache hits. - Cache keys leave every planned task’s declared outputs out of source filesets, unless a task reads them from a dependency. Nx hashes a tracked output like any file, so a task regenerating one changes its own key.
- The root tsconfig is keyed without the version of the installed
typescriptpackage Nx hashes beside it; qk keys what the task’s importers install instead. - The
pnpm-workspace.yamlqk adds to every key leaves out its resolution keys, which reach tasks through the lockfile. A task whose inputs select the file keys it whole, as in Nx. - Cache keys always include the task’s project’s
package.json, what itsproject.jsondecides for the task, and each dependency project’spackage.jsonwithout its version, scripts and other metadata, even when the task’s inputs leave them out. Nx keys these files only through declared inputs. qk keeps them so that a task’s scripts, and where a dependency’s imports resolve, cannot change without a cache miss. See What changes the key. - A tracked upstream can adjust the affected base to avoid counting changes already landed upstream.
- Deleting a project’s manifests affects the projects that depended on it, read from its manifests at the base revision, where Nx affects every project. See How changes reach projects.
qk affectedtakes--granularity task|project, which comes beforeNX_LEGACY_AFFECTEDand nx.jsonaffected.legacy. Nx has no such option. See Tasks or projects.- Nx’s Ultracache keys tasks by recordings Nx Cloud makes, which qk cannot
read. qk’s
qk:ultracacherecords tasks itself, with--recordon Linux, and keys them by the files they read, the directories they listed and the paths they looked up, rather than by globs. Nx’s targetultracachefield is read and merged, but has no effect. Inputs marked"always": truecount for a task keyed by its recordings, and read as without the flag otherwise. See Keying tasks by what they read. - A target’s
readyWhenis checked only while a task in the run waits for it, and its verdict is not shared with other qk processes. Thelocal-iphosts resolve to the machine’s outward address of their family, close to but not exactly the dev servers’ choice. exec --exclude-task-dependenciesruns the command in the selected projects; Nx’s leaves every project out and runs nothing.- From a package script,
execruns the script’s target with the run options it was given, and passes the arguments beyond the script’s own to the task. Nx runsnx runfor it without the options, and reads those arguments as its own options. - When a command that runs tasks is given both options it does not know and
arguments after
--, qk forwards both, the unknown options first. Nx forwards only what follows--and drops the others. qk run <name> --target <target>runs the target on project<name>; Nx ignores the positional and uses the current directory’s project.- Under
--nx-bail, a run that failed and left tasks unstarted exits with 1. Nx counts it as interrupted and exits with 130. - Task headers name qk rather than Nx:
> qk run <task>,✔ qk run <task>and✖ qk run <task> failedwhere Nx printsnx run. Anything that reads logs by Nx’s headers has to match qk’s. - qk’s default output styles are its own:
staticin CI, the live panel on a terminal, and otherwisequietfor several tasks and raw output for one. Nx defaults tostatic-failures-onlywhere it does not use its terminal UI, and tosummarywhen driven by an AI agent. --output-style=summarywrites the output of failed tasks only, to the worktree’s state directory; Nx writes every task’s output to its cache directory.project.local.json,nx.local.json,qk:threads,qk:warm,qk:affectedProfiles,qk:reachability,qk:env,qk:environments,qk:defaultEnvand a target-levelenvare qk extensions. Nx does not apply them: under Nx, a task inherits every variable.- A target default stays beneath a target the project’s files replace along
the way, for instance when
includedScriptsmakes a script target that project.json gives a command. Nx stamps the default with the executor the files end with, so the script target in between replaces it and the default is lost, unlike when the script is left out. - Two run-commands targets whose
commandis an array merge when the arrays are equal. Nx compares arrays by identity, so they never do. - run-script quotes each of the task’s arguments for the shell. Nx joins them as they are, so an argument with a space becomes several.
- Options a dependency forwards with
options: "forward"are taken as they are. Nx interpolates them again, which only normalizes as a path the text of a value holding braces, turninghttp://x/{args.y}intohttp:/x/{args.y}. - Tokens in a task’s arguments resolve with
/separators on every platform; Nx joins them with\on Windows. - Input negated-group exclusions apply to the pattern they came from in qk; Nx applies them across a project’s patterns. qk can include extra inputs.
- A
jsoninput naming one file reads it even when source ignore rules exclude it; Nx reads only the files a fileset could select, so qk can key a file Nx leaves out. - An
includeIgnoredfileset leaves out the task’s own declared outputs, as every qk fileset does, so a task that writes into what it reads can still be cached. Nx keys them. - In filesets,
jsoninputs,includeIgnoredfilesets and outputs,{projectRoot}and{projectName}go in with their glob characters escaped, so a project atapps/(marketing)reads and caches that directory. So do the project’s values in an output’s{project.…}and the root in the outputs a target withoutoutputstakes. Nx inserts them as they are, which turns such a root into a pattern that misses the project’s files. pnpm-workspace.yamlandlerna.jsonname workspace packages in a workspace without a rootpackage.jsontoo, and package.json dependencies make edges there; Nx then reads no workspace patterns and analyses no package.json.- With
analyzeLockfile: false, a lockfile change underprojectsAffectedByDependencyUpdates: "auto"affects every project even when no package changed, where Nx affects none; cache keys andexternalDependenciesinputs still read the lockfile. - Project discovery ignores the machine’s global Git excludes, and the
.gitignorefiles above a workspace that is not in a Git repository; Nx reads both, so what they hide depends on the machine. - An unnamed project at the workspace root is named after the workspace
directory; Nx names it
.. Two configurations giving the samerootare an error, where Nx merges them into one project, and arootoutside the workspace is refused. - Project selectors read
tag:*anddirectory:*as globs, where Nx takes them for every project, tagged or not. - An atomized target, one naming a
metadata.nonAtomizedTarget, reads its own.env.<target>files; Nx reads those of the first atomized target in its group for every target of the group. - With
NX_LOAD_DOT_ENV_FILES=false, a task keeps a shell variable whose value a root.envfile repeats. Nx removes it, unloading files it never loaded. - A
targetDefaultsfilter.projectsgiven as a string is one pattern; Nx spreads it into one pattern per character. - Globs in project selectors, workspace package patterns,
targetDefaultskeys, project target keys anddependsOntargets do not support minimatch’s extended globs such as@(a|b): their characters match themselves. - For affected-project selection, bare parenthesized extension groups such
as
**/*.(svg|png)select projects in qk that Nx 23.1.0 does not. See Inputs and outputs for compatible syntax.
See Affected selection, Inputs and outputs and Running tasks for the detailed rules.
Limits
This is a subset of the design’s compatibility surface. There is no Nx parity claim yet. In particular:
- The graph includes workspace projects only, as
nx graph --filedoes, and is written as JSON only: qk has no HTML graph viewer, sograph --file <name>.htmlfails.--externaladds a node per installation in the pnpm lockfile,npm:<name>@<version>with peers and patch in the version, with edges from projects to what they install directly and between installations. - Nx plugins and inferred targets are outside the design’s scope.
- qk has its own live output panel, which takes no input, and does not
implement Nx release commands. Nx adds an
nx-release-publishtarget to each public package when@nx/jsis installed; qk does not, as its executor is a release executor qk cannot run. - An
externalDependenciesentry naming a package nothing installs is an error only with a pnpm lockfile qk can read; with any other lockfile qk cannot tell, and keys the lockfile whole. - Pseudo-terminals are Unix only; on Windows commands see pipes, as they do
in Nx unless
NX_WINDOWS_PTY_SUPPORTis set. A pseudo-terminal passes\nthrough, where Nx’s turns it into\r\n.
The compatibility baseline is documented in Nx’s project configuration and workspace configuration references. The bounded subset above and fixture tests define qk’s current behavior; new Nx features are not automatically supported.
Architecture
qk separates workspace discovery from execution. Inspecting projects and exporting graphs reads configuration without running executor commands.
From a request to a run
qk-configdiscovers the workspace and normalizes layered definitions.qk-graphbuilds project dependency edges and selects projects.qk-affectednarrows selection when the request specifies changes.qk-taskgraphexpands target dependencies into a task graph, resolves configurations, and checks cycles and continuous-task relationships.qk-executorprepares commands and child environments before execution.qk-runnerschedules ready tasks with bounded concurrency and core shares, propagates failures and handles cancellation.qk-cachecomputes keys, restores results and warm state, and saves successful outputs.qk-lockfilesupplies pnpm installation fingerprints.qk-historyrecords runs and key changes.qk-clipresents results and optional JSON reports.
inputs analyze attaches a per-task recorder at command launch. The runner
collects macOS Seatbelt reports or Linux traces, from qk-trace’s eBPF recorder
or strace, and classifies observations
using the cache’s input resolver. qk-input-analysis owns the report model and
observation unions. Analysis never substitutes observations into cache keys.
--dry-run ends after planning. It does not load dotenv, validate executor
support or execute runtime inputs, so a valid dry run does not guarantee a
successful execution preflight.
Project graph and task graph
The project graph describes relationships between workspace projects, including package and implicit dependencies. Project cycles are allowed; reverse traversal terminates when a project has already been visited.
The task graph describes a particular request after dependsOn expansion.
It may run one shared task needed by several projects. Task cycles normally
fail; --nx-ignore-cycles drops the edge closing the cycle with a warning.
qk graph --file - exports project relationships; qk run web:build --graph
exports the project and task graph without running commands.
Storage and worktrees
Within qk-cache, the internal record module owns result and warm-state
record formats, identity checks, blob references and atomic publication.
Remote packs, detached uploads and eviction use its shared record interpretation.
Result and warm-state restoration retain their own selection rules and verify
blob contents and checkout-specific paths where the files are used.
By default, linked Git worktrees share a result cache and history database under the Git common directory. File digests, restore records and active scratch state live under each worktree’s own Git directory. Branch names and checkout locations are not part of result keys.
See Local cache for key construction, Warm state for scratch state, and Run history for recorded causes.
Declared task inputs
The internal qk-cache::inputs module owns the resolved input model,
declaration inspection and matching changes to file, linked-path and
installation inputs. Affected selection supplies revision contents and
propagates changes through the task graph; input analysis uses the same
declaration interpretation to classify observations. Hashing evaluates content
and runtime values for cache keys.
These uses preserve distinct rules: affected selection reacts to whole JSON input files, while keys include only the selected fields. Affected selection includes changes beneath links to the workspace root; hashing rejects those links. Revision matching does not evaluate environment or runtime inputs.
Design proposals
The implementation plan records design decisions and development milestones. Use the guides and reference for current behavior. Development describes crate responsibilities, parity tests, releases and benchmarks.
qk: design and implementation plan
qk (short for quick) is a standalone task runner written in Rust. It reads Nx-compatible workspace configuration without requiring an Nx process. Its design targets reproducible execution, precise change selection, and efficient caching across machines and Git worktrees.
This document records design intent and development milestones. The guides and configuration reference describe implemented behavior; a proposal here is not a claim that the corresponding feature is available.
Goals
- Speed: keep workspace inspection and cached runs inexpensive, and measure cold and warm paths using reproducible synthetic benchmarks.
- Affected precision: attribute file and package installation changes to the projects and tasks that consume them.
- Caching: maintain a content-addressed local store with optional S3-compatible remote storage, output verification and bounded disk usage.
- Observability: expose run results, timings and cache-key explanations through documented CLI output and versioned JSON reports.
- Compatibility: support a clearly bounded subset of Nx configuration, with fixture coverage and explicit documentation of differences.
Scope
The configuration model covers nx.json, project.json, package nx
objects and scripts, pnpm workspace membership and pnpm v9 lockfiles.
Task definitions include named inputs, outputs, configurations, dependency
expansion, caching and continuous execution.
Supported executor families are shell commands, package scripts and no-op aggregation. Commands use the platform shell and receive an explicit working directory, environment and forwarded arguments.
Nx plugins, inferred targets, generators, Nx Cloud and release automation are outside the initial scope. Unknown metadata may be retained for inspection without implying execution support. Any scope expansion needs its own design, fixtures and compatibility documentation.
Architecture
A Cargo workspace produces one qk binary. Libraries separate concerns:
| Concern | Responsibility |
|---|---|
| Configuration | Discover projects, parse files and normalize layered definitions |
| Project graph | Resolve package and implicit dependencies and project selectors |
| Lockfile | Resolve importer installations and package fingerprints |
| Affected selection | Attribute revision changes to projects or declared task inputs |
| Task graph | Expand dependencies, resolve configurations and validate execution relationships |
| Executor | Prepare commands, environments, process groups and output capture |
| Runner | Schedule tasks, manage concurrency and propagate failure or cancellation |
| Cache | Compute keys, verify and restore outputs, manage local and remote storage |
| History | Record runs, task results, key changes and critical paths |
| CLI | Map command syntax to the library interfaces and render results |
Workspace inspection must not execute task commands. Planning and execution preflight are distinct stages: a valid task graph does not establish that all executors, tools or runtime inputs can run successfully.
Design decisions
Explicit dependency graphs
Project edges come from package manifests and implicitDependencies.
Task edges come from dependsOn expansion. Shared tasks execute once;
missing targets, cycles and incompatible continuous-task relationships have
explicit, testable behavior. Source import analysis is a separate possible
extension, rather than an assumed graph input.
Inputs connect caching and affected selection
Resolved file inputs, environment declarations, runtime values, external packages and the dependency outputs a task reads determine its cache key. Task-level affected selection reuses the input model against a change set. Environment and runtime inputs require conservative treatment because a base revision’s process environment cannot be reconstructed from Git.
Lockfile changes are attributed by what each importer installs, including transitive dependencies, peer resolutions, patches and integrity data. Resolution-only pnpm workspace changes reach tasks through the lockfile; workspace membership and other configuration changes are handled separately.
Versioned, verified cache entries
Cache keys and storage layouts are versioned. Keys include the tooling and platform information needed to avoid incompatible reuse. Paths and serialized definitions are normalized deterministically.
A dependency counts for a task only through the declared outputs the task reads, as in Nx, so a dependency that reproduces them leaves the task cached even when its own inputs change. Reads a task does not declare are not keyed.
Per-file blobs allow deduplication across entries. Manifests identify output paths, content and file metadata. Restores verify content, remove stale declared outputs and preserve exclusions. Restored files must not share mutable storage with cached blobs.
Default result storage is shared by linked Git worktrees; file digests and restore bookkeeping remain specific to each worktree. Cache location overrides and eviction limits are configurable.
Optional remote storage
An S3-compatible store uses its own namespace and verified manifests. Reads fetch missing data; uploads publish output data before the manifest. Read-only operation, missing credentials and network failures have defined fallback behavior. Remote failures should be reported without turning a successful task into a failed run.
Warm state is separate from results
Incremental tools may reuse scratch state through a target’s qk:warm
configuration. Warm state is not a valid task result and never establishes
a cache hit. The tool must validate its own state before reuse.
Restore warm state before execution, save it after success, prevent conflicting scratch paths, and include it in storage accounting. Record its provenance and cost so users can assess its value.
Public run records
Persist run and task records with a versioned schema. JSON reports expose status, timing, cache results, key-change reasons and the critical path. Consumers should use this documented interface rather than private implementation details.
Development milestones
- Fixtures and baselines: create self-contained workspaces and capture project graphs, task graphs, affected sets, execution results and timings from a pinned Nx release. Record accepted differences with reasons.
- Read side: implement discovery, normalization, project selection, lockfile parsing and graph export. Validate against the fixtures.
- Affected selection: cover file ownership, dependency propagation, workspace configuration changes and installation-aware lockfile changes.
- Execution: implement dependency ordering, configurations, command preparation, bounded scheduling, failure propagation and cancellation. Validate continuous tasks and readiness using controlled fixture commands.
- Local caching: verify deterministic keys, output restoration, log replay, corruption handling and concurrent access across worktrees.
- Remote storage and observability: validate S3 modes, eviction, run history, key-change explanations and JSON reports.
- Distribution and documentation: publish platform binaries, prepare package-manager installation, and maintain searchable guides and references.
- Refinement: measure task-level affected selection, incremental warm state and concurrency controls using repeatable workloads. Expand support only when requirements and tests justify it.
Each milestone has observable acceptance criteria and runs in this project’s CI. Compatibility checks remain useful after a feature ships.
Test strategy
- Unit tests: configuration merging, selection, dependency expansion, input resolution, lockfile fingerprints, interpolation and size accounting.
- Synthetic fixtures: nested projects, package scripts, named inputs, configurations, continuous tasks, output exclusions and package changes.
- Nx parity: compare both tools on identical fixtures using pinned dependencies. Differences require a documented explanation.
- Execution and cache behavior: exit codes, skipped dependents, process cancellation, environment precedence, output replay, restore verification, concurrent writers and remote failure modes.
- Cross-platform CI: Linux, macOS and Windows, including shell quoting, path handling and executable-file behavior.
- Performance: record cold inspection, unchanged affected selection and fully cached runs on generated workspaces; investigate measured regressions.
Risks and open questions
- Shell syntax and argument quoting differ across platforms; document the supported forms and use environment variables where forwarding is limited.
- Native outputs require platform-aware cache keys; a shared store must not imply cross-platform artifact compatibility.
- Cached file metadata can become stale; content verification and conservative fallback behavior must protect correctness.
- Greater affected precision can change task selection; explain differences and keep regression cases demonstrating why the result is correct.
- Warm state can be stale or incomplete; tools must validate it independently of result caching.
- Keep bootstrapping independent of qk’s own task graph: Cargo builds the runner, and installation must work before workspace tasks can execute.
Bounded project watching
Project watching uses native filesystem events and the explicit project graph. It selects projects and optional dependency closures, refreshes discovery when files change, debounces event bursts, and queues edits while callbacks execute. Callbacks use the invocation directory and Nx’s project/file-change environment variables. Cancellation uses the executor’s process-group cleanup. It does not run plugins, sync generators or a daemon. Declared outputs, warm paths and runner state are excluded to prevent feedback loops. Tests cover dependency selection, new project discovery, queued changes and cancellation.
Warm state for native builds
Status: implemented. Warm state and the
qk:warm reference describe the behavior;
this section records why it takes this shape.
Native build systems keep two kinds of state. Some is bound to the checkout’s absolute path and trusts file timestamps, as Gradle’s execution history, CMake’s build directories and Ninja do. Some is content-addressed and path-independent, as ccache is when its base directory is the workspace root. The first kind makes rebuilds fast inside the worktree that produced it and is worth little elsewhere, where CMake refuses a cache created in another directory. The second kind carries across worktrees and machines. The portability and timestamp controls distinguish these two kinds of state.
A React Native Android debug build, whose prebuild step recreates the native project on every run, measured once on one machine:
| Situation | Build |
|---|---|
| Same worktree, build state deleted by the prebuild | 35–40 s |
| Same worktree, build state moved aside and back | 7–10 s |
| Same worktree, build state restored with Unix-epoch timestamps | 24 s |
| New worktree, cold | 182 s |
| New worktree, Gradle state copied from another worktree | 167 s |
| New worktree, CMake state copied from another worktree | Fails |
| New worktree, ccache filled by another worktree | 98 s |
Keeping state across a destructive dependency
"android": { "qk:warm": { "paths": ["…"], "survive": ["prebuild-android"] } }
survive names dependency targets that delete the warm paths. qk moves the
paths aside before such a dependency runs and back after it succeeds, so the
state keeps its files and timestamps without being stored or copied. If the
dependency fails, the paths return unchanged. A path the dependency itself
recreates is replaced by the kept one, so survive suits generated trees
that hold build state, not a dependency’s own outputs.
Timestamps
mtimes: "preserve" restores files with the modification times they were
saved with, instead of the Unix epoch. Epoch times make tsc --build check
sources against restored state; they also make Ninja rebuild every restored
object and Gradle rehash every restored file. Preserved times are only
meaningful against the worktree that saved them, so they apply to a
worktree’s own save and fall back to the epoch for any other.
Preferring the worktree’s own save
A restore chooses the worktree’s own most recent save before any other worktree’s. Before this, the most recent save of the task won, whichever worktree made it.
Groups that do not relocate
portable: false, the default, restores a group only from the worktree that
saved it. State that records absolute paths, such as CMake build directories, then never
reaches a checkout it would break or merely occupy. Portable groups can
restore from other worktrees and the remote store.
Named groups shared between targets
"qk:warm": {
"group": "ccache",
"portable": true,
"env": { "CCACHE_DIR": "{warm}", "CCACHE_BASEDIR": "{workspaceRoot}" }
}
A named group is one piece of warm state that several targets use, as one
ccache serves every native build. Its targets share the live directory,
which the tool itself keeps consistent, so only restoring and saving it are
serialized, and each save holds everything the directory held. A group has
no outputs or paths, which belong to one task. Combined with remote warm
state, a new worktree or machine starts from the default branch’s ccache.
Choosing a save by key
"qk:warm": {
"key": ["{workspaceRoot}/pnpm-lock.yaml", { "env": "ANDROID_NDK_VERSION" }],
"restoreKeys": 1
}
A group keeps several saves, each labeled with a hash of the declared key
inputs. A restore takes the save with an exact key, else one matching the
first restoreKeys entries, else none. Among equal candidates, the save
made at the commit nearest the checkout’s HEAD in Git history wins over the
newest, so a worktree cut from main starts from main’s state. The key only
chooses a save; it never makes warm state a result.
Excluding and globbing paths
A path starting with ! omits files no later build reads, such as packaged
artifacts that are rebuilt on every run, as it does for outputs. Glob
patterns in paths name directories whose location depends on package
versions, as pnpm places a package’s native build under a versioned
directory. Excluded files stay out of task inputs, like the rest of a warm
path.
Saving in the background
save: "background" saves after the task has reported success, so the next
task starts without waiting. Saves and restores of a group are serialized,
and the run waits for background saves before it finishes its remote
uploads, which a save may add to.
Suggesting warm paths
qk warm suggest <task> runs the task in the sandbox and lists what it wrote
outside its declared outputs as candidate warm paths, each under its topmost
directory that holds no source file. Native builds write to places that are
hard to guess, such as build directories that a build script moves to the
workspace root. It needs macOS, where the sandbox can report rather than
refuse.
Reporting warm state’s effect
Run records hold where warm state came from, what was restored, which
groups were on disk already and how long saving took. qk show task
compares the task’s duration from warm state with its duration without,
which shows whether its warm state is worth its size.
Import reachability
Status: implemented. Import reachability describes the behavior; this section records why it takes this shape.
A project is affected when a project it depends on changes, whether or not its code uses what changed, and a task is affected when any of its inputs changes. Both are safe, and both are coarse where the work is expensive: an app’s native builds when a shared package changes in a corner only another app imports, or a visual regression suite whose every case runs when one component changes. As code moves into shared packages, more changes pay for work they cannot affect.
fallout answers the missing question: whether any file reachable by imports from an anchor, such as an app’s entry point or a test case, is one of the changed files. qk can ask it in two places, with one vocabulary.
Anchors, cases and sources
- Anchors are files whose imports a task depends on through, such as an app’s entry points and build configuration, or a test suite’s harness. If any anchor reaches a change, the whole task runs.
- Cases are files a task runs separately, such as the case files of a visual regression suite. Each is asked about on its own, and a task can run only the cases a change reaches.
- Sources are the changed files that matter only through imports, so that fallout’s answer is the whole answer for them. Any other change that reaches the task, such as build configuration, a lockfile install or a test harness outside its anchors, runs it, because no import carries it.
A workspace names its configs once in nx.json, and each target takes part
through a name, settings of its own, false, or the profile’s default:
{
"qk:reachability": {
"app": {
"anchors": ["{projectRoot}/index.tsx", "{projectRoot}/bundler.config.js"],
"sources": ["{projectRoot}/src/**/*.ts", "{workspaceRoot}/packages/*/src/**/*.ts"]
},
"unit": {
"anchors": ["{projectRoot}/test/setup.ts"],
"cases": ["{projectRoot}/src/**/*.test.ts"],
"sources": ["{projectRoot}/src/**/*.ts", "{projectRoot}/test/**/*.ts", "{workspaceRoot}/packages/*/src/**/*.ts"]
}
},
"qk:affectedProfiles": { "reach": { "reachability": { "default": "app" } } }
}
{
"name": "app",
"targets": {
"build": {},
"test": { "qk:reachability": "unit" },
"lint": { "qk:reachability": false }
}
}
Paths take the usual {projectRoot} and {workspaceRoot} tokens, expanded
for each task’s project, so one config serves every project laid out alike,
and a target’s value can come from targetDefaults. A task that takes part
in no config is never narrowed. A package.json is never a source: it
decides what imports resolve to.
The settings belong to tasks because what a project’s tasks read differs. An app’s build reads what its entry points import, while its unit tests read a harness and helpers the app never imports. Settings on the project could only describe one of them, and leaving the project out would skip the other.
Reachability is opt-in through an affected profile, as
change projections are, and changes no
cache key. "reachability": true enables it without a default.
Project selection
Under the profile, project selection decides each project ordinary selection affects by its tasks: every target of it, with the tasks they depend on, are selected by their inputs and decided as below. The project is left out when the profile left out every task of it a change affects. One with a task that still runs is kept, and so is one none of whose affected tasks the profile decides, so reachability is the only way a project is left out. Each dependent is decided by its own tasks, since its inputs say what reaches it.
This is where a workspace decides which app builds a change runs, while checks keep ordinary selection and the default branch builds everything.
Task selection and cases
Under the profile, a task with settings that ordinary task selection affects is decided by its changed inputs:
- Any changed input its
sourcesdo not match runs the whole task, and says which file, unless it is a dependency’spackage.jsonwhose fields its dependents read are unchanged. So does an affected task whose outputs it reads. - Otherwise, if an anchor reaches a change, or the search lost an edge, the whole task runs, naming the chain or the gap.
- Otherwise, the cases a change reaches are selected, each with the changed file it reaches. With none, the task is not affected.
qk show tasks --affected --json lists the selected cases and why, and qk
passes them to the task in QK_AFFECTED_CASES, the path of a file listing
them, one per line, which is absent when the whole task runs. A task that
does not read it runs every case, which is always safe. A suite’s own rules,
such as running the cases that show translated copy when a translation
catalogue changes, stay in the suite, which can add cases to qk’s list but
should not drop them.
The task’s ordinary inputs are what make this sound: a change to the suite’s harness, its workflow or the files a native build is compiled from reaches the task only if they are inputs, and as inputs that are not sources they run every case. Declaring them is the workspace’s half of the contract.
Explaining it
qk show affected and --json list each project the profile left out with
what it decided for each affected task, and qk show tasks --affected each
task and case it did not select, with the anchors searched. For each project,
task or case it kept, they give the reason: the chain of imports that reached a change,
the gap, or the change imports cannot carry. A workspace can record the
profile’s answer beside ordinary selection without acting on it, and compare
what it would have skipped with what later broke on the default branch,
before letting it skip anything.
Requirements and limits
- The head revision must be the checkout, since fallout reads files from
disk. With
--headnaming anything else, the profile keeps everything and says why. - Workspace packages must be linked into
node_modules. Without them, imports between workspace packages resolve to nothing, which counts as a gap, so nothing is left out: the profile is safe and saves nothing. - Only static imports are seen. Code a bundler adds through configuration,
native code and requests built at run time are not, which is why changes
outside
sourceskeep everything. - fallout’s bundler settings, such as which
package.jsonfields an app’s bundler reads, come from the app’sfallout.toml. - fallout is a library dependency of
qk-affected, at a released version.
Not decided yet
- Whether symbol granularity is worth its cost over file granularity.
- Whether cases should be passed to tasks in another form as well, such as an argument.
- Discovering anchors from a bundler’s configuration rather than declaring them.
Development
cargo fmt --all -- --check
cargo clippy --workspace --all-targets --locked -- -D warnings
cargo test --workspace --locked
cargo build --release --locked
The Cargo workspace contains twelve crates:
| Crate | Responsibility |
|---|---|
qk-config | Workspace discovery, JSONC/YAML parsing, typed project configuration and normalization |
qk-graph | Workspace edges, project selection, reverse reachability and graph export |
qk-taskgraph | Dependency expansion, configuration selection and task DAG validation |
qk-executor | Command preparation, environment, argument interpolation, process groups and output capture |
qk-runner | Bounded task scheduling, dependency failure propagation and cancellation |
qk-lockfile | pnpm v9 lockfile parsing and what each importer installs |
qk-cache | Input hashing, cache entry storage, output restoration and log replay |
qk-affected | Git change analysis and project/task affected selection |
qk-history | SQLite run history, task records and cache-key explanations |
qk-input-analysis | File-access report schema, conservative suggestions and compatible observation unions |
qk-trace | The eBPF recorder: its kernel program, privileged helper and strace-format traces |
qk-cli | Argument parsing and output; produces the qk binary |
Fixture and CLI tests use self-contained workspaces.
Parity with Nx
tools/parity/parity.mjs measures qk against Nx. By default it uses the
synthetic workspace in tools/parity/fixture and the goldens committed in
tools/parity/goldens, captured with the Nx release pinned in
tools/parity/nx. It needs Node, and pnpm for the cases that run package
scripts:
node tools/parity/parity.mjs compare --qk target/release/qk
Each run builds a scratch git repository from the fixture. The cases in
tools/parity/cases.json compare task graphs, show projects --affected
for a change applied on a branch (the files in tools/parity/changes/<case>,
plus any deletions the case lists), with the affected tasks of
affectedTargets and the projects show projects --affected -t lists for
them, by default and with nx.json affected.legacy, and runs, which execute tasks without
cache and compare whether the run failed and which tasks ran. The comparison
requires byte-identical show projects --json, a graph --file equal after
normalisation, and --graph task graphs with the same tasks, with the same
target, root, outputs and cache, continuous and parallelism flags, and the
same dependencies and continuous dependencies. The differences it normalises away are listed at the
top of the script. An affected or run case that differs fails unless it
records the exact difference it accepts and why.
After changing the fixture or the pinned Nx, recapture and commit the goldens:
(cd tools/parity/nx && pnpm install)
node tools/parity/parity.mjs capture
capture --check captures into a scratch directory and fails when the
committed goldens differ. CI runs both compare and capture --check.
The harness can also point at any workspace with its own Nx installation
and git history, reading cases from <goldens>/parity.json, with affected
cases given as base and head revisions:
node tools/parity/parity.mjs capture --workspace <dir> --goldens <dir>
node tools/parity/parity.mjs compare --workspace <dir> --goldens <dir> --qk target/release/qk
For an external workspace, capture also records Nx’s median wall times, and compare prints them beside qk’s.
Releases
Releases are cut with cargo-release
(cargo install cargo-release --locked), configured under
[workspace.metadata.release] in the root Cargo.toml. From an up-to-date
main with a clean working tree:
cargo release <patch|minor|major> # dry run: shows the new version
cargo release <patch|minor|major> --execute
This bumps the version shared by every crate, commits it as chore: Release,
creates the single tag v<version> and pushes the commit and the tag. The tag
then triggers the release workflow below, which is the only thing that
publishes: the GitHub release, the npm packages and the crates.io crates.
Never run cargo publish by hand, and do not push the tag before the version
bump is on it, because the workflow rejects a tag that does not match the
workspace version.
Pushing a tag v<version> that matches the workspace version runs
.github/workflows/release.yml: it builds qk for Linux x64 and arm64, macOS
arm64 and Windows x64 and attaches the binaries to a GitHub release. When the
repository variable NPM_PUBLISH is true, the workflow publishes npm
packages generated by cargo-npm 0.1.4 using trusted publishing (OIDC).
Each package must trust the GitHub repository Pajn/qk and workflow
release.yml, with permission to publish directly and no environment
constraint. The npm job grants id-token: write; no npm token secret is needed.
The main package is
@runqk/cli, exposing the qk command. Its platform dependencies are:
@runqk/cli-linux-x64@runqk/cli-linux-arm64@runqk/cli-darwin-arm64@runqk/cli-win32-x64
The names and targets are configured in crates/qk-cli/Cargo.toml under
package.metadata.npm. Platform packages publish before the main package.
The workflow checks that all four are present before publishing. With
NPM_PUBLISH unset, GitHub binary releases still run and npm publishing is skipped.
The workflow also publishes every crate to crates.io through trusted publishing,
with tools/release/publish-crates.sh. It publishes only the crates whose
version crates.io does not hold yet, in dependency order. crates.io can be
slow to list a crate it has just accepted, which fails the publishing of the
crates depending on it, so the script tries again, publishing what is still
missing. If publishing still stops partway, run the workflow by hand with the
release’s tag (gh workflow run release.yml -f tag=v<version>): that run
publishes the release’s missing crates and nothing else.
To inspect packages locally after compiling the configured release targets:
cargo install cargo-npm --version 0.1.4 --locked
cargo npm generate -p qk-cli
npm bootstrap and trusted publishing
The five npm packages start at version 0.0.0 under the bootstrap tag.
These placeholders contain licensing and metadata but no native executable.
They allow configuring trust before the first functional binary release.
Their generator is separate from cargo-npm’s release packaging:
node tools/npm/bootstrap.mjs --repository Pajn/qk --out /tmp/qk-bootstrap
For initial setup, log in to npm locally with an account that owns the
packages and has two-factor authentication enabled. Publish the four platform
packages from that directory, then @runqk/cli, using npm publish <directory> --access public --tag bootstrap. Configure and verify trust for each package:
npm trust github <package> --repository Pajn/qk --file release.yml --allow-publish --yes
npm trust list <package>
These commands require npm 11.15.0 or newer and may request browser
verification. If the default user configuration is read-only, select a
writable login configuration with NPM_CONFIG_USERCONFIG=/path/to/.npmrc.
After verifying all five trust entries, set the GitHub repository variable
NPM_PUBLISH to true. Push the release workflow before creating a matching
version tag. See npm’s trusted publishing documentation.
crates.io releases
The release workflow publishes all ten workspace crates to crates.io through
OIDC, with qk-cli published after its internal dependencies. Install the CLI
with cargo install qk-cli --locked. Internal crate dependencies use exact
workspace versions so a release consistently uses the matching source.
CI verifies the packaged distribution with cargo publish --workspace --locked --dry-run, including compiling the extracted crates without workspace paths.
CI and release builds also run installation smoke tests on all four release platforms, including Linux arm64. They pack the host packages generated by cargo-npm and install their tarballs offline into a fresh npm prefix. The installed command shim runs from an independent workspace whose paths contain spaces. The same checks run against an executable extracted from a binary archive: version, project discovery, strict doctor, task execution, cache restoration after deleting outputs, and invalidation after changing inputs. No packages are published by these tests. To run them locally:
cargo npm generate -p qk-cli --target <host-triple>
node tools/npm/smoke.mjs --packages npm --binary target/<host-triple>/release/qk
Use qk.exe on Windows. The release build must already exist for the host triple.
Trusted publishing must be configured for every workspace crate, since the
short-lived token can only publish crates that trust this workflow. Each crate
must trust GitHub owner Pajn, repository qk, and workflow release.yml, with
no environment constraint. Configure these under each crate’s Settings →
Trusted Publishing on crates.io. See crates.io’s setup guide.
crates.io requires an initial local publication before trust can be configured.
qk-cli already has a metadata-only 0.0.0 reservation, whose isolated source
is retained in tools/crates-io/qk-cli. The nine dependency crates also need an
initial publication and trust configuration before the first workflow release.
Generate their metadata-only 0.0.0 reservations with
python3 tools/crates-io/bootstrap.py --out /tmp/qk-cargo-bootstrap, then publish
each generated manifest locally with cargo publish --manifest-path <manifest>.
These placeholders have no public API; functional releases use the workspace
source and version.
Subsequent releases need no long-lived Cargo token in GitHub secrets. The
workflow verifies the packages before requesting its short-lived token.
Benchmarks
tools/bench/bench.mjs generates a workspace of 300 projects, 3,600 files
and a 3,000-package pnpm lockfile, populates the cache, and measures with
hyperfine: show projects, graph, show projects --affected with no
changes, and a fully cached run-many, once with every output in place and
once with every output removed first, so all 9,300 are restored. Both cached
runs also run with .nxignore enabled to exercise filesystem walking and
ignored generated files. A separate restoration matrix uses the same graph with
both repeated and distinct blob contents, absent and stale complete output
directories, and partial restoration with an excluded sentinel. Every measured
restoration is followed by an untimed check of all output contents, stale-file
removal, exclusion preservation and build counters, so cache misses cannot pass
as restoration measurements. Preparation and verification are outside timing. Watch samples measure each source edit to the callback’s
timestamp, with Git ignore rules and with .nxignore, including native event
delivery, debounce, workspace reload and Node startup. Two initial samples are
discarded; results are observations, with no timing thresholds in CI. It needs
hyperfine, git and Node:
node tools/bench/bench.mjs --qk target/release/qk [--projects 300] [--runs 10] [--json out.json] [--markdown out.md]
CI runs it on every push, writing the table to the job summary and the numbers to an artifact.
GitHub Actions is configured for Linux, macOS and Windows. The lockfile is
checked in for reproducible dependency resolution. On Windows, C dependencies
are compiled with clang-cl (CC_x86_64_pc_windows_msvc=clang-cl), in CI and
for releases alike: tree-sitter-scss, which fallout parses stylesheets with,
passes its C compiler a GCC-style warning flag that cl.exe rejects.
See Maintaining the documentation for book builds and Pages setup.
The parity harness also captures isolated behavioral cases: direct/transitive
selective dependency artifacts, broad output globs, .nxignore negations,
stdin lists (including spaces and empty input), and watch callbacks queued
while a command runs. Capture uses the pinned Nx daemon only for the watch
oracle; qk remains daemon-free. Callback records live outside task inputs.
Distinct edit timestamps avoid coarse metadata-cache timing artifacts.
The Rust watch integration test runs on all three CI platforms; Windows uses
a private console so cancellation cannot signal parallel tests.
Maintaining the documentation
The book’s Markdown sources live in docs/src. docs/src/SUMMARY.md
defines the sidebar, and docs/book.toml configures the theme and built-in
search. Add new pages to the summary so they are built and indexed.
Build and preview
Install the same mdBook version as CI, then run these commands from the repository root:
cargo install mdbook --version 0.5.4 --locked
mdbook build docs
mdbook serve docs --open
mdbook serve watches the source files and rebuilds as you edit. Generated
HTML goes into docs/book/, which Git ignores. Search runs in the browser
using the index generated during the build; no external search service is
needed. Press / or s to open search.
Publish on GitHub Pages
Once the repository is hosted on GitHub:
- In Settings → Pages → Build and deployment, set Source to GitHub Actions.
- Push the documentation workflow to the repository’s default branch.
- Open the Documentation workflow run. Its deployment links to the site.
The workflow builds documentation on pull requests and pushes that touch its
sources. Only the default branch deploys; Run workflow can also deploy
from that branch. The github-pages environment records deployments.
The build and deployment jobs use GitHub’s
Pages artifact workflow.
Generated files are uploaded as an artifact rather than committed to a branch.
The workflow obtains the site’s base path from GitHub Pages, so it also
works with a renamed repository or a custom domain. site-url in
docs/book.toml is the fallback for local builds. CI also supplies a link to
the GitHub repository without hardcoding an owner in the source.
Writing reference pages
Document implemented behavior, with types, defaults, precedence and a small example. Give configuration keys their own headings so search results can link straight to the option. Explain when an accepted Nx setting has no effect in qk. Keep examples aligned with the fixtures and implementation. Use relative Markdown links between pages, and label design proposals clearly. The design plan is excluded from search so proposals do not compete with the reference for implemented behavior.
Before committing, run mdbook build docs and check changed pages and search
in the preview. Pull requests build the book but do not publish it.