RSConstruct - Rust Build Tool
A fast, incremental build tool written in Rust with C/C++ compilation, template support, Python linting, and parallel execution.
Features
- Incremental builds using SHA-256 checksums to detect changes
- C/C++ compilation with automatic header dependency tracking
- Parallel execution of independent build products with
-jflag - Template processing via the Tera templating engine
- Python linting with ruff and pylint
- Documentation spell checking using hunspell dictionaries
- Make integration — run make in directories containing Makefiles
.gitignoresupport — respects.gitignoreand.rsconstructignorepatterns- Deterministic builds — same input always produces same build order
- Graceful interrupt — Ctrl+C saves progress, next build resumes where it left off
- Config-aware caching — changing compiler flags or linter config triggers rebuilds
- Convention over configuration — simple naming conventions, minimal config needed
Philosophy
Convention over configuration — simple naming conventions, explicit config loading, incremental builds by default.
Nomenclature
This page defines the terminology used throughout RSConstruct’s code, configuration, CLI, and documentation.
Core concepts
| Term | Definition |
|---|---|
| pname | Processor name: the processor’s full path, processor.<type>.<name>, identical to its module path in the source tree (processor.checker.ruff is src/processor/checker/ruff.rs). Unique across all plugins because the type is part of it; the last segment alone is not (processor.mass_generator.generic and processor.explicit.generic are different processors). Used as the [processor.TYPE.NAME] config section header and in processor defconfig PNAME. |
| iname | Instance name. The name of a specific processor instance as declared in rsconstruct.toml. For a single instance, the iname equals the pname ([processor.checker.ruff] → processor.checker.ruff). For a named instance, the iname is the pname plus the sub-key ([processor.explicit.generic.venv] → processor.explicit.generic.venv). Used in processor config INAME, -p, --iset, build output and cache keys. |
| processor | A configured instance that discovers products and executes builds. Created from a plugin + TOML config. Immutable after creation. |
| plugin | A factory registered at compile time via inventory::submit!. Knows how to create processors from TOML config. Has a pname, a processor type, and config metadata. |
| native processor | A processor implemented in pure Rust inside rsconstruct; it needs no external tool. Examples: tera, ijsonlint, encoding. Declared with is_native: true on the plugin. |
| external processor | A processor that runs another program to do its work. Examples: ruff, pylint, tidy. |
| rust (processor flag) | Whether the code that does the work is written in Rust: true for every native processor, and for an external one only when its tool is written in Rust (ruff, taplo, rumdl, clippy, cargo, mdbook, pyrefly, rustc). Declared with is_rust on the plugin; shown by processor list and status. Processors that run a user-supplied command report false. |
| product | A single build unit with inputs, outputs, and a processor. The atomic unit of incremental building. |
| processor type | One of four categories — checker, generator, explicit, mass_generator. The second segment of every pname. Determines how inputs are discovered, how outputs are declared, and how results are cached. See Processor Types. |
| analyzer | A dependency scanner that runs after product discovery to add extra input edges to existing products (e.g., the cpp analyzer adds every #included header as an extra input of a C/C++ product). Analyzers never create products of their own. Declared with [analyzer.NAME] sections in rsconstruct.toml. Unlike processors, only analyzers explicitly declared in config run — there is no “auto-enable” default. See Dependency Analyzers. |
| analyzer plugin | A factory registered at compile time via inventory::submit! in the analyzer registry. Knows how to construct an analyzer from its [analyzer.NAME] TOML section. Each plugin declares its name, description, and whether it is native (pure Rust) or external (may invoke subprocesses). |
| native analyzer | An analyzer whose default configuration runs entirely in-process (no subprocesses). Example: icpp uses a pure-Rust regex scanner for #include directives. Some native analyzers become external in non-default configurations (e.g., icpp with pkg_config set shells out to pkg-config for include paths). |
| external analyzer | An analyzer that shells out to another program to do its work. Example: cpp always runs gcc -MM for exact compiler-accurate header scanning. |
Configuration
| Term | Definition |
|---|---|
| output_files | List of individual output files declared in explicit config. Cached as blobs. |
| output_dirs | List of output directories declared in explicit config. All files inside are walked and cached as a tree. |
| src_dirs | Directories to scan for input files. |
| src_extensions | File extensions to match during scanning. |
| dep_inputs | Extra files that trigger a rebuild when their content changes. |
| dep_auto | Config files added as dep_inputs (e.g., .eslintrc). Processor defaults are skipped when absent; entries listed in the config must exist unless [build] allow_missing_dep_auto is set. |
Cache
| Term | Definition |
|---|---|
| blob | A file’s raw content stored in the object store, addressed by SHA-256 hash. Blobs have no path — the consumer knows where to restore them. |
| tree | A serialized list of (path, mode, blob_checksum) entries describing a set of output files. Stored in the descriptor store. |
| marker | A zero-byte descriptor indicating a checker passed. Its presence is the cached result. |
| descriptor | A cache entry (blob reference, tree, or marker) stored in .rsconstruct/descriptors/, keyed by the descriptor key. |
| descriptor key | A content-addressed hash of (pname, config_hash, variant, input_checksum). Changes when processor config or input content changes. Does NOT include file paths — renaming a file with identical content produces the same key. |
| input checksum | Combined SHA-256 hash of all input file contents for a product. |
Build pipeline
| Term | Definition |
|---|---|
| discover | Phase where processors scan the file index and register products in the build graph. |
| classify | Phase where each product is classified as skip, restore, or build based on its cache state. |
| execute | Phase where products are built in dependency order. |
| anchor file | A file whose presence triggers a creator processor to run (e.g., Cargo.toml for cargo, requirements.txt for pip). |
CLI conventions
| Command | Name parameter | Meaning |
|---|---|---|
processor defconfig PNAME | pname | Processor type name — shows factory defaults |
processor config [INAME] | iname | Instance name from config — shows resolved config |
processor files [INAME] | iname | Instance name from config — shows discovered files |
analyzer defconfig [NAME] | analyzer name | Analyzer name from the analyzer registry — shows factory defaults |
analyzer config [NAME] | analyzer name | Analyzer name as declared in [analyzer.NAME] — shows resolved config |
Installation
Download pre-built binary (Linux)
Pre-built binaries are available for x86_64 and aarch64 (arm64).
Using the GitHub CLI:
# x86_64
gh release download --repo veltzer/rsconstruct --pattern 'rsconstruct-linux-x86_64' --output rsconstruct --clobber
# aarch64 / arm64
gh release download --repo veltzer/rsconstruct --pattern 'rsconstruct-linux-aarch64' --output rsconstruct --clobber
chmod +x rsconstruct
sudo mv rsconstruct /usr/local/bin/
Or with curl:
# x86_64
curl -Lo rsconstruct https://github.com/veltzer/rsconstruct/releases/latest/download/rsconstruct-linux-x86_64
# aarch64 / arm64
curl -Lo rsconstruct https://github.com/veltzer/rsconstruct/releases/latest/download/rsconstruct-linux-aarch64
chmod +x rsconstruct
sudo mv rsconstruct /usr/local/bin/
Install from crates.io
cargo install rsconstruct
This downloads, compiles, and installs the latest published version into ~/.cargo/bin/.
Build from source
cargo build --release
The binary will be at target/release/rsconstruct.
Release profile
The release build is configured in Cargo.toml for maximum performance with a small binary:
[profile.release]
strip = true # Remove debug symbols
lto = true # Link-time optimization across all crates
codegen-units = 1 # Single codegen unit for better optimization
For an even smaller binary at the cost of some runtime speed, add opt-level = "z" (optimize for size) or opt-level = "s" (balance size and speed).
Getting Started
This guide walks through setting up an rsconstruct project for the two primary supported languages: Python and C++.
Python
Prerequisites
- rsconstruct installed (Installation)
- ruff on PATH
Setup
Create a project directory and configuration:
mkdir myproject && cd myproject
# rsconstruct.toml
[processor.checker.ruff]
Create a Python source file:
mkdir -p src
# src/hello.py
def greet(name: str) -> str:
return f"Hello, {name}!"
if __name__ == "__main__":
print(greet("world"))
Run the build:
rsconstruct build
Expected output:
Processing ruff (1 product)
hello.py
Run again — nothing has changed, so rsconstruct skips the check:
Processing ruff (1 product)
Up to date
Adding pylint
Install pylint and add a section for it:
# rsconstruct.toml
[processor.checker.ruff]
[processor.checker.pylint]
Pass extra arguments via processor config:
[processor.checker.pylint]
args = ["--disable=C0114,C0115,C0116"]
Adding zspell for docs
If your project has markdown documentation, add a section for the zspell processor:
[processor.checker.ruff]
[processor.checker.pylint]
[processor.checker.zspell]
Create a .zspell-words file in the project root with any custom words (one per line) that the zspeller should accept.
C++
Prerequisites
- rsconstruct installed (Installation)
- gcc/g++ on PATH
Setup
Create a project directory and configuration:
mkdir myproject && cd myproject
# rsconstruct.toml
[processor.generator.cc_single_file]
Create a source file under src/:
mkdir -p src
// src/hello.c
#include <stdio.h>
int main() {
printf("Hello, world!\n");
return 0;
}
Run the build:
rsconstruct build
Expected output:
Processing cc_single_file (1 product)
hello.elf
The compiled executable is at out/processor.generator.cc_single_file/hello.elf.
Run again — the source hasn’t changed, so rsconstruct restores from cache:
Processing cc_single_file (1 product)
Up to date
Customizing compiler flags
Pass flags via processor config:
[processor.generator.cc_single_file]
cflags = ["-Wall", "-Wextra", "-O2"]
cxxflags = ["-Wall", "-Wextra", "-O2"]
include_paths = ["include"]
See the CC Single File processor docs for the full configuration reference.
Adding static analysis
Install cppcheck and add a section for it:
[processor.generator.cc_single_file]
[processor.checker.cppcheck]
Both processors run on the same source files — rsconstruct handles them independently.
Next Steps
- Commands — full list of rsconstruct commands
- Configuration — all configuration options
- Processors — detailed docs for each processor
Binary Releases
RSConstruct publishes pre-built binaries as GitHub releases. A release is made
with cargo release; CI builds and publishes the binaries from the release
commit it pushes.
Supported Platforms
| Platform | Binary name |
|---|---|
| Linux x86_64 | rsconstruct-linux-x86_64 |
| Linux aarch64 (arm64) | rsconstruct-linux-aarch64 |
| macOS x86_64 | rsconstruct-macos-x86_64 |
| macOS aarch64 (Apple Silicon) | rsconstruct-macos-aarch64 |
RSConstruct is unix-only. There is no Windows build: the release matrix in
.github/workflows/ci.yml has never contained a Windows target, and the
codebase assumes unix throughout (flock, /dev/null, $HOME, apt-based
tool installation).
How It Works
Everything runs from the single CI workflow (.github/workflows/ci.yml).
The workflow triggers on branch pushes only. Every push runs the test
job; a push to the default branch whose commit message starts with
chore: Release (the commit cargo release makes) also runs three release
jobs:
- build — a matrix job that builds the release binary for each platform and uploads it as a GitHub Actions artifact. It runs only after the test job passes, so a release never ships from a commit whose tests fail.
- release — waits for all builds to finish, downloads the binary
artifacts, and creates a GitHub release named after the version in
Cargo.toml(v<version>), with auto-generated release notes and all binaries attached. - docs — builds the mdBook documentation and deploys it to GitHub Pages.
Pushing a tag on its own does nothing: cargo release pushes the tag
together with the release commit, and the workflow ignores tag pushes so
the two do not start two identical runs.
Creating a Release
cargo release patch # dry run: shows what would happen
cargo release patch --execute # bump, commit, tag, publish, push
Use minor or major instead of patch for a bigger bump. With
--execute, cargo release (configured in release.toml):
- bumps
versioninCargo.toml, - commits it as
chore: Release rsconstruct version <version>, - creates a signed tag
v<version>, - publishes the crate to crates.io,
- pushes the commit and the tag.
The push of that commit to the default branch starts the release jobs
above. Bumping the version and tagging by hand does not produce a release,
because the commit message would not start with chore: Release .
Release Profile
The binary is optimized for size and performance:
[profile.release]
strip = true # Remove debug symbols
lto = true # Link-time optimization across all crates
codegen-units = 1 # Single codegen unit for better optimization
Command Reference
A command that takes a subcommand (clean, cache, processor, …) lists
its subcommands, each with a one-line description, when run without one, and
exits with status 2. The listing leaves the flags out; --help shows them.
Global Flags
These flags can be used with any command:
| Flag | Description |
|---|---|
--verbose, -v | Show skip/restore/cache messages during build |
--output-display, -O | What to show for output files (none, basename, path; default: none) |
--input-display, -I | What to show for input files (none, source, all; default: source) |
--path-format, -P | Path format for displayed files (basename, path; default: path) |
--show-child-processes | Print each child process command before execution |
--show-output | Show tool output even on success (default: only show on failure) |
--json | Output in JSON Lines format (machine-readable) |
--quiet, -q | Suppress all output except errors (useful for CI) |
--all | Show every column of every table, including the ones hidden by default and the ones the user config leaves out |
--fields | Print each table’s name and column names only, no data |
--phases | Show build phase messages (discover, add_dependencies, etc.) |
Example:
rsconstruct --phases build # Show phase messages during build
rsconstruct --show-child-processes build # Show each command being executed
rsconstruct --show-output build # Show compiler/linter output even on success
rsconstruct --phases --show-child-processes build # Show both phases and commands
rsconstruct -O path build # Show output file paths in build messages
rsconstruct -I all build # Show all input files (including headers)
rsconstruct --all processor list # Every column, Description included
rsconstruct --fields tool list # The table's name and columns, no rows
Table columns
Every table a command prints has a name and a fixed set of columns, and
some columns are hidden unless --verbose asks for them (processor list
hides Description, hook hides Function and Location). Which columns
show is decided in this order:
--fields: the table’s name, the command that prints it, and its column names, with no data rows. This is how to find the names for the next two.--all: every column.[tables]in the user config,~/.config/rsconstruct/config.toml: exactly the columns listed there, in that order. See[tables]for the file format and the list of every table.- Otherwise the default: every column except the hidden ones, which
--verboseadds back.
--all goes before the command name on tool install and
tool list-configured, which have an --all of their own
(rsconstruct --all tool list-configured). JSON output is not affected by
any of this: --json always carries every field.
rsconstruct build
Requires config. (no subcommands)
Incremental build — only rebuilds products whose inputs have changed.
rsconstruct build # Incremental build
rsconstruct build --force # Force full rebuild
rsconstruct build -j4 # Build with 4 parallel jobs
rsconstruct build --dry-run # Show what would be built without executing (honors -p/-x/-t)
rsconstruct build --keep-going # Continue after errors
rsconstruct build --timings # Show per-product and total timing info
rsconstruct build --stop-after discover # Stop after product discovery
rsconstruct build --stop-after add-dependencies # Stop after dependency scanning
rsconstruct build --stop-after resolve # Stop after graph resolution
rsconstruct build --stop-after classify # Stop after classifying products
rsconstruct build --show-output # Show compiler/linter output even on success
rsconstruct build --auto-add-words # Add misspelled words to .zspell-words instead of failing
rsconstruct build --auto-add-words -p processor.checker.zspell # Run only zspell and auto-add words
rsconstruct build -p processor.checker.ruff,processor.checker.pylint # Run only specific processors
rsconstruct build --explain # Show why each product is skipped/restored/rebuilt
rsconstruct build --retry 3 # Retry failed products up to 3 times
rsconstruct build --no-mtime # Disable mtime pre-check, always compute checksums
rsconstruct build --no-summary # Suppress the build summary
rsconstruct build --batch-size 10 # Limit batch size for batch-capable processors
rsconstruct build --verify-tool-versions # Verify tool versions against .tools.versions
rsconstruct build -t "src/*.c" # Only build products matching this glob pattern
rsconstruct build -d src # Only build products under this directory
rsconstruct build --show-all-config-changes # Show all config changes, not just output-affecting
By default, tool output (compiler messages, linter output) is only shown when a command fails. Use --show-output to see all output.
Selecting processors (-p, -x)
-p runs the named processors and the processors that generate their
inputs, transitively — selecting a checker of generated files also runs
the generator, so the checker has something to check even on a clean
checkout. (-t/-d do the same for the products they select.)
-x excludes a processor, and with it every product that needs its
outputs: those could only run against files nobody is producing. An
excluded processor is never pulled back in, even when a -p selection
depends on it.
Incremental recovery and batch behavior
Batch-capable processors run their rebuilding products in a single tool invocation, with or without --keep-going. A batch-capable checker waits until every one of its products is ready (its files that depend on a generated file included) and then runs once over all of them; a batch-capable generator’s products run as they become ready, since one of them may feed another. If the tool fails, all products in the batch are marked failed and must be rebuilt. Use --batch-size N to limit batch chunks and improve recovery granularity, or --batch-size -1 to run every product on its own; successfully completed products are cached as soon as their invocation finishes, so after a failure or an interruption the next run only rebuilds what wasn’t completed.
Processor Shortcuts (@ aliases)
The -p flag supports @-prefixed shortcuts that expand to groups of processors:
By type:
@checker— all checker processors (ruff, pylint, shellcheck, etc.)@generator— all generator processors (tera, cc_single_file, etc.)@creator— all creator processors (pip, npm, cargo, etc.)@explicit— all explicit processors@mass_generator— all mass generator processors
By tool or name — any other @x selects both of these:
- every processor that requires tool
x(@python3,@node; matched against each processor’srequired_tools()) - every processor whose short name is
x, with all its instances (@tera→processor.generator.teraand e.g.processor.generator.tera.docs;@generic→ the generic processor of every type)
An @x that selects nothing is reported as an unknown processor.
Examples:
rsconstruct build -p @checker # Run only checker processors
rsconstruct build -p @generator # Run only generator processors
rsconstruct build -p @python3 # Run all Python-based processors
rsconstruct build -p @checker,tera # Mix shortcuts with processor names
The --stop-after flag allows stopping the build at a specific phase:
discover— stop after discovering products (before dependency scanning)add-dependencies— stop after adding dependencies (before resolving graph)resolve— stop after resolving the dependency graph (before execution)classify— stop after classifying products (show skip/restore/build counts)build— run the full build (default)
rsconstruct clean
Clean build artifacts. A subcommand is required; run without one, clean lists them.
| Subcommand | Config required? |
|---|---|
outputs | Yes |
all | Yes |
git | Yes |
unknown | Yes |
Two nouns, two depths: clean outputs removes output files and keeps the
cache, so the next build restores them; clean all removes outputs and
cache. Both take -p to limit themselves to some processors, so
clean all -p generator.tera removes everything of tera, files and cache
entries, and the next build runs its products again.
rsconstruct clean # List the subcommands
rsconstruct clean outputs # Remove build output files (preserves cache)
rsconstruct clean outputs -p generator.tera,generator.tags # Only clean outputs of these processors
rsconstruct clean outputs --no-empty-dirs # Skip the empty-directory sweep
rsconstruct clean all # Remove out/ and .rsconstruct/ directories
rsconstruct clean all -p generator.tera # tera's files and cache entries; re-rendered next build
rsconstruct clean all -p @checker # Forget every check; all lint runs again
rsconstruct clean all -p mass_generator.linux_module --dry-run # Show what would go
rsconstruct clean git # Hard clean using git clean -qffxd (requires git repository)
rsconstruct clean unknown # Remove files not tracked by git and not known as build outputs
rsconstruct clean unknown --dry-run # Show what would be removed without deleting
rsconstruct clean unknown --no-gitignore # Include gitignored files as unknown
rsconstruct clean outputs
Removes build output files. The cache (.rsconstruct/) is preserved.
What gets removed depends on each product’s processor type:
- Checkers (ruff, cppcheck, shellcheck, terms, …) — produce no outputs, so nothing to delete.
- Generators (tera, tags, mako, jinja2, mermaid, pdflatex, pdfunite, …) — declare concrete output files. Each declared output file is removed individually with
fs::remove_file. Directories are never recursed into. - Creators (mdbook, jekyll, cargo, npm, gem, cc, …) — invoke an external build that produces an unknown set of files inside a declared output directory (e.g.
book/,_site/,target/). The whole declaredoutput_diris removed recursively. This is the only path that recursively deletes a directory, and it only ever affects directories the creator itself declared. - Explicit — declares both
output_filesandoutput_dirsdirectly inrsconstruct.toml. Files are removed individually; declaredoutput_dirsare removed recursively (because the user asked for that). - Mass generators — every file the tool’s plan predicted is a declared output and is removed individually. The
output_dirsa mass generator declares only bound where predicted files may live and are never removed.
Empty-directory sweep
After every product’s clean() has run, clean outputs walks every directory that previously contained an output file (and the parents of every removed output_dir), bottom-up, and removes any that are now empty. The sweep:
- Tries
fs::remove_dir(non-recursive) — only succeeds when the directory is empty. - Walks upward through parents until it hits a non-empty directory or the project root.
- Never removes a directory that still contains files — empty dirs only.
- Does not special-case
out/. Any path ancestor of a cleaned output is eligible.
Pass --no-empty-dirs to skip the sweep entirely. Files still get cleaned; empty parent directories are left in place.
Filtering by processor
-p / --processor takes a comma-separated list of the same selectors as
build -p: a processor name with or without the
processor. prefix (generator.tera, as the tables print it), an instance
name (generator.marp.slides), @checker for a type, @python3 for every
processor that needs a tool, @tera for a short name with all its
instances. A selector that matches nothing is an error listing the
processors in the config. Only the selected processors’ products are
cleaned.
rsconstruct clean all
Without -p, removes the output directory (out/ by default) and
.rsconstruct/ outright: every output and the whole cache. With -p,
removes everything of the selected processors only: their output files, as
clean outputs does, and the cache entries for their products, so the next
build runs them again instead of restoring them. Use the -p form to make
one processor redo its work without touching the rest of the cache:
rsconstruct clean all # everything
rsconstruct clean all -p generator.tera # everything of tera
rsconstruct clean all -p @checker --dry-run # what would go
-ptakes the selectors described under Filtering by processor.--dry-runnames the directories (without-p) or counts the files, output directories and cache entries (with-p;--verboselists the files) that would go, and removes nothing.--no-empty-dirsskips the empty-directory sweep of the-pform, as forclean outputs.
With -p, the cache entry removed is the one for each product’s current
inputs, the key the next build would look up. Entries left over from earlier
inputs are what cache remove-stale removes, and the blobs they point at
are what cache trim removes; clean all -p touches neither. Products
downstream of a cleaned one are not cleaned: they run again on the next
build if the regenerated output differs, and are rightly skipped if it is
byte-identical. With a remote cache and pull enabled, the next build may
still restore from the remote.
clean all -p replaced the clean products subcommand of 0.9.133.
Other clean variants
clean all— removesout/and.rsconstruct/outright (cache included). Use when you want a full reset.clean git— runsgit clean -qffxd. Requires a git repository.clean unknown— finds files in the working tree that are neither git-tracked nor declared as a build output, and deletes them. Pass--dry-runto only list them.--no-gitignoreincludes gitignored files in the search.
rsconstruct status
Requires config. (no subcommands)
Show product status — whether each product is up-to-date, stale, or restorable from cache.
rsconstruct status # Show per-processor and total summary
rsconstruct status -v # Show per-product status
rsconstruct status --breakdown # Show source file counts by processor and extension
rsconstruct --json status # Same summary as JSON
The per-processor table (and each entry of --json’s processor array) carries
two flags after the counts: native, whether the processor is pure Rust inside
rsconstruct, and rust, whether the code that does the work is written in Rust at
all — always so for a native processor, and for an external one only when the tool
it runs is (ruff, taplo, rumdl, clippy, cargo, mdbook, pyrefly, rustc). Processors
that run a user-supplied command (script, explicit, generator, creator,
mass_generator) report rust: false, since the language is unknown.
rsconstruct smart auto
Auto-detect relevant processors and add them to rsconstruct.toml. Scans the project for files matching each processor’s conventions and checks that the required tools are installed. Only adds new sections — existing processor sections are preserved. Requires config.
rsconstruct smart auto
Example output:
Added 3 processor(s): pylint, ruff, shellcheck
rsconstruct init
No config needed. (no subcommands)
Initialize a new rsconstruct project in the current directory.
rsconstruct init
rsconstruct watch
Requires config. (no subcommands)
Watch source files and auto-rebuild on changes.
rsconstruct watch # Watch and rebuild on changes
rsconstruct watch --auto-add-words # Watch with zspell auto-add words
rsconstruct watch -j4 # Watch with 4 parallel jobs
rsconstruct watch -p processor.checker.ruff # Watch and only run the ruff processor
The watch command accepts the same build flags as rsconstruct build (e.g., --jobs, --keep-going, --timings, --processor, --batch-size, --explain, --retry, --no-mtime, --no-summary).
rsconstruct graph
Display the build dependency graph.
| Subcommand | Config required? |
|---|---|
show | Yes |
view | Yes |
stats | Yes |
rsconstruct graph show # Default SVG format
rsconstruct graph show --format dot # Graphviz DOT format
rsconstruct graph show --format mermaid # Mermaid format
rsconstruct graph show --format json # JSON format
rsconstruct graph show --format text # Plain text hierarchical view
rsconstruct graph show --format svg # SVG format (requires Graphviz dot)
rsconstruct graph view # Open as SVG (default viewer)
rsconstruct graph view --viewer mermaid # Open as HTML with Mermaid in browser
rsconstruct graph view --viewer svg # Generate and open SVG using Graphviz dot
rsconstruct graph stats # Show graph statistics (products, processors, dependencies)
rsconstruct cache
Manage the build cache.
| Subcommand | Config required? |
|---|---|
clear | No |
size | Yes |
trim | Yes |
list | Yes |
stale | Yes |
stats | Yes |
remove-stale | Yes |
rsconstruct cache clear # Clear the entire cache
rsconstruct cache size # Show cache size
rsconstruct cache trim # Remove unreferenced objects
rsconstruct cache list # List all cache entries and their status
rsconstruct cache stale # Show which cache entries are stale vs current
rsconstruct cache stats # Show per-processor cache statistics
rsconstruct cache remove-stale # Remove stale index entries not matching any current product
rsconstruct webcache
Manage the web request cache. Schemas fetched by iyamlschema (and any future processors that fetch URLs) are cached in .rsconstruct/webcache.redb.
| Subcommand | Config required? |
|---|---|
clear | No |
stats | No |
list | No |
rsconstruct webcache clear # Clear all cached web responses
rsconstruct webcache stats # Show cache size and entry count
rsconstruct webcache list # List all cached URLs and their sizes
rsconstruct analyzer
Manage dependency analyzers and inspect the dependency cache they fill. An analyzer scans source files for dependencies (C/C++ #includes, Python imports, Sass @uses, Markdown and Tera references) and records them in .rsconstruct/deps.redb; see Dependency Analyzers. Analyzers are referred to by pname (the type: cpp, icpp, markdown, python, sass, tera) or by iname (the instance as declared in rsconstruct.toml, e.g. cpp or cpp.kernel).
| Subcommand | Config required? |
|---|---|
add | Yes (--dry-run: no) |
build | Yes |
clean | Yes |
config | Yes |
defconfig | No |
delete | Yes |
disable | Yes |
enable | Yes |
list | No |
show | Yes |
stats | Yes |
used | Yes |
rsconstruct analyzer list # List all available analyzers
rsconstruct analyzer used # Show which analyzers are enabled and detected
rsconstruct analyzer defconfig cpp # Show the default configuration of an analyzer type
rsconstruct analyzer config # Show the resolved configuration of every declared analyzer
rsconstruct analyzer config cpp # ...of one analyzer instance
rsconstruct analyzer add cpp # Append [analyzer.cpp] to rsconstruct.toml with comments
rsconstruct analyzer add cpp --dry-run # Print the snippet instead of writing it
rsconstruct analyzer enable cpp # Set enabled = true on the stanza
rsconstruct analyzer disable cpp # Set enabled = false on the stanza
rsconstruct analyzer delete cpp # Remove the stanza entirely
rsconstruct analyzer build # Run dependency analysis without building
rsconstruct analyzer show all # Show all cached dependencies
rsconstruct analyzer show files src/main.c # Show dependencies for a specific file
rsconstruct analyzer show files src/a.c src/b.c # ...for several files
rsconstruct analyzer show files --hash-pieces src/main.c # Also show each analyzer's hash pieces
rsconstruct analyzer show analyzers cpp # Show dependencies found by the C/C++ analyzer
rsconstruct analyzer show analyzers cpp python # ...by several analyzers
rsconstruct analyzer stats # Show statistics by analyzer
rsconstruct analyzer clean # Clear the entire dependency cache
rsconstruct analyzer clean --analyzer cpp # Clear only entries from the cpp analyzer
Example output for rsconstruct analyzer show all:
src/main.c: [icpp] (no dependencies)
src/test.c: [icpp]
src/utils.h
src/config.h
Example output for rsconstruct analyzer stats:
╭──────────┬───────┬──────────────╮
│ Analyzer │ Files │ Dependencies │
├──────────┼───────┼──────────────┤
│ icpp │ 2 │ 2 │
├──────────┼───────┼──────────────┤
│ Total │ 2 │ 2 │
╰──────────┴───────┴──────────────╯
show, stats and clean read the dependency cache directly. If it is empty, run rsconstruct analyzer build (or a build) first to populate it.
These commands are useful for:
- Debugging why a file is being rebuilt
- Understanding the include/import structure of your project
- Verifying that dependency analyzers are finding the right files
- Clearing dependencies for a specific analyzer without affecting others
rsconstruct smart
Smart config manipulation commands for managing processor sections in rsconstruct.toml.
| Subcommand | Config required? |
|---|---|
disable-all | No |
enable-all | No |
enable | No |
disable | No |
only | No |
reset | No |
enable-detected | Yes |
enable-if-available | Yes |
minimal | Yes |
auto | Yes |
remove-no-file-processors | Yes |
rsconstruct smart enable pylint # Add [processor.checker.pylint] section
rsconstruct smart disable pylint # Remove [processor.checker.pylint] section
rsconstruct smart enable-all # Add sections for all builtin processors
rsconstruct smart disable-all # Remove all processor sections
rsconstruct smart enable-detected # Add sections for auto-detected processors
rsconstruct smart enable-if-available # Add sections for detected processors with tools installed
rsconstruct smart minimal # Remove all, then add only detected processors
rsconstruct smart only ruff pylint # Remove all, then add only listed processors
rsconstruct smart reset # Remove all processor sections
rsconstruct smart remove-no-file-processors # Remove processors that don't match any files (skips enabled = false)
rsconstruct processor
| Subcommand | Config required? |
|---|---|
add | Yes (--dry-run: no) |
allowlist | Yes |
config | Yes |
defconfig | No |
delete | Yes |
disable | Yes |
enable | Yes |
files | Yes |
graph | Yes |
info | No |
list | No |
names | Yes |
recommend | No |
search | No |
types | No |
used | Yes |
rsconstruct processor list # List all built-in processors with type and description
rsconstruct processor list --type checker # ...only one processor type
rsconstruct --json processor list # Same list as JSON
rsconstruct processor info processor.checker.ruff # Describe a processor and every config field
rsconstruct processor info # ...every processor
rsconstruct --json processor info # Same as JSON
rsconstruct processor types # List the processor types
rsconstruct processor search yaml # Search processors by name, description or keywords
rsconstruct processor recommend # Show the recommended processor for each file extension
rsconstruct processor used # Show which processors are enabled and detected
rsconstruct processor names # Print the iname of every enabled processor, one per line
rsconstruct processor files # Show source and target files for each declared processor
rsconstruct processor files processor.checker.ruff # Show files for a specific processor
rsconstruct processor config processor.checker.ruff # Show resolved configuration for a processor
rsconstruct processor config --diff # Show only fields that differ from defaults
rsconstruct processor defconfig processor.checker.ruff # Show default configuration for a processor
rsconstruct processor add processor.checker.ruff # Append [processor.checker.ruff] to rsconstruct.toml (fields pre-populated + comments)
rsconstruct processor add processor.checker.ruff --dry-run # Preview the snippet without writing
rsconstruct processor enable processor.checker.ruff # Set enabled = true on the stanza
rsconstruct processor disable processor.checker.ruff # Set enabled = false on the stanza
rsconstruct processor delete processor.checker.ruff # Remove the stanza entirely
rsconstruct processor allowlist # Show the current processor allowlist
rsconstruct processor graph # Show inter-processor dependencies
rsconstruct processor graph --format dot # Graphviz DOT format
rsconstruct processor graph --format mermaid # Mermaid format
rsconstruct processor files --headers # Show files with processor headers
list prints one row per processor with its type and four flags: Native
(pure Rust inside rsconstruct, or external when it runs another program),
Rust (whether the code that does the work is written in Rust — every
native processor, plus external ones whose tool is Rust: ruff, taplo, rumdl,
clippy, cargo, mdbook, pyrefly, rustc), Batch (whether the processor can
take several input files in one tool invocation — a linter that accepts a
file list can, a converter that writes one output per input such as pandoc
or marp cannot) and Cache (whether a cache hit stands in for a run: it
restores a generator’s outputs or proves a checker’s pass, so nothing is
lost by skipping the run; No for pip, npm and gem, which install into an
environment and write only a stamp — the stamp is cached while the installed
packages may be gone). The JSON form carries the same as native, rust,
supports_batch and caches_outputs booleans. rust is what to read when
the question is “how much of this project’s toolchain is Rust”; native is
what to read when the question is “which processors need no tool
installed”; supports_batch is what decides whether a stanza’s batch field
and the build’s --batch-size have any effect on it (a processor that
cannot batch runs one file per invocation whatever they say).
In every table, processor names drop the processor. prefix that all of
them carry (checker.ruff, not processor.checker.ruff): it says nothing
in a column of processors and costs a word of width on every row. JSON
output, error messages and processor info keep the full name, which is
what rsconstruct.toml and scripts use.
A fifth column, Rust alternative, names the Rust processors that can
replace a non-Rust one (ishellcheck for shellcheck, iluacheck and
selene for luacheck); the JSON form has them as a rust_alternatives array, empty
where there is none. Each Rust processor declares what it replaces in its own
file, and its alternative’s docs page links it.
recommend maps file extensions to the processor for them. Where a Rust
processor can replace the classic recommendation, the Rust one is listed and
the classic one moves to the Also column (also in the JSON, next to
processors, every Rust option, and processor, the first of them).
info is the long form of one processor (or, without a name, of every
processor): its description, type, keywords, the same flags as list, the
max_jobs cap and the implementation version, then a table
of every config field it accepts — type, default, whether it is required,
whether it is part of the cache key (Checksum), and what it does. It is
defconfig -v with the processor’s own description on top, and reads only
the processors’ built-in metadata, so it needs no rsconstruct.toml.
rsconstruct tool
List or check external tools required by declared processors. All subcommands use config if available; without config, they operate on all built-in processors.
| Subcommand | Config required? |
|---|---|
list | Uses config if available |
check | Uses config if available |
lock | Uses config if available |
install | Uses config if available |
install-deps | Uses config if available |
stats | Uses config if available |
graph | Uses config if available |
rsconstruct tool list # List all tools known to rsconstruct (from the central registry)
rsconstruct tool list -M # ...with every install method for each tool
rsconstruct tool list-configured # List tools this project's processors require, and which needs them
rsconstruct tool list-configured -a # Include tools from disabled processors
rsconstruct tool check # Verify tool versions against .tools.versions lock file
rsconstruct tool lock # Lock tool versions to .tools.versions
rsconstruct tool install # Install missing external tools for enabled processors
rsconstruct tool install ruff # Install a specific tool by name
rsconstruct tool install --all # Install every tool in the registry, ignoring the config (no config needed)
rsconstruct tool install -i # Ask for confirmation first (default: install without asking)
rsconstruct tool install --no-eatmydata # Don't wrap apt/dnf/pacman with eatmydata
rsconstruct tool install-deps # Install declared [dependencies] (pip set from uv.lock, npm set from package-lock.json by default) in fixed order: system → pip → npm → gem → cargo
rsconstruct tool install-deps -i # Ask for confirmation first (default: install without asking)
rsconstruct tool install-deps --no-eatmydata # Don't wrap apt/dnf/pacman with eatmydata
rsconstruct tool stats # Show tool availability and language runtime breakdown
rsconstruct tool stats --json # Show tool stats in JSON format
rsconstruct tool graph # Show tool-to-processor dependency graph (DOT format)
rsconstruct tool graph --format mermaid # Mermaid format
rsconstruct tool graph --view # Open tool graph in browser
install --all walks the central registry instead of the project config, so it
needs no rsconstruct.toml. It never skips: a tool with no automatable install
method is a hard error, not a warning. This makes it the intended way to
provision CI — one command covers the whole matrix, and a registry entry that
loses its install method fails the provisioning step instead of quietly
shrinking the matrix. --all cannot be combined with a tool name.
Ruby gems without a writable gem dir
Only system package managers (apt/dnf/pacman/snap) are sudo-wrapped; pip,
npm, cargo, and gem run unprivileged. With a distro ruby, that would
make gem install die on the root-owned system gem dir (/var/lib/gems on
Debian/Ubuntu), so when the default gem dir isn’t writable, gem installs get
--user-install appended automatically. To make the resulting executables
resolvable, every rsconstruct invocation appends the user gem bin dirs that
exist ($GEM_HOME/bin, ~/.gem/ruby/*/bin, ~/.local/share/gem/ruby/*/bin)
to its own PATH at startup — tool probes and spawned processors see them
without any workflow- or shell-level GEM_HOME/PATH setup.
Node.js packages from the project’s node_modules
The same startup step prepends the project’s node_modules/.bin, when it
exists, so the packages install-deps put there from package.json (with
npm ci, see [dependencies] in the configuration reference) resolve ahead
of any global copy. tool install therefore finds a locked tool already
installed and does not add a global one on top.
rsconstruct tag
Search and query frontmatter tags from markdown files.
| Subcommand | Config required? |
|---|---|
list | Yes |
count | Yes |
tree | Yes |
stats | Yes |
files | Yes |
grep | Yes |
for-file | Yes |
frontmatter | Yes |
unused | Yes |
validate | Yes |
matrix | Yes |
coverage | Yes |
orphans | Yes |
check | Yes |
suggest | Yes |
merge | Yes |
collect | Yes |
rsconstruct tag list # List all unique tags
rsconstruct tag count # Show each tag with file count, sorted by frequency
rsconstruct tag tree # Show tags grouped by prefix/category
rsconstruct tag stats # Show statistics about the tags database
rsconstruct tag files docker # List files matching a tag (AND semantics)
rsconstruct tag files docker --or k8s # List files matching any tag (OR semantics)
rsconstruct tag files level:advanced # Match key:value tags
rsconstruct tag grep deploy # Search for tags containing a substring
rsconstruct tag grep deploy -i # Case-insensitive tag search
rsconstruct tag for-file src/main.md # List all tags for a specific file
rsconstruct tag frontmatter src/main.md # Show raw frontmatter for a file
rsconstruct tag validate # Validate tags against tags_dir allowlist
rsconstruct tag unused # List tags in tags_dir not used by any file
rsconstruct tag unused --strict # Exit with error if unused tags found (CI)
rsconstruct tag check # Run all tag validations without building
rsconstruct tag suggest src/main.md # Suggest tags for a file based on similarity
rsconstruct tag coverage # Show percentage of files with each tag category
rsconstruct tag matrix # Show coverage matrix of tag categories per file
rsconstruct tag orphans # Find markdown files with no tags
rsconstruct tag merge ../other/tags # Merge tags from another project
rsconstruct tag collect # Add missing tags from source files to tag collection
rsconstruct complete
Generate shell completions. No config needed when shell is specified as argument; uses config to read default shells if no argument given.
rsconstruct complete bash # Generate bash completions
rsconstruct complete zsh # Generate zsh completions
rsconstruct complete fish # Generate fish completions
rsconstruct term
Manage term checking and fixing in markdown files.
| Subcommand | Config required? |
|---|---|
fix | Yes |
merge | Yes |
stats | Yes |
rsconstruct term fix
Add backticks around terms from the terms directory that appear unquoted in markdown files.
rsconstruct term fix
rsconstruct term fix --remove-non-terms # also remove backticks from non-terms
rsconstruct term merge
Merge terms from another project’s terms directory. Unions matching files and copies missing files in both directions.
rsconstruct term merge ../other-project/terms
rsconstruct doctor
Requires config. (no subcommands)
Diagnose build environment — checks config, tools, versions, and declared
[dependencies] (Python deps declared in pyproject.toml are checked too;
see the [dependencies] section in Configuration). Tools are probed by
running them; system dependencies are
packages, not tools, so they are probed through the platform’s package manager
(dpkg-query/rpm/pacman/brew) — a binary-less package like aspell-en counts
as installed even though no binary carries its name.
rsconstruct doctor
rsconstruct info
Show project information.
| Subcommand | Config required? |
|---|---|
source | Yes |
rsconstruct info source # Show source file counts by extension
rsconstruct page
Query GitHub Pages publishing settings from the [pages] config section.
| Subcommand | Config required? |
|---|---|
dir | Yes |
rsconstruct page dir # Print the published directory ([pages].dir)
rsconstruct --json page dir # {"configured": true, "dir": "out/web"}
page dir prints nothing and still exits 0 when [pages] is not configured — CI branches on the output being empty, not on exit codes. See GitHub Actions for the workflow pattern this enables.
rsconstruct sloc
No config needed. (no subcommands)
Count source lines of code (SLOC) by language, with optional COCOMO effort/cost estimation.
rsconstruct sloc # Show SLOC by language
rsconstruct sloc --cocomo # Include COCOMO effort/cost estimation
rsconstruct sloc --cocomo --salary 80000 # Custom annual salary for COCOMO
rsconstruct toml
rsconstruct toml check # validate rsconstruct.toml (unknown fields, types, required fields)
rsconstruct toml files # list every config file rsconstruct may read and whether it exists
toml files needs no config. It prints the merge chain lowest precedence
first — the user config ($XDG_CONFIG_HOME/rsconstruct/config.toml, see
User config),
rsconstruct.toml and rsconstruct.local.toml — followed by the other
project files rsconstruct reads (.rsconstructignore, .tools.versions),
each with its resolved path and whether it is present. Use it to find out
which file a setting is coming from, or where the user config lives on a
given machine. With --json the same list is emitted as an array of
{precedence, role, path, exists, note} objects.
rsconstruct error
No config needed. (no subcommands)
List every exit code rsconstruct can return and its name. Errors are printed as Error [EXIT_...]: ..., so the name in a message maps back to the code here.
rsconstruct error
| Code | Name |
|---|---|
| 0 | EXIT_SUCCESS |
| 1 | EXIT_BUILD_ERROR |
| 2 | EXIT_CONFIG_ERROR |
| 3 | EXIT_TOOL_ERROR |
| 4 | EXIT_GRAPH_ERROR |
| 5 | EXIT_IO_ERROR |
| 130 | EXIT_INTERRUPTED |
rsconstruct function
No config needed.
Inspect the built-in functions rsconstruct exposes to Tera templates (load_python, load_lua, load_toml, toml_get, …).
| Subcommand | Config required? |
|---|---|
list | No |
rsconstruct function list # Signature, return value, dependency tracking and an example for each function
Example entry:
load_toml(path: string)
Parse a TOML file and expose its top-level table to the template.
returns: object (key → value, nested tables preserved)
dep tracking: The file at `path` is added as an input (content-tracked).
example: {% set cfg = load_toml(path="pyproject.toml") %}{{ cfg.project.version }}
rsconstruct hook
No config needed. (no subcommands)
List the post-config hooks: built-in adjustments applied to the configuration after it is loaded, such as defaults that change when running under CI (CI=true).
rsconstruct hook
╭──────────────────────┬─────────────────────────────────────────────────────────────────────╮
│ Hook │ Description │
├──────────────────────┼─────────────────────────────────────────────────────────────────────┤
│ eatmydata_ci_default │ When CI=true, enable eatmydata wrapping for apt/dnf/pacman installs │
│ marp_ci_cap │ When CI=true and marp.max_jobs is unset, cap it at 2 │
╰──────────────────────┴─────────────────────────────────────────────────────────────────────╯
rsconstruct product
Requires config.
Inspect a single product in the build graph: everything that goes into its cache key and whether it would be rebuilt. Use it to answer “why is this file rebuilding?”.
| Subcommand | Config required? |
|---|---|
show | Yes |
rsconstruct product show out/processor.generator.cc_single_file/src/main.elf # By output path
rsconstruct product show src/main.c # By primary input
The path is looked up as an output first; if no product owns it, products that have it as their primary input are shown. Example output:
processor: processor.generator.cc_single_file
outputs:
out/processor.generator.cc_single_file/src/test.elf
inputs:
primary src/test.c
analyzer [icpp]
src/utils.h
src/config.h
cache_key: 7b1eccc2...
config b884aa0c...
tool 2a4d5d7d...
hash_pieces: (none)
input_checksum: 71e9982e...
descriptor_key: 8c2a5920...
cache_state: BUILD (no cache entry)
rsconstruct symlink-install
Requires config. (no subcommands)
Symlink every file under one or more source folders into target folders, recreating the directory structure. Configure the folder pairs in rsconstruct.toml; sources[i] is installed into targets[i], so both arrays must have the same length. A leading ~/ in a target is expanded to $HOME.
[command.symlink_install]
sources = ["dotfiles", "bin"]
targets = ["~/", "~/.local/bin"]
rsconstruct symlink-install
dotfiles → /home/user/
created .bashrc
created .config/git/config
Symlink install: 2 created, 0 updated, 0 unchanged
Each link points to the absolute path of the source file. Running it again is safe: links that already point to the right file are left alone (unchanged), and links pointing elsewhere are replaced (updated). A regular file at a target path is deleted and replaced by the link, so make sure the target folders hold nothing you want to keep under the same names.
rsconstruct version
No config needed. (no subcommands)
Print version information.
rsconstruct version
Shell Completions
RSConstruct generates shell completion scripts that provide tab-completion for commands, subcommands, flags, and argument values.
Generating Completions
# Generate for the default shell (configured in rsconstruct.toml)
rsconstruct complete
# Generate for a specific shell
rsconstruct complete bash
rsconstruct complete zsh
rsconstruct complete fish
To install, source the output in your shell profile:
# Bash (~/.bashrc)
eval "$(rsconstruct complete bash)"
# Zsh (~/.zshrc)
eval "$(rsconstruct complete zsh)"
# Fish (~/.config/fish/config.fish)
rsconstruct complete fish | source
Configuration
The default shell(s) for rsconstruct complete (with no argument) are configured in rsconstruct.toml:
[completions]
shells = ["bash"]
What Gets Completed
Commands and subcommands
All top-level commands (build, processor, analyzer, config, etc.) and their subcommands complete automatically via clap.
Processor type names (pnames)
These commands complete with processor type names from the plugin registry (e.g., ruff, pylint, cc_single_file):
rsconstruct processor defconfig <TAB>rsconstruct build --processor <TAB>/rsconstruct build -p <TAB>rsconstruct watch --processor <TAB>/rsconstruct watch -p <TAB>
The list is drawn from the plugin registry at compile time.
Processor instance names (inames)
These commands complete with instance names declared in the current project’s rsconstruct.toml (e.g., pylint, pylint.tests, cc_single_file):
rsconstruct processor config <TAB>rsconstruct processor files <TAB>
Instance names are extracted from [processor.TYPE.NAME] and [processor.TYPE.NAME.INSTANCE] headings in rsconstruct.toml at tab-completion time. Requires a project config in the current directory. Bash only.
Analyzer names
These commands complete analyzer names (every registered analyzer, as listed by rsconstruct analyzer list):
rsconstruct analyzer config <TAB>rsconstruct analyzer clean --analyzer <TAB>
Analyzer names are specified via clap’s value_parser attribute, so they work in all shells without post-processing.
Flags and options
All --flags and -f short flags complete in all shells via clap’s built-in generation.
Implementation
Completions are generated by clap_complete in src/cli.rs. Two mechanisms provide argument-value completions:
1. clap value_parser (preferred)
For arguments with a small, fixed set of values, use #[arg(value_parser = [...])] on the field. This works in all shells automatically because clap embeds the values in the generated script.
Example from AnalyzersAction::Config:
#![allow(unused)]
fn main() {
#[arg(value_parser = ["cpp", "markdown", "python", "tera"])]
name: Option<String>,
}
2. Bash post-processing (processor names)
Processor names are not known to clap at derive time because they come from the inventory plugin registry. The function inject_bash_processor_completions() post-processes the generated bash script to inject processor names into the opts variable for specific command sections.
This only works for bash. Other shells get the base clap completions without processor name injection.
The targets for injection are identified by their case labels in the generated bash script:
rsconstruct__processor__config)rsconstruct__processor__defconfig)rsconstruct__processor__files)
The function also patches --processor / -p flag completions in build and watch commands to suggest processor names instead of file paths.
Adding Completions for New Arguments
- Fixed set of values (analyzer names, enum variants): Use
#[arg(value_parser = [...])]. Works in all shells. - Dynamic set from registry (processor names): Add the case label to
inject_bash_processor_completions()targets. Only works in bash. - Enum types: Use
#[arg(value_enum)]on a clap-derived enum. Works in all shells.
Configuration
RSConstruct is configured via an rsconstruct.toml file in the project root.
An optional rsconstruct.local.toml overlay, when present, is merged over the
main file at load time — see Local overlay.
An optional user-level file can set [build] defaults underneath both and
choose the columns the CLI’s tables show — see
User config.
Full reference
[build]
parallel = 1 # Number of parallel jobs (1 = sequential, 0 = auto-detect CPU cores)
# Also settable via RSCONSTRUCT_THREADS env var (CLI -j takes precedence)
batch_size = 0 # Max files per batch for batch-capable processors (0 = no limit)
output_dir = "out" # Global output directory prefix for generator processors
# Declare processors by adding [processor.TYPE.NAME] sections.
# Only declared processors run — no processors are enabled by default.
# Use `rsconstruct smart auto` to auto-detect and add relevant processors.
[processor.checker.ruff]
# args = []
[processor.checker.pylint]
# args = ["--disable=C0114"]
[processor.generator.cc_single_file]
# cc = "gcc"
# cflags = ["-Wall", "-O2"]
[vars]
my_excludes = ["/vendor/", "/third_party/"] # Define variables for reuse with ${var_name}
[cache]
restore_method = "auto" # auto (default: copy in CI, hardlink otherwise), hardlink, or copy
compression = false # Compress cached objects with zstd (requires restore_method = "copy")
remote = "s3://my-bucket/rsconstruct-cache" # Optional: remote cache URL
remote_push = true # Push local builds to remote (default: true)
remote_pull = true # Pull from remote cache on cache miss (default: true)
mtime_check = true # Use mtime pre-check to skip unchanged file checksums (default: true)
webcache_ttl_secs = 604800 # How long a fetched HTTP response stays fresh (default: 7 days)
[analyzer]
auto_detect = true
enabled = ["cpp", "python"]
[graph]
viewer = "google-chrome" # Command to open graph files (default: platform-specific)
[completions]
shells = ["bash"]
[dependencies]
pip = ["pyyaml", "jinja2"] # Python packages
npm = ["eslint", "prettier"] # Node.js packages
gem = ["mdl"] # Ruby gems
cargo = ["cargo-deny"] # Crates from crates.io (cargo install --locked)
system = ["pandoc", "graphviz"] # System packages (checked but not auto-installed)
[pages]
dir = "out/web" # Directory published to GitHub Pages (omit the section entirely for non-Pages repos)
[command.symlink_install]
sources = ["dotfiles"] # Folders whose files `rsconstruct symlink-install` links...
targets = ["~/"] # ...into these folders, pairwise (same length as sources)
Per-processor configuration is documented on each processor’s page under Processors.
Processor instances
Processors are declared by adding a [processor.TYPE.NAME] section to rsconstruct.toml. An empty section enables the processor with default settings:
[processor.checker.pylint]
Customize with config fields:
[processor.checker.pylint]
args = ["--disable=C0114,C0116"]
src_dirs = ["src"]
Remove the section to disable the processor.
Multiple instances
Run the same processor multiple times with different configurations by adding named sub-sections:
[processor.checker.pylint.core]
src_dirs = ["src/core"]
args = ["--disable=C0114"]
[processor.checker.pylint.tests]
src_dirs = ["tests"]
args = ["--disable=C0114,C0116"]
Each instance runs independently with its own config and cache.
You cannot mix single-instance and multi-instance formats for the same processor — use either [processor.checker.pylint] or [processor.checker.pylint.NAME], not both.
Processor names
A processor is named by its full path, processor.<type>.<name>, the same path its source file has (src/processor/checker/ruff.rs is processor.checker.ruff). The type is one of checker, generator, creator, explicit, mass_generator and lua; the name is unique only within its type, which is why the config-driven processors share one name: processor.creator.generic, processor.generator.generic, processor.explicit.generic, processor.mass_generator.generic. The section header is the name: [processor.checker.ruff].
rsconstruct processor list prints every processor’s full name; rsconstruct processor list --type creator narrows it to one type.
Instance naming
A single instance declared as [processor.checker.pylint] has the instance name processor.checker.pylint. Named instances declared as [processor.checker.pylint.core] and [processor.checker.pylint.tests] have instance names processor.checker.pylint.core and processor.checker.pylint.tests.
The instance name is used everywhere a processor is identified:
- Build output and progress:
[processor.checker.pylint.core] src/core/main.py - Error messages:
Error: [processor.checker.pylint.tests] tests/test_foo.py: ... - Build statistics: each instance reports its own file counts and durations
- Cache keys: instances have separate caches, so changing one config does not invalidate the other
- Output directories: generator processors default to
out/{instance_name}—out/processor.generator.terafor a single tera instance,out/processor.generator.marp.slidesandout/processor.generator.marp.docsfor two marp instances — so outputs never collide - The
--processorfilter: use the full instance name, e.g.,rsconstruct build -p processor.checker.pylint.core processor config,processor files,--iset:rsconstruct processor config processor.checker.pylint.core,--iset processor.checker.pylint.core.max_jobs=2
Migrating from short names
Until rsconstruct 0.9.x a processor was named by its bare name ([processor.ruff], -p ruff, out/tera). The full-name scheme is a one-shot, mechanical rewrite of every rsconstruct.toml:
| Before | After |
|---|---|
[processor.ruff] | [processor.checker.ruff] |
[processor.pylint.core] | [processor.checker.pylint.core] |
[processor.creator.venv] | [processor.creator.generic.venv] |
[processor.explicit] | [processor.explicit.generic] |
src_dirs = ["out/tera"] | src_dirs = ["out/processor.generator.tera"] |
rsconstruct build -p ruff | rsconstruct build -p processor.checker.ruff |
scripts/migrate_processor_names.py in the rsconstruct repository performs the rewrite for one or more repositories (python3 scripts/migrate_processor_names.py ~/git/*/rsconstruct.toml): it renames every section header, rewrites out/<name> references to processors whose default output directory moved, and reports any other file in the repository that still mentions an old out/<name> path, so Makefiles and CI that point into out/ can be fixed by hand. It asks the installed rsconstruct processor list --json which type each name has, so it needs a rsconstruct that already speaks full names. An old-style header is a config error with a hint ([processor.ruff]: unknown processor type 'ruff' … did you mean [processor.checker.ruff]?), so a missed file fails loudly rather than silently building nothing.
Every cache entry is keyed by the processor name, so the first build after migrating rebuilds everything once.
Auto-detection
Run rsconstruct smart auto to scan the project and automatically add [processor.TYPE.NAME] sections for all processors whose files are detected and whose tools are installed. It does not remove existing sections.
Variable substitution
Define variables in a [vars] section and reference them using ${var_name} syntax:
[vars]
kernel_excludes = ["/kernel/", "/kernel_standalone/", "/examples_standalone/"]
[processor.checker.cppcheck]
src_exclude_dirs = "${kernel_excludes}"
[processor.generator.cc_single_file]
src_exclude_dirs = "${kernel_excludes}"
Variables are substituted before TOML parsing. The "${var_name}" (including quotes) is replaced with the TOML-serialized value, preserving types (arrays stay arrays, strings stay strings). A reference must constitute the entire quoted value — an embedded reference like "${base}/src" cannot be substituted and is a config error, as is any undefined variable reference.
Local overlay (rsconstruct.local.toml)
If a file named rsconstruct.local.toml exists next to rsconstruct.toml, it
is loaded automatically and deep-merged over the main config. This lets many
repos share one canonical, identical rsconstruct.toml while keeping
repo-specific configuration — [dependencies] lists, [processor.explicit.*]
generators, exclude lists, one-off workarounds — in the local file.
Merge semantics:
- Tables merge recursively. A local
[processor.checker.mypy]with onlybatch = falseadds that one key to the main file’s mypy section; the rest of the section is untouched. - Arrays and scalars replace wholesale. If the local file sets
src_dirs, that exact list is used — values are never concatenated, so the local file can also remove entries by setting a shorter list. - Local-only sections are added. The overlay can declare whole processors, analyzers, or global sections the main file doesn’t have.
Rules and caveats:
- The overlay only extends a main config:
rsconstruct.local.tomlwithout anrsconstruct.tomlis an error. [vars]substitution is per-file — each file’s${...}references resolve against its own[vars]section only.- The merged result is validated as one config, so unknown fields or type errors in the local file are reported the same way as in the main file.
rsconstruct processor config <iname>shows the merged values.- In watch mode, both files are watched for changes.
This supports a shared-config pattern: one rsconstruct.toml distributed
across many repos plus a small optional rsconstruct.local.toml per repo for
what’s genuinely repo-specific. Note that every src_dirs entry must exist in
every repo that carries it (see Missing src_dirs and src_files
entries); a shared file can no longer
list directories only some repos have, and [build] allow_missing_src_dirs
exists for a project that still wants that.
User config (~/.config/rsconstruct/config.toml)
A per-user file, read from $XDG_CONFIG_HOME/rsconstruct/config.toml (or
~/.config/rsconstruct/config.toml when XDG_CONFIG_HOME is unset), sets
[build] defaults for every project you build on this machine, and under
[tables] chooses which columns the CLI’s tables show. For
[build] the precedence is, lowest first:
- built-in defaults
- the user config
rsconstruct.tomlrsconstruct.local.toml
Keys merge one by one, so a repo that sets a [build] key overrides the user
value for that key only. Example:
# ~/.config/rsconstruct/config.toml
[build]
reject_dot_src_dirs = true
Rules:
- Only
[build]and[tables]are allowed. A[processor.*],[analyzer.*]or any other table in this file is a config error naming the offending section. Anything that changes what a repo scans or builds belongs in the repo’s own files, where every machine and CI see the same thing; a machine-wide file that could add processors would make the same repo build differently on two machines.[tables]passes because it changes nothing about the build, only how the CLI prints its tables. - CI never reads it. Continuous integration checks out the repo and
nothing else, so a switch that must hold in CI has to be set in
rsconstruct.toml. The user file is a local safety net and a way to try a policy across all your projects before committing it to each of them. - It applies even in a directory without
rsconstruct.toml, for the commands that run without a config. - Watch mode does not watch this file; restart the watcher after editing it.
rsconstruct toml filesprints the resolved path of this file, together with the rest of the chain, and whether each one exists.
[tables]
Every table rsconstruct prints has a name and a fixed set of columns. By
default a table shows all of them except a few that are hidden unless
--verbose asks for them. An entry under [tables] replaces that default
for one table: the value is the list of columns to show, in the order to
show them, spelled exactly as the column headers are.
# ~/.config/rsconstruct/config.toml
[tables]
# processor list: just the names and whether each one batches.
processor_list = ["Name", "Batch"]
# tool list: always show how to install a tool, without --verbose.
tool_list = ["Tool", "Status", "Install"]
# hook: the Lua function names first, descriptions last.
hook_list = ["Function", "Hook", "Description"]
rsconstruct --fields <command> prints a table’s name, the command that
prints it and its column names, with no data; rsconstruct --all <command>
shows every column of every table, whatever this section says. A key that
names no table, or a column that the table does not have, is a config error
listing the valid names. The two tables whose columns depend on the data
(product_status, tag_matrix) are checked when they are printed; an
unknown column there is a warning and is skipped.
The tables, their commands and their columns; a column marked (verbose) is hidden by default:
| Table | Command | Columns |
|---|---|---|
analyzer_defconfig | rsconstruct analyzer defconfig | Field, Type, Default |
analyzer_list | rsconstruct analyzer list | Name, Native, Description (verbose) |
analyzer_stats | rsconstruct analyzer stats, analyzer build | Analyzer, Files, Dependencies |
analyzer_used | rsconstruct analyzer used | Name, Detected, Description (verbose) |
build_timings | rsconstruct build --timings | Phase, Duration |
error_list | rsconstruct error | Code, Name, Description (verbose) |
hook_list | rsconstruct hook | Hook, Function (verbose), Location (verbose), Description |
info_source | rsconstruct info source | Extension, Count |
processor_config | rsconstruct processor config | Field, Value |
processor_fields | rsconstruct processor defconfig, processor info | Field, Type, Default, Required, Checksum, Description (verbose) |
processor_list | rsconstruct processor list | Name, Type, Native, Rust, Batch, Cache, Rust alternative, Description (verbose) |
processor_recommend | rsconstruct processor recommend | Extension / File, Processor, Also, Reason |
processor_search | rsconstruct processor search | Name, Type, Description, Keywords |
processor_types | rsconstruct processor types | Type, Batch, Description, Batching (verbose) |
processor_used | rsconstruct processor used | Name, Type, Detected, Description (verbose) |
product_status | rsconstruct status, build --dry-run | Processor, one column per product state (up-to-date, restorable, stale, new for status; skip, restore, build, build-new for build --dry-run), native, rust |
sloc | rsconstruct sloc | Language, Files, Blank, Comment, Code |
status_breakdown | rsconstruct status --breakdown | Processor, Files, Breakdown |
tag_count | rsconstruct tag count | Count, Tag |
tag_coverage | rsconstruct tag coverage | Category, Files, Coverage |
tag_matrix | rsconstruct tag matrix | File, then one column per tag category |
tag_suggest | rsconstruct tag suggest | Tag, Score |
tool_list | rsconstruct tool list | Tool, Status, Runtime, Install (verbose) |
tool_runtimes | rsconstruct tool stats | Runtime, Installed |
tool_stats | rsconstruct tool stats | Tool, Status, Processors, Install |
webcache_list | rsconstruct webcache list | URL, Size, Age, State |
Section details
[build]
| Key | Type | Default | Description |
|---|---|---|---|
parallel | integer | 1 | Number of parallel jobs. 1 = sequential, 0 = auto-detect CPU cores. Can also be set via the RSCONSTRUCT_THREADS environment variable (CLI -j takes precedence). |
batch_size | integer | 0 | Maximum files per batch for batch-capable processors. 0 = no limit (all files in one batch). To disable batching, pass --batch-size -1 on the CLI or set batch = false on individual processors. |
output_dir | string | "out" | Global output directory prefix. Processor output_dir defaults that start with out/ are remapped to use this prefix (e.g., setting "build" changes out/processor.generator.marp to build/marp). Individual processors can still override their output_dir explicitly. |
max_discovery_passes | integer | 10 | Maximum fixed-point discovery passes. Discovery repeats while processors keep adding products from each other’s declared outputs; a config still adding products at the cap fails the build (naming the processors involved) instead of silently truncating the graph. |
hash_tool_versions | boolean | true | Mix the identity of each processor’s external tools into its products’ cache keys, so upgrading a tool invalidates what that tool produced. Tools are identified by a content hash of the resolved binary (no --version subprocess; the mtime cache makes repeat builds a stat), or by the pinned version_output when rsconstruct tool lock has written a .tools.versions file. Set to false to leave tool identity out of cache keys entirely. |
warn_symlinks | boolean | false | Warn about every symlink skipped during the file-index walk. The walker never follows symlinks, so a symlinked source (or a symlinked directory of sources) is absent from the index — never checked, never built. Set to true to make that gap visible. Off by default because projects that deliberately keep symlinks around (vendored trees, dotfile farms) would drown in warnings. |
command_timeout_secs | integer | 0 | Wall-clock limit in seconds for every external command a processor runs; a command still running at the limit is killed and its product fails with Command timed out after Ns and was killed. 0 means no limit. Off by default, on purpose: a tool that hangs is a bug in the tool, its input, or the environment, and the fix is to find that cause — not to cut the tool off. Set this only where a build must not be allowed to sit forever (an unattended runner) and a loud kill beats a silent hang. A processor with its own timeout (marp’s timeout_secs) keeps it; the explicit value wins. |
allow_missing_dep_auto | boolean | false | Skip a dep_auto entry you listed when the file does not exist, as every entry used to be skipped. By default such an entry is a config error naming the processor, the file and the config line: a listed file that is missing is a typo or a stale copy of a shared config, and either way a dependency that silently tracks nothing. Processor defaults (.pylintrc, ruff.toml, …) are optional by nature and are never checked. |
allow_missing_src_dirs | boolean | false | Skip a src_dirs entry that names a directory which does not exist, as every entry used to be skipped. By default such an entry is a config error naming the processor and the entry: a directory listed in the config that is not there is a typo, a stanza left behind when the directory moved, or a copy of another repo’s config, and in every case a processor that checks nothing while the build stays green. A directory an upstream processor declares as its output is never reported, whatever this is set to. This switch covers directories only: a missing src_files entry is always an error. |
reject_dot_src_dirs | boolean | false | Make a src_dirs entry of "." a config error. "." and "" both mean the whole project tree, recursively; written as "." it reads like “the root directory” and hides that the stanza sweeps every subdirectory too. Turn this on in a project whose stanzas are meant to name the directories they cover: the error names the stanza and the config line, and the fix is to list the real directories, or to write "" if sweeping the tree is intended. Off by default because "." is not wrong, only easy to misread. Disabled stanzas are not checked. |
worker_stack_mib | integer | 256 | Stack size, in MiB, of each build worker thread. The native checkers parse arbitrary files with recursive parsers; on a small stack a deeply nested file (thousands of ( in Lua, of <div> in HTML) overflows it, which aborts the whole process. Only touched pages are committed, so the reservation costs address space, not memory. A panic inside a processor fails that product only. |
The output_dir prefix is purely a layout choice — rsconstruct clean outputs does not special-case it. Cleanup is driven by per-product outputs and output_dirs declarations, then a generic empty-directory sweep walks parents bottom-up. See Clean behavior and rsconstruct clean for details.
[processor.TYPE.NAME]
Each [processor.TYPE.NAME] section declares a processor instance. The header is the processor’s full name: TYPE is its type (checker, generator, creator, explicit, or mass_generator) and NAME the processor within that type ([processor.checker.ruff], [processor.generator.cc_single_file]). A fourth segment names an instance ([processor.checker.pylint.core]).
Common fields available to all processors:
| Key | Type | Default | Description |
|---|---|---|---|
enabled | boolean | true | Set to false to disable this processor without removing the stanza. Accepted on every processor. A disabled processor is skipped during discovery, so it produces no products; smart remove-no-file-processors ignores disabled stanzas rather than reporting them as matching no files. |
args | array of strings | [] | Extra command-line arguments passed to the tool. |
dep_inputs | array of strings | [] | Additional input files that trigger rebuild when changed. |
dep_auto | array of strings | varies | Config files added as inputs. The processor’s default list (e.g. .pylintrc) is skip-if-absent; a list you write here replaces it, and every entry in it must exist unless [build] allow_missing_dep_auto is set. |
required_tools | array of strings | [] | Extra tools this processor needs beyond command. Normally command is the tool, so this is empty. Name the real tool here when command is a wrapper script that shells out to it — otherwise that tool is invisible to rsconstruct tool install and to version locking, and a missing tool surfaces only as a failure inside the wrapper. Each name must have a registry entry (rsconstruct tool list). |
batch | boolean | true | Whether to batch multiple files into a single tool invocation. A batching checker waits for all of its files to be ready and runs once over them; --batch-size on the command line limits or disables batching build-wide. For external tools, a batch failure marks all products in the chunk as failed. Internal processors (i-prefixed) return per-file results, so partial failure is handled correctly. |
output_depends_on_input_name | boolean | false (true for ruff, pylint and mypy) | Set to true when the processor’s result depends on the input files’ paths and not only on their content: its output names the input (// Generated from foo.c), or it is a checker with per-path rules (ruff’s per-file-ignores, mypy’s module resolution). The cache normally keys a product by input content, so a renamed file with the same content, or an identical file at another path, reuses the cached result; with this set, the input paths are part of the key and such a file is rebuilt. Unchanged files are still skipped and restored from the cache as usual. Accepted on every processor. |
max_jobs | integer | none | Maximum concurrent jobs for this processor. When set, limits how many instances of this processor run in parallel, regardless of the global -j setting. Useful for heavyweight processors (e.g., marp spawns Chromium). Omit to use the global parallelism. |
src_dirs | array of strings | [] | Directories to scan for source files. Every processor defaults to [], which scans nothing — no processor guesses a directory, so one declared without src_dirs matches no files and simply builds nothing. A default like ["src"] would be a guess that silently matches the wrong directory in a project laid out differently; naming the directory is the user’s call. To scan the whole project deliberately, use src_dirs = [""] — an empty string means the project root and walks everything beneath it, including node_modules/, .venv/ and target/ unless excluded. "." means exactly the same thing, but reads as if it named only the root directory; [build] reject_dot_src_dirs = true forbids that spelling. src_files is an alternative to src_dirs for listing exact paths. Every entry must name a directory that exists; an entry that does not is a config error (exit code 2) naming the processor and the entry — see Missing src_dirs and src_files entries below. The one exception is a directory an upstream processor declares as its output: it does not exist before that processor runs, and is accepted. Use rsconstruct processor defconfig <name> to see a processor’s defaults. |
src_extensions | array of strings | varies | File extensions to match. |
src_exclude_dirs | array of strings | varies | Directory path segments to exclude from scanning. |
src_exclude_files | array of strings | [] | File names to exclude. |
src_exclude_paths | array of strings | [] | Paths (relative to project root) to exclude. |
src_files | array of strings | [] | Exact paths to match, relative to the project root. On its own it is an allowlist: exactly the named files are matched and nothing else (no directory scan, no extension filter). Together with src_dirs, the named files are added to what the directories yield. Every entry must name a file that exists, or a path an upstream processor declares as its output; anything else is a config error (exit code 2), and unlike a missing directory it is never forgiven — allow_missing_src_dirs does not apply to files. See Missing src_dirs and src_files entries. |
Processor-specific fields are documented on each processor’s page under Processors.
[cache]
| Key | Type | Default | Description |
|---|---|---|---|
restore_method | string | "auto" | How to restore cached outputs. "auto" (default) uses "copy" in CI environments (CI=true) and "hardlink" otherwise. "hardlink" is faster but requires same filesystem; "copy" works everywhere. |
compression | boolean | false | Compress cached objects with zstd. Incompatible with restore_method = "hardlink" — requires "copy". |
remote | string | none | Remote cache URL. See Remote Caching. |
remote_push | boolean | true | Push locally built artifacts to remote cache. |
remote_pull | boolean | true | Pull from remote cache on local cache miss. |
mtime_check | boolean | true | Persist file checksums across builds using an mtime database. Set to false in CI/CD environments where the cache won’t survive the build and the write overhead isn’t worth it. Can also be disabled via --no-mtime-cache flag. See Checksum Cache. |
webcache_ttl_secs | integer | 604800 (7 days) | How long a cached HTTP response (remote JSON schemas fetched by iyamlschema) stays fresh. An entry older than this is re-fetched. Set to 0 to disable the web cache entirely and always re-fetch. Expired entries are reclaimed by rsconstruct cache trim. |
rsconstruct cache trim reclaims three kinds of dead weight: unreferenced objects in the store, mtime-database rows for files that no longer exist, and expired web-cache entries. Without it these grow with the history of the project rather than its current size.
[analyzer]
| Key | Type | Default | Description |
|---|---|---|---|
auto_detect | boolean | true | When true, only run enabled analyzers that auto-detect relevant files. |
enabled | array of strings | ["cpp", "python"] | List of dependency analyzers to enable. |
[graph]
| Key | Type | Default | Description |
|---|---|---|---|
viewer | string | platform-specific | Command to open graph files |
[completions]
| Key | Type | Default | Description |
|---|---|---|---|
shells | array | ["bash"] | Shells to generate completions for |
Missing src_dirs and src_files entries
A src_dirs or src_files entry is a claim about where the project keeps its
sources. When the directory or file it names is not there, the build fails at
discovery with exit code 2 (EXIT_CONFIG_ERROR) and lists every offending
entry:
Invalid config:
[processor.checker.pylint] src_dirs entry 'scripts' does not exist or is not a directory
[processor.checker.luacheck] src_dirs entry 'config' does not exist or is not a directory
[processor.checker.taplo] src_files entry 'pyproject.toml' does not exist or is not a file
Every src_dirs entry must name a directory that exists and every src_files
entry a file that exists (or a path an upstream processor declares as its
output) — fix the path or remove the entry; [build] allow_missing_src_dirs =
true skips absent directories as before, never absent files
Directories used to be a silent skip, visible only under --phases, and files
were never checked at all. That let a stanza whose directory had moved, one
copied from another repo, or one with a typo in a file name keep the build
green while checking nothing at all — the worst kind of failure, because the
green tick says the check ran. The fix is always in the config: point the
entry at the directory or file that actually exists, or delete the stanza if
the files are gone.
What is not reported:
""and".", which mean the project root and always exist.- A directory or file an upstream processor declares as its output (for
example a linter whose
src_dirsis a generator’soutput_dir, or whosesrc_filesnames one of its outputs). It does not exist before that processor has run; discovery sees the declared outputs as virtual files and accepts the entry. - Entries of a stanza with
enabled = false. - Anything during
rsconstruct cleanandrsconstruct smart remove-no-file-processors: clean must still be able to remove the outputs of a build whose source directory has since gone, and the repair command exists to delete the very stanzas this check rejects. Both report the entries under--phasesinstead.
[build] allow_missing_src_dirs = true restores the old skip for directories
in a project that needs it; the skipped entries are then listed under
--phases. It never covers src_files: a directory can at least plausibly be
empty, a named file that is not there is simply wrong. Prefer fixing the
entry: the switch hides exactly the misconfiguration this check exists to
catch.
[dependencies]
Declare project dependencies by package manager. Used by rsconstruct doctor to verify availability and rsconstruct tool install-deps to install missing packages.
| Key | Type | Default | Description |
|---|---|---|---|
pip | array of strings | [] | Python packages to install via pip install. Supports version specifiers (e.g., "ruff>=0.4"). |
pip_source | string | "uv-lock" | Where the Python package set comes from: "uv-lock" installs the pinned closure from uv.lock; "pyproject" resolves the names pyproject.toml declares at install time. |
python_installer | string | "uv" | Which tool installs the Python package set: "uv" runs uv pip install --python python3; "pip" runs pip install. See below for why the two differ on the same lock. |
npm | array of strings | [] | Node.js packages to install globally via npm install -g, on top of the project’s own package.json set. |
npm_source | string | "package-lock" | Where the project’s Node.js package set comes from: "package-lock" installs the closure package-lock.json pins with npm ci; "package-json" resolves package.json’s ranges with npm install. |
gem | array of strings | [] | Ruby gems to install via gem install. |
cargo | array of strings | [] | Crates to install from crates.io via cargo install --locked, by crate name (e.g. "cargo-deny", "cargo-nextest", "mdbook"). Bare names only; the version floats. See below. |
system | array of strings | [] | System packages installed via the detected package manager (apt-get, dnf, pacman, or brew). |
Python dependencies come from uv.lock by default
In the default pip_source = "uv-lock" mode, install-deps and doctor
take the Python package set from uv.lock: the exact pinned closure —
including transitive dependencies — that uv lock resolved, so CI installs
are reproducible instead of floating to whatever pip resolves that day. Only
registry packages are read from the lock; the project’s own entry is
skipped, and any other source kind (git, path) is an error. A project whose
pyproject.toml declares Python dependencies but has no uv.lock is an
error: run uv lock, or opt out per repo with pip_source = "pyproject".
In pip_source = "pyproject" mode the set is instead the dependency names
pyproject.toml declares, resolved by pip at install time:
[project].dependencies- every list in
[project.optional-dependencies] - every PEP 735
[dependency-groups]list (entries that are{include-group = "..."}tables are skipped, since every group is read anyway)
In both modes [dependencies].pip entries merge in front and the result is
deduplicated by PEP 503-normalized distribution name plus extras, so a
[dependencies].pip entry wins over a lock or pyproject one. Use
[dependencies].pip for repos without a pyproject.toml and for packages
CI needs that the project itself does not declare.
uv installs the Python set by default
With the default python_installer = "uv", install-deps hands the Python
set to uv pip install --python python3 — targeting whichever interpreter
python3 on PATH resolves to, the same environment a bare pip install
would touch. Installing a uv-produced lock with uv matters for two reasons:
- Requires-Python upper bounds. uv deliberately ignores a package’s
declared upper Requires-Python bound when resolving (upper bounds are
almost always stale metadata), so
uv lockcan pin a version whose bound excludes the running interpreter. uv installs that pin; pip enforces the bound and refuses it with “No matching distribution found”. - Environment markers. In uv-lock mode the pins come from
uv export(run with--frozen, so an out-of-sync lock is an error, not a silent re-resolve), which annotates platform-conditional packages with markers likepywin32==312 ; sys_platform == 'win32'. uv skips a pin whose marker does not match the current platform, where the flattened lock closure would ask for another platform’s package and fail.
uv also audits the whole installed set itself in one pass, so uv mode skips
the per-package pip show preflight probing.
When uv is not on PATH and there is Python work to do, install-deps
bootstraps it with pip install uv first — CI runner images ship pip but
not necessarily uv, and the consuming repo’s workflow should not need to
know which installer rsconstruct uses.
Set python_installer = "pip" to keep the pre-uv behavior: per-package
pip show probing followed by pip install of the flattened set.
Node.js dependencies come from package-lock.json by default
The Node side works the same way. A project’s Node.js packages are declared
in its package.json (dependencies, devDependencies and
optionalDependencies — the three sections npm ci installs) and pinned by
the package-lock.json beside it. In the default npm_source = "package-lock" mode, install-deps runs npm ci in the project root, which
installs exactly the lock’s closure into the project’s own node_modules/
and fails if the lock is out of sync with the manifest instead of silently
re-resolving. A package.json that declares packages but has no
package-lock.json is an error: run npm install --package-lock-only, or
opt out per repo with npm_source = "package-json", which runs npm install
and lets the versions float.
The install is skipped when every declared package is already present in
node_modules/ at the version the lock pins — npm ci wipes node_modules/
before installing, so a needless run is not free. doctor reports each
declared package the same way: installed means present in the project’s
node_modules/, not somewhere global.
To make the installed executables resolvable, every rsconstruct invocation
prepends the project’s node_modules/.bin, when it exists, to its own
PATH at startup (the same mechanism that appends the user gem bin dirs, see
rsconstruct tool in the commands reference). Prepended, so the version the
lock pins wins over a global install of the same tool — processors keep
invoking stylelint or mmdc plainly, and tool install sees the locked
copy and does not install a global one. This is what npm run does for
scripts.
[dependencies].npm remains for a package that must be global; it is
installed with npm install -g alongside, and is not the place for a
project’s own tools.
Rust crates are a list, not tools
A cargo subcommand or other Rust-built tool a build runs (cargo-deny,
cargo-nextest, mdbook) is a crate on crates.io, and crates.io is a
package registry like PyPI or npm. So these are declared as a plain list
under cargo, by crate name, and are not entries in the tool registry.
install-deps installs the missing ones in one cargo install --locked
call: --locked builds each crate with the Cargo.lock it was published
with, so a given crate version yields the same binary everywhere, while the
version itself floats to the latest release — the manifest carries bare
names, as everywhere else in the fleet.
Presence is judged by cargo install --list, by crate name, not by a binary
on PATH. That is exact (a crate’s binary need not carry its name: ripgrep
ships rg) and it is cheap, where an unconditional cargo install would
rebuild every crate that has a newer release on each run. The flip side is
that a binary obtained any other way — a prebuilt tarball, a distro package
— is not in the listing and is treated as missing; the install passes
--force so such a binary is replaced rather than refused with “binary
already exists in destination” (a CI cache holding a prebuilt nextest did
exactly that). doctor reports each declared crate the same way.
Components rustup manages (rustfmt, clippy) are not crates and do not
belong in this list.
Install order
rsconstruct tool install-deps always installs in this fixed order:
system— OS packages (apt, dnf, pacman, brew)pip— Python packagesnpm— Node.js packages (the globalnpmlist, then the project’snpm ci)gem— Ruby gemscargo— crates from crates.io
This order is deliberate and must not be changed. Language-level packages frequently build native extensions that link against system libraries at install time. For example, installing manim via pip pulls in manimpango, which compiles a C extension and uses pkg-config to find pangocairo — so libpango1.0-dev must already be on the system before pip install runs. Running pip (or gem, or npm) before system causes wheel/extension builds to fail with messages like Package 'pangocairo' was not found.
The keys inside [dependencies] may appear in any order in rsconstruct.toml; the install order is enforced by install-deps regardless.
eatmydata wrapping
When eatmydata is installed on the system and CI=true is in the environment, both rsconstruct tool install and rsconstruct tool install-deps wrap their apt/dnf/pacman invocations with it. eatmydata no-op’s fsync() for the wrapped process, which speeds up package installs by 3–10×.
The trade-off is loss-on-power-cut: any package files written during the install are not flushed to disk, so a power loss mid-install can leave the package database inconsistent. That’s fine on transient CI hosts and wrong on developer workstations — hence the CI=true gate.
The wrap inserts eatmydata after sudo so the LD_PRELOAD applies to the package manager, not to sudo itself: e.g. sudo eatmydata apt-get install -y ….
If eatmydata is not installed, the commands run unwrapped — no error, no warning.
Controlling the wrap
| To… | Do this |
|---|---|
| Use the wrap (the CI default) | Set CI=true and have eatmydata installed |
| Skip the wrap in CI | Unset CI (or set it to anything other than true) |
| Use the wrap outside CI | Run with CI=true rsconstruct tool install-deps |
| Skip the wrap for a single invocation | Pass --no-eatmydata |
The CLI flag --no-eatmydata always wins. Otherwise the policy is driven entirely by CI=true. There is no rsconstruct.toml field for this — the env var is the knob.
The mechanism is a post-config hook: eatmydata_ci_default runs after config load and flips the in-memory dependencies.eatmydata flag when CI=true. List it with rsconstruct hook.
What’s never wrapped
brew is never wrapped (eatmydata is Linux-only). pip, npm, cargo, and gem are never wrapped either — those don’t fsync excessively, so the wrap adds nothing.
[pages]
Declares that this repo publishes a directory to GitHub Pages. The section is optional — its presence is the signal. rsconstruct page dir prints the directory when the section exists and prints nothing (exit 0) when it doesn’t, which lets a single shared CI workflow decide whether to run the Pages upload/deploy steps. See GitHub Actions for the workflow pattern.
| Key | Type | Default | Description |
|---|---|---|---|
dir | string | required | Directory whose contents are published to GitHub Pages (e.g., "out/web" or "_site"). |
[command.symlink_install]
Configures rsconstruct symlink-install, which symlinks every file under each source folder into the matching target folder, recreating the directory structure. sources[i] is installed into targets[i]; the command fails if the arrays differ in length or are empty. A leading ~/ in a target is expanded to $HOME. The section is read only by that command, never by a build.
| Key | Type | Default | Description |
|---|---|---|---|
sources | string[] | [] | Folders (relative to the project root) whose files are linked. |
targets | string[] | [] | Folders the links are created in, one per entry of sources. |
Remote Caching
RSConstruct supports sharing build artifacts across machines via remote caching. When enabled, build outputs are pushed to a remote store and can be pulled by other machines, avoiding redundant rebuilds.
Configuration
Add a remote URL to your [cache] section in rsconstruct.toml:
[cache]
remote = "s3://my-bucket/rsconstruct-cache"
Supported Backends
Amazon S3
[cache]
remote = "s3://bucket-name/optional/prefix"
Requires:
- AWS CLI installed (
awscommand) - AWS credentials configured (
~/.aws/credentialsor environment variables)
The S3 backend uses aws s3 cp and aws s3 ls commands.
HTTP/HTTPS
[cache]
remote = "http://cache-server.example.com:8080/rsconstruct"
# or
remote = "https://cache-server.example.com/rsconstruct"
Requires:
curlcommand- Server that supports GET and PUT requests
The HTTP backend expects:
GET /pathto return the object or 404PUT /pathto store the objectHEAD /pathto check existence (returns 200 or 404)
Local Filesystem
[cache]
remote = "file:///shared/cache/rsconstruct"
Useful for:
- Network-mounted filesystems (NFS, CIFS)
- Testing remote cache behavior locally
Control Options
You can control push and pull separately:
[cache]
remote = "s3://my-bucket/rsconstruct-cache"
remote_push = true # Push local builds to remote (default: true)
remote_pull = true # Pull from remote on cache miss (default: true)
Pull-only mode
To share a read-only cache (e.g., from CI):
[cache]
remote = "s3://ci-cache/rsconstruct"
remote_push = false
remote_pull = true
Push-only mode
To populate a cache without using it (e.g., in CI):
[cache]
remote = "s3://ci-cache/rsconstruct"
remote_push = true
remote_pull = false
How It Works
Cache Structure
Remote cache stores two types of objects, mirroring the local layout:
-
Descriptors at
descriptors/{descriptor_key}- JSON describing what a product build produced: a marker (a check passed), a blob (one output), or a tree (a list of output paths and their content checksums)
- One entry per product build — see Cache keys
-
Objects at
objects/{xx}/{rest_of_checksum}- Content-addressed storage (like git)
- Actual file contents identified by SHA-256
On Build
-
RSConstruct computes the cache key and input checksum
-
Checks local cache first
-
If local miss and
remote_pull = true:- Fetches the descriptor from remote (and caches it locally)
- Fetches the objects the descriptor names
- Restores outputs locally
Fetched objects are verified against their checksum before being admitted to the local content-addressed store, and fetched descriptors must parse; a corrupt remote cannot poison the local cache. A remote miss, or an unreachable remote, simply degrades to a local rebuild.
-
If rebuild required:
- Executes the processor
- Stores outputs in local cache
- If
remote_push = true, pushes the objects and the descriptor to remote
Cache Hit Flow
Local cache hit → Restore from local → Done
↓ miss
Remote cache hit → Download descriptor + objects → Restore → Done
↓ miss
Execute processor → Cache locally → Push to remote → Done
Best Practices
CI/CD Integration
In your CI pipeline:
# .github/workflows/build.yml
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
steps:
- run: rsconstruct build
Separate CI and Developer Caches
Use different prefixes to avoid conflicts:
# CI: rsconstruct.toml.ci
[cache]
remote = "s3://cache/rsconstruct/ci"
remote_push = true
remote_pull = true
# Developers: rsconstruct.toml
[cache]
remote = "s3://cache/rsconstruct/ci"
remote_push = false # Read from CI cache only
remote_pull = true
Cache Invalidation
Cache entries are keyed by:
- Processor name
- Source file path
- Processor configuration hash
To force a full rebuild ignoring caches:
rsconstruct build --force
To clear only the local cache:
rsconstruct cache clear
Troubleshooting
S3 Access Denied
Check your AWS credentials:
aws s3 ls s3://your-bucket/
HTTP Upload Failures
Ensure your server accepts PUT requests. Many static file servers are read-only.
Slow Remote Cache
Consider:
- Using a closer region for S3
- Enabling S3 Transfer Acceleration
- Using a caching proxy
Debug Mode
Use verbose output to see cache operations:
rsconstruct build -v
This shows which products are restored from local cache, remote cache, or rebuilt.
Project Structure
RSConstruct follows a convention-over-configuration approach. The directory layout determines how files are processed.
Directory layout
project/
├── rsconstruct.toml # Configuration file
├── .rsconstructignore # Glob patterns for files to exclude
├── config/ # Python config files (loaded by templates)
├── tera.templates/ # .tera template files
├── templates.mako/ # .mako template files
├── src/ # C/C++ source files
├── out/
│ ├── cc_single_file/ # Compiled executables
│ ├── ruff/ # Ruff lint stub files
│ ├── pylint/ # Pylint lint stub files
│ ├── cppcheck/ # C/C++ lint stub files
│ ├── zspell/ # Zspell stub files
│ └── make/ # Make stub files
└── .rsconstruct/ # Cache directory
├── index.json # Cache index
├── objects/ # Cached build artifacts
└── deps/ # Dependency files
Conventions
Templates
Files in tera.templates/ with configured extensions (default .tera) are rendered to the project root:
tera.templates/Makefile.teraproducesMakefiletera.templates/config.toml.teraproducesconfig.toml
Similarly, files in templates.mako/ with .mako extensions are rendered via the Mako processor:
templates.mako/Makefile.makoproducesMakefiletemplates.mako/config.toml.makoproducesconfig.toml
C/C++ sources
Files in the source directory (default src/) are compiled to executables under out/processor.generator.cc_single_file/, preserving the directory structure:
src/main.cproducesout/processor.generator.cc_single_file/main.elfsrc/utils/helper.ccproducesout/processor.generator.cc_single_file/utils/helper.elf
Python files
Python files are linted by processor.checker.ruff or processor.checker.pylint. Checkers write no files: a passing check is recorded in the cache, so an unchanged file is not checked again.
Build artifacts
All build outputs go into out/. The cache lives in .rsconstruct/. Use rsconstruct clean to remove every declared output (preserving the cache) — directories left empty afterward are also pruned, so out/ typically disappears too. Use rsconstruct clean all to remove out/ and .rsconstruct/ outright. See rsconstruct clean for the full breakdown of clean behavior.
Dependency Analyzers
rsconstruct uses dependency analyzers to scan source files and discover dependencies between files. Analyzers run after processors discover products and add dependency information to the build graph.
How analyzers work
- Product discovery: Processors discover products (source → output mappings).
- Dependency analysis: Analyzers scan source files to find dependencies.
- Graph resolution: Dependencies are added to products for correct build ordering.
Analyzers are decoupled from processors — they operate on any product with matching source files, regardless of which processor created it.
Built-in analyzers
Per-analyzer reference pages:
- cpp — C/C++
#includescanning (invokesgcc/pkg-config) - icpp — C/C++
#includescanning, pure Rust (no subprocess) - python — Python
import/from ... importresolution - markdown — Markdown image and link references
- sass — Sass/SCSS
@use,@forwardand@importresolution - tera — Tera
{% include %},{% import %},{% extends %}references
Configuration
Analyzers are configured in rsconstruct.toml:
[analyzer]
auto_detect = true # default: true
enabled = ["cpp", "markdown", "python", "tera"] # instances to run
[analyzer.cpp]
include_paths = ["include", "src"]
Only analyzers listed under [analyzer.X] (or enabled) are instantiated — there is no global “all analyzers always run” mode.
Auto-detection
An analyzer runs if:
- It is declared (listed in
enabledor configured via[analyzer.X]). - AND either
auto_detect = false, OR the analyzer detects relevant files in the project.
This mirrors how processors work.
Caching
Analyzer results are cached in the dependency cache (.rsconstruct/deps.redb). On subsequent builds:
- If neither a source file nor any of its cached dependencies has changed, the cached dependencies are used.
- If the source or any dependency has changed, or a dependency is gone, the source is re-scanned. Checking the dependencies is what catches a header that gains a new
#include: the sources that include it are rescanned and pick up the new header. - If a file appears where resolution previously found nothing — a
src/x.hthat now shadows theinclude/x.han#include "x.h"used to resolve to, or a localfoo.pyfor animport foothat used to be external — the source is re-scanned. Analyzers record the paths they probed and found missing. - If the analyzer’s configuration changes (
include_paths,load_paths, pkg-config output, …), every source it scanned is re-scanned: an entry is only trusted under the configuration it was scanned with. - The
sassandteraanalyzers do not use the cached list at all: they rescan every source on every build. - The cache is shared across all analyzers.
Generated files
A reference to a file that another product generates is a dependency even before the file exists: analyzers resolve references against the project’s files plus every declared output. The product that references it is ordered after the product that generates it.
A source that is itself generated (or that includes a generated header) is analyzed again during the build, right after the product generating it ran. On a clean checkout that is the first time its content can be read; on an incremental build it replaces the analysis of the stale copy that was on disk when the graph was built. Either way the products reading it are ordered, checksummed and cached against what they will actually read, and the build after a clean build finds everything up to date.
The cpp analyzer asks the compiler (-MM), which only sees files on disk,
so it cannot resolve an include of a header that has not been generated
yet; use icpp for projects that generate headers. The icpp, markdown,
sass and tera analyzers all resolve generated files. The tera
functions that query a directory (glob, grep_count, shell_output’s
depends_on, workflow_names) see only the files on disk.
Use the analyzer command to manage analyzers and inspect the cache (full
reference in Commands):
rsconstruct analyzer list # list available analyzers
rsconstruct analyzer defconfig cpp # show default config for an analyzer
rsconstruct analyzer add cpp # append [analyzer.cpp] to rsconstruct.toml with comments
rsconstruct analyzer add cpp --dry-run # preview without writing
rsconstruct analyzer show all # show all cached dependencies
rsconstruct analyzer show files src/main.c # show dependencies for specific files
rsconstruct analyzer clean # clear the dependency cache
Build phases
With --phases, you can see when analyzers run:
rsconstruct --phases build
Output:
Phase: Building dependency graph...
Phase: discover
Phase: add_dependencies # Analyzers run here
Phase: apply_tool_version_hashes
Phase: resolve_dependencies
Use --stop-after add-dependencies to stop after dependency analysis:
rsconstruct build --stop-after add-dependencies
Adding a custom analyzer
Analyzers implement the DepAnalyzer trait:
#![allow(unused)]
fn main() {
pub trait DepAnalyzer: Sync + Send {
fn description(&self) -> &str;
fn auto_detect(&self, file_index: &FileIndex) -> bool;
fn match_product(&self, product: &Product) -> Option<PathBuf>;
fn scan(&self, ctx: &BuildContext, source: &Path, file_index: &FileIndex)
-> Result<ScanResult>;
fn fingerprint_parts(&self, ctx: &BuildContext) -> Result<Vec<String>>;
fn always_rescan(&self) -> bool { false }
}
}
An analyzer only answers two questions: which source a product’s
dependencies come from (match_product), and what one source depends on
(scan). Everything else — grouping products by source, the dependency
cache, adding inputs to the graph, re-analysis of generated sources — is
done by the framework (analyzers::Analysis), the same way for every
analyzer.
scan returns a ScanResult:
deps— the files the source depends on. Resolve againstfile_indexas well as the disk: it lists files other products will generate.absent— every path resolution probed and found missing before its answer. If one of them appears later, the cached result is dropped.hash_pieces— non-file state to mix into the cache key (glob results, command text). Only allowed withalways_rescan, since it is never cached.
fingerprint_parts returns everything besides file contents that decides
what scan resolves — normally the serialized config
(analyzers::config_fingerprint), plus any search paths resolved at run
time.
cpp
Scans C/C++ source files for #include directives and adds header file dependencies to the build graph.
Native: No (may invoke gcc, pkg-config).
Auto-detects: Projects with .c, .cc, .cpp, .cxx, .h, .hh, .hpp, or .hxx files.
Features
- Recursive header scanning (follows includes in header files)
- Queries compiler for system include paths (only tracks project-local headers)
- Handles both
#include "file"(relative to source) and#include <file>(searches include paths) - Supports native regex scanning and compiler-based scanning (
gcc -MM) - Uses the dependency cache for incremental builds
System header detection
The cpp analyzer queries the compiler for its include search paths using gcc -E -Wp,-v -xc /dev/null. This allows it to properly identify which headers are system headers vs project-local headers. Only headers within the project directory are tracked as dependencies.
Configuration
[analyzer.cpp]
include_scanner = "native" # or "compiler" for gcc -MM
include_paths = ["include", "src"]
pkg_config = ["gtk+-3.0", "libcurl"]
include_path_commands = ["gcc -print-file-name=plugin"]
src_exclude_dirs = ["/kernel/", "/vendor/"]
cc = "gcc"
cxx = "g++"
cflags = ["-I/usr/local/include"]
cxxflags = ["-std=c++17"]
include_path_commands
Shell commands whose stdout (trimmed) is added to the include search paths. Useful for compiler-specific include directories:
[analyzer.cpp]
include_path_commands = [
"gcc -print-file-name=plugin", # GCC plugin development headers
"llvm-config --includedir", # LLVM headers
]
pkg_config integration
Runs pkg-config --cflags-only-I for each package and adds the resulting include paths to the search path. Useful when your code includes headers from system libraries:
[analyzer.cpp]
pkg_config = ["gtk+-3.0", "glib-2.0"]
This automatically finds headers like <gtk/gtk.h> and <glib.h> without manually specifying their include paths.
Failures are errors
Both are resolved once, when the analyzer is set up, before any file is
scanned. Any failure stops the build with an error naming the analyzer and
the package or command: pkg-config missing, an unknown package, a command
that exits non-zero, prints nothing, or prints something that is not a
directory. (They used to be printed and skipped. The analyzer then ran
without those include directories, headers found through them dropped out
of the dependency lists, and editing one rebuilt nothing — with the build
still green.) A package that resolves to no -I flags at all is not an
error: its headers live in a default directory.
See also
- icpp — native (no-subprocess) C/C++ dependency analyzer
icpp
Native C/C++ dependency analyzer. Scans #include directives by parsing source files directly in Rust, without invoking gcc. It runs no subprocess unless pkg_config or include_path_commands is set; those run once, at setup, to find extra include directories.
Native: Yes.
Auto-detects: Projects with .c, .cc, .cpp, .cxx, .h, .hh, .hpp, or .hxx files.
When to use
- You want faster analysis without the overhead of launching
gccper file. - You don’t need compiler-driven include path discovery.
- You’re happy to enumerate include paths explicitly in
rsconstruct.toml.
Prefer cpp if you need the compiler’s own include resolution (macros, conditional includes, built-in system paths).
Configuration
[analyzer.icpp]
include_paths = ["include", "src"]
pkg_config = ["gtk+-3.0"]
include_path_commands = ["gcc -print-file-name=plugin"]
src_exclude_dirs = ["/kernel/", "/vendor/"]
follow_angle_brackets = false
skip_not_found = false
pkg_config and include_path_commands
The same as for cpp: directories from
pkg-config --cflags-only-I and from shell commands that each print one
directory are searched after include_paths. Any failure — pkg-config
missing, an unknown package, a command that fails, prints nothing, or
prints something that is not a directory — stops the build with an error
naming the package or command (see cpp).
follow_angle_brackets (default: false)
Controls whether #include <foo.h> directives are followed.
false(default) — angle-bracket includes are skipped entirely. System headers never enter the dependency graph, even when they resolve through configured include paths.true— angle-bracket includes are resolved and followed the same way as quoted includes. Unresolved angles are still tolerated (not an error), so missing system headers don’t break analysis.
Quoted includes (#include "foo.h") always resolve and must be found — this setting does not affect them (see skip_not_found below).
skip_not_found (default: false)
Controls what happens when an include cannot be resolved.
false(default) — a quoted include (#include "foo.h") that cannot be resolved is a hard error. Unresolved angle-bracket includes are silently ignored (whenfollow_angle_brackets = true).true— unresolved includes of any kind are silently skipped.
Use true for partial / work-in-progress codebases where some headers aren’t generated yet.
See also
- cpp — compiler-aware (external) C/C++ dependency analyzer
python
Scans Python source files for import and from ... import statements and adds dependencies on local Python modules.
Native: Yes.
Auto-detects: Projects with .py files.
Features
- Follows imports transitively: running
main.pyruns what its imports import, so a module two imports away is a dependency, and editing it rebuildsmain.py’s products. Import cycles are fine; a module is never its own dependency. - Resolves imports to local files and ignores stdlib and third-party
packages. A module name is looked up as
name.pyorname/__init__.pyunder each root in turn: the importing file’s directory, the project root, then each entry ofsearch_paths(defaultsrc, for thesrc/package layout). The first root that has it wins. - Depends on the
__init__.pyof every package above an imported module: importingapp.util.fsrunsapp/__init__.pyandapp/util/__init__.py. from pkg import namealso resolvespkg.name, so importing a submodule by name (from app.util import fs) depends onapp/util/fs.py. A name that is not a module resolves nowhere and adds nothing.- Parses
import a, b,import a.b as c,from x import (…)across lines, and imports indented inside functions ortryblocks. Comments are skipped. - Every path probed and found missing is recorded, so a local module that appears later, or one that would now win in an earlier root, triggers a rescan.
Relative imports (from . import x) are not resolved.
Configuration
[analyzer.python]
search_paths = ["src"] # extra roots for absolute imports, relative to the project root
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Set to false to keep the stanza but not run the analyzer |
search_paths | string[] | ["src"] | Directories that absolute imports resolve against, after the importing file’s directory and the project root. A directory that does not exist matches nothing. Changing it rescans every file |
markdown
Scans Markdown source files for image and link references (, [text](path)) and adds referenced local files as dependencies.
Native: Yes.
Auto-detects: Projects with .md files.
Features
- Extracts
image references and[text](path)link references - Resolves paths relative to the source file’s directory
- Skips URLs (
http://,https://,ftp://), data URIs, and anchor-only links - Strips title text and anchor fragments from paths
This ensures that when an image or linked file changes, any Markdown product that references it is rebuilt.
Configuration
[analyzer.markdown]
# currently no tunables
sass
Scans Sass and SCSS files for @use, @forward and @import rules and adds every local file they load as a dependency, following imports transitively. Declare it whenever a project compiles Sass with isass or sass: without it, a stylesheet’s only input is its own file, so editing a partial it @uses does not rebuild it and the old CSS stays in out/.
Native: Yes.
Auto-detects: Projects with .scss or .sass files.
Features
- Follows
@use "x",@forward "x"and@import "a", "b", in both SCSS and the indented syntax (where@import a, bmay be unquoted). - Resolves URLs the way the Sass compiler does: relative to the importing file, then each of
load_paths; a URL without an extension is tried as.scss,.sassand.css, each also as a partial (_name), then as a directory’sindex/_indexfile. - Follows imports transitively: a partial that
@uses another partial makes both inputs of every stylesheet that loads it. - Ignores what loads no project file: built-in modules (
@use "sass:math"),pkg:and remote URLs, and plain CSS imports (@import "x.css",@import url(...)). A@use "x.css"does load the file and is tracked. - Ignores rules inside
//and/* */comments. - Resolves a partial that another product generates, before it exists: the stylesheet is built after the partial’s producer (see Generated files).
Every source is rescanned on every build rather than read from the dependency cache. The cache is keyed by the importing file’s own contents, so it cannot see a @use added inside a partial; scanning is cheap enough that always rescanning costs nothing noticeable.
Configuration
[analyzer.sass]
load_paths = ["node_modules", "vendor/styles"]
skip_not_found = false
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Set to false to keep the stanza but skip the analyzer |
load_paths | string[] | [] | Directories searched after the importing file’s own directory, like the compiler’s --load-path |
skip_not_found | bool | false | Skip imports that resolve nowhere instead of failing |
skip_not_found (default: false)
-
false(default) — an import of a local file that resolves nowhere is a hard error naming the file, the URL and the directories searched:Sass import not found: "nope" in sass/style.scss (searched sass and load_paths []). Add its directory to load_paths, or set skip_not_found = true. -
true— such imports are skipped. Use it when the compiler finds files through a mechanism the analyzer does not model (a custom importer, say); the imports it skips are then not tracked.
See also
tera
Scans Tera template files for {% include %}, {% import %}, and {% extends %} directives and adds referenced template files as dependencies.
Native: Yes.
Auto-detects: Projects with .tera files.
Features
- Extracts paths from
{% include "path" %},{% import "path" %}, and{% extends "path" %} - Handles both double- and single-quoted paths
- Resolves paths relative to the source file’s directory and the project root
- Resolves a template or data file that another product generates, before it exists: the template is rendered after its producer (see Generated files)
This ensures that when an included template changes, any template that includes it is rebuilt.
Configuration
[analyzer.tera]
# currently no tunables
Processors
RSConstruct uses processors to discover and build products. Each processor scans for source files matching its conventions and produces output files.
Processor Types
There are four processor types: checker, generator, explicit, and mass generator. They differ in how inputs are discovered, how outputs are declared, and how results are cached.
See Processor Types for full descriptions, examples, and a comparison table.
Configuration
Declare processors by adding [processor.TYPE.NAME] sections to rsconstruct.toml:
[processor.checker.ruff]
[processor.checker.pylint]
args = ["--disable=C0114"]
[processor.generator.cc_single_file]
Only declared processors run — no processors are enabled by default. Use rsconstruct smart auto to auto-detect and add relevant processors.
Use rsconstruct processor list to see declared processors and descriptions.
Use rsconstruct processor list --all to show all built-in processors, not just those enabled in the project.
Use rsconstruct processor files to see which files each processor discovers.
Available Processors
- Tera — renders Tera templates into output files
- Ruff — lints Python files with ruff
- Pylint — lints Python files with pylint
- Mypy — type-checks Python files with mypy
- Pyrefly — type-checks Python files with pyrefly
- CC — builds full C/C++ projects from cc.yaml manifests
- CC Single File — compiles C/C++ source files into executables (single-file)
- Linux Module — builds Linux kernel modules from linux-module.yaml manifests
- Cppcheck — runs static analysis on C/C++ source files
- Clang-Tidy — runs clang-tidy static analysis on C/C++ source files
- Shellcheck — lints shell scripts using shellcheck
- Zspell — checks documentation files for spelling errors
- Rumdl — lints Markdown files with rumdl
- Oxlint — lints JavaScript/TypeScript files with oxlint
- Selene — lints Lua files with selene
- Iluacheck — lints Lua files with luacheck’s analysis (native)
- Biome — lints CSS/JavaScript/TypeScript/JSON files with biome
- Make — runs make in directories containing Makefiles
- Cargo — builds Rust projects using Cargo
- Yamllint — lints YAML files with yamllint
- Jq — validates JSON files with jq
- Jsonlint — lints JSON files with jsonlint
- Taplo — checks TOML files with taplo
- Terms — checks that technical terms are backtick-quoted in Markdown files
- Json Schema — validates JSON schema propertyOrdering
- Iyamlschema — validates YAML files against JSON schemas (native)
- Yaml2json — converts YAML files to JSON (native)
- Markdown2html — converts Markdown to HTML using markdown CLI
- Imarkdown2html — converts Markdown to HTML (native)
- Mass Generator — runs a tool that enumerates its outputs in advance, one cached product per predicted file
- Zola — builds a zola site, one cached product per file zola writes, planned natively
Clean behavior
rsconstruct clean outputs calls each product’s processor-defined clean(). What gets removed depends on the processor type:
| Processor type | What clean() removes | Recursive? |
|---|---|---|
| Checker | Nothing — checkers produce no outputs | — |
| Generator | Each declared output file (product.outputs) | No |
| Explicit | Declared output_files + declared output_dirs | Yes (for output_dirs only) |
| Mass generator | Each predicted output file (product.outputs) | No |
Recursive directory removal is reserved for the user-declared output_dirs of Explicit. All other processor types remove individually-declared files and nothing else. A mass generator’s output_dirs only bound where its predicted files may live; the directory itself is never removed directly, though the empty-directory sweep removes directories that become empty.
After every product’s clean() completes, the orchestrator runs an empty-directory sweep: every parent of every removed output (and every parent of every removed output_dir) is tried with fs::remove_dir (non-recursive — succeeds only on already-empty directories), walking upward until a non-empty directory or the project root. The sweep does not special-case out/ — any ancestor of a cleaned output is eligible.
See rsconstruct clean for the full command reference, the --no-empty-dirs flag, the -p filter, and the other clean variants (all, git, unknown).
Worked example
Consider this project:
# rsconstruct.toml
[processor.checker.ruff] # Checker — no outputs
src_dirs = ["src"]
[processor.generator.tera] # Generator — declared file outputs
src_dirs = ["templates"]
src_extensions = [".tera"]
[processor.mass_generator.mdbook] # Mass generator — planned file outputs
src_dirs = ["docs"]
Layout:
project/
├── rsconstruct.toml
├── src/main.py # ruff lints this
├── templates/index.html.tera # tera renders to → out/processor.generator.tera/index.html
├── docs/
│ ├── book.toml
│ └── src/SUMMARY.md
└── book/ # mdbook output_dir; each file is a planned product
├── index.html
├── css/general.css
└── ...
After rsconstruct build, both out/processor.generator.tera/index.html and the planned files under book/ exist.
Now run rsconstruct clean outputs:
- ruff — Checker.
clean()is a no-op. Nothing removed. - tera — Generator.
clean()callsfs::remove_file("out/processor.generator.tera/index.html"). The file goes;out/processor.generator.tera/andout/are not yet touched. - mdbook — Mass generator.
clean()removes each planned output file underbook/. - Empty-directory sweep. The orchestrator collects parents of every removed path (
out/processor.generator.tera,out,book/css,book) and removes those that are now empty, deepest-first.
Final state: src/main.py, templates/index.html.tera, docs/..., and rsconstruct.toml remain untouched. The cache (.rsconstruct/) is untouched. out/ and book/ are gone.
If you instead ran rsconstruct clean outputs --no-empty-dirs, the only difference is step 4 is skipped — out/processor.generator.tera/ and out/ would remain as empty directories.
If you ran rsconstruct clean outputs -p processor.generator.tera, only step 2 + the sweep of its parents runs. book/ is left intact because mdbook is filtered out.
Custom Processors
A command of your own runs through a config-driven processor: [processor.checker.script] for a check, [processor.generator.generic] for one output per input, [processor.explicit.generic] for declared inputs and outputs, and [processor.mass_generator.generic] for a tool that can predict its outputs. A new tool with its own discovery or output rules is a new processor file under src/processor/.
Processor Types
Every processor in RSConstruct has a type that determines how it discovers inputs, produces outputs, and interacts with the cache. There are four types.
Run rsconstruct processor types to list them. Its Batch column is the
type’s contract on taking more than one input in one tool invocation:
optional for checkers and generators, where each processor decides
(processor list shows which do), and always for explicit processors and
mass generators, which take every input in one invocation by construction.
--verbose adds the one-line contract behind each value.
Checker
A checker validates input files without producing any output. If the check passes, the result is cached — if the inputs haven’t changed on the next build, the check is skipped entirely.
How it works
- Scans for files matching
src_extensionsinsrc_dirs - Creates one product per input file
- Runs the tool on each file (or batch of files)
- If the tool exits successfully, records a marker in the cache
- On the next build, if inputs are unchanged, the check is skipped
What gets cached
A marker entry — no files, no blobs. The marker’s presence means “this check passed with these inputs.”
Examples
Lint Python files with ruff:
[processor.checker.ruff]
Scans for .py files, runs ruff check on each. No output files produced.
src/main.py → (checker)
src/utils.py → (checker)
Lint shell scripts:
[processor.checker.shellcheck]
Scans for .sh and .bash files, runs shellcheck on each.
Validate YAML files:
[processor.checker.yamllint]
Scans for .yml and .yaml files, runs yamllint on each.
Validate JSON files with jq:
[processor.checker.jq]
Scans for .json files, validates each with jq.
Spell check Markdown files:
[processor.checker.zspell]
Scans for .md files, checks spelling with the built-in zspell engine.
Built-in checkers
actionlint, ascii, aspell, biome, black, checkpatch, checkstyle, clang_tidy, clippy, cmake, cppcheck, cpplint, doctest, duplicate_files, encoding, eslint, hadolint, htmlhint, htmllint, iactionlint, icpplint, idockerfile, ijq, ijsonlint, iluacheck, isvglint, itaplo, itidy, ixmllint, iyamllint, iyamlschema, iyq, jq, jshint, jslint, json_schema, jsonlint, license_header, luacheck, make, markdownlint, marp_images, mdl, mypy, oxlint, perlcritic, php_lint, prettier, pylint, pyrefly, pytest, ruff, rumdl, script, selene, shellcheck, slidev, standard, stylelint, svglint, svgo, taplo, terms, tidy, xmllint, yamllint, yq, zspell
Generator
A generator transforms each input file into one or more output files. It creates one product per input file (or one per input x format pair for multi-format generators like pandoc).
How it works
- Scans for files matching
src_extensionsinsrc_dirs - For each input file, computes the output path from the input path, output directory, and format
- Creates one product per input x format pair
- Runs the tool to produce the output file
- Stores the output as a content-addressed blob in the cache
What gets cached
One blob per output file. The blob is the raw file content, stored by its SHA-256 hash. On restore, the blob is hardlinked (or copied) to the output path.
Examples
Render Tera templates:
[processor.generator.tera]
Scans tera.templates/ for .tera files, renders each template. The output path is the template path with the .tera extension stripped:
tera.templates/config.py.tera → config.py
tera.templates/README.md.tera → README.md
Convert Marp slides to PDF:
[processor.generator.marp]
Scans marp/ for .md files, converts each to PDF (and optionally other formats):
marp/slides.md → out/processor.generator.marp/slides.pdf
marp/intro.md → out/processor.generator.marp/intro.pdf
Convert documents with pandoc (multi-format):
[processor.generator.pandoc]
Scans pandoc/ for .md files, converts each to PDF, HTML, and DOCX. Each format is a separate product with its own cache entry:
pandoc/syllabus.md → out/processor.generator.pandoc/syllabus.pdf
pandoc/syllabus.md → out/processor.generator.pandoc/syllabus.html
pandoc/syllabus.md → out/processor.generator.pandoc/syllabus.docx
Compile single-file C programs:
[processor.generator.cc_single_file]
Scans src/ for .c and .cc files, compiles each into an executable:
src/main.c → out/processor.generator.cc_single_file/src/main.elf
src/test.c → out/processor.generator.cc_single_file/src/test.elf
Convert Mermaid diagrams:
[processor.generator.mermaid]
Scans for .mmd files, converts each to PNG (configurable formats):
diagrams/flow.mmd → out/processor.generator.mermaid/diagrams/flow.png
Compile SCSS to CSS:
[processor.generator.sass]
Scans sass/ for .scss and .sass files, compiles each to CSS:
sass/styles.scss → out/processor.generator.sass/styles.css
Built-in generators
a2x, cc_single_file, chromium, drawio, generic, ijinja2, imarkdown2html, ipdfunite, iprotobuf, isass, jinja2, libreoffice, mako, markdown2html, marp, mermaid, objdump, pandoc, pdflatex, pdfunite, protobuf, requirements, rust_single_file, sass, tags, tera, yaml2json
Explicit
An explicit processor aggregates many inputs into (possibly) many output files and/or directories. Unlike other types which create one product per discovered file, explicit creates a single product with all declared inputs and outputs.
How it works
- Inputs are listed explicitly via
inputsandinput_globsin the config - Creates a single product with all inputs and all outputs
- Runs the command, passing
--inputsand--outputson the command line - Stores each output file as a content-addressed blob
What gets cached
One blob per output file (like generator).
Examples
Build a static site from generated HTML:
[processor.explicit.generic.site]
command = "python3"
args = ["build_site.py"]
input_globs = ["out/processor.generator.pandoc/*.html", "templates/*.html"]
inputs = ["site.yaml"]
outputs = ["out/site/index.html", "out/site/style.css"]
Waits for pandoc to produce HTML files, then combines them with templates into a site. All inputs are aggregated into one product:
out/processor.generator.pandoc/page1.html, out/processor.generator.pandoc/page2.html, templates/base.html, site.yaml → out/site/index.html, out/site/style.css
Merge PDFs into a course bundle:
[processor.explicit.generic.course]
command = "pdfunite"
input_globs = ["out/processor.generator.pdflatex/*.pdf"]
outputs = ["out/course/full-course.pdf"]
Aggregates all PDF outputs from pdflatex into a single merged PDF.
Built-in explicit processors
generic
Mass Generator
A mass generator runs a tool that produces many files in one invocation and enumerates them in advance. Every planned file becomes a product of its own with its own inputs and blob cache entry, while the underlying build tool runs at most once per manifest per build: once for the whole instance when the tool has a single plan (generic), once per manifest file when the processor scans for them (one make per linux-module.yaml, one cargo build per Cargo.toml).
How it works
- At graph-build time, computes the output plan (natively or via a planner command)
- Creates one product per planned file:
inputs= the entry’ssources,outputs= itspath - Classifies each product individually (skip, restore, or build)
- The first product that needs building runs
command; the rest find the tool already run - Compares the files the tool wrote against the plan — a missing predicted file or an off-plan write fails the build. Files another product declares, as
output_filesor under itsoutput_dirs, are that product’s and are not reported even when it writes them during the tool’s run - Stores each predicted file as a content-addressed blob
What gets cached
One blob per predicted file (like generator). When every product is clean the tool does not run at all; when some are, the tool runs once and every dirty product caches its own file.
Examples
A static site generator with a plan mode:
[processor.mass_generator.generic.site]
command = "rssite"
args = ["build"]
predict_command = "rssite"
predict_args = ["plan"]
output_dirs = ["_site"]
rssite plan prints which _site/*.html files rssite build will write and which sources each depends on. Changing docs/about.md dirties only _site/about/index.html; a linter scanning _site/ depends on each page individually.
docs/index.md, templates/default.html → _site/index.html
docs/about.md, templates/default.html → _site/about/index.html
Build documentation with Sphinx:
[processor.mass_generator.sphinx]
dep_inputs = ["src/mypkg/**/*.py", "pyproject.toml", "uv.lock"]
Asks Sphinx itself which files sphinx-build -b html will write (a planner runs Sphinx’s read phase, nothing more), builds sphinx/ into docs/ through a staging directory, and caches every page, static file and index as a product of its own.
sphinx/*, src/mypkg/**/*.py → docs/index.html, docs/mypkg.html, docs/_static/basic.css, …
Built-in mass generators
cargo, cc, gem, generic, jekyll, linux_module, mdbook, npm, pip, sphinx, zola
Any tool that honors the manifest contract plugs into the config-driven generic mass generator via [processor.mass_generator.generic.NAME]; see Mass Generator.
Comparison
| Checker | Generator | Explicit | Mass Generator | |
|---|---|---|---|---|
| Purpose | Validate | Transform | Aggregate | Transform, many at once |
| Inputs | Scanned | Scanned | Declared in config | Declared by the tool’s plan |
| Products | One per input | One per input (x format) | One total | One per predicted file |
| Outputs | None | Derived from input path | Declared files | Predicted files |
| Cache type | Marker | Blob | Blob | Blob |
| Runs in | Project root | Project root | Project root | Project root / anchor dir |
| Command args | Input files | Input + output | --inputs + --outputs | User-defined args |
| Batching | Optional: a processor that supports it runs once over all its files | Optional: a processor that supports it runs once over the ready inputs | Always: one invocation, every input | Always: the tool runs once per manifest and writes every product of it |
Checkers
A checker validates its input files and produces no output. A passing check is recorded as a cache marker, so unchanged inputs are not checked again. See Processor Types for how checkers work.
The pages in this section document the built-in checkers. These checkers have no dedicated page yet: duplicate_files, encoding, ijq, ijsonlint, itaplo, iyamllint, license_header, marp_images, prettier, svglint, svgo.
Run rsconstruct processor list for the full list with each processor’s type.
Actionlint Processor
Purpose
Lints GitHub Actions workflow files using actionlint.
The Rust alternative is iactionlint: the same rules, the
same .github/actionlint.yaml, the same messages, in-process. Switching is
actionlint → iactionlint in the stanza header. It does not run
shellcheck or pyflakes over run: scripts; a repository that relies on
that stays here.
How It Works
Discovers .yml/.yaml files in the configured directories, runs
actionlint on each file, and records success in the cache. A non-zero exit
code from actionlint fails the product.
actionlint validates workflow syntax against the GitHub Actions schema,
type-checks ${{ }} expressions, checks runs-on labels and cron syntax,
flags untrusted-input injection in run: steps, and (when shellcheck is
installed) lints the shell scripts inside run: blocks.
actionlint reads its own configuration from .github/actionlint.yaml (or
.yml) in the repository — e.g. a self-hosted-runner.labels allowlist for
runner labels its built-in database doesn’t know. Both paths are tracked via
dep_auto, so editing the config retriggers the check.
This processor supports batch mode.
Source Files
- Input:
**/*.yml,**/*.yaml(pointsrc_dirsat.github/workflows) - Output: none (checker)
Configuration
[processor.checker.actionlint]
src_dirs = [".github/workflows"]
args = []
dep_inputs = []
| Key | Type | Default | Description |
|---|---|---|---|
src_dirs | string[] | [] | Directories to scan — set to [".github/workflows"] |
args | string[] | [] | Extra arguments passed to actionlint |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Scoping src_dirs to .github/workflows matters: actionlint treats every
file it is given as a workflow file, so pointing it at a directory of
ordinary YAML would report false errors.
Batch support
The tool accepts multiple files on the command line. When batching is enabled (default), rsconstruct passes all files in a single invocation for better performance.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Ascii Check Processor
Purpose
Validates that files contain only ASCII characters.
How It Works
Discovers .md files in the project and checks each for non-ASCII characters.
Files containing non-ASCII bytes fail the check. This is a built-in processor
that does not require any external tools.
This processor supports batch mode, allowing multiple files to be checked in a single invocation.
Source Files
- Input:
**/*.md - Output: none (checker)
Configuration
[processor.checker.ascii]
args = [] # Additional arguments (unused, for consistency)
dep_inputs = [] # Additional files that trigger rebuilds when changed
| Key | Type | Default | Description |
|---|---|---|---|
args | string[] | [] | Extra arguments (reserved for future use) |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
The tool accepts multiple files on the command line. When batching is enabled (default), rsconstruct passes all files in a single invocation for better performance.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Aspell Processor
Purpose
Checks spelling in Markdown files using aspell.
How It Works
Discovers .md files in the project and runs aspell on each file using the
configured aspell configuration file. A non-zero exit code fails the product.
Source Files
- Input:
**/*.md - Output: none (checker)
Configuration
[processor.checker.aspell]
command = "aspell" # The aspell command to run
conf = ".aspell.conf" # Aspell configuration file
args = [] # Additional arguments to pass to aspell
dep_inputs = [] # Additional files that trigger rebuilds when changed
| Key | Type | Default | Description |
|---|---|---|---|
command | string | "aspell" | The aspell executable to run |
conf | string | ".aspell.conf" | Aspell configuration file |
args | string[] | [] | Extra arguments passed to aspell |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
The tool accepts multiple files on the command line. When batching is enabled (default), rsconstruct passes all files in a single invocation for better performance.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Biome Processor
Purpose
Lints CSS, JavaScript, TypeScript and JSON files using biome.
It is the Rust alternative to the Stylelint processor, and a
second alternative, next to oxlint, to ESLint: a
single static binary that needs no node runtime and no node_modules/.
biome does not parse SCSS, Sass or Less. A repo with those stays on stylelint for them.
How It Works
Discovers .css, .js, .jsx, .ts, .tsx, .mjs, .cjs, .json and
.jsonc files in the project (excluding common build tool directories), runs
biome lint on each file, and records success in the cache. A non-zero exit
code from biome fails the product.
This processor supports batch mode, allowing multiple files to be checked in a single biome invocation for better performance.
If a biome config file exists (biome.json, biome.jsonc, .biome.json or
.biome.jsonc), it is
automatically added as an extra input so that configuration changes trigger
rebuilds.
Choosing what biome lints
biome has one linter switch per language in its config
(javascript.linter.enabled, json.linter.enabled, …). A repo that lints
JavaScript with oxlint turns biome’s JavaScript linter off in .biome.jsonc
and narrows this processor to stylesheets:
[processor.checker.biome]
src_dirs = ["src"]
src_extensions = [".css"]
Keep the two in step: biome exits non-zero when every file it was handed belongs to a language whose linter is off (“No files were processed”).
Migrating from stylelint
Rule names differ but the coverage is close. Of stylelint’s standard
correctness rules, three need no biome rule because biome’s CSS parser
rejects the construct outright (color-no-invalid-hex,
no-invalid-double-slash-comments, string-no-newline), one has no
equivalent (function-calc-no-unspaced-operator), and no-empty-source
differs in that biome’s noEmptySource flags a comment-only stylesheet
where stylelint does not. no-duplicate-selectors maps to
noDuplicateSelectors, still a nursery rule that has to be enabled
explicitly. biome explain <rule> shows any rule’s group and options.
Source Files
- Input:
**/*.css,**/*.js,**/*.jsx,**/*.ts,**/*.tsx,**/*.mjs,**/*.cjs,**/*.json,**/*.jsonc - Output: none (checker)
Configuration
[processor.checker.biome]
command = "biome"
args = []
dep_inputs = []
| Key | Type | Default | Description |
|---|---|---|---|
command | string | "biome" | The biome executable to run |
args | string[] | [] | Extra arguments passed after lint and before the file paths |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
dep_auto | string[] | biome.json, biome.jsonc, .biome.json, .biome.jsonc | Config files auto-detected as inputs |
Installation
rsconstruct tool install downloads the static Linux binary from the latest
biome GitHub release. The npm package is the fallback.
Batch support
The tool accepts multiple files on the command line. When batching is enabled (default), rsconstruct passes all files in a single invocation for better performance.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Black Processor
Purpose
Checks Python file formatting using Black, the uncompromising code formatter. Runs black --check which verifies files are already formatted without modifying them.
How It Works
Python files matching configured extensions are checked via black --check. The command exits with a non-zero status if any file would be reformatted, causing the build to fail.
Source Files
- Input:
**/*{src_extensions}(default:*.py)
Configuration
[processor.checker.black]
src_extensions = [".py"] # File extensions to check (default: [".py"])
dep_inputs = [] # Additional files that trigger rechecks when changed
args = [] # Extra arguments passed to black
| Key | Type | Default | Description |
|---|---|---|---|
src_extensions | string[] | [".py"] | File extensions to discover |
dep_inputs | string[] | [] | Extra files whose changes trigger rechecks |
dep_auto | string[] | ["pyproject.toml"] | Config files that auto-trigger rechecks |
args | string[] | [] | Additional arguments passed to black |
Batch support
The tool accepts multiple files on the command line. When batching is enabled (default), rsconstruct passes all files in a single invocation for better performance.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Checkpatch Processor
Purpose
Checks C source files using the Linux kernel’s checkpatch.pl script.
The Rust alternative is icheckpatch: checkpatch’s file-mode checks, in-process, with checkpatch’s report. Switching is checkpatch → icheckpatch in the stanza header.
How It Works
Discovers .c and .h files under src/ (excluding common C/C++ build
directories), runs checkpatch.pl on each file, and records success in the
cache. A non-zero exit code from checkpatch fails the product.
This processor supports batch mode.
Source Files
- Input:
src/**/*.c,src/**/*.h - Output: none (checker)
Configuration
[processor.checker.checkpatch]
args = []
dep_inputs = []
| Key | Type | Default | Description |
|---|---|---|---|
args | string[] | [] | Extra arguments passed to checkpatch.pl |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
The tool processes one file at a time. Each file is checked in a separate invocation.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Checkstyle Processor
Purpose
Checks Java code style using Checkstyle.
The Rust alternative is icheckstyle: checkstyle’s grammar and the checks of the fleet’s checkstyle.xml, in-process, with checkstyle’s messages and positions. Switching is checkstyle → icheckstyle in the stanza header, dropping args = ["-c", "checkstyle.xml"].
How It Works
Discovers .java files in the project (excluding common build tool directories),
runs checkstyle on each file, and records success in the cache. A non-zero exit
code from checkstyle fails the product.
This processor supports batch mode.
If a checkstyle.xml file exists, it is automatically added as an extra input so
that configuration changes trigger rebuilds.
Source Files
- Input:
**/*.java - Output: none (checker)
Configuration
[processor.checker.checkstyle]
args = []
dep_inputs = []
| Key | Type | Default | Description |
|---|---|---|---|
args | string[] | [] | Extra arguments passed to checkstyle |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
The tool accepts multiple files on the command line. When batching is enabled (default), rsconstruct passes all files in a single invocation for better performance.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Clang-Tidy Processor
Purpose
Runs clang-tidy static analysis on C/C++ source files.
How It Works
Discovers .c and .cc files under the configured source directory, runs
clang-tidy on each file individually, and creates a stub file on success. A
non-zero exit code from clang-tidy fails the product.
Note: This processor does not support batch mode. Each file is checked separately to avoid cross-file analysis issues with unrelated files.
Source Files
- Input:
{source_dir}/**/*.c,{source_dir}/**/*.cc - Output: none (checker — a passing check is recorded in the cache, no file is written)
Configuration
[processor.checker.clang_tidy]
args = ["-checks=*"] # Arguments passed to clang-tidy
compiler_args = ["-std=c++17"] # Arguments passed after -- to the compiler
dep_inputs = [".clang-tidy"] # Additional files that trigger rebuilds when changed
| Key | Type | Default | Description |
|---|---|---|---|
args | string[] | [] | Arguments passed to clang-tidy |
compiler_args | string[] | [] | Compiler arguments passed after -- separator |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
The tool processes one file at a time. Each file is checked in a separate invocation.
Compiler Arguments
Clang-tidy requires knowing compiler flags to properly parse the source files.
Use compiler_args to specify include paths, defines, and language standards:
[processor.checker.clang_tidy]
compiler_args = ["-std=c++17", "-I/usr/include/mylib", "-DDEBUG"]
Using .clang-tidy File
Clang-tidy automatically reads configuration from a .clang-tidy file in the
project root. Add it to dep_inputs so changes trigger rebuilds:
[processor.checker.clang_tidy]
dep_inputs = [".clang-tidy"]
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Clippy Processor
Purpose
Lints Rust projects using Cargo Clippy. Each Cargo.toml
produces a cached success marker, allowing RSConstruct to skip re-linting when source files haven’t changed.
How It Works
Discovers files named Cargo.toml in the project. For each Cargo.toml found,
the processor runs cargo clippy in that directory. A non-zero exit code fails the product.
Input Tracking
The clippy processor tracks all .rs and .toml files, plus Cargo.lock, in the Cargo.toml’s
directory tree as inputs. This includes:
Cargo.tomlandCargo.lock- All Rust source files (
src/**/*.rs) - Test files, examples, benches
- Workspace member Cargo.toml files
When any tracked file changes, rsconstruct will re-run clippy.
Source Files
- Input:
Cargo.tomlplus all.rsand.tomlfiles andCargo.lockin the project tree - Output: None (checker-style caching)
Configuration
[processor.checker.clippy]
cargo = "cargo" # Cargo binary to use
command = "clippy" # Cargo command (usually "clippy")
args = [] # Extra arguments passed to cargo clippy
src_dirs = [""] # Directory to scan ("" = project root)
src_extensions = ["Cargo.toml"]
dep_inputs = [] # Additional files that trigger rebuilds
| Key | Type | Default | Description |
|---|---|---|---|
cargo | string | "cargo" | Path or name of the cargo binary |
command | string | "clippy" | Cargo subcommand to run |
args | string[] | [] | Extra arguments passed to cargo clippy |
src_dirs | string[] | [""] | Directory to scan for Cargo.toml files |
src_extensions | string[] | ["Cargo.toml"] | File names to match |
src_exclude_dirs | string[] | ["/.git/", "/target/", ...] | Directory patterns to exclude |
src_exclude_paths | string[] | [] | Paths (relative to project root) to exclude |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
The tool processes one file at a time. Each file is checked in a separate invocation.
Examples
Basic Usage
[processor.checker.clippy]
Deny All Warnings
[processor.checker.clippy]
args = ["--", "-D", "warnings"]
Use Both Cargo Build and Clippy
[processor.mass_generator.cargo]
[processor.checker.clippy]
Notes
- Clippy uses the
cargobinary which is shared with the cargo processor - The
target/directory is automatically excluded from input scanning - For monorepos with multiple Rust projects, each Cargo.toml is linted separately
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
CMake Processor
Purpose
Lints CMake files using cmakelint.
How It Works
Discovers CMakeLists.txt files in the project (excluding common build tool
directories), runs cmakelint on each file, and records success in the cache.
A non-zero exit code from cmakelint fails the product.
This processor supports batch mode.
Source Files
- Input:
**/CMakeLists.txt - Output: none (checker)
Configuration
[processor.checker.cmake]
args = []
dep_inputs = []
| Key | Type | Default | Description |
|---|---|---|---|
args | string[] | [] | Extra arguments passed to cmakelint |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
The tool accepts multiple files on the command line. When batching is enabled (default), rsconstruct passes all files in a single invocation for better performance.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Cppcheck Processor
Purpose
Runs cppcheck static analysis on C/C++ source files.
How It Works
Discovers .c and .cc files under the configured source directory, runs
cppcheck on each file individually, and creates a stub file on success. A
non-zero exit code from cppcheck fails the product.
Note: This processor does not support batch mode. Each file is checked
separately because cppcheck performs cross-file analysis (CTU - Cross Translation
Unit) which produces false positives when unrelated files are checked together.
For example, standalone example programs that define classes with the same name
will trigger ctuOneDefinitionRuleViolation errors even though the files are
never linked together. Cppcheck has no flag to disable this cross-file analysis
(--max-ctu-depth=0 does not help), so files must be checked individually.
Source Files
- Input:
{source_dir}/**/*.c,{source_dir}/**/*.cc - Output: none (checker — a passing check is recorded in the cache, no file is written)
Configuration
[processor.checker.cppcheck]
args = ["--error-exitcode=1", "--enable=warning,style,performance,portability"]
dep_inputs = [".cppcheck-suppressions"] # Additional files that trigger rebuilds when changed
| Key | Type | Default | Description |
|---|---|---|---|
args | string[] | ["--error-exitcode=1", "--enable=warning,style,performance,portability"] | Arguments passed to cppcheck |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
To use a suppressions file, add "--suppressions-list=.cppcheck-suppressions" to args.
Batch support
The tool processes one file at a time. Each file is checked in a separate invocation.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Cpplint Processor
Purpose
Lints C/C++ files using cpplint (Google C++ style checker).
The Rust alternative is icpplint: cpplint 2.0.2’s checks, CPPLINT.cfg chain and NOLINT handling, in-process, with cpplint’s messages and exit rule. Switching is cpplint → icpplint in the stanza header, with args = ["--config=NAME"] becoming config_file = "NAME".
How It Works
Discovers .c, .cc, .h, and .hh files under src/ (excluding common
C/C++ build directories), runs cpplint on each file, and records success in
the cache. A non-zero exit code from cpplint fails the product.
This processor supports batch mode.
Source Files
- Input:
src/**/*.c,src/**/*.cc,src/**/*.h,src/**/*.hh - Output: none (checker)
Configuration
[processor.checker.cpplint]
args = []
dep_inputs = []
| Key | Type | Default | Description |
|---|---|---|---|
args | string[] | [] | Extra arguments passed to cpplint |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
The tool processes one file at a time. Each file is checked in a separate invocation.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Doctest Processor
Purpose
Runs Python doctests embedded in .py files using python3 -m doctest.
How It Works
Python files (.py) are checked for embedded doctests. Each file is run through
python3 -m doctest — failing doctests cause the build to fail.
Source Files
- Input:
**/*.py - Output: none (checker — pass/fail only)
Configuration
[processor.checker.doctest]
src_extensions = [".py"] # File extensions to process (default: [".py"])
dep_inputs = [] # Additional files that trigger rebuilds
| Key | Type | Default | Description |
|---|---|---|---|
src_extensions | string[] | [".py"] | File extensions to discover |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
The tool accepts multiple files on the command line. When batching is enabled (default), rsconstruct passes all files in a single invocation for better performance.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Duplicate Files Processor
Purpose
Finds files with identical content. Native (in-process, SHA-256; no external tools required).
How It Works
Unlike most checkers, this processor creates one product for the whole set of scanned files, because duplication is a property of the set, not of a single file. Every file is hashed with SHA-256, and the product fails if two or more files share a hash, listing each group:
src/__init__.py: [processor.checker.duplicate_files] 2 set(s) of duplicate files found:
src/__init__.py, src/empty.py
src/b.sh, src/c.sh
The error is reported under the first file of the product. Since any change to any scanned file changes the product’s inputs, the whole set is re-checked whenever one file changes.
All empty files have the same hash, so two empty files are duplicates (for example two empty __init__.py files in different packages). Exclude them with src_exclude_files, or narrow src_dirs.
Source Files
- Input: files under
src_dirswith one of the default extensions:.py .rs .js .ts .c .cc .h .hh .java .rb .go .sh .md .yaml .yml .json .toml .xml .html .css - Output: none (checker)
The default src_dirs is empty, so a stanza with no src_dirs or src_files checks nothing: name the directories ([""] for the whole project) or files to check.
Configuration
[processor.checker.duplicate_files]
src_dirs = ["src"]
src_exclude_files = ["__init__.py"]
| Key | Type | Default | Description |
|---|---|---|---|
src_dirs | string[] | [] | Directories to scan |
src_extensions | string[] | (list above) | File extensions to compare |
src_exclude_files | string[] | [] | File names to leave out of the comparison |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
Not applicable: all scanned files form a single product.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Encoding Processor
Purpose
Checks that text files are valid UTF-8 without a byte order mark. Native (in-process; no external tools required).
How It Works
Each file is read as bytes and rejected if it:
- starts with a UTF-8 BOM (
EF BB BF):file has UTF-8 BOM (byte order mark) - starts with a UTF-16 BOM (
FF FEorFE FF):file appears to be UTF-16 encoded - contains bytes that are not valid UTF-8:
invalid UTF-8 at byte 5 (line 1)
src/bom.py: [processor.checker.encoding] Encoding errors found:
src/bom.py: file has UTF-8 BOM (byte order mark)
Source Files
- Input: files under
src_dirswith one of the default text extensions:.py .rs .js .ts .c .cc .h .hh .java .rb .go .sh .bash .lua .pl .pm .php .md .yaml .yml .json .toml .xml .html .htm .css .scss .sass .tex .txt - Output: none (checker)
The default src_dirs is empty, so a stanza with no src_dirs or src_files checks nothing: name the directories ([""] for the whole project) or files to check.
Configuration
[processor.checker.encoding]
src_dirs = ["src", "docs"]
| Key | Type | Default | Description |
|---|---|---|---|
src_dirs | string[] | [] | Directories to scan |
src_extensions | string[] | (list above) | File extensions to check |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
Files are checked individually within a batch, so one bad file fails only its own product.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
ESLint Processor
Purpose
Lints JavaScript and TypeScript files using ESLint.
A Rust alternative is oxlint: a single static binary that reads an ESLint-style config and needs no node runtime. A second is biome, whose linter covers JavaScript and TypeScript too.
How It Works
Discovers .js, .jsx, .ts, .tsx, .mjs, and .cjs files in the project
(excluding common build tool directories), runs eslint on each file, and records
success in the cache. A non-zero exit code from eslint fails the product.
This processor supports batch mode, allowing multiple files to be checked in a single eslint invocation for better performance.
If an ESLint config file exists (.eslintrc* or eslint.config.*), it is
automatically added as an extra input so that configuration changes trigger rebuilds.
Source Files
- Input:
**/*.js,**/*.jsx,**/*.ts,**/*.tsx,**/*.mjs,**/*.cjs - Output: none (checker)
Configuration
[processor.checker.eslint]
command = "eslint"
args = []
dep_inputs = []
| Key | Type | Default | Description |
|---|---|---|---|
command | string | "eslint" | The eslint executable to run |
args | string[] | [] | Extra arguments passed to eslint |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
The tool accepts multiple files on the command line. When batching is enabled (default), rsconstruct passes all files in a single invocation for better performance.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Hadolint Processor
Purpose
Lints Dockerfiles using Hadolint.
The Rust alternative is idockerfile: hadolint’s DL rules, pragmas and .hadolint.yaml, in-process, with hadolint’s messages and exit rule. Switching is hadolint → idockerfile in the stanza header. It does not run ShellCheck, so a repo that wants the SC findings on its RUN scripts stays here.
How It Works
Discovers Dockerfile files in the project (excluding common build tool
directories), runs hadolint on each file, and records success in the cache.
A non-zero exit code from hadolint fails the product.
This processor supports batch mode.
Source Files
- Input:
**/Dockerfile - Output: none (checker)
Configuration
[processor.checker.hadolint]
args = []
dep_inputs = []
| Key | Type | Default | Description |
|---|---|---|---|
args | string[] | [] | Extra arguments passed to hadolint |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
The tool accepts multiple files on the command line. When batching is enabled (default), rsconstruct passes all files in a single invocation for better performance.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
HTMLHint Processor
Purpose
Lints HTML files using HTMLHint.
How It Works
Discovers .html and .htm files in the project (excluding common build tool
directories), runs htmlhint on each file, and records success in the cache.
A non-zero exit code from htmlhint fails the product.
This processor supports batch mode.
If a .htmlhintrc file exists, it is automatically added as an extra input so
that configuration changes trigger rebuilds.
Source Files
- Input:
**/*.html,**/*.htm - Output: none (checker)
Configuration
[processor.checker.htmlhint]
command = "htmlhint"
args = []
dep_inputs = []
| Key | Type | Default | Description |
|---|---|---|---|
command | string | "htmlhint" | The htmlhint executable to run |
args | string[] | [] | Extra arguments passed to htmlhint |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
The tool accepts multiple files on the command line. When batching is enabled (default), rsconstruct passes all files in a single invocation for better performance.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
HTMLLint Processor
Purpose
Lints HTML files using htmllint.
How It Works
Discovers .html and .htm files in the project (excluding common build tool
directories), runs htmllint on each file, and records success in the cache.
A non-zero exit code from htmllint fails the product.
This processor supports batch mode.
Source Files
- Input:
**/*.html,**/*.htm - Output: none (checker)
Configuration
[processor.checker.htmllint]
args = []
dep_inputs = []
| Key | Type | Default | Description |
|---|---|---|---|
args | string[] | [] | Extra arguments passed to htmllint |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
The tool accepts multiple files on the command line. When batching is enabled (default), rsconstruct passes all files in a single invocation for better performance.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Iactionlint Processor
Purpose
Lints GitHub Actions workflow files with actionlint’s rules. Native (in-process; no Go binary, no network). The Rust alternative to the actionlint processor: it reads the same .github/actionlint.yaml, runs the same checks, and reports the same problems at the same positions, in actionlint’s own words.
How It Works
Each workflow file is parsed into actionlint’s syntax tree and every rule actionlint runs is applied: the structure of the file (syntax-check), every ${{ }} expression parsed and type-checked with the contexts available where it sits (expression), on: events, activity types, filters and cron schedules (events, glob), runs-on labels (runner-label), needs graphs (job-needs), job and step IDs (id), permissions, shell, env, matrix combinations (matrix), credentials, deprecated workflow commands, constant if: conditions (if-cond), uses: of popular actions, local actions and reusable workflows (action, workflow-call). Problems are reported in actionlint’s format, one per line:
.github/workflows/ci.yml:4:14: label "ubuntu-lates" is unknown. available labels are ... [runner-label]
.github/workflows/ci.yml:6:25: property "foo" is not defined in object type {action: string; ...} [expression]
.github/workflows/ci.yml:3:3: "runs-on" section is missing in job "test" [syntax-check]
Any problem fails the file, as any problem makes actionlint exit 1.
What is not ported
actionlint can run shellcheck over run: scripts and pyflakes over Python steps when those tools are installed. Those two rules call external programs and are not part of this processor. A repository that wants its workflow scripts shell-checked keeps the actionlint processor, or checks the scripts another way.
Configuration file
actionlint’s configuration is found the way actionlint finds it: .github/actionlint.yaml, then .github/actionlint.yml, else none. config_file names a file explicitly instead. All of it is honoured: self-hosted-runner.labels (plain names or globs), config-variables (null disables the check, an empty list allows none), and paths with per-path ignore regular expressions matched against error messages.
Popular actions
actionlint knows the inputs and outputs of a few hundred widely used actions (actions/checkout@v4 and friends) and which old versions no longer run on GitHub’s runners. The same data set is embedded here, generated from actionlint’s popular_actions.go by scripts/gen-actionlint-popular-actions.py; re-run it when moving to a newer actionlint release. Local actions (uses: ./path) are read from action.yml in the project, as actionlint reads them; remote actions outside the data set are not fetched, by either tool.
Fidelity
The port follows actionlint v1.7.12 file by file (src/engines/actionlint/ mirrors parse.go, expr_*.go and rule_*.go). It was checked against actionlint’s own test corpus: every one of the 740 problems actionlint reports over its testdata/err, testdata/examples, testdata/ok and testdata/projects cases (local actions, reusable workflows, configuration files included) is reported at the same position with the same message, and nothing else is. Over the fleet’s 343 workflow files in 219 repositories both tools report nothing.
Source Files
- Input:
.ymland.yamlfiles undersrc_dirs - Output: none (checker)
The default src_dirs is empty, so a stanza with no src_dirs or src_files checks nothing: name .github/workflows.
The config files (.github/actionlint.yaml, .github/actionlint.yml) are dep_auto inputs: a change to one re-checks every file. A file named by config_file is not tracked automatically; add it to dep_inputs if it lives elsewhere. Local actions and reusable workflows a workflow refers to are read when the workflow is checked; list them in dep_inputs to re-check on their changes.
Configuration
[processor.checker.iactionlint]
src_dirs = [".github/workflows"]
| Key | Type | Default | Description |
|---|---|---|---|
config_file | string | "" | actionlint config file to read; empty means .github/actionlint.yaml, then .github/actionlint.yml, else no configuration |
src_dirs | string[] | [] | Directories to scan |
src_extensions | string[] | [".yml", ".yaml"] | File extensions to check |
dep_auto | string[] | [".github/actionlint.yaml", ".github/actionlint.yml"] | Config files whose changes re-check every file |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
Files are checked individually within a batch, so one bad file fails only its own product.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Icheckpatch Processor
Purpose
Checks C source files with the Linux kernel’s checkpatch.pl rules, in-process (no Perl script to download, no Perl). The Rust alternative to the checkpatch processor: a port of checkpatch.pl at kernel commit e5f92606156a, as checkpatch.pl --no-tree -f FILE runs it, so a file gets the same report, message for message, and passes or fails the way checkpatch would exit.
How It Works
Each file goes through what --no-tree -f does to it: the file becomes the new-file diff checkpatch reads (diff -u /dev/null FILE, including its handling of a missing final newline and of binary files), its lines are sanitised as checkpatch sanitises them (comments and the insides of strings blanked), and every check that file mode reaches runs in checkpatch’s order, with checkpatch’s patterns. Those patterns are Perl regular expressions built from each other ($Type, $Declare, $FuncArg, …) and rebuilt while a file is read, as checkpatch learns the file’s own type names: the engine carries a Perl-compatible matcher and interpolates the patterns the way Perl does, so they match what they match in Perl.
Any ERROR, WARNING or CHECK fails the product, with checkpatch’s report as the message:
ERROR: open brace '{' following function definitions go on the next line
#2: FILE: src/bad.c:2:
+int f(void) {
WARNING: space prohibited before semicolon
#3: FILE: src/bad.c:3:
+ return 0 ;
total: 3 errors, 2 warnings, 4 lines checked
As in checkpatch, CHECKs (its --strict messages) are reported only for files under net/, drivers/net/ and drivers/staging/, and long lines are a CHECK in file mode.
[processor.checker.icheckpatch]
src_dirs = ["src"]
Fidelity
Checked against checkpatch.pl (the pinned commit) file by file over the fleet’s 5666 C files, a 12005-file sample of the Linux 7.1.4 tree (C, headers, assembler, device trees, Kconfig, shell, MAINTAINERS, run from the tree’s root so the path-dependent checks apply) and 17117 mutated files: identical output, byte for byte, and the same verdict. That includes checkpatch dying: a macro argument that makes one of its interpolated patterns invalid kills checkpatch, and fails the file here. It also includes checkpatch’s slow corners: one mutant (static ##inline int f(void)) sends checkpatch’s patterns into exponential backtracking, and the port, matching the same patterns the same way, takes as long (that is, forever).
The engine’s unit tests (src/engines/checkpatch/tests.rs) keep it that way: they run checkpatch.pl itself (a missing one fails them) over the files in src/engines/checkpatch/testdata/ and deterministic mutants of them, check the pattern variables against the ones checkpatch builds, and check the matcher against Perl.
Faster than checkpatch.pl: about twice as fast on ordinary sources (the fleet in half the time), as fast on machine-generated register headers, where both spend their time trying type patterns at every identifier.
What is not ported, because --no-tree -f cannot reach it or the processor does not use it: patch and commit-message checks, --fix, the options (each file is checked with checkpatch’s defaults), .checkpatch.conf, spelling (spelling.txt is not installed with the script) and the checks that read a kernel tree. checkpatch’s CamelCase CHECK seeds its word list from a git checkout’s include/*.h; here it starts empty, as checkpatch does outside a git checkout. A file name the shell would split or reinterpret (a space, a quote, $, ;, …) makes checkpatch’s diff fail and the file pass unchecked; the port does the same.
Source Files
- Input:
.cand.hfiles undersrc_dirs, or the files insrc_files - Output: none (checker)
Configuration
No fields beyond the standard ones.
Switching a repo from checkpatch: [processor.checker.checkpatch] → [processor.checker.icheckpatch].
Batch support
Not used: each file is checked on its own, as checkpatch.pl --no-tree -f FILE checks it, in parallel as separate products. (checkpatch given several files carries state from one to the next, and stops the whole run at an empty one.)
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Icheckstyle Processor
Purpose
Checks Java code with Checkstyle’s checks, in-process (no checkstyle program, no Java runtime to install). The Rust alternative to the checkstyle processor: a port of checkstyle 8.36.1, so a file gets the same messages, at the same lines and columns, and passes or fails the way checkstyle would exit.
How It Works
Each file goes through what checkstyle does to it: its Java grammar (the ANTLR 2 lexer and parser checkstyle generates from java.g, with the same lookahead decisions, the same tree and the same parse errors), the comments and Javadoc it records on the way, and the checks the configuration names, walked over the tree as checkstyle’s TreeWalker walks it. Messages are printed as checkstyle prints them, in its order, and a message of severity error fails the product:
[ERROR] src/Bad.java:1: File does not end with a newline. [NewlineAtEndOfFile]
[ERROR] src/Bad.java:1:8: Unused import - java.util.List. [UnusedImports]
[ERROR] src/Bad.java:3:14: Should use uppercase 'L'. [UpperEll]
Messages of severity warning and info are printed and the file passes; ignore hides them. A file checkstyle cannot check (a syntax error, or one of the cases where checkstyle itself throws) fails with the exception checkstyle stops on:
src/Broken.java:2:13: unexpected token: {
The checks are configured as checkstyle is, by a checkstyle.xml:
[processor.checker.icheckstyle]
src_dirs = ["src"]
Implemented modules
Checker (with severity, tabWidth, fileExtensions, charset UTF-8), TreeWalker, NewlineAtEndOfFile, ArrayTypeStyle, CovariantEquals, DefaultComesLast, EmptyStatement, EqualsHashCode, FallThrough, ModifiedControlVariable, OneStatementPerLine, RedundantImport, SimplifyBooleanExpression, SimplifyBooleanReturn, StringLiteralEquality, UnusedImports and UpperEll, each with all its properties, severity, id, tabWidth and <message> overrides. These are the checks of the fleet’s shared checkstyle.xml. Any other module, or a property this port does not reproduce, is a configuration error that names it, so a configuration either runs as checkstyle runs it or does not run.
Fidelity
Checked against checkstyle 8.36.1 over 5169 Java files (the fleet’s 2559, checkstyle’s own sources and the 1700 Java inputs of its test suite, many of them deliberately odd syntax) and 6000 mutated copies (broken quotes, comments, braces and literals, deleted, duplicated and swapped lines), under the fleet’s configuration and two configurations that set every other property: the same messages, line for line, and for every file checkstyle stops on, the same exception (about 4400 of them, parse errors and checkstyle’s own exceptions alike).
It is also lighter: those 5169 files take under a second of CPU, against checkstyle’s 25 seconds (and its JVM).
The engine’s unit tests (src/engines/checkstyle/tests.rs) keep it that way: they run checkstyle 8.36.1 itself (a missing or different version fails them) and the engine over the files in src/engines/checkstyle/testdata/ and deterministic mutants of them, under three configurations, and require the same result for every file.
Known differences: checkstyle checks the files of a run in one process, and two checks carry a little state from one file to the next (the line of the last lambda in OneStatementPerLine); each file here is checked on its own. Messages are English only, ${property} references resolve only through a default, and files are read as UTF-8.
Source Files
- Input:
.javafiles undersrc_dirs, or the files insrc_files - Output: none (checker)
The default src_dirs is empty, so a stanza with no src_dirs or src_files checks nothing: name the directories ([""] for the whole project) or files to check.
Configuration
| Key | Type | Default | Description |
|---|---|---|---|
config_file | string | "checkstyle.xml" | The checkstyle configuration, as checkstyle -c; relative to the project root |
dep_auto | string[] | ["checkstyle.xml"] | Config files that are inputs when they exist |
A config_file other than checkstyle.xml should also be listed in dep_inputs, so that changing it rebuilds.
Switching a repo from checkstyle: [processor.checker.checkstyle] → [processor.checker.icheckstyle], and args = ["-c", "checkstyle.xml"] is dropped (or becomes config_file for another file name).
Batch support
Not used: checking in-process has no start-up cost for a batch to share, so files are checked in parallel as separate products.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Icpplint Processor
Purpose
Lints C and C++ files with cpplint’s rules, in-process (no cpplint program, no Python). The Rust alternative to the cpplint processor: a port of cpplint 2.0.2, so a file gets the same findings with the same messages, categories and confidence levels, and passes or fails the way cpplint would exit.
How It Works
Each file goes through what cpplint does to it: the four line views (raw, raw strings blanked, comments removed, strings collapsed), the nesting stack of namespaces, classes, extern "C" blocks, constructors and preprocessor branches, the include-order and header-guard state, NOLINT/NOLINTNEXTLINE/NOLINTBEGIN/NOLINTEND suppressions, and every check cpplint has, from legal/copyright to whitespace/todo (the sixty-six categories cpplint --filter= lists). Findings are printed in cpplint’s own format and any finding fails the file:
src/main.cc:0: No copyright message found. You should have a line: "Copyright [year] <Copyright Owner>" [legal/copyright] [5]
src/main.cc:1: Missing space before { [whitespace/braces] [5]
src/main.cc:2: Tab found; better to use spaces [whitespace/tab] [1]
src/main.cc:2: Using C-style cast. Use static_cast<int>(...) instead [readability/casting] [4]
Header guards are derived the way cpplint derives them: from the path relative to the repository root (the nearest directory with .git, .hg or .svn, or repository), adjusted by root. The file name cpplint sees is the path relative to the project root, which is what cpplint src/foo.cc run from the project root would see, so filters with a file part (-whitespace:src/foo.cc:14) use that path.
Configuration
cpplint’s per-directory configuration files work as they do in cpplint: CPPLINT.cfg (or the file config_file names) is read from the file’s directory upwards until one says set noparent, with filter, exclude_files, linelength, root, headers, extensions and includeorder. Every such file between a source file and the project root is an input of that file’s product, so editing one re-checks the files under it; there is no need to list them in dep_inputs. The TOML fields are cpplint’s command-line options:
[processor.checker.icpplint]
src_dirs = ["src"]
config_file = ".cpplint"
filters = ["-whitespace/tab", "-build/include_order"]
line_length = 100
A file an exclude_files pattern matches is skipped, as cpplint skips it. A configuration file with an option cpplint does not know, a non-numeric linelength, a filter that does not start with + or -, or a file whose extension is not one cpplint accepts fails the build; cpplint prints a message and goes on, which hides the mistake.
Fidelity
Checked against cpplint 2.0.2 over its own sample corpus (samples/*/*.def: seven projects under twelve option sets, including CPPLINT.cfg, --filter, --includeorder=standardcfirst, --headers, --recursive --exclude), the 1245 snippets its unit tests lint, demos-os-linux’s 1231 C/C++ files with and without the repo’s .cpplint, and 3000 mutated copies of those files (tabs, casts, missing spaces, using namespace, VLAs, sprintf, NOLINT variants, header-guard and include edits, CRLF line endings, no final newline): the same findings, file for file. Two things cpplint does are deliberately not reproduced: its “Unexpected \r (^M)” warning, which cpplint itself can never emit because Python’s text mode translates line endings before it looks; and the way a linelength, root, headers or extensions setting read from one directory’s CPPLINT.cfg stays in force for every file cpplint processes afterwards in the same run (here every file starts from the configured options).
Source Files
- Input:
.c,.cc,.cpp,.cxx,.c++,.cu,.h,.hh,.hpp,.hxx,.h++,.cuhfiles undersrc_dirs, or the files insrc_files - Output: none (checker)
The default src_dirs is empty, so a stanza with no src_dirs or src_files checks nothing: name the directories ([""] for the whole project) or files to check.
Configuration
| Key | Type | Default | Description |
|---|---|---|---|
filters | string[] | [] | Category filters as --filter: -whitespace, +whitespace/braces, -runtime/printf:foo.cc:14 (an entry may hold several, comma-separated) |
verbose | integer | 1 | Report only findings with at least this confidence (0-5), as --verbose |
line_length | integer | 80 | The allowed line length, as --linelength |
root | string | "" | The directory header guards are derived from, as --root. Empty: unset |
repository | string | "" | The repository top directory, as --repository. Empty: the nearest .git, .hg or .svn |
headers | string[] | [] | Extensions (without the dot) treated as headers, as --headers |
extensions | string[] | [] | Extensions (without the dot) cpplint accepts, as --extensions |
include_order | string | "default" | default or standardcfirst, as --includeorder |
config_file | string | "CPPLINT.cfg" | The name of the per-directory configuration file, as --config |
src_dirs | string[] | [] | Directories to scan |
src_extensions | string[] | the twelve above | File name suffixes to check |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
dep_auto | string[] | [] | Config files that are inputs when they exist (the config_file chain is tracked already) |
Switching a repo from cpplint: [processor.checker.cpplint] → [processor.checker.icpplint], and args = ["--config=.cpplint"] becomes config_file = ".cpplint". Any other --option=value in args becomes the field of the same name.
Batch support
Files are checked individually within a batch, so one bad file fails only its own product.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Idockerfile Processor
Purpose
Lints Dockerfiles with hadolint’s rules, in-process (no hadolint binary). The Rust alternative to the hadolint processor: a port of hadolint 2.15.1’s Dockerfile parser, its # hadolint ignore= pragmas, its .hadolint.yaml configuration and all of its DL rules, so a Dockerfile gets the same findings, with the same messages, codes and severities, and passes or fails the way hadolint would exit.
What is not here is ShellCheck. hadolint runs ShellCheck over every RUN script and reports its SC codes alongside its own; ShellCheck is a separate program with no Rust counterpart, so idockerfile reports DL findings only. A repo that wants the SC findings keeps the hadolint processor.
How It Works
Each Dockerfile is parsed the way hadolint’s parser (language-docker) reads it: instructions and their flags, escaped line breaks, heredocs, the # escape= and # syntax= pragmas, exec-form JSON arrays. RUN scripts are read as hadolint reads them for its rules: which commands run, with which arguments and flags, including commands inside $(...), if, loops and subshells, and whether anything is piped. The 71 DL rules then run over the result, from DL3000 (absolute WORKDIR) through the package-manager rules (apt-get, apk, pip, npm, gem, yum, dnf, zypper, go, yarn) to DL4006 (pipefail). Findings are printed in hadolint’s own format:
Dockerfile:1 DL3006 warning: Always tag the version of an image explicitly
Dockerfile:4 DL3008 warning: Pin versions in apt get install. Instead of `apt-get install <package>` use `apt-get install <package>=<version>`
Dockerfile:4 DL3015 info: Avoid additional packages by specifying `--no-install-recommends`
Dockerfile:7 DL3059 info: Multiple consecutive `RUN` instructions. Consider consolidation.
A file fails when any finding is at least as severe as the failure threshold (info by default, like hadolint: error and warning and info fail, style does not). A file whose only findings are below the threshold passes, and nothing is printed for it. A Dockerfile hadolint’s parser cannot read fails with the position of the problem.
Configuration
idockerfile reads .hadolint.yaml (or .hadolint.yml) from the project root when one exists, exactly as hadolint run from there would, with the keys hadolint’s checks use: ignored, override (error, warning, info, style lists), trustedRegistries, label-schema, strict-labels, disable-ignore-pragma, failure-threshold, no-fail. config_file names a different file. The TOML fields below add to or override what the file says. Inline pragmas work as in hadolint: # hadolint ignore=DL3008,DL3015 for the next instruction, # hadolint stage ignore=... for the rest of the stage, # hadolint global ignore=... for the file.
[processor.checker.idockerfile]
src_files = ["Dockerfile", "docker/api/Dockerfile"]
ignored = ["DL3059"]
failure_threshold = "warning"
Fidelity
Checked against hadolint 2.15.1 over the fleet’s 154 Dockerfiles, with and without the fleet’s .hadolint.yaml, and over 2000 mutated copies (unpinned installs, missing -y, sudo, cd, pipes, untagged and latest images, bad COPY --from references, duplicate stage names, ONBUILD wrappers, pragmas, heredocs, lower-cased keywords, exec-form commands): the same DL findings, file for file. Two things had to be copied from hadolint rather than reasoned out: its tree walk yields every shell command twice (once for ShellCheck’s redirection wrapper, once for the command), which is what makes DL3059 fire only for single-command RUNs; and a RUN script ShellCheck cannot parse (an unterminated quote, a redirection without a target) contributes no commands at all. One known divergence: hadolint 2.15.1 rejects a heredoc followed directly by the next instruction (a bug in its parser release, fixed upstream since); idockerfile accepts it.
Source Files
- Input: files named
Dockerfileundersrc_dirs, or the files insrc_files - Output: none (checker)
The default src_dirs is empty, so a stanza with no src_dirs or src_files checks nothing: name the directories ([""] for the whole project) or files to check.
Configuration
| Key | Type | Default | Description |
|---|---|---|---|
config_file | string | "" | A hadolint configuration file (YAML). Empty: .hadolint.yaml or .hadolint.yml in the project root when present |
ignored | string[] | [] | Rule codes to ignore, added to the configuration file’s ignored |
trusted_registries | string[] | [] | Registries FROM images may come from (DL3026); * wildcards allowed |
label_schema | table | {} | Labels that must be present, name = type (text, url, semver, hash, rfc3339, spdx, email) |
strict_labels | bool | false | Forbid labels outside label_schema (DL3050) |
disable_ignore_pragma | bool | false | Ignore # hadolint ignore=... pragmas in the files |
failure_threshold | string | "" | Fail only on findings at least this severe: error, warning, info, style, none. Empty: the configuration file’s, else info |
src_dirs | string[] | [] | Directories to scan |
src_extensions | string[] | ["Dockerfile"] | File name suffixes to check |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
dep_auto | string[] | [".hadolint.yaml", ".hadolint.yml"] | Config files that are inputs when they exist |
Switching a repo from hadolint: [processor.checker.hadolint] → [processor.checker.idockerfile]; the .hadolint.yaml stays as it is. A repo whose findings include SC codes it wants to keep stays on hadolint.
Batch support
Files are checked individually within a batch, so one bad file fails only its own product.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Ijq Processor
Purpose
Checks that JSON files parse. Native (in-process, using serde_json; no external tools required). It is the in-process counterpart of jq, but it only parses: no jq filter is evaluated.
How It Works
Each file is read and parsed as JSON. A file that does not parse fails its product with an Invalid JSON: message giving the position.
The check is identical to ijsonlint; use whichever name reads better in your config. Duplicate keys are accepted (the last value wins).
Source Files
- Input:
.jsonfiles undersrc_dirs - Output: none (checker)
The default src_dirs is empty, so a stanza with no src_dirs or src_files checks nothing: name the directories ([""] for the whole project) or files to check.
Configuration
[processor.checker.ijq]
src_dirs = ["data"]
| Key | Type | Default | Description |
|---|---|---|---|
src_dirs | string[] | [] | Directories to scan |
src_extensions | string[] | [".json"] | File extensions to check |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
Files are checked individually within a batch, so one bad file fails only its own product.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Ijsonlint Processor
Purpose
Checks that JSON files parse. Native (in-process, using serde_json; no external tools required). A Rust alternative to jsonlint.
How It Works
Each file is read and parsed as JSON. A file that does not parse fails its product with the parser’s message and position:
data/bad.json: [processor.checker.ijsonlint] Invalid JSON:
data/bad.json: trailing comma at line 1 column 9
This is a well-formedness check only. There are no style rules, and duplicate keys are accepted (the last value wins).
Source Files
- Input:
.jsonfiles undersrc_dirs - Output: none (checker)
The default src_dirs is empty, so a stanza with no src_dirs or src_files checks nothing: name the directories ([""] for the whole project) or files to check.
Configuration
[processor.checker.ijsonlint]
src_dirs = [""]
| Key | Type | Default | Description |
|---|---|---|---|
src_dirs | string[] | [] | Directories to scan |
src_extensions | string[] | [".json"] | File extensions to check |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
Files are checked individually within a batch, so one bad file fails only its own product.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Iluacheck Processor
Purpose
Lints Lua files with luacheck’s analysis, in-process (no luacheck program, no Lua runtime to install). The Rust alternative to the luacheck processor: a port of luacheck 1.2.0, so a set of files gets the same findings with the same codes and messages, and passes or fails the way luacheck would exit.
How It Works
Each file goes through what luacheck does to it: its lexer and parser (Lua 5.1 through 5.4 and LuaJIT syntax: goto and labels, integer division and bitwise operators, <const>/<close> attributes, compound assignments, cdata literals), the linearized flow graph of every function, the resolution of which assignments reach which accesses (closures included), and every check luacheck has: globals and their fields traced through local t = table, unused and overwritten variables, values and arguments, shadowing, unreachable code, empty blocks, unbalanced assignments, duplicate table keys, cyclomatic complexity, whitespace and line length. -- luacheck: inline comments (ignore, globals, push/pop, …) apply as in luacheck, including their errors. Findings are printed as luacheck --formatter plain --codes prints them, and any finding fails the build:
config/project.lua:1:7: (W211) unused variable 'x'
config/project.lua:2:1: (W111) setting non-standard global variable 'Y'
config/project.lua:4:1: (E011) expected 'end' (to close 'if' on line 1) near 'local' (indentation-based guess)
All the files of a stanza are checked together, as one luacheck run checks the files given to it: with allow_defined or allow_defined_top, a global set in one file is defined for all the others, and an implicitly defined global no file reads is reported as unused (W131). The processor therefore makes a single product of all its files, rechecked as a whole when any of them changes.
Configuration
.luacheckrc is found and run as luacheck runs it: looked up from the project root upwards, executed as Lua (in the embedded interpreter) in an environment that falls back to the standard globals, with files["glob"] per-path overrides, custom stds, and a returned table merged in. luacheck’s default per-path stds apply (busted for *_spec.lua under spec/, test/ or tests/, rockspec, luacheckrc, ldoc). exclude_files and include_files filter the checked files as in luacheck. config_file names a different file; empty means no configuration file (--no-config).
The TOML fields are luacheck’s command-line options and override the configuration file the same way:
[processor.checker.iluacheck]
src_dirs = ["config"]
std = "lua54"
globals = ["vim"]
ignore = ["212/self"]
max_line_length = 100
Fidelity
Checked against luacheck 1.2.0 (the apt package, which runs on Lua 5.1) over the 7101 Lua files on a development machine (Neovim plugins, nmap’s NSE libraries, TeX Live, lua-language-server, ardour scripts), luacheck’s own sources and samples, the fleet’s Lua files, and 3000 mutated copies (broken tokens and escapes, missing ends, labels and goto, varargs, attributes, compound operators, duplicate keys, inline options valid and invalid), under default options, ten command-line option sets and three .luacheckrc configurations: the same findings, line for line (about 2.4 million compared). Configuration errors give luacheck’s messages. One difference is inherent: a runtime error raised inside .luacheckrc is worded by the interpreter running it, Lua 5.4 here and Lua 5.1 in luacheck (attempt to index a nil value (local 't') instead of attempt to index local 't' (a nil value)).
Not supported: the global configuration file (~/.config/luacheck/.luacheckrc), checking rockspecs as file lists, the cache, and luacheck’s output formatters other than plain with codes.
Source Files
- Input:
.luafiles undersrc_dirs, or the files insrc_files - Output: none (checker)
The default src_dirs is empty, so a stanza with no src_dirs or src_files checks nothing: name the directories ([""] for the whole project) or files to check.
Configuration
| Key | Type | Default | Description |
|---|---|---|---|
config_file | string | ".luacheckrc" | The configuration file, as --config; empty for --no-config |
std | string | "" | Standard globals, as --std (max, lua51, lua54+busted, …); empty: unset |
globals | string[] | unset | Custom globals or fields, as --globals |
read_globals | string[] | unset | Read-only globals or fields, as --read-globals |
new_globals | string[] | unset | Globals replacing those set before, as --new-globals |
new_read_globals | string[] | unset | Read-only globals replacing those set before, as --new-read-globals |
not_globals | string[] | unset | Globals or fields to remove, as --not-globals |
ignore | string[] | unset | Patterns of warnings to filter out (code, name, or code/name), as --ignore |
enable | string[] | unset | Patterns of warnings not to filter out, as --enable |
only | string[] | unset | Report only warnings matching these patterns, as --only |
operators | string[] | unset | Compound operators to allow, as --operators |
allow_defined | bool | false | --allow-defined |
allow_defined_top | bool | false | --allow-defined-top |
module | bool | false | --module |
no_global, no_unused, no_redefined, no_unused_args, no_unused_secondaries, no_self | bool | false | -g, -u, -r, -a, -s, --no-self |
max_line_length, max_code_line_length, max_string_line_length, max_comment_line_length | integer | unset | The --max-...-line-length limits; 0 for --no-max-...-line-length |
max_cyclomatic_complexity | integer | unset | --max-cyclomatic-complexity; 0 for no limit |
dep_auto | string[] | [".luacheckrc"] | Config files that are inputs when they exist |
Switching a repo from luacheck: [processor.checker.luacheck] → [processor.checker.iluacheck]; the .luacheckrc stays as it is, and options in args become the fields of the same name (args = ["--globals", "vim", "--"] becomes globals = ["vim"]).
Batch support
Not applicable: the processor’s one product covers all its files.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Iperlcritic Processor
Purpose
Checks Perl code with Perl::Critic’s policies, in-process (no perlcritic program, no Perl to install). The Rust alternative to the perlcritic processor: a port of Perl::Critic 1.156 and of PPI 1.284, the Perl parser under it, so a file gets the same violations, at the same lines and columns, and passes or fails the way perlcritic would exit.
How It Works
Each file goes through what Perl::Critic does to it: PPI’s tokenizer and lexer (with the same guesses PPI makes where Perl’s grammar needs a runtime to decide — regex or division, hash or block, prototype or signature — the same tree, and the same parse errors), the ## no critic annotations, and the policies the profile enables, each over the elements it applies to. Violations are printed in the profile’s verbose format, sorted by position as perlcritic sorts them, and any violation fails the product:
src/bad.pl:1:1: [TestingAndDebugging::RequireUseStrict] Code before strictures are enabled
src/bad.pl:3:1: [InputOutput::ProhibitTwoArgOpen] Two-argument "open" used
src/bad.pl:4:9: [Subroutines::ProhibitExplicitReturnUndef] "return" statement with explicit "undef"
A file PPI cannot parse fails with perlcritic’s message:
Problem while critiquing "src/broken.pl": Can't parse code: Encountered unexpected character '195'
The policies are configured as Perl::Critic’s are, by a profile (.perlcriticrc):
[processor.checker.iperlcritic]
src_dirs = [""]
Implemented policies
The 22 policies of severity 5 (gentle), which are what Perl::Critic’s default profile and the fleet’s shared .perlcriticrc enable: BuiltinFunctions::ProhibitSleepViaSelect, ProhibitStringyEval, RequireGlobFunction, ClassHierarchies::ProhibitOneArgBless, ControlStructures::ProhibitMutatingListFunctions, InputOutput::ProhibitBarewordDirHandles, ProhibitBarewordFileHandles, ProhibitInteractiveTest, ProhibitTwoArgOpen, RequireEncodingWithUTF8Layer, Modules::ProhibitEvilModules, RequireBarewordIncludes, RequireFilenameMatchesPackage, Subroutines::ProhibitExplicitReturnUndef, ProhibitNestedSubs, ProhibitReturnSort, ProhibitSubroutinePrototypes, TestingAndDebugging::ProhibitNoStrict, RequireUseStrict, ValuesAndExpressions::ProhibitLeadingZeros, Variables::ProhibitConditionalDeclarations and RequireLexicalLoopIterators, each with all its parameters, plus severity and maximum_violations_per_document.
The profile’s global options severity, verbose (a number or a format, every % field except %d), force, only, include, exclude, top and program-extensions work as in perlcritic, as do [Policy] and [-Policy] sections. A profile that enables a policy this port does not implement (say severity = 4, or [CodeLayout::ProhibitHardTabs] raised to severity = 5) is an error naming it, as are theme and single-policy, so a profile either runs as perlcritic runs it or does not run. Without a profile file, Perl::Critic’s defaults apply (severity 5); perlcritic’s fallback to ~/.perlcriticrc is not taken, so the build does not depend on the home directory.
Fidelity
Checked against Perl::Critic 1.156 on PPI 1.284 over the fleet’s 1313 Perl files, the 3950 modules of a Debian Perl installation (/usr/share/perl5 and the architecture-specific tree), 207 hand-written edge cases (heredocs, formats, __END__/__DATA__, POD, prototypes and signatures under every way of enabling them, #line directives, byte order marks, CR and CRLF line ends, every policy’s exemptions) and 12300 mutated copies: the same PPI tree for every file (every element, every token’s content and location) and the same report, violation for violation. That includes PPI’s failures: a character it cannot tokenize, and the cases where a use statement’s version or arguments make version.pm or PPI’s own feature detection die.
The engine’s unit tests (src/engines/perlcritic/tests.rs) keep it that way: they run PPI and Perl::Critic themselves (a missing or different version fails them) and the engine over the files in src/engines/perlcritic/testdata/ and deterministic mutants of them, comparing trees and reports under five profiles.
Known differences: when a policy dies on a file (a few malformed argument lists make Perl::Critic call a method on a list), perlcritic stops the whole run with Perl’s message, which names a line of its own source; here only that file fails, with the same first words. Where PPI’s feature detection dies on a string argument that names a Perl package already loaded in perlcritic’s process (use feature 'strict', (...)), Perl’s message lacks the “perhaps you forgot to load” hint that the port always gives. The default verbosity (4) prints as verbosity 5, with the file name, as perlcritic does when it is given several files, which a build always does.
Source Files
- Input:
.pland.pmfiles undersrc_dirs, or the files insrc_files - Output: none (checker)
The default src_dirs is empty, so a stanza with no src_dirs or src_files checks nothing: name the directories ([""] for the whole project) or files to check.
Configuration
| Key | Type | Default | Description |
|---|---|---|---|
profile | string | ".perlcriticrc" | The Perl::Critic profile, as perlcritic --profile; relative to the project root. A missing file means Perl::Critic’s defaults |
dep_auto | string[] | [".perlcriticrc"] | Config files that are inputs when they exist |
A profile other than .perlcriticrc should also be listed in dep_inputs, so that changing it rebuilds.
Switching a repo from perlcritic: [processor.checker.perlcritic] → [processor.checker.iperlcritic]; the fleet’s .perlcriticrc needs no change.
Batch support
Not used: checking in-process has no start-up cost for a batch to share, so files are checked in parallel as separate products.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Ishellcheck Processor
Purpose
Lints shell scripts with ShellCheck’s analysis, in-process (no shellcheck program, no Haskell runtime to install). The Rust alternative to the shellcheck processor: a port of ShellCheck 0.11.0, so a script gets the same findings with the same codes, positions and messages, and passes or fails the way shellcheck would exit.
How It Works
Each file goes through what ShellCheck does to it: its parser (sh, bash, dash, ksh and busybox dialects, here-documents, arithmetic, [[ ]] conditions, the parse errors and their notes), the control flow graph and its dataflow analysis (which assignments reach which uses, through functions and subshells), and every check of ShellCheck’s Analytics, Commands and ShellSupport modules, optional checks included. The dialect comes from shell, a # shellcheck shell= directive, the shebang or the file extension, in ShellCheck’s order. # shellcheck directives (disable, enable, source, source-path, external-sources, extended-analysis) and .shellcheckrc files (looked up from the script’s directory upwards, then in ~/.shellcheckrc and the XDG config directory) apply as in ShellCheck. Findings are printed as shellcheck --format=gcc prints them, and any finding fails the product:
src/bad.sh:2:1: warning: foo appears unused. Verify use (or export if used externally). [SC2034]
src/bad.sh:3:6: note: Double quote to prevent globbing and word splitting. [SC2086]
Each file is its own product, checked as shellcheck FILE checks it: sourced files are read only with external_sources, and their findings are reported only with check_sourced.
[processor.checker.ishellcheck]
src_dirs = ["scripts"]
exclude = ["SC1091"]
severity = "warning"
Fidelity
Checked against shellcheck 0.11.0 over 4494 real scripts (1850 from the fleet, 2644 from a development machine’s /usr/bin and friends), the 2194 snippets of ShellCheck’s own unit tests, and 28000 mutated copies (broken quotes, brackets and keywords, swapped operators, deleted and duplicated lines): the same findings, line for line (about 360,000 compared), under default options, each shell override, external_sources and check_sourced. Neither side crashes on any of them.
It is also faster and smaller than shellcheck: 2781 real scripts (the fleet’s and a development machine’s) take 2.3 seconds against shellcheck’s 15.7 on 16 threads (18 against 137 seconds of CPU), and the largest script, a 3500-line dkms, takes 1.0 second and 400 MB against 6.4 seconds and 1.9 GB.
The engine’s unit tests (src/engines/shellcheck/tests.rs) keep it that way: they run shellcheck 0.11.0 itself (a missing or different version fails them) and the engine over the scripts in src/engines/shellcheck/testdata/ and deterministic mutants of them, under each option, and require the same output line for line. The processor’s tests check that each TOML field matches the shellcheck option of the same name.
Not supported: output formats other than gcc, auto-fixes (--format=diff), and the --wiki-link-count/--color presentation options.
Source Files
- Input:
.shand.bashfiles undersrc_dirs, or the files insrc_files - Output: none (checker)
The default src_dirs is empty, so a stanza with no src_dirs or src_files checks nothing: name the directories ([""] for the whole project) or files to check.
Configuration
| Key | Type | Default | Description |
|---|---|---|---|
shell | string | "" | The dialect, as --shell (sh, bash, dash, ksh, busybox); empty: from directives, the shebang or the extension |
external_sources | bool | false | Follow sourced files that are not inputs, as --external-sources |
source_path | string[] | [] | Where to look for sourced files, as --source-path (SCRIPTDIR is the script’s directory) |
severity | string | "style" | The minimum severity reported, as --severity: error, warning, info, style |
exclude | string[] | [] | Codes to exclude, as --exclude (SC2034 or 2034) |
include | string[] | [] | Codes to report exclusively, as --include; empty: all codes |
enable | string[] | [] | Optional checks to enable, as --enable (all for every one) |
rcfile | string | "" | A .shellcheckrc to use instead of searching for one, as --rcfile |
norc | bool | false | Ignore .shellcheckrc files, as --norc |
check_sourced | bool | false | Report findings in sourced files too, as --check-sourced |
extended_analysis | string | "" | Dataflow analysis, as --extended-analysis: true, false, or empty for the default (directives decide) |
dep_auto | string[] | [".shellcheckrc"] | Config files that are inputs when they exist |
Switching a repo from shellcheck: [processor.checker.shellcheck] → [processor.checker.ishellcheck]; a .shellcheckrc stays as it is, and options in args become the fields of the same name (args = ["-e", "SC1091", "-S", "warning"] becomes exclude = ["SC1091"], severity = "warning").
Batch support
Not used: checking in-process has no start-up cost for a batch to share, so files are checked in parallel as separate products.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Isvglint Processor
Purpose
Lints SVG files with svglint’s rules, in-process (no Node, no svglint binary). The Rust alternative to the svglint processor: a port of svglint 4.2.1’s three built-in rules and of the machinery they run on, so a file gets the same findings, with the same messages, as svglint would give it under the same rules.
How It Works
Each file is parsed the way svglint parses it (htmlparser2 in XML mode, leniently: an unclosed tag closes at the end of the file, a repeated attribute keeps its first value, entities stay as written), then every configured rule runs over the result:
valid(on by default) checks that the file is well-formed XML, with fast-xml-parser’s validator and its messages.elmchecks which elements appear, by CSS selector: present, absent, an exact count or a range.attrchecks the attributes of the elements a CSS selector picks: required, forbidden, exact values, one of several values, a regular expression, attribute order and a whitelist.
svglint’s custom rules are JavaScript functions and have no counterpart. Findings are printed one per line, the rule’s name as svglint shows it (valid, elm, attr-1 for the first attr table, …), with the element and its position when there is one:
img/icon.svg:1:1: attr-1: Wrong ordering of attributes, found "xmlns, viewBox, role", expected "role, viewBox, xmlns" (<svg>)
img/icon.svg:3:3: attr-2: Expected attribute 'fill' to be one of ["red","blue"], was "green" (<path>)
img/icon.svg:4:3: elm: Element disallowed (<rect>)
img/icon.svg: elm: Found 0 elements for 'svg > title', expected 1
img/icon.svg: valid: Attribute 'fill' is repeated.
Any finding fails the file, as svglint’s exit status does. Positions are 1-based line and column of the element’s start tag (svglint’s own output prints them 0-based and miscounts lines after a leading newline; the elements are the same).
Rules in TOML
svglint reads its rules from a JavaScript file; isvglint takes the same rules as TOML in the stanza:
[processor.checker.isvglint]
src_dirs = ["icons"]
[processor.checker.isvglint.elm]
"svg" = 1 # exactly one
"svg > title" = true # at least one
"svg > path" = [1, 3] # between one and three
"*" = false # nothing else (an element another rule allows is fine)
[[processor.checker.isvglint.attr]]
"rule::selector" = "svg"
"rule::whitelist" = true # no attributes beyond those named here
"rule::order" = true # alphabetical; or a list giving the order
xmlns = "http://www.w3.org/2000/svg"
role = "img"
viewBox = { regex = "^0 0 \\d+ \\d+$" }
"width?" = true # optional: checked when present
[[processor.checker.isvglint.attr]]
"rule::selector" = "svg > path"
d = { regex = "^m[-mzlhvcsqtae\\d,. ]+$", flags = "i" }
fill = ["red", "blue"]
onclick = false
An attr value is true (must exist), false (must not), a string (that exact value), an array of strings (one of them) or { regex = "...", flags = "..." } (a JavaScript-style regular expression; flags take i, m, s). A key ending in ? is optional. Selectors follow css-select’s XML-mode semantics: case-sensitive names, #id, .class, all attribute operators including != and the i flag, the four combinators, selector lists, :not, :is, :has (with a leading combinator), the -child and -of-type families with An+B, :empty, :contains, :icontains. A selector using anything else (pseudo-elements, :root, :scope, namespaces) is a configuration error, as is a rule value of the wrong shape; svglint would warn and go on.
With no elm or attr configured, only valid runs, exactly like svglint without a .svglintrc.js.
Fidelity
Checked against svglint 4.2.1 over the fleet’s 5776 tracked SVG files and 3000 mutated copies of them (dropped and renamed closing tags, duplicate, unquoted, valueless and boolean attributes, stray ampersands, extra roots, truncation, misplaced XML declarations, CDATA and comment edge cases, byte order marks, control characters), under the default rules and two rule sets exercising every selector feature and attribute check above: the same findings, file for file. The lenient parser is a step-for-step port of htmlparser2 3.10.1, including how it chains one node’s start position from the previous node’s end; the validator is a port of fast-xml-parser 5.10.1, including that a self-closing root is never “multiple roots” and that an XML declaration after a tag is read as an instruction named ?xml and passes.
Source Files
- Input:
.svgfiles undersrc_dirs - Output: none (checker)
The default src_dirs is empty, so a stanza with no src_dirs or src_files checks nothing: name the directories ([""] for the whole project) or files to check.
Configuration
| Key | Type | Default | Description |
|---|---|---|---|
valid | bool | true | The valid rule: every file must be well-formed XML |
elm | table | {} | The elm rule: CSS selector = true, false, a count or [min, max] |
attr | array of tables | [] | The attr rule: one table per "rule::selector", naming the attributes and their values |
src_dirs | string[] | [] | Directories to scan |
src_extensions | string[] | [".svg"] | File extensions to check |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Switching a repo from svglint: [processor.checker.svglint] → [processor.checker.isvglint]; the rules of .svglintrc.js become the elm table and attr tables above (a /regex/i literal is { regex = "regex", flags = "i" }); custom rules have to stay with svglint.
Batch support
Files are checked individually within a batch, so one bad file fails only its own product.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Itaplo Processor
Purpose
Checks that TOML files parse. Native (in-process, using the toml crate; no external tools required). An in-process alternative to taplo.
How It Works
Each file is read and parsed as TOML. A file that does not parse fails its product with the parser’s message and a pointer to the position:
bad.toml: [processor.checker.itaplo] Invalid TOML:
bad.toml: TOML parse error at line 1, column 5
|
1 | a =
| ^
string values must be quoted, expected literal string
Only syntax is checked. Unlike taplo check, there is no schema validation and no .taplo.toml is read.
Source Files
- Input:
.tomlfiles undersrc_dirs - Output: none (checker)
The default src_dirs is empty, so a stanza with no src_dirs or src_files checks nothing: name the directories ([""] for the whole project) or files to check.
Configuration
[processor.checker.itaplo]
src_dirs = [""]
| Key | Type | Default | Description |
|---|---|---|---|
src_dirs | string[] | [] | Directories to scan |
src_extensions | string[] | [".toml"] | File extensions to check |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
Files are checked individually within a batch, so one bad file fails only its own product.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Itidy Processor
Purpose
Checks HTML files the way tidy -errors does: structure, missing and misplaced tags, unknown elements, attributes and attribute values, entities, and the repairs tidy would make. Native (in-process; no tidy binary). The Rust alternative to the tidy processor: a port of HTML Tidy 5.8.0’s lexer, parser, attribute checks and clean-up passes, so it reports what tidy reports, at the same line and column, with the same warning and error totals. The pretty-printer is not ported: itidy never writes HTML.
How It Works
Each file is tokenized and parsed with tidy’s own algorithm, element by element, inferring the start and end tags the markup leaves out, moving misplaced content where tidy moves it and reporting every repair. The attribute checks (which attributes an element allows in the document’s HTML version, their values, id and name uniqueness, URIs) and the clean-up passes that follow parsing (style elements in the body, the meta charset, the doctype, HTML5-removed elements, proprietary elements and attributes) run as well. Problems are reported in tidy’s format, with the file in front:
docs/page.html: line 3 column 7 - Error: <foo> is not recognized!
docs/page.html: line 3 column 7 - Warning: discarding unexpected <foo>
docs/page.html: line 12 column 5 - Warning: <img> lacks "alt" attribute
docs/page.html: line 20 column 1 - Warning: trimming empty <p>
docs/page.html: line 31 column 17 - Warning: <div> proprietary attribute "ng-model"
The verdict is tidy’s: a file with warnings or errors fails (tidy exits 1 or 2), a clean file passes. The totals follow tidy’s counting, including the fact that tidy stops printing reports after show-errors (6) errors while still counting them. Only the report lines are printed; tidy’s trailing dialogue (“Tidy found 2 warnings and 1 error!”, “This document has errors that must be fixed…”) carries nothing a build needs and is left out, as are the Info: lines -q hides.
Options
tidy’s options drive the checks exactly as they drive tidy. config_file names a tidy configuration file (name: value lines, # comments, continuation lines), read like tidy -config FILE. The file is an input of every product, so editing it re-checks every file; there is no need to list it in dep_inputs. options lists further settings as "name: value" strings, applied after the file in order, like --name value arguments:
[processor.checker.itidy]
src_dirs = ["docs"]
options = ["custom-tags: blocklevel", "drop-empty-elements: no"]
Every option of tidy 5.8.0 is accepted and its value validated, so an existing tidy configuration parses unchanged. Options that only shape tidy’s pretty-printed output (indent, wrap, output-file, …) are ignored. A few are refused by name because itidy does not reproduce their effect: clean, gdoc, bare, word-2000 and logical-emphasis set to yes, input-xml: yes, accessibility-check above 0, mute, and any input or output encoding other than utf8. An unknown option is a build error, where tidy prints a notice and carries on.
Input is UTF-8 (a UTF-8 byte order mark is accepted; a UTF-16 one is an error naming the file). Tabs count as tab-size (8) columns, as in tidy, so column numbers agree.
Fidelity
Checked against tidy -errors -q 5.8.0 over the fleet’s 599 tracked HTML files and 6000 mutated copies of them (dropped and swapped tags, unquoted and duplicated attributes, bad entities, truncated files, control characters, invalid UTF-8, deprecated elements, misplaced list items and table cells): identical report lines, in the same order, for every file, under the default options and under the fleet’s option sets (custom-tags, drop-empty-elements, new-blocklevel-tags, drop-empty-paras, warn-proprietary-attributes). The quirks this required are documented in the source: tidy treats an empty attribute value as absent, does not count an ESC character as a column, and reads a byte past the end of an emptied text node to decide whether it was blank.
Source Files
- Input:
.htmland.htmfiles undersrc_dirs - Output: none (checker)
The default src_dirs is empty, so a stanza with no src_dirs or src_files checks nothing: name the directories ([""] for the whole project) or files to check.
Configuration
[processor.checker.itidy]
src_dirs = ["docs"]
config_file = "support/tidy.conf"
options = ["warn-proprietary-attributes: no"]
| Key | Type | Default | Description |
|---|---|---|---|
config_file | string | "" | A tidy configuration file read like tidy -config; relative to the project root. Empty: none |
options | string[] | [] | Further tidy options as "name: value" strings, applied after the config file, like --name value arguments |
src_dirs | string[] | [] | Directories to scan |
src_extensions | string[] | [".html", ".htm"] | File extensions to check |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds (config_file is tracked already) |
Switching a repo from tidy: [processor.checker.tidy] → [processor.checker.itidy]; args = ["-config", "x.conf"] → config_file = "x.conf"; args = ["--name", "value"] → options = ["name: value"]; -errors, -q and -utf8 are the behaviour already and are dropped.
Batch support
Files are checked individually within a batch, so one bad file fails only its own product.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Ixmllint Processor
Purpose
Checks that XML files are well-formed and, with schema, that they validate against an XSD schema. Native (in-process; no libxml2, no external tools). The Rust alternative to the xmllint processor: it reports what xmllint --noout and xmllint --noout --schema x.xsd report, at the same line, with the same verdict.
How It Works
Each file is decoded as its XML declaration says (UTF-8 by default; UTF-16, ISO-8859-1 and US-ASCII are understood, another declared encoding is an error), tokenized, and checked for everything xmllint checks: tag matching, duplicate attributes, entity and character references against the internal DTD subset, namespace declarations and prefixes, and the document’s shape. Problems are reported in xmllint’s format, one per line:
test.xml:3: parser error : Opening and ending tag mismatch: b line 2 and a
test.xml:6: parser error : Attribute b redefined
test.xml:1: namespace error : Namespace prefix x on b is not defined
test.xml:7: Schemas validity error : Element '{urn:keynote}slidex': This element is not expected. Expected is ( {urn:keynote}slide ).
The verdict is xmllint’s. A parser error (the file is not XML) or a Schemas validity error fails the file. A namespace error, namespace warning or parser warning is printed and the file passes, because xmllint exits 0 on those; strict = true makes them fail too.
Where xmllint reports a problem is reproduced as well: a duplicate attribute, an undeclared prefix or a schema violation is reported on the line where the element’s start tag ends (libxml2 checks the tag once it is complete and stamps the node with that line), an unfinished document on its last line, and only when nothing else went wrong first.
Schema validation
schema names an XSD file, relative to the project root, like --schema. Every file must validate against it; the schema is read once per build. The schema file is an input of every product, so a change to it re-checks every file; there is no need to list it in dep_inputs.
The validator implements the schema constructs in use: global and local elements, named and anonymous complex types with sequence, choice and all content models (with minOccurs/maxOccurs), any, mixed and simple content, attributes with use and fixed, anyAttribute, simple types restricted by the standard facets (enumeration, pattern, lengths, bounds, digits, whiteSpace), list and union, extension of simple and complex types, include, and the built-in types from xs:string to xs:dateTime. A schema using anything else (group, attributeGroup, import, redefine, key/unique, substitution groups, abstract, complex-type restriction, xsi:type in a document) is a build failure naming the construct and its line: a schema that is only half enforced would pass files xmllint rejects. Keep such a file on the xmllint processor, or extend src/engines/xmllint/xsd.rs.
Messages follow xmllint’s wording (This element is not expected. Expected is ( ... )., The attribute 'a' is required but missing., 'x' is not a valid value of the atomic type 'xs:int'., [facet 'enumeration'] The value 'v' is not an element of the set {'a', 'b'}.).
Fidelity
Checked against xmllint (libxml2 2.15) over the fleet’s 6256 tracked XML and SVG files plus 58 hand-made edge cases: every file gets the same verdict, every problem is reported at the same line with the same class, and the messages read the same. Schema validation was checked over the one fleet schema (java-keynote) with its presentations and eight mutations of them: identical reports, line for line.
Two differences remain. After a lexical error (a bad comment, an invalid character), xmllint keeps parsing and may report further problems; ixmllint stops at that error, which is already fatal. And xmllint’s parser does not load external DTDs, nor does ixmllint, so neither validates against a DTD; --valid, --dtdvalid and --relaxng have no counterpart here.
Source Files
- Input:
.xmland.svgfiles undersrc_dirs - Output: none (checker)
The default src_dirs is empty, so a stanza with no src_dirs or src_files checks nothing: name the directories ([""] for the whole project) or files to check.
Configuration
[processor.checker.ixmllint]
src_dirs = ["src"]
[processor.checker.ixmllint.presentations]
src_dirs = ["presentations"]
schema = "xsd/keynote.xsd"
| Key | Type | Default | Description |
|---|---|---|---|
schema | string | "" | XSD schema to validate every file against, like xmllint --schema; relative to the project root. Empty: well-formedness only |
strict | bool | false | Fail on namespace errors and parser warnings too (xmllint exits 0 on them) |
src_dirs | string[] | [] | Directories to scan |
src_extensions | string[] | [".xml", ".svg"] | File extensions to check |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds (schema is tracked already) |
Batch support
Files are checked individually within a batch, so one bad file fails only its own product.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Iyamllint Processor
Purpose
Lints YAML files with yamllint’s rules. Native (in-process; no Python, no external tools). The Rust alternative to the yamllint processor: it reads the same .yamllint.yaml, runs the same 23 rules with the same options, and reports the same problems at the same positions.
How It Works
Each file is tokenized (with libyaml, the scanner design yamllint’s PyYAML implements too), then every enabled rule runs over its tokens, comments and lines, exactly as in yamllint’s linter.py. Problems are reported in yamllint’s parsable format, one per line:
test.yaml:2:11: [error] trailing spaces (trailing-spaces)
test.yaml:4:7: [error] wrong indentation: expected 4 but found 6 (indentation)
test.yaml:1:1: [warning] missing document start "---" (document-start)
A problem at the error level fails the file. A warning is printed and the file passes, as a plain yamllint run passes; strict = true makes warnings fail too, like yamllint --strict.
Configuration file
The yamllint configuration is found the way yamllint finds it: .yamllint, .yamllint.yaml or .yamllint.yml in the project root, in that order, else yamllint’s built-in default configuration. config_file names a file explicitly instead. The user-level ~/.config/yamllint/config that yamllint also consults is not read: a build must not depend on the machine it runs on.
Everything in yamllint’s configuration format is supported, with two exceptions:
extends(default,relaxed, or a path relative to the project root),ruleswithenable/disable/settings,level, every rule option,ignoreandignore-from-fileat the top level and per rule: all honoured, with yamllint’s own validation errors for an unknown rule, an unknown option or a wrong value. An invalid config fails the build.yaml-filesis accepted and ignored: rsconstruct decides which files the processor scans, throughsrc_dirs,src_filesandsrc_extensions.localeis rejected. It changes howkey-orderingcollates; this processor compares code points, which is what yamllint does when no locale is set.
Rules
All 23 rules: anchors, braces, brackets, colons, commas, comments, comments-indentation, document-end, document-start, empty-lines, empty-values, float-values, hyphens, indentation, key-duplicates, key-ordering, line-length, new-line-at-end-of-file, new-lines, octal-values, quoted-strings, trailing-spaces, truthy. See yamllint’s rule documentation for their options.
The # yamllint disable, # yamllint enable, # yamllint disable-line and # yamllint disable-file directives work as in yamllint.
Syntax errors are reported like yamllint’s, at the position the parser gives up. The wording differs: it is libyaml’s (did not find expected key) rather than PyYAML’s (expected <block end>, but found '?'). One input the two parsers disagree on: inside a flow collection, a colon directly followed by , ] } ? [ or { ({a: 1, b:, c: 3}) is a syntax error for libyaml (found unexpected ':'), while PyYAML reads an empty value. Write b: , or b: null.
Fidelity
The port follows yamllint’s source file by file (src/engines/yamllint/ mirrors yamllint/parser.py, config.py, linter.py and rules/). It was checked against yamllint 1.38.0 over the fleet’s 362 tracked YAML files, under each repository’s own .yamllint.yaml and again under yamllint’s stricter default configuration: every one of the 1505 problems yamllint reported was reported at the same file, line, column, level and rule, and nothing else was.
Source Files
- Input:
.ymland.yamlfiles undersrc_dirs - Output: none (checker)
The default src_dirs is empty, so a stanza with no src_dirs or src_files checks nothing: name the directories ([""] for the whole project) or files to check.
The config files (.yamllint, .yamllint.yaml, .yamllint.yml) are dep_auto inputs: a change to one re-checks every file. A file named by config_file is not tracked automatically; add it to dep_inputs if it lives elsewhere.
Configuration
[processor.checker.iyamllint]
src_dirs = [".github"]
| Key | Type | Default | Description |
|---|---|---|---|
config_file | string | "" | yamllint config file to read; empty means .yamllint, .yamllint.yaml or .yamllint.yml in the project root, else yamllint’s default configuration |
strict | bool | false | Fail on warnings too, like yamllint --strict |
src_dirs | string[] | [] | Directories to scan |
src_extensions | string[] | [".yml", ".yaml"] | File extensions to check |
dep_auto | string[] | [".yamllint", ".yamllint.yml", ".yamllint.yaml"] | Config files whose changes re-check every file |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
Files are checked individually within a batch, so one bad file fails only its own product.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Iyamlschema Processor
Purpose
Validates YAML files against JSON schemas referenced by a $schema URL field in each file. Checks both schema conformance and property ordering. Native (in-process, no external tools required).
How It Works
For each YAML file:
- Parses the YAML content
- Reads the
$schemafield to get the schema URL - Fetches the schema (cached in
.rsconstruct/webcache.redb) - Validates the data against the schema (including resolving remote
$refreferences) - Checks that object keys appear in the order specified by
propertyOrderingfields in the schema
Fails if any file is missing $schema, fails schema validation, or has keys in the wrong order.
Configuration
[processor.checker.iyamlschema]
src_dirs = ["yaml"]
check_ordering = true # Check propertyOrdering (default: true)
| Key | Type | Default | Description |
|---|---|---|---|
check_ordering | boolean | true | Whether to check property ordering against propertyOrdering in the schema |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Schema Requirements
Each YAML file must contain a $schema field with a URL pointing to a JSON schema:
$schema: "https://example.com/schemas/mydata.json"
name: Alice
age: 30
The schema is fetched via HTTP and cached locally. Subsequent builds use the cached version. Use rsconstruct webcache clear to force re-fetching.
Property Ordering
If the schema contains propertyOrdering arrays, the processor checks that data keys appear in the specified order:
{
"type": "object",
"properties": {
"name": { "type": "string" },
"age": { "type": "integer" }
},
"propertyOrdering": ["name", "age"]
}
Set check_ordering = false to disable this check.
Batch Support
Files are validated individually within a batch. Partial failure is handled correctly.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Iyq Processor
Purpose
Checks YAML files with a jq filter, in-process (no yq, no jq). The Rust alternative to the yq checker, which runs yq . over every YAML file: each file is read as YAML, every document in it is turned into JSON, and the filter is evaluated over each document with jaq, a Rust implementation of jq. A file that is not valid YAML, or on which the filter raises an error, fails.
How It Works
With the default filter, ., a file passes when it parses as YAML: the check yq . performs. Any jq filter works, so a stanza can assert a shape:
[processor.checker.iyq]
src_dirs = ["deploy"]
filter = 'if has("apiVersion") and has("kind") then . else error("not a Kubernetes object") end'
The filter runs over every document of a multi-document file in turn; the first error names the file and the document:
deploy/web.yaml: document 2: jq: "not a Kubernetes object"
A filter that does not parse is a configuration error naming the filter. The filter’s outputs are not written anywhere: this is a checker, like the yq processor it replaces.
Fidelity
Checked against yq (the jq wrapper, version 4) over the fleet’s 1015 YAML files under the filters . and [.. | strings] | length: the same files pass and fail. Two details of yq had to be matched: a leading byte order mark is skipped (PyYAML does; libyaml does not), and each document is handed to the filter on its own.
Source Files
- Input:
.ymland.yamlfiles undersrc_dirs - Output: none (checker)
The default src_dirs is empty, so a stanza with no src_dirs or src_files checks nothing: name the directories ([""] for the whole project) or files to check.
Configuration
| Key | Type | Default | Description |
|---|---|---|---|
filter | string | "." | The jq filter evaluated over every document; . only checks that the YAML parses |
src_dirs | string[] | [] | Directories to scan |
src_extensions | string[] | [".yml", ".yaml"] | File extensions to check |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Switching a repo from yq: [processor.checker.yq] → [processor.checker.iyq]; args = ["FILTER"] → filter = "FILTER".
Batch support
Files are checked individually within a batch, so one bad file fails only its own product.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Jq Processor
Purpose
Validates JSON files using jq.
The Rust alternative is ijq: each file parsed as JSON in-process. It only checks that a file parses; no jq filter is evaluated.
How It Works
Discovers .json files in the project (excluding common build tool
directories), runs jq empty on each file, and records success in the cache.
The empty filter validates JSON syntax without producing output — a non-zero
exit code from jq fails the product.
This processor supports batch mode — multiple files are checked in a single jq invocation.
Source Files
- Input:
**/*.json - Output: none (linter)
Configuration
[processor.checker.jq]
command = "jq" # The jq command to run
args = [] # Additional arguments to pass to jq (after "empty")
dep_inputs = [] # Additional files that trigger rebuilds when changed
| Key | Type | Default | Description |
|---|---|---|---|
command | string | "jq" | The jq executable to run |
args | string[] | [] | Extra arguments passed to jq (after the empty filter) |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
The tool accepts multiple files on the command line. When batching is enabled (default), rsconstruct passes all files in a single invocation for better performance.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
JSHint Processor
Purpose
Lints JavaScript files using JSHint.
How It Works
Discovers .js, .jsx, .mjs, and .cjs files in the project (excluding
common build tool directories), runs jshint on each file, and records success
in the cache. A non-zero exit code from jshint fails the product.
This processor supports batch mode.
If a .jshintrc file exists, it is automatically added as an extra input so
that configuration changes trigger rebuilds.
Source Files
- Input:
**/*.js,**/*.jsx,**/*.mjs,**/*.cjs - Output: none (checker)
Configuration
[processor.checker.jshint]
command = "jshint"
args = []
dep_inputs = []
| Key | Type | Default | Description |
|---|---|---|---|
command | string | "jshint" | The jshint executable to run |
args | string[] | [] | Extra arguments passed to jshint |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
The tool accepts multiple files on the command line. When batching is enabled (default), rsconstruct passes all files in a single invocation for better performance.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
JSLint Processor
Purpose
Lints JavaScript files using JSLint.
How It Works
Discovers .js files in the project (excluding common build tool directories),
runs jslint on each file, and records success in the cache. A non-zero exit
code from jslint fails the product.
This processor supports batch mode.
Source Files
- Input:
**/*.js - Output: none (checker)
Configuration
[processor.checker.jslint]
args = []
dep_inputs = []
| Key | Type | Default | Description |
|---|---|---|---|
args | string[] | [] | Extra arguments passed to jslint |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
The tool accepts multiple files on the command line. When batching is enabled (default), rsconstruct passes all files in a single invocation for better performance.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Json Schema Processor
Purpose
Validates JSON schema files by checking that every object’s propertyOrdering
array exactly matches its properties keys.
How It Works
Discovers .json files in the project (excluding common build tool
directories), parses each as JSON, and recursively walks the structure. At every
object node with "type": "object", if both properties and
propertyOrdering exist, it verifies that the two key sets match exactly.
Mismatches (keys missing from propertyOrdering or extra keys in
propertyOrdering) are reported with their JSON path. Files that contain no
propertyOrdering at all pass silently.
This is a pure-Rust checker — no external tool is required.
Source Files
- Input:
**/*.json - Output: none (checker)
Configuration
[processor.checker.json_schema]
args = [] # Reserved for future use
dep_inputs = [] # Additional files that trigger rebuilds when changed
| Key | Type | Default | Description |
|---|---|---|---|
args | string[] | [] | Reserved for future use |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
The tool processes one file at a time. Each file is checked in a separate invocation.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Jsonlint Processor
Purpose
Lints JSON files using jsonlint.
The Rust alternative is ijsonlint: each file parsed in-process with serde_json, no Node.
How It Works
Discovers .json files in the project (excluding common build tool
directories), runs jsonlint on each file, and records success in the cache.
A non-zero exit code from jsonlint fails the product.
This processor does not support batch mode — each file is checked individually.
Source Files
- Input:
**/*.json - Output: none (checker)
Configuration
[processor.checker.jsonlint]
command = "jsonlint" # The jsonlint command to run
args = [] # Additional arguments to pass to jsonlint
dep_inputs = [] # Additional files that trigger rebuilds when changed
| Key | Type | Default | Description |
|---|---|---|---|
command | string | "jsonlint" | The jsonlint executable to run |
args | string[] | [] | Extra arguments passed to jsonlint |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
The tool accepts multiple files on the command line. When batching is enabled (default), rsconstruct passes all files in a single invocation for better performance.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
License Header Processor
Purpose
Checks that source files carry a license header. Native (in-process; no external tools required).
How It Works
Each file must start with the header: the lines of header_lines, every one of them, in order, each matching a whole line exactly and ending with a newline. Line endings are compared after reading \r\n and \r as \n. Two things may come before the header:
- A shebang line (
#!...), skipped whenskip_shebangis on (the default), so scripts can keep it first. optional_prefix_lines, all of them or none, matched exactly. This is how kernel sources carry the SPDX line the kernel requires first, followed by the project’s license block. One stanza then covers ordinary sources (header at the top) and kernel sources (SPDX line, then header).
The SPDX line can also be the header itself: with header_lines = ["// SPDX-License-Identifier: GPL-2.0"], every file must open with exactly that line.
The first line that differs is reported, with its line number in the file and its number in the header. When a file starts with the prefix, the mismatch is reported in the header after it:
src/user.c:1: license header line 1 differs: expected "// SPDX-License-Identifier: GPL-2.0", found "/*"
src/broken.c:3: license header line 2 differs: expected " * This file is part of the demo package.", found " * Some other package."
src/short.c:17: file ends without a newline after license header line 17
A file that is not valid UTF-8 fails with a read error. header_lines is required and must not be empty.
Source Files
- Input: files under
src_dirswith one of the default extensions:.py .rs .js .ts .c .cc .h .hh .java .rb .go .sh .bash - Output: none (checker)
The default src_dirs is empty, so a stanza with no src_dirs or src_files checks nothing: name the directories ([""] for the whole project) or files to check.
Configuration
# Kernel sources must open with the SPDX line
[processor.checker.license_header.kernel]
src_dirs = ["src/kernel_standalone"]
src_extensions = [".c", ".h"]
header_lines = ["// SPDX-License-Identifier: GPL-2.0"]
# Every source must start with the project's license block, kernel
# sources with their SPDX line before it. Shortened here: list every line
# of the real header, since all of them must match.
[processor.checker.license_header.block]
src_dirs = ["src"]
src_extensions = [".c", ".cc", ".h", ".hh"]
skip_shebang = false
optional_prefix_lines = ["// SPDX-License-Identifier: GPL-2.0"]
header_lines = [
"/*",
" * This file is part of the demos-os-linux package.",
" * Copyright (C) 2011-2026 Mark Veltzer <mark.veltzer@gmail.com>",
" */",
]
| Key | Type | Default | Description |
|---|---|---|---|
header_lines | string[] | (required) | The lines every file must start with, one entry per line |
optional_prefix_lines | string[] | [] | Lines that may come right before the header, all or none |
skip_shebang | bool | true | Skip a leading #! line before looking for the header |
src_dirs | string[] | [] | Directories to scan |
src_extensions | string[] | (list above) | File extensions to check |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Changing header_lines, optional_prefix_lines or skip_shebang re-checks every file.
Batch support
Files are checked individually within a batch, so one bad file fails only its own product.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Luacheck Processor
Purpose
Lints Lua scripts using luacheck. Rust alternatives are iluacheck, a port of luacheck with the same findings, and selene.
How It Works
Discovers .lua files in the project (excluding common build tool
directories), runs luacheck on each file, and records success in the cache.
A non-zero exit code from luacheck fails the product.
This processor supports batch mode, allowing multiple files to be checked in a single luacheck invocation for better performance.
Source Files
- Input:
**/*.lua - Output: none (linter)
Configuration
[processor.checker.luacheck]
command = "luacheck" # The luacheck command to run
args = [] # Additional arguments to pass to luacheck
dep_inputs = [] # Additional files that trigger rebuilds when changed
| Key | Type | Default | Description |
|---|---|---|---|
command | string | "luacheck" | The luacheck executable to run |
args | string[] | [] | Extra arguments passed to luacheck |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
The tool accepts multiple files on the command line. When batching is enabled (default), rsconstruct passes all files in a single invocation for better performance.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Make Processor
Purpose
Runs make in directories containing Makefiles. Each Makefile produces a stub
file on success, allowing RSConstruct to track incremental rebuilds.
How It Works
Discovers files named Makefile in the project. For each Makefile found, the
processor runs make (or a configured alternative) in the Makefile’s directory.
A stub file is created on success.
Directory-Level Inputs
The make processor treats all files in the Makefile’s directory (and subdirectories) as inputs. This means that if any file alongside the Makefile changes — source files, headers, scripts, included makefiles — rsconstruct will re-run make.
This is slightly conservative: a change to a file that the Makefile does not actually depend on will trigger a rebuild. In practice this is the right trade-off because Makefiles can depend on arbitrary files and there is no reliable way to know which ones without running make itself.
Source Files
- Input:
**/Makefileplus all files in the Makefile’s directory tree - Output: none (checker — a passing
makerun is recorded in the cache, no file is written)
Dependency Tracking Approaches
RSConstruct uses the directory-scan approach described above. Here is why, and what the alternatives are.
1. Directory scan (current)
Track every file under the Makefile’s directory as an input. Any change triggers a rebuild.
Pros: simple, correct, zero configuration. Cons: over-conservative — a change to an unrelated file in the same directory triggers a needless rebuild.
2. User-declared extra inputs
The user lists specific files or globs in dep_inputs. Only those files
(plus the Makefile itself) are tracked.
Pros: precise, no unnecessary rebuilds. Cons: requires the user to manually maintain the list. Easy to forget a file and get stale builds.
This is available today via the dep_inputs config key, but on its own
it would miss source files that the Makefile compiles.
3. Parse make --dry-run --print-data-base
Ask make to dump its dependency database and extract the real inputs.
Pros: exact dependency information, no over-building. Cons: fragile — output format varies across make implementations (GNU Make, BSD Make, nmake). Some Makefiles behave differently in dry-run mode. Complex to implement and maintain.
4. Hash the directory tree
Instead of listing individual files, compute a single hash over every file in the directory. Functionally equivalent to option 1 but with a different internal representation.
Pros: compact cache key. Cons: same over-conservatism as option 1, and no ability to report which file changed.
Configuration
[processor.checker.make]
command = "make" # Make binary to use
args = [] # Extra arguments passed to make
target = "" # Make target (empty = default target)
src_dirs = [""] # Directory to scan ("" = project root)
src_extensions = ["Makefile"]
dep_inputs = [] # Additional files that trigger rebuilds when changed
| Key | Type | Default | Description |
|---|---|---|---|
command | string | "make" | Path or name of the make binary |
args | string[] | [] | Extra arguments passed to every make invocation |
target | string | "" | Make target to build (empty = default target) |
src_dirs | string[] | [""] | Directory to scan for Makefiles |
src_extensions | string[] | ["Makefile"] | File names to match |
src_exclude_paths | string[] | [] | Paths (relative to project root) to exclude |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds (in addition to directory contents) |
Batch support
The tool processes one file at a time. Each file is checked in a separate invocation.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Markdownlint Processor
Purpose
Lints Markdown files using markdownlint (Node.js).
The Rust alternative is rumdl, a Markdown linter written in Rust with markdownlint’s rules.
How It Works
Discovers .md files in the project and runs markdownlint on each file. A
non-zero exit code fails the product.
Depends on the npm processor — uses the markdownlint binary installed by npm.
Source Files
- Input:
**/*.md - Output: none (checker)
Configuration
[processor.checker.markdownlint]
command = "markdownlint" # Path to the markdownlint binary
args = [] # Additional arguments to pass to markdownlint
dep_inputs = [] # Additional files that trigger rebuilds when changed
| Key | Type | Default | Description |
|---|---|---|---|
command | string | "markdownlint" | Path to the markdownlint executable |
args | string[] | [] | Extra arguments passed to markdownlint |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
The tool processes one file at a time. Each file is checked in a separate invocation.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Marp Images Processor
Purpose
Checks that every local image referenced from a Markdown file exists. Built for Marp slide decks, but works on any Markdown. Native (in-process; no external tools required).
How It Works
Every  and  reference is read from each file, line by line. References to http://, https:// and data: URIs are skipped. Every other path is resolved relative to the directory of the Markdown file, and must exist:
slides/deck.md: [processor.checker.marp_images] Missing image references:
slides/deck.md:2: missing image: img/missing.png
Only inline Markdown image syntax is recognized: HTML <img> tags, reference-style images (![alt][ref]) and Marp background directives in comments are not checked.
Source Files
- Input:
.mdfiles undersrc_dirs - Output: none (checker)
The default src_dirs is empty, so a stanza with no src_dirs or src_files checks nothing: name the directories ([""] for the whole project) or files to check.
Configuration
[processor.checker.marp_images]
src_dirs = ["marp"]
| Key | Type | Default | Description |
|---|---|---|---|
src_dirs | string[] | [] | Directories to scan |
src_extensions | string[] | [".md"] | File extensions to check |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Only the Markdown files are inputs: adding or deleting an image file does not by itself re-run the check. Run rsconstruct build after touching the deck, or list image directories in dep_inputs.
Batch support
Files are checked individually within a batch, so one bad file fails only its own product.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Mdl Processor
Purpose
Lints Markdown files using mdl (Ruby markdownlint).
The Rust alternative is rumdl, a Markdown linter written in Rust with markdownlint’s rules.
How It Works
Discovers .md files in the project and runs mdl on each file. A non-zero
exit code fails the product.
With local_repo = true, mdl runs with GEM_HOME/GEM_PATH set to
gem_home, using the mdl gem that the gem processor
installs, and lists the gem processor’s install stamp (gem_stamp) as an
input of every product — so the graph runs mdl only after bundle install
succeeded, and a reinstall re-checks every file. Without local_repo, mdl
is the system install and does not depend on the gem processor.
Source Files
- Input:
**/*.md - Output: none (checker)
Configuration
[processor.checker.mdl]
local_repo = false # Run mdl from the gems the gem processor installs
gem_home = "gems" # GEM_HOME directory
command = "mdl" # Path to the mdl binary
args = [] # Additional arguments to pass to mdl
gem_stamp = "out/processor.mass_generator.gem/root.stamp" # Gem install stamp (input when local_repo)
dep_inputs = [] # Additional files that trigger rebuilds when changed
| Key | Type | Default | Description |
|---|---|---|---|
local_repo | boolean | false | Run mdl from the local gem repository the gem processor installs, and depend on its install |
gem_home | string | "gems" | GEM_HOME directory for Ruby gems (used when local_repo) |
command | string | "mdl" | Path to the mdl executable |
args | string[] | [] | Extra arguments passed to mdl |
gem_stamp | string | "out/processor.mass_generator.gem/root.stamp" | The gem processor’s install stamp for the Gemfile mdl comes from; an input when local_repo, so mdl runs after the install. Point it at out/processor.mass_generator.gem/<dir>.stamp for a Gemfile in a subdirectory |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
The tool processes one file at a time. Each file is checked in a separate invocation.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Mypy Processor
Purpose
Type-checks Python source files using mypy.
The Rust alternative is pyrefly, a Python type checker written in Rust, which the fleet type-checks with. It is a different checker, not a port of mypy: its findings are its own.
How It Works
Discovers .py files in the project (excluding common non-source directories),
runs mypy on each file, and creates a stub file on success.
A non-zero exit code from mypy fails the product.
This processor supports batch mode, allowing multiple files to be checked in a single mypy invocation for better performance.
If a mypy.ini file exists in the project root, it is automatically added as an
extra input so that configuration changes trigger rebuilds.
Source Files
- Input:
**/*.py - Output: none (checker — a passing check is recorded in the cache, no file is written)
Configuration
[processor.checker.mypy]
command = "mypy" # The mypy command to run
args = [] # Additional arguments to pass to mypy
dep_inputs = [] # Additional files that trigger rebuilds (e.g. ["pyproject.toml"])
| Key | Type | Default | Description |
|---|---|---|---|
command | string | "mypy" | The mypy executable to run |
args | string[] | [] | Extra arguments passed to mypy |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
output_depends_on_input_name | bool | true | Key cached results by file path as well as content, because mypy derives module identity and per-module settings from the path. A renamed file is re-checked instead of reusing its old verdict. See output_depends_on_input_name |
Batch support
The tool accepts multiple files on the command line. When batching is enabled (default), rsconstruct passes all files in a single invocation for better performance.
Using mypy.ini
Mypy automatically reads configuration from a mypy.ini file in the project
root. This file is detected automatically and added as an extra input, so
changes to it will trigger rebuilds without manual configuration.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Oxlint Processor
Purpose
Lints JavaScript and TypeScript files using oxlint,
the linter from the oxc project. It is the Rust alternative to the
ESLint processor: a single static binary that needs no node
runtime and no node_modules/.
How It Works
Discovers .js, .jsx, .ts, .tsx, .mjs, and .cjs files in the project
(excluding common build tool directories), runs oxlint on each file, and
records success in the cache. A non-zero exit code from oxlint fails the
product.
This processor supports batch mode, allowing multiple files to be checked in a single oxlint invocation for better performance.
If an oxlint config file exists (.oxlintrc.json or oxlint.config.*), it is
automatically added as an extra input so that configuration changes trigger
rebuilds.
Migrating from ESLint
oxlint reads an ESLint-style config and honours eslint-disable comments, so
a repo moves over by translating its rule list once into .oxlintrc.json.
Rule names are the same; two rules from eslint:recommended do not exist in
oxlint because its parser rejects the constructs as syntax errors:
no-dupe-args and no-octal. oxlint refuses to start on an unknown rule
name, so these must be left out of the translated config.
Note that oxlint parses .js files with JSX enabled, so a file that ESLint
rejected for stray HTML may parse cleanly under oxlint.
Source Files
- Input:
**/*.js,**/*.jsx,**/*.ts,**/*.tsx,**/*.mjs,**/*.cjs - Output: none (checker)
Configuration
[processor.checker.oxlint]
command = "oxlint"
args = ["-c", ".oxlintrc.json"]
dep_inputs = []
| Key | Type | Default | Description |
|---|---|---|---|
command | string | "oxlint" | The oxlint executable to run |
args | string[] | [] | Extra arguments passed to oxlint |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
dep_auto | string[] | .oxlintrc.json, oxlint.config.* | Config files auto-detected as inputs |
Installation
rsconstruct tool install downloads the static Linux binary from the oxc
GitHub release. The npm package and the crate are the fallbacks.
Batch support
The tool accepts multiple files on the command line. When batching is enabled (default), rsconstruct passes all files in a single invocation for better performance.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Perlcritic Processor
Purpose
Analyzes Perl code using Perl::Critic.
The Rust alternative is iperlcritic: PPI and the severity-5 policies, in-process, with perlcritic’s violations and positions. Switching is perlcritic → iperlcritic in the stanza header; .perlcriticrc is read as before.
How It Works
Discovers .pl and .pm files in the project (excluding common build tool
directories), runs perlcritic on each file, and records success in the cache.
A non-zero exit code from perlcritic fails the product.
This processor supports batch mode.
If a .perlcriticrc file exists, it is automatically added as an extra input so
that configuration changes trigger rebuilds.
Source Files
- Input:
**/*.pl,**/*.pm - Output: none (checker)
Configuration
[processor.checker.perlcritic]
args = []
dep_inputs = []
| Key | Type | Default | Description |
|---|---|---|---|
args | string[] | [] | Extra arguments passed to perlcritic |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
The tool accepts multiple files on the command line. When batching is enabled (default), rsconstruct passes all files in a single invocation for better performance.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
PHP Lint Processor
Purpose
Checks PHP syntax using php -l.
How It Works
Discovers .php files in the project (excluding common build tool directories),
runs php -l on each file, and records success in the cache. A non-zero exit
code fails the product.
This processor supports batch mode.
Source Files
- Input:
**/*.php - Output: none (checker)
Configuration
[processor.checker.php_lint]
args = []
dep_inputs = []
| Key | Type | Default | Description |
|---|---|---|---|
args | string[] | [] | Extra arguments passed to php |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
The tool accepts multiple files on the command line. When batching is enabled (default), rsconstruct passes all files in a single invocation for better performance.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Prettier Processor
Purpose
Checks that files are formatted the way Prettier would format them.
How It Works
Runs prettier --check <files>. Prettier exits non-zero when a file is not formatted, which fails the product; its output names the files:
[warn] web/a.js
[warn] Code style issues found in the above file. Run Prettier with --write to fix.
Prettier’s own config file is read as usual, and any of .prettierrc, .prettierrc.json, .prettierrc.js, .prettierrc.yml, .prettierrc.yaml, .prettierrc.toml, .prettierrc.cjs, .prettierrc.mjs, prettier.config.js, prettier.config.cjs and prettier.config.mjs that exists is an input (dep_auto), so editing it re-checks every file.
This processor checks only. To reformat, run prettier --write yourself.
Source Files
- Input: files under
src_dirswith extensions.js .jsx .ts .tsx .mjs .cjs .css .scss .less .html .json .md .yaml .yml - Output: none (checker)
The default src_dirs is empty, so a stanza with no src_dirs or src_files checks nothing: name the directories ([""] for the whole project) or files to check.
Configuration
[processor.checker.prettier]
src_dirs = ["web"]
| Key | Type | Default | Description |
|---|---|---|---|
command | string | "prettier" | The prettier executable to run |
args | string[] | [] | Extra arguments passed to prettier |
src_dirs | string[] | [] | Directories to scan |
src_extensions | string[] | (list above) | File extensions to check |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
dep_auto | string[] | (config files above) | Config files that are inputs when they exist |
Batch support
The tool accepts multiple files on the command line. When batching is enabled (default), rsconstruct passes all files in a single invocation; a failure then marks every file in that invocation as failed (see batch in Configuration). Set batch = false to have each file reported on its own.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Pylint Processor
Purpose
Lints Python source files using pylint.
How It Works
Discovers .py files in the project (excluding common non-source directories),
runs pylint on each file, and creates a stub file on success.
A non-zero exit code from pylint fails the product.
This processor supports batch mode, allowing multiple files to be checked in a single pylint invocation for better performance.
If a .pylintrc file exists in the project root, it is automatically added as an
extra input so that configuration changes trigger rebuilds.
Source Files
- Input:
**/*.py - Output: none (checker — a passing check is recorded in the cache, no file is written)
Configuration
[processor.checker.pylint]
args = [] # Additional arguments to pass to pylint
dep_inputs = [] # Additional files that trigger rebuilds (e.g. ["pyproject.toml"])
| Key | Type | Default | Description |
|---|---|---|---|
args | string[] | [] | Extra arguments passed to pylint |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
output_depends_on_input_name | bool | true | Key cached results by file path as well as content, because pylint derives the module name and import resolution from the path. A renamed file is re-checked instead of reusing its old verdict. See output_depends_on_input_name |
Batch support
The tool accepts multiple files on the command line. When batching is enabled (default), rsconstruct passes all files in a single invocation for better performance.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Pyrefly Processor
Purpose
Type-checks Python source files using pyrefly.
How It Works
Discovers .py files in the project (excluding common non-source directories),
runs pyrefly check on each file, and records success in the cache.
A non-zero exit code from pyrefly fails the product.
This processor supports batch mode, allowing multiple files to be checked in a single pyrefly invocation for better performance.
Source Files
- Input:
**/*.py - Output: none (linter)
Configuration
[processor.checker.pyrefly]
command = "pyrefly" # The pyrefly command to run
args = [] # Additional arguments to pass to pyrefly
dep_inputs = [] # Additional files that trigger rebuilds when changed
| Key | Type | Default | Description |
|---|---|---|---|
command | string | "pyrefly" | The pyrefly executable to run |
args | string[] | [] | Extra arguments passed to pyrefly |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
The tool accepts multiple files on the command line. When batching is enabled (default), rsconstruct passes all files in a single invocation for better performance.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Pytest Processor
Purpose
Runs Python test files using pytest to verify they pass.
How It Works
Python test files (.py) in the tests/ directory are run using pytest.
Each test file is checked individually — a failing test causes the build to fail.
Source Files
- Input:
tests/**/*.py - Output: none (checker — pass/fail only)
Configuration
[processor.checker.pytest]
src_extensions = [".py"] # File extensions to process (default: [".py"])
src_dirs = ["tests"] # Directories to scan (required; no default)
dep_inputs = [] # Additional files that trigger rebuilds
| Key | Type | Default | Description |
|---|---|---|---|
src_extensions | string[] | [".py"] | File extensions to discover |
src_dirs | string[] | ["tests"] | Directories to scan for test files |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
The tool accepts multiple files on the command line. When batching is enabled (default), rsconstruct passes all files in a single invocation for better performance.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Ruff Processor
Purpose
Lints Python source files using ruff.
How It Works
Discovers .py files in the project (excluding common non-source directories),
runs ruff check on each file, and creates a stub file on success.
A non-zero exit code from ruff fails the product.
This processor supports batch mode, allowing multiple files to be checked in a single ruff invocation for better performance.
Source Files
- Input:
**/*.py - Output: none (checker — a passing check is recorded in the cache, no file is written)
Configuration
[processor.checker.ruff]
command = "ruff" # The ruff command to run
args = [] # Additional arguments to pass to ruff
dep_inputs = [] # Additional files that trigger rebuilds (e.g. ["pyproject.toml"])
| Key | Type | Default | Description |
|---|---|---|---|
command | string | "ruff" | The ruff executable to run |
args | string[] | [] | Extra arguments passed to ruff |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
output_depends_on_input_name | bool | true | Key cached results by file path as well as content, because per-file-ignores, INP001, N999 and import sorting depend on where a file lives. A renamed file is re-checked instead of reusing its old verdict. See output_depends_on_input_name |
Batch support
The tool accepts multiple files on the command line. When batching is enabled (default), rsconstruct passes all files in a single invocation for better performance.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Rumdl Processor
Purpose
Lints Markdown files using rumdl.
How It Works
Discovers .md files in the project (excluding common non-source directories),
runs rumdl check on each file, and creates a stub file on success.
A non-zero exit code from rumdl fails the product.
This processor supports batch mode, allowing multiple files to be checked in a single rumdl invocation for better performance.
Source Files
- Input:
**/*.md - Output: none (checker — a passing check is recorded in the cache, no file is written)
Configuration
[processor.checker.rumdl]
command = "rumdl" # The rumdl command to run
args = [] # Additional arguments to pass to rumdl
dep_inputs = [] # Additional files that trigger rebuilds when changed
| Key | Type | Default | Description |
|---|---|---|---|
command | string | "rumdl" | The rumdl executable to run |
args | string[] | [] | Extra arguments passed to rumdl |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
The tool accepts multiple files on the command line. When batching is enabled (default), rsconstruct passes all files in a single invocation for better performance.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Script Processor
Purpose
Runs a user-configured script or command as a linter on discovered files. This is a generic linter that lets you plug in any script without writing a custom processor.
How It Works
Discovers files matching the configured extensions in the configured scan directory, then runs the configured linter command on each file (or batch of files). A non-zero exit code from the script fails the product.
The command field has no default: declaring the processor without one is an
error when it runs.
This processor supports batch mode, allowing multiple files to be checked in a single invocation for better performance.
Source Files
- Input: configured via
src_extensionsandsrc_dirs - Output: none (checker)
Configuration
[processor.checker.script]
command = "python"
args = ["scripts/md_lint.py", "-q"]
src_extensions = [".md"]
src_dirs = ["marp"]
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Set to false to keep the stanza but skip the processor |
command | string | (required) | The command to run |
args | string[] | [] | Extra arguments passed before file paths |
src_extensions | string[] | [] | File extensions to scan for |
src_dirs | string[] | [""] | Directory to scan (empty = project root) |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
dep_auto | string[] | [] | Auto-detected input files |
Batch support
The tool accepts multiple files on the command line. When batching is enabled (default), rsconstruct passes all files in a single invocation for better performance.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Selene Processor
Purpose
Lints Lua files using selene, a Lua linter written in Rust. It is the Rust alternative to the luacheck processor: a single binary that needs no Lua runtime and no LuaRocks.
How It Works
Discovers .lua files under src_dirs, runs selene on them, and records
success in the cache. selene exits non-zero on warnings as well as errors,
so any finding fails the product, as with luacheck.
This processor supports batch mode, allowing multiple files to be checked in a single selene invocation for better performance.
If selene.toml exists in the project root, it is automatically added as an
extra input so that configuration changes trigger rebuilds.
Migrating from luacheck
selene is a different linter, not a port of luacheck, so a repo moving over
writes a selene.toml and expects different findings:
- Lua version. selene parses Lua 5.1 (and Luau). Its 0.32.0 release
binary has no Lua 5.4 standard library at all, and
std = "lua52"or"lua53"prints “feature for it is not enabled” and still parses 5.1, so//, bitwise operators,gotoand<const>are parse errors. A source build with selene-lib’slua53/lua54features enabled behaves the same. Code written for Lua 5.3/5.4 stays on luacheck. - Host globals. A global the program gets from its host (
vim,pandoc) is declared in a custom standard library file next toselene.toml(std = "lua51+neovim"readsneovim.yml, which startsbase: lua51and listsglobals:), not on the command line as with luacheck’s--globals. - Globals. Assigning a global at the top level is a warning
(
unscoped_variables), and a global nothing in the same file reads isunused_variable. Globals one file defines and another reads throughdofileareundefined_variableerrors in the reading file: selene checks each file on its own and does not followdofile. There is no counterpart of luacheck’sallow_defined_top. - Scope. One
selene.tomlcovers the whole project; luacheck’s per-directoryfiles["dir"]overrides have no equivalent. Narrower exceptions are comments in the source:-- selene: allow(lint_name)for the next statement,--# selene: allow(lint_name)for the whole file.
Source Files
- Input:
**/*.lua - Output: none (checker)
The default src_dirs is empty, so a stanza with no src_dirs or src_files
checks nothing: name the directories ([""] for the whole project) or files
to check.
Configuration
[processor.checker.selene]
src_dirs = ["config"]
args = []
dep_inputs = []
| Key | Type | Default | Description |
|---|---|---|---|
command | string | "selene" | The selene executable to run |
args | string[] | [] | Extra arguments passed to selene |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds (a custom standard library file, say) |
dep_auto | string[] | ["selene.toml"] | Config files auto-detected as inputs |
Installation
rsconstruct tool install downloads the Linux binary from the selene GitHub
release (a zip, extracted with unzip). The crate is the fallback.
Batch support
The tool accepts multiple files on the command line. When batching is enabled (default), rsconstruct passes all files in a single invocation for better performance.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Shellcheck Processor
Purpose
Lints shell scripts using shellcheck.
The Rust alternative is ishellcheck: ShellCheck’s analysis, in-process, with shellcheck’s codes, messages and positions. Switching is shellcheck → ishellcheck in the stanza header, and options in args become fields (args = ["-e", "SC1091"] → exclude = ["SC1091"]).
How It Works
Discovers .sh and .bash files in the project (excluding common build tool
directories), runs shellcheck on each file, and records success in the cache.
A non-zero exit code from shellcheck fails the product.
This processor supports batch mode, allowing multiple files to be checked in a single shellcheck invocation for better performance.
Source Files
- Input:
**/*.sh,**/*.bash - Output: none (linter)
Configuration
[processor.checker.shellcheck]
command = "shellcheck" # The shellcheck command to run
args = [] # Additional arguments to pass to shellcheck
dep_inputs = [] # Additional files that trigger rebuilds when changed
| Key | Type | Default | Description |
|---|---|---|---|
command | string | "shellcheck" | The shellcheck executable to run |
args | string[] | [] | Extra arguments passed to shellcheck |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
The tool accepts multiple files on the command line. When batching is enabled (default), rsconstruct passes all files in a single invocation for better performance.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Slidev Processor
Purpose
Builds Slidev presentations.
How It Works
Discovers .md files in the project (excluding common build tool directories),
runs slidev build on each file, and records success in the cache. A non-zero
exit code from slidev fails the product.
This processor supports batch mode.
Source Files
- Input:
**/*.md - Output: none (checker)
Configuration
[processor.checker.slidev]
args = []
dep_inputs = []
| Key | Type | Default | Description |
|---|---|---|---|
args | string[] | [] | Extra arguments passed to slidev build |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
The tool accepts multiple files on the command line. When batching is enabled (default), rsconstruct passes all files in a single invocation for better performance.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Standard Processor
Purpose
Checks JavaScript code style using standard.
How It Works
Discovers .js files in the project (excluding common build tool directories),
runs standard on each file, and records success in the cache. A non-zero exit
code from standard fails the product.
This processor supports batch mode.
Source Files
- Input:
**/*.js - Output: none (checker)
Configuration
[processor.checker.standard]
args = []
dep_inputs = []
| Key | Type | Default | Description |
|---|---|---|---|
args | string[] | [] | Extra arguments passed to standard |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
The tool accepts multiple files on the command line. When batching is enabled (default), rsconstruct passes all files in a single invocation for better performance.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Stylelint Processor
Purpose
Lints CSS, SCSS, Sass, and Less files using stylelint.
A Rust alternative for plain CSS is biome: a single static binary that needs no node runtime. It does not parse SCSS, Sass or Less.
How It Works
Discovers .css, .scss, .sass, and .less files in the project (excluding
common build tool directories), runs stylelint on each file, and records success
in the cache. A non-zero exit code from stylelint fails the product.
This processor supports batch mode.
If a stylelint config file exists (.stylelintrc* or stylelint.config.*), it
is automatically added as an extra input so that configuration changes trigger
rebuilds.
Source Files
- Input:
**/*.css,**/*.scss,**/*.sass,**/*.less - Output: none (checker)
Configuration
[processor.checker.stylelint]
command = "stylelint"
args = []
dep_inputs = []
| Key | Type | Default | Description |
|---|---|---|---|
command | string | "stylelint" | The stylelint executable to run |
args | string[] | [] | Extra arguments passed to stylelint |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
The tool accepts multiple files on the command line. When batching is enabled (default), rsconstruct passes all files in a single invocation for better performance.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Svglint Processor
Purpose
Lints SVG files using svglint.
The Rust alternative is isvglint: svglint’s valid, elm and attr rules, in-process, with svglint’s messages, the rules written as TOML in the stanza instead of .svglintrc.js. Switching is svglint → isvglint in the stanza header plus the rules translated; a repo with custom (JavaScript) rules stays here.
How It Works
Runs svglint <files>. A non-zero exit fails the product. With no .svglintrc.js, svglint only checks that each file is valid SVG/XML:
x img/bad.svg
x valid Expected closing tag 'g' (opened in line 1, col 6) instead of closing
tag 'svg'.
Rules are configured in .svglintrc.js, which is an input when it exists (dep_auto), so editing it re-checks every file.
Source Files
- Input:
.svgfiles undersrc_dirs - Output: none (checker)
The default src_dirs is empty, so a stanza with no src_dirs or src_files checks nothing: name the directories ([""] for the whole project) or files to check.
Configuration
[processor.checker.svglint]
src_dirs = ["images"]
| Key | Type | Default | Description |
|---|---|---|---|
command | string | "svglint" | The svglint executable to run |
args | string[] | [] | Extra arguments passed to svglint |
src_dirs | string[] | [] | Directories to scan |
src_extensions | string[] | [".svg"] | File extensions to check |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
dep_auto | string[] | [".svglintrc.js"] | Config files that are inputs when they exist |
Batch support
The tool accepts multiple files on the command line. When batching is enabled (default), rsconstruct passes all files in a single invocation; a failure then marks every file in that invocation as failed (see batch in Configuration). Set batch = false to have each file reported on its own.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Svgo Processor
Purpose
Checks that SVG files are well-formed, using svgo’s parser.
How It Works
Runs svgo --quiet -o - -i <file> for each file. svgo optimizes the file and writes the result to stdout, which is discarded; the source file is never modified. Only the exit code matters: svgo fails when it cannot parse the file, and that fails the product:
SvgoParserError: img/bad.svg:1:14: Unexpected close tag
> 1 | <svg><g></svg>
| ^
This is a well-formedness check, not a check that the file is already optimized. An svgo.config.js, svgo.config.mjs or svgo.config.cjs that exists is an input (dep_auto).
Source Files
- Input:
.svgfiles undersrc_dirs - Output: none (checker)
The default src_dirs is empty, so a stanza with no src_dirs or src_files checks nothing: name the directories ([""] for the whole project) or files to check.
Configuration
[processor.checker.svgo]
src_dirs = ["images"]
| Key | Type | Default | Description |
|---|---|---|---|
command | string | "svgo" | The svgo executable to run |
args | string[] | [] | Extra arguments passed to svgo |
src_dirs | string[] | [] | Directories to scan |
src_extensions | string[] | [".svg"] | File extensions to check |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
dep_auto | string[] | ["svgo.config.js", "svgo.config.mjs", "svgo.config.cjs"] | Config files that are inputs when they exist |
Batch support
Not supported: svgo needs one output per input, and the processor gives it the single output - (stdout), so each file is checked in its own invocation.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Taplo Processor
Purpose
Checks TOML files using taplo.
How It Works
Discovers .toml files in the project (excluding common build tool
directories), runs taplo check on each file, and records success in the cache.
A non-zero exit code from taplo fails the product.
This processor supports batch mode, allowing multiple files to be checked in a single taplo invocation for better performance.
Source Files
- Input:
**/*.toml - Output: none (checker)
Configuration
[processor.checker.taplo]
command = "taplo" # The taplo command to run
args = [] # Additional arguments to pass to taplo
dep_inputs = [] # Additional files that trigger rebuilds when changed
| Key | Type | Default | Description |
|---|---|---|---|
command | string | "taplo" | The taplo executable to run |
args | string[] | [] | Extra arguments passed to taplo |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
The tool accepts multiple files on the command line. When batching is enabled (default), rsconstruct passes all files in a single invocation for better performance.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Terms Processor
Purpose
Checks that technical terms from a terms directory are backtick-quoted in Markdown files, and provides commands to auto-fix and merge term lists across projects.
How It Works
Loads terms from dir_terms_unambiguous/*.txt files (one term per line, organized by category).
For each .md file, simulates what rsconstruct term fix would produce. If the
result differs from the current content, the product fails.
The processor skips YAML frontmatter and fenced code blocks. Terms are matched case-sensitively with word-boundary detection, longest-first to avoid partial matches (e.g., “Android Studio” matches before “Android”).
Case sensitivity
All comparisons in the terms processor are case-sensitive — the term lists,
the scanner, and the disjoint check between dir_terms_unambiguous and
dir_terms_ambiguous. Terms are stored verbatim in their canonical casing
(e.g. Docker, AWS, awk, /etc/fstab) and prose must match that casing
exactly to be flagged. Docker in the list will match Docker in prose but
not docker or DOCKER. If you want to enforce multiple casings of the
same word, list each one separately.
The disjoint invariant between the two directories is also case-sensitive,
so Docker in dir_terms_unambiguous and docker in dir_terms_ambiguous
will not be reported as overlapping. (If both lists are maintained in
canonical casing this is a non-issue.)
If dir_terms_ambiguous exists on disk, terms in it are loaded and
validated to be disjoint from dir_terms_unambiguous — any term
appearing in both fails the build with the offending term names listed.
A missing ambiguous directory is treated as an empty list (no error), so
projects without an ambiguous list just use the unambiguous-only behavior. Ambiguous terms are
not required to be backticked; the directory exists so projects can track
words that look technical but have ordinary meanings (e.g. “server”, “client”)
without forcing them to be quoted everywhere. Both directories’ .txt files
are tracked as build inputs, so editing either invalidates the cache.
By default (forbid_backticked_ambiguous = true), backticking an ambiguous
term is itself a build error: backticks falsely assert the technical
reading, so prose like the `server` was slow fails. term fix
strips those backticks. Set forbid_backticked_ambiguous = false to
disable this check (ambiguous terms are then loaded only to validate the
disjoint invariant, and their use in backticks is neither flagged nor
modified).
Auto-detected when dir_terms_unambiguous exists and .md files are present.
Source Files
- Input:
**/*.md - Output: none (checker)
Configuration
[processor.checker.terms]
dir_terms_unambiguous = "terms/unambiguous" # Directory of unambiguous term lists
dir_terms_ambiguous = "terms/ambiguous" # Optional: directory of ambiguous terms
forbid_backticked_ambiguous = true # Backticking an ambiguous term is an error
batch = true # Enable batch execution
dep_inputs = [] # Additional files that trigger rebuilds
| Key | Type | Default | Description |
|---|---|---|---|
dir_terms_unambiguous | string | "terms/unambiguous" | Directory containing .txt files of unambiguous terms. Terms here must be backticked in markdown. |
dir_terms_ambiguous | string | "terms/ambiguous" | Directory of ambiguous terms. Treated as empty if the directory does not exist on disk. Build fails if any term overlaps with dir_terms_unambiguous. Terms here are not required to be backticked. |
forbid_backticked_ambiguous | bool | true | If true, backticking an ambiguous term is a build error and term fix strips those backticks. |
batch | bool | true | Enable batch execution |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
The tool accepts multiple files on the command line. When batching is enabled (default), rsconstruct passes all files in a single invocation for better performance.
Term List Format
Each .txt file in a terms directory contains one term per line. Files are
typically organized by category. Projects that distinguish unambiguous
from ambiguous terms use two parallel directories:
terms/unambiguous/
programming_languages.txt
frameworks_and_libraries.txt
databases_and_storage.txt
devops_and_cicd.txt
...
terms/ambiguous/
general_technical_terms.txt
...
The two directories must be disjoint — the same term cannot appear in both. The build fails (with the overlapping term names listed) if the invariant is violated.
Example programming_languages.txt:
Python
JavaScript
TypeScript
Rust
C++
Go
Commands
rsconstruct term fix
Add backticks around unquoted unambiguous terms in all markdown files.
When forbid_backticked_ambiguous = true (default), also strip backticks
around ambiguous terms.
rsconstruct term fix
rsconstruct term fix --remove-non-terms # also remove backticks from arbitrary non-terms
The fix is idempotent: running it twice produces the same result.
rsconstruct term merge <path>
Merge terms from another project’s terms directory into the current one. For matching filenames, new terms are added (union). Missing files are copied in both directions.
rsconstruct term merge ../other-project/terms
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Tidy Processor
Purpose
Validates HTML files using HTML Tidy.
The Rust alternative is itidy: tidy’s checks, in-process, with tidy’s messages, positions and verdicts. Switching is tidy → itidy in the stanza header, args = ["-config", "x.conf"] → config_file = "x.conf", and args = ["--name", "value"] → options = ["name: value"]. Files that need tidy’s clean, gdoc or accessibility checks stay here.
How It Works
Discovers .html and .htm files in the project (excluding common build tool
directories), runs tidy -errors on each file, and records success in the cache.
A non-zero exit code from tidy fails the product.
This processor supports batch mode.
Source Files
- Input:
**/*.html,**/*.htm - Output: none (checker)
Configuration
[processor.checker.tidy]
args = []
dep_inputs = []
| Key | Type | Default | Description |
|---|---|---|---|
args | string[] | [] | Extra arguments passed to tidy |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
The tool accepts multiple files on the command line. When batching is enabled (default), rsconstruct passes all files in a single invocation for better performance.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
XMLLint Processor
Purpose
Validates XML files using xmllint.
The Rust alternative is ixmllint: the same well-formedness checks and --schema validation, in-process, with libxml2’s messages and verdicts. Switching is xmllint → ixmllint in the stanza header, and args = ["--schema", "x.xsd"] → schema = "x.xsd". Files that need --valid, --dtdvalid or --relaxng stay here.
How It Works
Discovers .xml files in the project (excluding common build tool directories),
runs xmllint --noout on each file, and records success in the cache. A non-zero
exit code from xmllint fails the product.
This processor supports batch mode.
Source Files
- Input:
**/*.xml - Output: none (checker)
Configuration
[processor.checker.xmllint]
args = []
dep_inputs = []
| Key | Type | Default | Description |
|---|---|---|---|
args | string[] | [] | Extra arguments passed to xmllint |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
The tool accepts multiple files on the command line. When batching is enabled (default), rsconstruct passes all files in a single invocation for better performance.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Yamllint Processor
Purpose
Lints YAML files using yamllint.
The Rust alternative is iyamllint: the same rules and the same .yamllint.yaml, in-process, with no Python to install. Switching is yamllint → iyamllint in the stanza header.
How It Works
Discovers .yml and .yaml files in the project (excluding common build tool
directories), runs yamllint on each file, and records success in the cache.
A non-zero exit code from yamllint fails the product.
This processor supports batch mode, allowing multiple files to be checked in a single yamllint invocation for better performance.
Source Files
- Input:
**/*.yml,**/*.yaml - Output: none (checker)
Configuration
[processor.checker.yamllint]
command = "yamllint" # The yamllint command to run
args = [] # Additional arguments to pass to yamllint
dep_inputs = [] # Additional files that trigger rebuilds when changed
| Key | Type | Default | Description |
|---|---|---|---|
command | string | "yamllint" | The yamllint executable to run |
args | string[] | [] | Extra arguments passed to yamllint |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
The tool accepts multiple files on the command line. When batching is enabled (default), rsconstruct passes all files in a single invocation for better performance.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Yq Processor
Purpose
Validates YAML files using yq.
The Rust alternative is iyq: YAML read in-process and the jq filter evaluated with jaq, the same files passing and failing. Switching is yq → iyq in the stanza header, with args = ["FILTER"] → filter = "FILTER".
How It Works
Discovers .yml and .yaml files in the project (excluding common build tool
directories), runs yq . on each file to validate syntax, and records success
in the cache. A non-zero exit code from yq fails the product.
This processor supports batch mode.
Source Files
- Input:
**/*.yml,**/*.yaml - Output: none (checker)
Configuration
[processor.checker.yq]
args = []
dep_inputs = []
| Key | Type | Default | Description |
|---|---|---|---|
args | string[] | [] | Extra arguments passed to yq |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
The tool accepts multiple files on the command line. When batching is enabled (default), rsconstruct passes all files in a single invocation for better performance.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Zspell Processor
Purpose
Checks documentation files for spelling errors using Hunspell-compatible
dictionaries (via the zspell crate, pure Rust).
How It Works
Discovers files matching the configured extensions, extracts words from markdown content (stripping code blocks, inline code, URLs, and HTML tags), and checks each word against the system Hunspell dictionary and a custom words file (if it exists). Fails with a list of misspelled words on error.
Dictionaries are read from /usr/share/hunspell/.
This processor supports batch mode when auto_add_words is enabled, collecting
all misspelled words across files and writing them to the words file at the end.
Source Files
- Input:
**/*{src_extensions}(default:**/*.md) - Output: none (checker)
Custom Words File
The processor loads custom words from the file specified by words_file
(default: .zspell-words) if the file exists. Format: one word per line,
# comments supported, blank lines ignored.
The words file is also auto-detected as an input via dep_auto, so changes
to it invalidate all zspell products. To disable words file detection, set
dep_auto = [].
Configuration
[processor.checker.zspell]
src_extensions = [".md"] # File extensions to check (default: [".md"])
language = "en_US" # Hunspell dictionary language (default: "en_US")
words_file = ".zspell-words" # Path to custom words file (default: ".zspell-words")
auto_add_words = false # Auto-add misspelled words to words_file (default: false)
dep_auto = [".zspell-words"] # Auto-detected config files (default: [".zspell-words"])
dep_inputs = [] # Additional files that trigger rebuilds when changed
| Key | Type | Default | Description |
|---|---|---|---|
src_extensions | string[] | [".md"] | File extensions to discover |
language | string | "en_US" | Hunspell dictionary language (requires system package) |
words_file | string | ".zspell-words" | Path to custom words file (relative to project root) |
auto_add_words | bool | false | Auto-add misspelled words to words_file instead of failing (also available as --auto-add-words CLI flag) |
dep_auto | string[] | [".zspell-words"] | Config files auto-detected as inputs |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
The tool accepts multiple files on the command line. When batching is enabled (default), rsconstruct passes all files in a single invocation for better performance.
Clean behavior
This processor is a Checker — rsconstruct clean outputs is a no-op for it (checkers produce no outputs). See Clean behavior and rsconstruct clean.
Generators
A generator transforms each input file into one or more output files whose paths are derived from the input path. Outputs are cached as blobs and restored when inputs are unchanged. See Processor Types for how generators work.
The pages in this section document the built-in generators. These generators have no dedicated page yet: ipdfunite, isass.
Run rsconstruct processor list for the full list with each processor’s type.
A2x Processor
Purpose
Converts AsciiDoc files to PDF (or other formats) using a2x.
How It Works
Discovers .txt (AsciiDoc) files in the project and runs a2x on each file,
producing output in the configured format.
Source Files
- Input:
**/*.txt - Output:
out/processor.generator.a2x/{relative_path}.pdf
Configuration
[processor.generator.a2x]
a2x = "a2x" # The a2x command to run
format = "pdf" # Output format (pdf, xhtml, dvi, ps, epub, mobi)
args = [] # Additional arguments to pass to a2x
output_dir = "out/processor.generator.a2x" # Output directory
dep_inputs = [] # Additional files that trigger rebuilds when changed
| Key | Type | Default | Description |
|---|---|---|---|
a2x | string | "a2x" | The a2x executable to run |
format | string | "pdf" | Output format |
args | string[] | [] | Extra arguments passed to a2x |
output_dir | string | "out/processor.generator.a2x" | Output directory |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
Each input file is processed individually, producing its own output file.
Clean behavior
This processor is a Generator — rsconstruct clean outputs removes each declared output file individually with no directory recursion. After all per-product cleans complete, the orchestrator removes any parent directories that are now empty. Pass --no-empty-dirs to keep them. See Clean behavior and rsconstruct clean.
CC Single File Processor
Purpose
Compiles C (.c) and C++ (.cc) source files into executables, one source file per executable.
How It Works
Source files under the configured source directory are compiled into executables
under out/processor.generator.cc_single_file/, mirroring the directory structure:
src/main.c → out/processor.generator.cc_single_file/main.elf
src/a/b.c → out/processor.generator.cc_single_file/a/b.elf
src/app.cc → out/processor.generator.cc_single_file/app.elf
Header dependencies are automatically tracked via compiler-generated .d files
(-MMD -MF). When a header changes, all source files that include it are rebuilt.
Source Files
- Input:
{source_dir}/**/*.c,{source_dir}/**/*.cc - Output:
out/processor.generator.cc_single_file/{relative_path}{output_suffix}
Per-File Flags
Per-file compile and link flags can be set via special comments in source files. This allows individual files to require specific libraries or compiler options without affecting the entire project.
Flag directives
// EXTRA_COMPILE_FLAGS_BEFORE=-pthread
// EXTRA_COMPILE_FLAGS_AFTER=-O2 -DNDEBUG
// EXTRA_LINK_FLAGS_BEFORE=-L/usr/local/lib
// EXTRA_LINK_FLAGS_AFTER=-lX11
Command directives
Execute a command and use its stdout as flags (no shell):
// EXTRA_COMPILE_CMD=pkg-config --cflags gtk+-3.0
// EXTRA_LINK_CMD=pkg-config --libs gtk+-3.0
Shell directives
Execute via sh -c (full shell syntax):
// EXTRA_COMPILE_SHELL=echo -DLEVEL2_CACHE_LINESIZE=$(getconf LEVEL2_CACHE_LINESIZE)
// EXTRA_LINK_SHELL=echo -L$(brew --prefix openssl)/lib
Backtick substitution
Flag directives also support backtick substitution for inline command execution:
// EXTRA_COMPILE_FLAGS_AFTER=`pkg-config --cflags gtk+-3.0`
// EXTRA_LINK_FLAGS_AFTER=`pkg-config --libs gtk+-3.0`
Command caching
All command and shell directives (EXTRA_*_CMD, EXTRA_*_SHELL, and backtick substitutions) are cached in memory during a build. If multiple source files use the same command (e.g., pkg-config --cflags gtk+-3.0), it is executed only once. This improves build performance when many files share common dependencies.
Compiler profile-specific flags
When using multiple compiler profiles, you can specify flags that only apply to a specific compiler by adding [profile_name] after the directive name:
// EXTRA_COMPILE_FLAGS_BEFORE=-g
// EXTRA_COMPILE_FLAGS_BEFORE[gcc]=-femit-struct-debug-baseonly
// EXTRA_COMPILE_FLAGS_BEFORE[clang]=-gline-tables-only
In this example:
-gis applied to all compilers-femit-struct-debug-baseonlyis only applied when compiling with the “gcc” profile-gline-tables-onlyis only applied when compiling with the “clang” profile
The profile name matches the name field in your [[processor.generator.cc_single_file.compilers]] configuration:
[[processor.generator.cc_single_file.compilers]]
name = "gcc" # Matches [gcc] suffix
cc = "gcc"
[[processor.generator.cc_single_file.compilers]]
name = "clang" # Matches [clang] suffix
cc = "clang"
This works with all directive types:
EXTRA_COMPILE_FLAGS_BEFORE[profile]EXTRA_COMPILE_FLAGS_AFTER[profile]EXTRA_LINK_FLAGS_BEFORE[profile]EXTRA_LINK_FLAGS_AFTER[profile]EXTRA_COMPILE_CMD[profile]EXTRA_LINK_CMD[profile]EXTRA_COMPILE_SHELL[profile]EXTRA_LINK_SHELL[profile]
Excluding files from specific profiles
To exclude a source file from being compiled with specific compiler profiles, use EXCLUDE_PROFILE:
// EXCLUDE_PROFILE=clang
This is useful when a file uses compiler-specific features that aren’t available in other compilers. For example, a file using GCC-only builtins like __builtin_va_arg_pack_len():
// EXCLUDE_PROFILE=clang
// This file uses GCC-specific builtins
#include <stdarg.h>
void example(int first, ...) {
int count = __builtin_va_arg_pack_len(); // GCC-only
// ...
}
You can exclude multiple profiles by listing them space-separated:
// EXCLUDE_PROFILE=clang icc
Directive summary
| Directive | Execution | Use case |
|---|---|---|
EXTRA_COMPILE_FLAGS_BEFORE | Literal flags | Flags before default cflags |
EXTRA_COMPILE_FLAGS_AFTER | Literal flags | Flags after default cflags |
EXTRA_LINK_FLAGS_BEFORE | Literal flags | Flags before default ldflags |
EXTRA_LINK_FLAGS_AFTER | Literal flags | Flags after default ldflags |
EXTRA_COMPILE_CMD | Subprocess (no shell) | Dynamic compile flags via command |
EXTRA_LINK_CMD | Subprocess (no shell) | Dynamic link flags via command |
EXTRA_COMPILE_SHELL | sh -c (full shell) | Dynamic compile flags needing shell features |
EXTRA_LINK_SHELL | sh -c (full shell) | Dynamic link flags needing shell features |
Supported comment styles
Directives can appear in any of these comment styles:
C++ style:
// EXTRA_LINK_FLAGS_AFTER=-lX11
C block comment (single line):
/* EXTRA_LINK_FLAGS_AFTER=-lX11 */
C block comment (multi-line, star-prefixed):
/*
* EXTRA_LINK_FLAGS_AFTER=-lX11
*/
Command Line Ordering
The compiler command is constructed in this order:
compiler -MMD -MF deps -I... [compile_before] [cflags/cxxflags] [compile_after] -o output source [link_before] [ldflags] [link_after]
Link flags come after the source file so the linker can resolve symbols correctly.
| Position | Source |
|---|---|
compile_before | EXTRA_COMPILE_FLAGS_BEFORE + EXTRA_COMPILE_CMD + EXTRA_COMPILE_SHELL |
cflags/cxxflags | [processor.generator.cc_single_file] config cflags or cxxflags |
compile_after | EXTRA_COMPILE_FLAGS_AFTER |
link_before | EXTRA_LINK_FLAGS_BEFORE + EXTRA_LINK_CMD + EXTRA_LINK_SHELL |
ldflags | [processor.generator.cc_single_file] config ldflags |
link_after | EXTRA_LINK_FLAGS_AFTER |
Verbosity Levels (--processor-verbose N)
| Level | Output |
|---|---|
| 0 (default) | Target basename: main.elf |
| 1 | Target path + compiler commands: out/processor.generator.cc_single_file/main.elf |
| 2 | Adds source path: out/processor.generator.cc_single_file/main.elf <- src/main.c |
| 3 | Adds all inputs: out/processor.generator.cc_single_file/main.elf <- src/main.c, src/utils.h |
Configuration
Single Compiler (Legacy)
[processor.generator.cc_single_file]
cc = "gcc" # C compiler (default: "gcc")
cxx = "g++" # C++ compiler (default: "g++")
cflags = [] # C compiler flags
cxxflags = [] # C++ compiler flags
ldflags = [] # Linker flags
include_paths = [] # Additional -I paths (relative to project root)
src_dirs = ["src"] # Source directory (required; no default)
output_suffix = ".elf" # Suffix for output executables (default: ".elf")
dep_inputs = [] # Additional files that trigger rebuilds when changed
include_scanner = "native" # Method for scanning header dependencies (default: "native")
Multiple Compilers
To compile with multiple compilers (e.g., both GCC and Clang), use the compilers array:
[processor.generator.cc_single_file]
src_dirs = ["src"]
include_paths = ["include"] # Shared across all compilers
[[processor.generator.cc_single_file.compilers]]
name = "gcc"
cc = "gcc"
cxx = "g++"
cflags = ["-Wall", "-Wextra"]
cxxflags = ["-Wall", "-Wextra"]
ldflags = []
output_suffix = ".elf"
[[processor.generator.cc_single_file.compilers]]
name = "clang"
cc = "clang"
cxx = "clang++"
cflags = ["-Wall", "-Wextra", "-Weverything"]
cxxflags = ["-Wall", "-Wextra"]
ldflags = []
output_suffix = ".elf"
When using multiple compilers, outputs are organized by compiler name:
src/main.c → out/processor.generator.cc_single_file/gcc/main.elf
→ out/processor.generator.cc_single_file/clang/main.elf
Each source file is compiled once per compiler profile, allowing you to:
- Test code with multiple compilers to catch different warnings
- Compare output between compilers
- Build for different targets (cross-compilation)
Configuration Reference
| Key | Type | Default | Description |
|---|---|---|---|
cc | string | "gcc" | C compiler command |
cxx | string | "g++" | C++ compiler command |
cflags | string[] | [] | Flags passed to the C compiler |
cxxflags | string[] | [] | Flags passed to the C++ compiler |
ldflags | string[] | [] | Flags passed to the linker |
include_paths | string[] | [] | Additional -I include paths (shared) |
src_dirs | string[] | [] | Directories to scan for source files. No default — an unset src_dirs scans nothing. |
output_suffix | string | ".elf" | Suffix appended to output executables |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
include_scanner | string | "native" | Method for scanning header dependencies |
compilers | array | [] | Multiple compiler profiles (overrides single-compiler fields) |
Compiler Profile Fields
Each entry in the compilers array can have:
| Key | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Profile name (used in output path) |
cc | string | No | C compiler (default: “gcc”) |
cxx | string | No | C++ compiler (default: “g++”) |
cflags | string[] | No | C compiler flags |
cxxflags | string[] | No | C++ compiler flags |
ldflags | string[] | No | Linker flags |
output_suffix | string | No | Output suffix (default: “.elf”) |
Batch support
Each input file is processed individually, producing its own output file.
Include Scanner
The include_scanner option controls how header dependencies are discovered:
| Value | Description |
|---|---|
native | Fast regex-based scanner (default). Parses #include directives directly without spawning external processes. Handles #include "file" and #include <file> forms. |
compiler | Uses gcc -MM / g++ -MM to scan dependencies. More accurate for complex cases (computed includes, conditional compilation) but slower as it spawns a compiler process per source file. |
Native scanner behavior
The native scanner:
- Recursively follows
#includedirectives - Searches include paths in order: source file directory, configured
include_paths, project root - Skips system headers (
/usr/...,/lib/...) - Only tracks project-local headers (relative paths)
When to use compiler scanner
Use include_scanner = "compiler" if you have:
- Computed includes:
#include MACRO_THAT_EXPANDS_TO_FILENAME - Complex conditional compilation affecting which headers are included
- Headers outside the standard search paths that the native scanner misses
The native scanner may occasionally report extra dependencies (false positives), which is safe—it just means some files might rebuild unnecessarily. It will not miss dependencies (false negatives) for standard #include patterns.
Clean behavior
This processor is a Generator — rsconstruct clean outputs removes each declared output file individually with no directory recursion. After all per-product cleans complete, the orchestrator removes any parent directories that are now empty. Pass --no-empty-dirs to keep them. See Clean behavior and rsconstruct clean.
Chromium Processor
Purpose
Converts HTML files to PDF using headless Chromium (Google Chrome).
How It Works
Discovers .html files in the configured scan directory (default: out/processor.generator.marp) and runs
headless Chromium with --print-to-pdf on each file, producing a PDF output.
This is typically used as a post-processing step after another processor (e.g., Marp) generates HTML files.
Source Files
- Input:
out/processor.generator.marp/**/*.html(default scan directory) - Output:
out/processor.generator.chromium/{relative_path}.pdf
Configuration
[processor.generator.chromium]
command = "google-chrome" # The Chromium/Chrome executable to run
args = [] # Additional arguments to pass to Chromium
output_dir = "out/processor.generator.chromium" # Output directory for PDFs
dep_inputs = [] # Additional files that trigger rebuilds when changed
| Key | Type | Default | Description |
|---|---|---|---|
command | string | "google-chrome" | The Chromium or Google Chrome executable |
args | string[] | [] | Extra arguments passed to Chromium |
output_dir | string | "out/processor.generator.chromium" | Base output directory for PDF files |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
Each input file is processed individually, producing its own output file.
Clean behavior
This processor is a Generator — rsconstruct clean outputs removes each declared output file individually with no directory recursion. After all per-product cleans complete, the orchestrator removes any parent directories that are now empty. Pass --no-empty-dirs to keep them. See Clean behavior and rsconstruct clean.
Drawio Processor
Purpose
Converts Draw.io diagram files to PNG, SVG, or PDF.
How It Works
Discovers .drawio files in the project and runs drawio in export mode on
each file, generating output in the configured formats.
Source Files
- Input:
**/*.drawio - Output:
out/processor.generator.drawio/{format}/{relative_path}.{format}
Configuration
[processor.generator.drawio]
command = "drawio" # The drawio command to run
formats = ["png"] # Output formats (png, svg, pdf)
args = [] # Additional arguments to pass to drawio
output_dir = "out/processor.generator.drawio" # Output directory
dep_inputs = [] # Additional files that trigger rebuilds when changed
| Key | Type | Default | Description |
|---|---|---|---|
command | string | "drawio" | The drawio executable to run |
formats | string[] | ["png"] | Output formats to generate (png, svg, pdf) |
args | string[] | [] | Extra arguments passed to drawio |
output_dir | string | "out/processor.generator.drawio" | Base output directory |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
Each input file is processed individually, producing its own output file.
Clean behavior
This processor is a Generator — rsconstruct clean outputs removes each declared output file individually with no directory recursion. After all per-product cleans complete, the orchestrator removes any parent directories that are now empty. Pass --no-empty-dirs to keep them. See Clean behavior and rsconstruct clean.
Generator Processor
Purpose
Runs a user-configured script or command as a generator, producing output files from input files. The script receives input/output path pairs on the command line.
How It Works
Discovers files matching the configured extensions, computes output paths under
output_dir with the configured output_extension, and invokes the command with
path pairs.
In single mode: command [args...] <input> <output>
In batch mode: command [args...] <input1> <output1> <input2> <output2> ...
Auto-detected when the configured scan directories contain matching files.
Source Files
- Input: files matching
src_extensionsinsrc_dirs - Output:
{output_dir}/{relative_path}.{output_extension}
Configuration
[processor.generator.generic]
command = "scripts/convert.py"
output_dir = "out/converted"
output_extension = "html"
src_dirs = ["syllabi"]
src_extensions = [".md"]
batch = true
args = []
dep_inputs = []
| Key | Type | Default | Description |
|---|---|---|---|
command | string | "true" | Script or command to run |
output_dir | string | "out/processor.generator.generic" (out/processor.generator.generic.NAME for a named instance) | Directory for output files |
output_extension | string | "out" | Extension for output files |
batch | bool | true | Pass all pairs in one invocation |
args | string[] | [] | Extra arguments prepended before file pairs |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
Configurable via batch = true (default). In batch mode, the script receives all input/output pairs in a single invocation. Set batch = false to invoke the script once per file.
Clean behavior
This processor is a Generator — rsconstruct clean outputs removes each declared output file individually with no directory recursion. After all per-product cleans complete, the orchestrator removes any parent directories that are now empty. Pass --no-empty-dirs to keep them. See Clean behavior and rsconstruct clean.
Ijinja2 Processor
Purpose
Renders Jinja2 templates into output files in-process, with minijinja (no Python, no jinja2 package). The Rust alternative to the jinja2 generator, with the same contract: the same files are found, written to the same places, rendered with the same context.
How It Works
Files under src_dirs whose names end in a src_extensions entry are rendered, and the output is written to the same relative path with the extension removed:
templates.jinja2/app.config.j2 → app.config
templates.jinja2/sub/readme.txt.j2 → sub/readme.txt
The project root is the template root, so {% include %}, {% extends %} and {% import %} take paths relative to it ({% include 'templates.jinja2/parts/header.inc' %}). The environment variables are the context: {{ HOME }} is the home directory, {{ MISSING }} renders as nothing and {{ MISSING | default('x') }} as x. The engine is set up like a plain Jinja2 Environment: no autoescaping (not even for .html outputs), the single trailing newline of a template dropped, blocks and lines trimmed only where - asks for it.
What minijinja renders differently
minijinja implements Jinja2’s syntax, its filters and tests, and (through minijinja-contrib) the Python string and dict methods templates call, such as .items(), .lower(), .startswith(), .format(). Where minijinja’s version of a filter takes fewer arguments than Jinja2’s, ijinja2 supplies Jinja2’s: truncate(length, killwords, end), replace(old, new, count), round(precision, method), int(default, base), sum(attribute, start), center(width), forceescape. Checked against Python Jinja2 over 34 templates and 59 single-construct probes covering control flow, loop variables, set and namespace, macros with call, include/extends/super(), whitespace control, comments and raw, dictionaries and slicing, the string methods, the tests, and the filters: identical output except for the following, each a construct rather than a quirk of the data:
wordwraptakes its width as a keyword (wordwrap(width=5)), andtruncate’sleewayis keyword-only.sumadds numbers only (sum(start=[])to concatenate lists is an error).2 ** -1is an error (Python gives0.5).- A dictionary key that is also a Python method name (
items,keys,get) is read as the key:{{ d.items }}is the value where Jinja2 prints a bound method. Likewise theattrfilter reads keys, where Jinja2 only reads Python attributes. [1, 2] | reverseprints the reversed list where Jinja2 prints an iterator object.urlencodeon a dictionary encodes a space as%20, Jinja2 as+.
A template that hits one of the first three fails with a message naming the template and the construct; it stays on the jinja2 generator or is rewritten.
Source Files
- Input:
.j2files undersrc_dirs - Output: project root, mirroring the template path (minus the
src_dirsprefix) with the extension removed
The default src_dirs is empty, so a stanza with no src_dirs or src_files renders nothing: name the template directories.
Configuration
[processor.generator.ijinja2]
src_dirs = ["templates.jinja2"]
dep_inputs = ["templates.jinja2/parts/header.inc"] # included files are not tracked automatically
| Key | Type | Default | Description |
|---|---|---|---|
src_dirs | string[] | [] | Directories to scan |
src_extensions | string[] | [".j2"] | File extensions to render |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds (included and extended templates) |
Switching a repo from jinja2: [processor.generator.jinja2] → [processor.generator.ijinja2]; command (the Python interpreter) goes away.
Batch support
Each template is rendered individually, producing its own output file.
Clean behavior
This processor is a Generator — rsconstruct clean outputs removes each declared output file individually with no directory recursion. After all per-product cleans complete, the orchestrator removes any parent directories that are now empty. Pass --no-empty-dirs to keep them. See Clean behavior and rsconstruct clean.
Imarkdown2html Processor
Purpose
Converts Markdown files to HTML using the pulldown-cmark Rust crate. Native (in-process, no external tools required).
This is the native equivalent of markdown2html, which uses the external markdown Perl script.
Source Files
- Input:
**/*.md - Output:
out/processor.generator.imarkdown2html/{relative_path}.html
Configuration
[processor.generator.imarkdown2html]
src_dirs = ["docs"]
output_dir = "out/processor.generator.imarkdown2html" # Output directory (default)
| Key | Type | Default | Description |
|---|---|---|---|
output_dir | string | "out/processor.generator.imarkdown2html" | Output directory for HTML files |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch Support
Each input file is processed individually, producing its own output file.
Clean behavior
This processor is a Generator — rsconstruct clean outputs removes each declared output file individually with no directory recursion. After all per-product cleans complete, the orchestrator removes any parent directories that are now empty. Pass --no-empty-dirs to keep them. See Clean behavior and rsconstruct clean.
Ipdfunite Processor
Purpose
Merges PDFs from subdirectories into one PDF per subdirectory. Native (in-process, using lopdf; no external tools required). An in-process alternative to pdfunite, with the same configuration apart from command and args.
How It Works
Built for course/module workflows, where the slide decks of a course live in one directory and an upstream processor (usually marp) renders each deck to PDF:
- Every file with extension
source_extundersource_diris found and grouped by its directory. - For each directory, the PDF the upstream processor made from each file is located under
source_output_dir, at the same relative path with a.pdfextension. - Those PDFs are merged, in file-name order, into
<output_dir>/<directory>.pdf.
marp/courses/rust/01-intro.md -> out/processor.generator.marp/courses/rust/01-intro.pdf ┐
marp/courses/rust/02-types.md -> out/processor.generator.marp/courses/rust/02-types.pdf ┴> out/processor.generator.ipdfunite/rust.pdf
The input PDFs are declared as the product’s inputs, so the merge runs after the upstream processor has produced them and reruns when any of them changes.
Bookmarks (outlines) of the input PDFs are not carried over to the merged PDF.
Source Files
- Input: PDFs under
source_output_dirthat correspond to thesource_extfiles undersource_dir - Output:
out/processor.generator.ipdfunite/<directory>.pdf, one per source directory
Configuration
[processor.generator.ipdfunite]
source_dir = "marp/courses" # Directory containing course subdirectories
source_ext = ".md" # Extension of the source files in them
source_output_dir = "out/processor.generator.marp" # Where the upstream processor put the PDFs
| Key | Type | Default | Description |
|---|---|---|---|
source_dir | string | "marp/courses" | Directory containing course subdirectories |
source_ext | string | ".md" | Extension of the source files to look for |
source_output_dir | string | "out/processor.generator.marp" | Directory where the upstream processor writes the PDFs |
output_dir | string | "out/processor.generator.ipdfunite" | Output directory for merged PDFs |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
Each directory is merged individually, producing its own output file. Merges run one at a time (max_jobs is capped at 1).
Clean behavior
This processor is a Generator — rsconstruct clean outputs removes each declared output file individually with no directory recursion. After all per-product cleans complete, the orchestrator removes any parent directories that are now empty. Pass --no-empty-dirs to keep them. See Clean behavior and rsconstruct clean.
Iprotobuf Processor
Purpose
Compiles Protocol Buffer definitions in-process, with protox, a Rust implementation of the protobuf compiler, and generates Rust code with prost. The Rust alternative to the protobuf generator, which runs protoc and emits C++.
How It Works
All the .proto files under src_dirs are compiled together as one product. import statements resolve against include_paths (the src_dirs by default); the well-known types (google/protobuf/*.proto) are built in. prost-build then writes one Rust file per protobuf package into the output directory, named after the package with each component in snake case, or _.rs for files without a package statement:
proto/greet.proto (package greet.v1) → out/processor.generator.iprotobuf/greet.v1.rs
proto/loose.proto (no package) → out/processor.generator.iprotobuf/_.rs
The files of one package land in the same Rust file, which is why the stanza is one product: adding a message to any .proto regenerates the set. Well-known types map to the prost-types crate rather than being generated. With descriptor_set set, the compiled FileDescriptorSet is written there too, the file protoc --include_imports --descriptor_set_out produces (without source locations, as protoc writes it by default).
Fidelity
The descriptor set protox compiles was compared with protoc 3.21’s over proto2 and proto3 files covering nested and map fields, oneofs, enums with reserved ranges, groups, extensions, default values, field and method options, services with streaming methods, and imports of user files and well-known types: byte-identical. A compile error names the file, line and column and what was expected, as protoc does. Editions (edition = "2023") are not supported by protox 0.10 (nor by protoc 3.21).
Source Files
- Input:
.protofiles undersrc_dirs - Output:
<package>.rsper package underoutput_dir, plusdescriptor_setwhen set
The default src_dirs is empty, so a stanza with no src_dirs or src_files compiles nothing: name the directories.
Configuration
[processor.generator.iprotobuf]
src_dirs = ["proto"]
include_paths = ["proto", "third_party/proto"]
descriptor_set = "out/descriptors.pb"
| Key | Type | Default | Description |
|---|---|---|---|
include_paths | string[] | [] | Directories import statements resolve against. Empty: the src_dirs |
descriptor_set | string | "" | Where to write the compiled FileDescriptorSet. Empty: not written |
src_dirs | string[] | [] | Directories to scan |
src_extensions | string[] | [".proto"] | File extensions to compile |
output_dir | string | "out/processor.generator.iprotobuf" | Where the Rust files go |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Switching a repo from protobuf: [processor.generator.protobuf] → [processor.generator.iprotobuf]; the output changes from <name>.pb.cc per file to <package>.rs per package. A repo that needs C++ (or any language other than Rust) stays on protobuf.
Batch support
Not applicable: the stanza’s files are one product.
Clean behavior
This processor is a Generator — rsconstruct clean outputs removes each declared output file individually with no directory recursion. After all per-product cleans complete, the orchestrator removes any parent directories that are now empty. Pass --no-empty-dirs to keep them. See Clean behavior and rsconstruct clean.
Isass Processor
Purpose
Compiles Sass and SCSS files to CSS. Native (in-process, using the grass compiler; no external tools required). An in-process alternative to sass.
How It Works
Each .scss or .sass file under src_dirs is compiled to a .css file under output_dir, keeping its path relative to the source directory:
sass/style.scss -> out/processor.generator.isass/style.css
sass/components/button.scss -> out/processor.generator.isass/components/button.css
@use and @import resolve relative to the importing file. Compile errors fail the product with grass’s message.
Declare the sass analyzer alongside this processor. It makes every file a stylesheet loads (@use, @forward, @import, transitively) an input of that stylesheet. Without it, a product’s only input is its own source file: editing a partial does not rebuild the stylesheets that use it, and they keep their old CSS.
Like the sass command line, partials are not compiled: a file whose name starts with _ (_vars.scss) exists to be @used by other stylesheets and gets no .css of its own. Set skip_partials = false to compile every matching file, partials included.
Source Files
- Input:
.scssand.sassfiles undersrc_dirs - Output:
out/processor.generator.isass/mirroring the source structure, with.cssextension
The default src_dirs is empty, so a stanza with no src_dirs or src_files compiles nothing.
Configuration
[processor.generator.isass]
src_dirs = ["sass"]
[analyzer.sass]
| Key | Type | Default | Description |
|---|---|---|---|
src_dirs | string[] | [] | Directories to scan |
src_extensions | string[] | [".scss", ".sass"] | File extensions to compile |
output_dir | string | "out/processor.generator.isass" | Output directory |
skip_partials | bool | true | Leave files whose name starts with _ (partials) out of discovery, as the sass CLI does. false compiles every matching file |
dep_inputs | string[] | [] | Extra files whose changes rebuild every product |
Batch support
Each input file is processed individually, producing its own output file.
Clean behavior
This processor is a Generator — rsconstruct clean outputs removes each declared output file individually with no directory recursion. After all per-product cleans complete, the orchestrator removes any parent directories that are now empty. Pass --no-empty-dirs to keep them. See Clean behavior and rsconstruct clean.
Jinja2 Processor
Purpose
Renders Jinja2 template files into output files using the Python Jinja2 template library.
The Rust alternative is ijinja2: the same discovery, output paths and environment-variable context, rendered in-process with minijinja. Switching is jinja2 → ijinja2 in the stanza header. A template that leans on Python-only calls ('{}'.format(), %-formatting, pprint) stays here.
How It Works
Files matching configured extensions in templates.jinja2/ are rendered via python3 using
the jinja2 Python library. Output is written with the extension stripped and the
templates.jinja2/ prefix removed:
templates.jinja2/app.config.j2 → app.config
templates.jinja2/sub/readme.txt.j2 → sub/readme.txt
Templates use the Jinja2 templating engine. A
FileSystemLoader is configured with the project root as the search directory, so
templates can include or extend other templates using relative paths. Environment
variables are passed to the template context.
Source Files
- Input:
templates.jinja2/**/*{src_extensions} - Output: project root, mirroring the template path (minus
templates.jinja2/prefix) with the extension removed
Configuration
[processor.generator.jinja2]
src_extensions = [".j2"] # File extensions to process (default: [".j2"])
dep_inputs = ["config/settings.py"] # Additional files that trigger rebuilds when changed
| Key | Type | Default | Description |
|---|---|---|---|
src_extensions | string[] | [".j2"] | File extensions to discover |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
Each input file is processed individually, producing its own output file.
Clean behavior
This processor is a Generator — rsconstruct clean outputs removes each declared output file individually with no directory recursion. After all per-product cleans complete, the orchestrator removes any parent directories that are now empty. Pass --no-empty-dirs to keep them. See Clean behavior and rsconstruct clean.
Libreoffice Processor
Purpose
Converts LibreOffice documents (e.g., .odp presentations) to PDF or other formats.
How It Works
Discovers .odp files in the project and runs libreoffice in headless mode
to convert each file to the configured output formats. Uses flock to serialize
invocations since LibreOffice only supports a single running instance.
Source Files
- Input:
**/*.odp - Output:
out/processor.generator.libreoffice/{format}/{relative_path}.{format}
Configuration
[processor.generator.libreoffice]
command = "libreoffice" # The libreoffice command to run
formats = ["pdf"] # Output formats (pdf, pptx)
args = [] # Additional arguments to pass to libreoffice
output_dir = "out/processor.generator.libreoffice" # Output directory
dep_inputs = [] # Additional files that trigger rebuilds when changed
| Key | Type | Default | Description |
|---|---|---|---|
command | string | "libreoffice" | The libreoffice executable to run |
formats | string[] | ["pdf"] | Output formats to generate (pdf, pptx) |
args | string[] | [] | Extra arguments passed to libreoffice |
output_dir | string | "out/processor.generator.libreoffice" | Base output directory |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
Each input file is processed individually, producing its own output file.
Clean behavior
This processor is a Generator — rsconstruct clean outputs removes each declared output file individually with no directory recursion. After all per-product cleans complete, the orchestrator removes any parent directories that are now empty. Pass --no-empty-dirs to keep them. See Clean behavior and rsconstruct clean.
Mako Processor
Purpose
Renders Mako template files into output files using the Python Mako template library.
How It Works
Files matching configured extensions in templates.mako/ are rendered via python3 using
the mako Python library. Output is written with the extension stripped and the
templates.mako/ prefix removed:
templates.mako/app.config.mako → app.config
templates.mako/sub/readme.txt.mako → sub/readme.txt
Templates use the Mako templating engine. A
TemplateLookup is configured with the project root as the lookup directory, so
templates can include or inherit from other templates using relative paths.
Source Files
- Input:
templates.mako/**/*{src_extensions} - Output: project root, mirroring the template path (minus
templates.mako/prefix) with the extension removed
Configuration
[processor.generator.mako]
src_extensions = [".mako"] # File extensions to process (default: [".mako"])
dep_inputs = ["config/settings.py"] # Additional files that trigger rebuilds when changed
| Key | Type | Default | Description |
|---|---|---|---|
src_extensions | string[] | [".mako"] | File extensions to discover |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
Each input file is processed individually, producing its own output file.
Clean behavior
This processor is a Generator — rsconstruct clean outputs removes each declared output file individually with no directory recursion. After all per-product cleans complete, the orchestrator removes any parent directories that are now empty. Pass --no-empty-dirs to keep them. See Clean behavior and rsconstruct clean.
Markdown2html Processor
Purpose
Converts Markdown files to HTML using the markdown Perl script.
The Rust alternative is imarkdown2html: Markdown converted in-process with pulldown-cmark, no Perl script. pulldown-cmark follows CommonMark, so its HTML can differ from the markdown script’s in the corners where the two disagree. Switching is markdown2html → imarkdown2html in the stanza header.
How It Works
Discovers .md files in the project and runs markdown on each file,
producing an HTML output file.
Source Files
- Input:
**/*.md - Output:
out/processor.generator.markdown2html/{relative_path}.html
Configuration
[processor.generator.markdown2html]
command = "markdown" # The markdown command to run
args = [] # Additional arguments to pass to markdown
output_dir = "out/processor.generator.markdown2html" # Output directory
dep_inputs = [] # Additional files that trigger rebuilds when changed
| Key | Type | Default | Description |
|---|---|---|---|
command | string | "markdown" | The markdown executable to run |
args | string[] | [] | Extra arguments passed to markdown |
output_dir | string | "out/processor.generator.markdown2html" | Output directory for HTML files |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
Each input file is processed individually, producing its own output file.
Clean behavior
This processor is a Generator — rsconstruct clean outputs removes each declared output file individually with no directory recursion. After all per-product cleans complete, the orchestrator removes any parent directories that are now empty. Pass --no-empty-dirs to keep them. See Clean behavior and rsconstruct clean.
Marp Processor
Purpose
Converts Markdown slides to PDF, PPTX, or HTML using Marp.
How It Works
Discovers .md files in the project and runs marp on each file, generating
output in the configured formats. Each format produces a separate output file.
Each marp invocation spawns a headless Chromium browser instance via Puppeteer to render the slides. This makes marp significantly more resource-intensive than typical processors — see Concurrency limiting below.
Source Files
- Input:
**/*.md - Output:
out/processor.generator.marp/{format}/{relative_path}.{format}
Configuration
[processor.generator.marp]
command = "marp" # The marp command to run
formats = ["pdf"] # Output formats (pdf, pptx, html)
args = ["--html", "--allow-local-files"] # Additional arguments to pass to marp
output_dir = "out/processor.generator.marp" # Output directory
dep_inputs = [] # Additional files that trigger rebuilds when changed
max_jobs = 2 # Limit concurrent marp instances (each spawns Chromium)
| Key | Type | Default | Description |
|---|---|---|---|
command | string | "marp" | The marp executable to run |
formats | string[] | ["pdf"] | Output formats to generate (pdf, pptx, html) |
args | string[] | ["--html", "--allow-local-files"] | Extra arguments passed to marp |
output_dir | string | "out/processor.generator.marp" | Base output directory |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
max_jobs | integer | none | Max concurrent marp processes. See Concurrency limiting. |
Concurrency Limiting
Each marp invocation launches a full headless Chromium browser process, which
consumes hundreds of megabytes of RAM. When running parallel builds with -j N,
too many simultaneous Chromium instances cause resource exhaustion and
non-deterministic crashes:
TargetCloseError: Protocol error (Target.setDiscoverTargets): Target closed
Use max_jobs to limit how many marp processes run concurrently, independent of
the global -j setting. For example, with -j 20 and max_jobs = 2, at most
2 Chromium instances will be alive at once while other processors still use the
full 20 threads:
[processor.generator.marp]
formats = ["pdf"]
max_jobs = 2
Recommended value: 2. A value of 4 may work on machines with plenty of RAM
but has been observed to produce occasional failures on large projects (700+ slides).
Without max_jobs, the global -j value applies, which typically causes crashes
at higher parallelism levels.
Batch Support
Each input file is processed individually, producing its own output file.
Temporary Files
Marp creates temporary Chromium profile directories (marp-cli-*) in /tmp for
each invocation. RSConstruct automatically cleans these up after each marp process
completes, since marp itself does not delete them.
Clean behavior
This processor is a Generator — rsconstruct clean outputs removes each declared output file individually with no directory recursion. After all per-product cleans complete, the orchestrator removes any parent directories that are now empty. Pass --no-empty-dirs to keep them. See Clean behavior and rsconstruct clean.
Mermaid Processor
Purpose
Converts Mermaid diagram files to PNG, SVG, or PDF using mmdc (mermaid-cli).
How It Works
Discovers .mmd files in the project and runs mmdc on each file, generating
output in the configured formats. Each format produces a separate output file.
Source Files
- Input:
**/*.mmd - Output:
out/processor.generator.mermaid/{format}/{relative_path}.{format}
Configuration
[processor.generator.mermaid]
command = "mmdc" # The mmdc command to run
formats = ["png"] # Output formats (png, svg, pdf)
args = [] # Additional arguments to pass to mmdc
output_dir = "out/processor.generator.mermaid" # Output directory
dep_inputs = [] # Additional files that trigger rebuilds when changed
| Key | Type | Default | Description |
|---|---|---|---|
command | string | "mmdc" | The mermaid-cli executable to run |
formats | string[] | ["png"] | Output formats to generate (png, svg, pdf) |
args | string[] | [] | Extra arguments passed to mmdc |
output_dir | string | "out/processor.generator.mermaid" | Base output directory |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
Each input file is processed individually, producing its own output file.
Clean behavior
This processor is a Generator — rsconstruct clean outputs removes each declared output file individually with no directory recursion. After all per-product cleans complete, the orchestrator removes any parent directories that are now empty. Pass --no-empty-dirs to keep them. See Clean behavior and rsconstruct clean.
Objdump Processor
Purpose
Disassembles ELF binaries using objdump.
How It Works
Discovers .elf files under out/processor.generator.cc_single_file/, runs objdump to produce
disassembly output, and writes the result to the configured output directory.
Source Files
- Input:
out/processor.generator.cc_single_file/**/*.elf - Output: disassembly files in output directory
Configuration
[processor.generator.objdump]
args = []
dep_inputs = []
output_dir = "out/processor.generator.objdump"
| Key | Type | Default | Description |
|---|---|---|---|
args | string[] | [] | Extra arguments passed to objdump |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
output_dir | string | "out/processor.generator.objdump" | Directory for disassembly output |
Batch support
Each input file is processed individually, producing its own output file.
Clean behavior
This processor is a Generator — rsconstruct clean outputs removes each declared output file individually with no directory recursion. After all per-product cleans complete, the orchestrator removes any parent directories that are now empty. Pass --no-empty-dirs to keep them. See Clean behavior and rsconstruct clean.
Pandoc Processor
Purpose
Converts documents between formats using pandoc.
How It Works
Discovers .md files in the project (default pandoc/ subdirectory) and runs
pandoc on each one, producing one output per format listed in formats.
Source Files
- Input:
pandoc/**/*.md(configurable viasrc_dirs/src_extensions) - Output:
out/processor.generator.pandoc/{relative_path}.{format}for eachformat
Configuration
[processor.generator.pandoc]
command = "pandoc" # Path to the pandoc executable
formats = ["pdf", "html", "docx"]
args = [] # Extra arguments passed to pandoc
output_dir = "out/processor.generator.pandoc"
dep_inputs = [] # Additional files that trigger rebuilds when changed
pdf_engine = "" # PDF engine; empty = pandoc default (pdflatex)
| Key | Type | Default | Description |
|---|---|---|---|
command | string | "pandoc" | Path to the pandoc executable |
formats | string[] | ["pdf", "html", "docx"] | Output formats to generate |
args | string[] | [] | Extra arguments passed to pandoc |
output_dir | string | "out/processor.generator.pandoc" | Base output directory |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
pdf_engine | string | "" | Forwarded to --pdf-engine= when generating PDFs. See below. |
PDF engine selection
Pandoc’s default engine for PDF output is pdflatex, which only handles
8-bit-encoded text. Source files containing characters outside latin-1
(Hebrew, Greek, Cyrillic, CJK, accented Unicode like λ, ש, Ω, é) will
fail to compile under pdflatex without manual \usepackage{...} setup.
Set pdf_engine to use a Unicode-aware engine instead:
[processor.generator.pandoc]
formats = ["pdf"]
pdf_engine = "xelatex" # or "lualatex"
Recognized values:
| Value | When to use |
|---|---|
"" (default) | Pandoc’s default (pdflatex). ASCII / latin-1 only. |
pdflatex | Same as default; explicit form. |
xelatex | Unicode-aware. Best general-purpose choice for non-ASCII text. |
lualatex | Unicode-aware, embeds a Lua interpreter for advanced typesetting. |
tectonic | Self-contained engine that downloads packages on demand. |
wkhtmltopdf, weasyprint, prince | HTML-based PDF rendering pipelines. |
context | ConTeXt-based typesetting. |
Notes:
pdf_engineonly affects products whose output format ispdf. For other formats (html, docx, etc.) the field is silently ignored.- The configured engine is added to the processor’s required-tools list, so
rsconstruct doctorandrsconstruct tool list-configuredwill flag it as missing if it isn’t installed. - Changing
pdf_engineinvalidates the cache for affected products (it is part of the config-change checksum), so a rebuild is triggered automatically. - An unknown value is rejected at config-load time with a clear error; the build never starts.
- The pdflatex-specific
\pdftrailerid{}reproducibility hint is only injected when the engine is pdflatex (or the empty default). Other engines would error on it.
Batch support
Each input file is processed individually, producing its own output file.
Clean behavior
Pandoc is a Generator — see Clean behavior and rsconstruct clean.
Pdflatex Processor
Purpose
Compiles LaTeX documents to PDF using pdflatex.
How It Works
Discovers .tex files in the project and runs pdflatex on each file. Runs
multiple compilation passes (configurable) to resolve cross-references and
table of contents. Optionally uses qpdf to linearize the output PDF.
Source Files
- Input:
**/*.tex - Output:
out/processor.generator.pdflatex/{relative_path}.pdf
Configuration
[processor.generator.pdflatex]
command = "pdflatex" # The pdflatex command to run
runs = 2 # Number of compilation passes
qpdf = true # Use qpdf to linearize output PDF
args = [] # Additional arguments to pass to pdflatex
output_dir = "out/processor.generator.pdflatex" # Output directory
dep_inputs = [] # Additional files that trigger rebuilds when changed
| Key | Type | Default | Description |
|---|---|---|---|
command | string | "pdflatex" | The pdflatex executable to run |
runs | integer | 2 | Number of compilation passes (for cross-references) |
qpdf | bool | true | Use qpdf to linearize the output PDF |
args | string[] | [] | Extra arguments passed to pdflatex |
output_dir | string | "out/processor.generator.pdflatex" | Output directory for PDF files |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
Each input file is processed individually, producing its own output file.
Clean behavior
This processor is a Generator — rsconstruct clean outputs removes each declared output file individually with no directory recursion. After all per-product cleans complete, the orchestrator removes any parent directories that are now empty. Pass --no-empty-dirs to keep them. See Clean behavior and rsconstruct clean.
Pdfunite Processor
Purpose
Merges PDF files from subdirectories into single combined PDFs using pdfunite.
The Rust alternative is ipdfunite: the same one PDF per subdirectory, merged in-process with lopdf, no poppler. Switching is pdfunite → ipdfunite in the stanza header.
How It Works
Scans subdirectories of the configured source directory for files matching the configured extension. For each subdirectory, it locates the corresponding PDFs (generated by an upstream processor such as marp) and merges them into a single output PDF.
This processor is designed for course/module workflows where slide decks in subdirectories are combined into course bundles.
Source Files
- Input: PDFs from upstream processor (e.g.,
out/processor.generator.marp/{subdir}/*.pdf) - Output:
out/processor.generator.pdfunite/{subdir}.pdf
Configuration
[processor.generator.pdfunite]
command = "pdfunite" # The pdfunite command to run
source_dir = "marp/courses" # Base directory containing course subdirectories
source_ext = ".md" # Extension of source files in subdirectories
source_output_dir = "out/processor.generator.marp" # Where the upstream processor puts PDFs
args = [] # Additional arguments to pass to pdfunite
output_dir = "out/processor.generator.pdfunite" # Output directory for merged PDFs
dep_inputs = [] # Additional files that trigger rebuilds when changed
| Key | Type | Default | Description |
|---|---|---|---|
command | string | "pdfunite" | The pdfunite executable to run |
source_dir | string | "marp/courses" | Directory containing course subdirectories |
source_ext | string | ".md" | Extension of source files to look for |
source_output_dir | string | "out/processor.generator.marp" | Directory where the upstream processor outputs PDFs |
args | string[] | [] | Extra arguments passed to pdfunite |
output_dir | string | "out/processor.generator.pdfunite" | Output directory for merged PDFs |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
Each input file is processed individually, producing its own output file.
Clean behavior
This processor is a Generator — rsconstruct clean outputs removes each declared output file individually with no directory recursion. After all per-product cleans complete, the orchestrator removes any parent directories that are now empty. Pass --no-empty-dirs to keep them. See Clean behavior and rsconstruct clean.
Protobuf Processor
Purpose
Compiles Protocol Buffer (.proto) files to generated source code using protoc.
The Rust alternative is iprotobuf: the files compiled in-process with protox (descriptors byte-identical to protoc’s) and Rust code generated with prost, one file per package. It emits Rust only; a repo that needs the C++ output stays here.
How It Works
Files matching configured extensions in the proto/ directory are compiled using the
Protocol Buffer compiler. Output is written to out/processor.generator.protobuf/:
proto/hello.proto → out/processor.generator.protobuf/hello.pb.cc
The --proto_path is automatically set to the parent directory of each input file.
Source Files
- Input:
proto/**/*.proto - Output:
out/processor.generator.protobuf/with.pb.ccextension
Configuration
[processor.generator.protobuf]
command = "protoc" # Protoc binary (default: "protoc")
src_extensions = [".proto"] # File extensions to process
output_dir = "out/processor.generator.protobuf" # Output directory (default: "out/processor.generator.protobuf")
dep_inputs = [] # Additional files that trigger rebuilds
| Key | Type | Default | Description |
|---|---|---|---|
command | string | "protoc" | Path to protoc compiler |
src_extensions | string[] | [".proto"] | File extensions to discover |
output_dir | string | "out/processor.generator.protobuf" | Output directory |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
Each input file is processed individually, producing its own output file.
Clean behavior
This processor is a Generator — rsconstruct clean outputs removes each declared output file individually with no directory recursion. After all per-product cleans complete, the orchestrator removes any parent directories that are now empty. Pass --no-empty-dirs to keep them. See Clean behavior and rsconstruct clean.
Requirements Processor
Purpose
Generates a requirements.txt file for a Python project by scanning the
project’s .py source files for import statements and listing the
third-party PyPI distributions they reference.
How It Works
- Scans every
.pyfile in the project’s source directories. - Extracts the top-level module name from each
import/fromstatement. - Drops imports that resolve to a local project file (intra-project imports).
- Drops imports that are part of the Python standard library.
- Drops imports listed in
exclude. - Maps each remaining import name to its PyPI distribution name using the
built-in curated table (e.g.
cv2→opencv-python,yaml→PyYAML). User-suppliedmappingentries win over the built-in table. - Appends any distributions listed in
extra(see below). - Writes the deduplicated result to
requirements.txt.
Extra distributions
Some packages do import x at module top level for a runtime helper they
do not declare as an install_requires dependency. The classic case is
manim-voiceover doing import pkg_resources, which lives in
setuptools. When that package’s metadata omits setuptools, pip will
not install it, and import manim_voiceover will fail at runtime.
Static AST scanning cannot see those transitive needs, so the extra
field lets you list distributions that should always appear in
requirements.txt:
[processor.generator.requirements]
extra = ["setuptools"]
Entries in extra bypass the exclude and stdlib filters — they are
distribution names the user is explicitly asserting must ship.
Import → Distribution Mapping
Most Python packages publish under the same name as their top-level import,
so the default is identity (import requests → requests). A curated table
handles the common exceptions:
| Import | Distribution |
|---|---|
cv2 | opencv-python |
yaml | PyYAML |
PIL | Pillow |
sklearn | scikit-learn |
bs4 | beautifulsoup4 |
dateutil | python-dateutil |
dotenv | python-dotenv |
jwt | PyJWT |
Projects that import an unusual name should add an override:
[processor.generator.requirements.mapping]
internal_tools = "acme-internal-tools"
Limitations
- No version pinning. The generated file lists bare distribution names.
Running
pip freeze > requirements.txtis the right tool if you need pinned versions. - Static analysis only. Conditional imports inside
tryblocks, runtime__import__calls, and string-based imports are not detected. - Curated mapping is finite. Packages with import/distribution name
mismatches not in the built-in table default to identity; add them to
mappingwhen needed.
Source Files
- Input:
**/*.py(configurable viasrc_dirs/src_extensions) - Output:
requirements.txt(configurable viaoutput)
Configuration
[processor.generator.requirements]
output = "requirements.txt" # Output file path
exclude = [] # Import names to never emit
sorted = true # Sort entries alphabetically
header = true # Include a "# Generated by rsconstruct" header
extra = [] # Distribution names to always include
[processor.generator.requirements.mapping]
# Per-project overrides: import_name = "pypi-distribution-name"
# These win over the built-in curated table.
| Key | Type | Default | Description |
|---|---|---|---|
output | string | "requirements.txt" | Output file path |
exclude | string[] | [] | Import names to never emit |
sorted | bool | true | Sort entries alphabetically (false preserves first-seen order) |
header | bool | true | Include a comment header line |
mapping | map | {} | Per-project import→distribution overrides |
extra | string[] | [] | Distribution names to always include (transitive deps undeclared by upstream) |
Batch support
Runs as a single whole-project operation — all .py files feed into one
requirements.txt output.
Clean behavior
This processor is a Generator — rsconstruct clean outputs removes each declared output file individually with no directory recursion. After all per-product cleans complete, the orchestrator removes any parent directories that are now empty. Pass --no-empty-dirs to keep them. See Clean behavior and rsconstruct clean.
Rust Single File Processor
Purpose
Compiles single-file Rust programs (.rs) into executables, similar to the cc_single_file
processor but for Rust.
How It Works
Rust source files in the src/ directory are compiled directly to executables using rustc.
This is useful for exercise, example, or utility repositories where each .rs file is a
standalone program.
Output is written to out/processor.generator.rust_single_file/ preserving the directory structure:
src/hello.rs → out/processor.generator.rust_single_file/hello.elf
src/exercises/ex1.rs → out/processor.generator.rust_single_file/exercises/ex1.elf
Source Files
- Input:
src/**/*.rs - Output:
out/processor.generator.rust_single_file/with configured suffix (default:.elf)
Configuration
[processor.generator.rust_single_file]
command = "rustc" # Rust compiler (default: "rustc")
flags = [] # Additional compiler flags
output_suffix = ".elf" # Output file suffix (default: ".elf")
output_dir = "out/processor.generator.rust_single_file" # Output directory
dep_inputs = [] # Additional files that trigger rebuilds
| Key | Type | Default | Description |
|---|---|---|---|
command | string | "rustc" | Path to Rust compiler |
flags | string[] | [] | Additional compiler flags |
output_suffix | string | ".elf" | Suffix for output executables |
output_dir | string | "out/processor.generator.rust_single_file" | Output directory |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
Each input file is processed individually, producing its own output file.
Clean behavior
This processor is a Generator — rsconstruct clean outputs removes each declared output file individually with no directory recursion. After all per-product cleans complete, the orchestrator removes any parent directories that are now empty. Pass --no-empty-dirs to keep them. See Clean behavior and rsconstruct clean.
Sass Processor
Purpose
Compiles SCSS and SASS files into CSS using the Sass compiler.
The Rust alternative is isass: Sass and SCSS compiled in-process with the grass compiler, no Sass installation needed. Switching is sass → isass in the stanza header.
How It Works
Files matching configured extensions in the sass/ directory are compiled to CSS.
Output is written to out/processor.generator.sass/ preserving the directory structure:
sass/style.scss -> out/processor.generator.sass/style.css
sass/components/button.scss -> out/processor.generator.sass/components/button.css
Declare the sass analyzer alongside this processor. It makes every file a stylesheet loads (@use, @forward, @import, transitively) an input of that stylesheet. Without it, editing a partial does not rebuild the stylesheets that use it, and they keep their old CSS. If you pass --load-path in args, give the analyzer the same directories in load_paths.
Source Files
- Input:
sass/**/*{src_extensions} - Output:
out/processor.generator.sass/mirroring the source structure with.cssextension
Configuration
[processor.generator.sass]
command = "sass" # Sass compiler binary (default: "sass")
src_extensions = [".scss", ".sass"] # File extensions to process
output_dir = "out/processor.generator.sass" # Output directory (default: "out/processor.generator.sass")
dep_inputs = [] # Additional files that trigger rebuilds
| Key | Type | Default | Description |
|---|---|---|---|
command | string | "sass" | Path to sass compiler |
src_extensions | string[] | [".scss", ".sass"] | File extensions to discover |
output_dir | string | "out/processor.generator.sass" | Output directory |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
Each input file is processed individually, producing its own output file.
Clean behavior
This processor is a Generator — rsconstruct clean outputs removes each declared output file individually with no directory recursion. After all per-product cleans complete, the orchestrator removes any parent directories that are now empty. Pass --no-empty-dirs to keep them. See Clean behavior and rsconstruct clean.
Tags Processor
Purpose
Extracts YAML frontmatter tags from markdown files into a searchable database with comprehensive validation.
How It Works
Scans .md files for YAML frontmatter blocks (delimited by ---), parses tag
metadata, and builds a redb database. The
database enables querying files by tags via rsconstruct tag subcommands.
Tag Indexing
Two kinds of frontmatter fields are indexed:
-
List fields — each item becomes a tag as-is.
tags: - tools:docker - tools:pythonProduces tags:
tools:docker,tools:python. -
Scalar fields — indexed as
key:value(colon separator).level: beginner category: big-data duration_hours: 24Produces tags:
level:beginner,category:big-data,duration_hours:24.
Both inline YAML lists (tags: [a, b, c]) and multi-line lists are supported.
The tags_dir Allowlist
The tags_dir directory (default: tags/) contains .txt files that
define the allowed tags. Each file <name>.txt contributes tags as
<name>:<line> pairs. For example:
tags/
├── level.txt # Contains: beginner, intermediate, advanced
├── languages.txt # Contains: python, rust, go, ...
├── tools.txt # Contains: docker, ansible, ...
└── audiences.txt # Contains: developers, architects, ...
level.txt with content beginner produces the allowed tag level:beginner.
The tags processor is only auto-detected when tags_dir contains .txt files.
Build-Time Validation
During every build, the tags processor runs the following checks. Any failure stops the build with a descriptive error message.
Required Frontmatter Fields
When required_fields is configured, every .md file must contain those
frontmatter fields. Empty lists ([]) and empty strings are treated as missing.
Files with no frontmatter block at all also fail:
[processor.generator.tags]
required_fields = ["tags", "level", "category", "duration_hours", "audiences"]
Missing required frontmatter fields:
syllabi/courses/intro.md: category, duration_hours
syllabi/courses/advanced.md: audiences
Required Field Groups
When required_field_groups is configured, every file must satisfy at least
one group (all fields in that group present). This handles cases where files
may have alternative sets of fields:
[processor.generator.tags]
required_field_groups = [
["duration_hours"],
["duration_hours_long", "duration_hours_short"],
]
A file with duration_hours passes. A file with both duration_hours_long and
duration_hours_short passes. A file with only duration_hours_short (partial
group) or none of these fields fails:
Files missing required field groups (must satisfy at least one):
syllabi/courses/intro.md: none of [duration_hours] or [duration_hours_long, duration_hours_short]
Required Values
When required_values is configured, scalar fields must contain a value that
exists in the corresponding tags/<field>.txt file. This catches typos in
scalar values:
[processor.generator.tags]
required_values = ["level", "category"]
Invalid values for validated fields:
syllabi/courses/intro.md: level=begginer (not in tags/level.txt)
Field Types
When field_types is configured, frontmatter fields must have the expected
type. Supported types: "list", "scalar", "number".
[processor.generator.tags.field_types]
tags = "list"
level = "scalar"
duration_hours = "number"
Field type mismatches:
syllabi/courses/intro.md: 'level' expected list, got scalar
Unique Fields
When unique_fields is configured, no two files may share the same value for
that field:
[processor.generator.tags]
unique_fields = ["title"]
Duplicate values for unique fields:
title='Intro to Docker' in:
- syllabi/courses/docker_intro.md
- syllabi/courses/containers/docker_intro.md
Sorted Tags
When sorted_tags = true, list-type frontmatter fields must have their items
in lexicographic sorted order. This reduces diff noise in version control:
[processor.generator.tags]
sorted_tags = true
List tags are not in sorted order:
syllabi/courses/intro.md field 'tags': 'tools:alpha' should come after 'tools:beta'
Duplicate Tags Within a File
The same tag cannot appear twice in a single file’s frontmatter:
Duplicate tags found within files:
tools:docker in syllabi/courses/containers/intro.md
Duplicate Tags Across Tag Lists
The same category:value tag cannot be defined in multiple tags_dir/*.txt
files. Note that the same value in different categories is fine (tools:docker
and infra:docker are distinct tags):
Duplicate tags found across tags files:
tools:docker in tools.txt and infra.txt
Unknown Tags
Every tag found in frontmatter must exist in tags_dir. Unknown tags cause an
error with a typo suggestion (Levenshtein distance):
Unknown tags found (not in tags):
tools:dockker (did you mean 'tools:docker'?)
- syllabi/courses/containers/intro.md
Unused Tags
Every tag defined in tags_dir/*.txt must be used by at least one .md file.
This catches stale entries that should be cleaned up:
Unused tags in tags (not used by any file):
tools:vagrant
languages:fortran
Source Files
- Input:
**/*.md(configurable viasrc_dirs/src_extensions) - Output:
out/processor.generator.tags/tags.db
Configuration
[processor.generator.tags]
output = "out/processor.generator.tags/tags.db" # Output database path
tags_dir = "tags" # Directory containing tag list files
required_fields = ["tags", "level", "category"] # Fields every .md file must have
required_field_groups = [ # At least one group must be fully present
["duration_hours"],
["duration_hours_long", "duration_hours_short"],
]
required_values = ["level", "category"] # Scalar fields validated against tags
unique_fields = ["title"] # Fields that must be unique across files
sorted_tags = true # Require list items in sorted order
dep_inputs = [] # Additional files that trigger rebuilds
[processor.generator.tags.field_types]
tags = "list" # Must be a YAML list
level = "scalar" # Must be a string
duration_hours = "number" # Must be numeric
| Key | Type | Default | Description |
|---|---|---|---|
output | string | "out/processor.generator.tags/tags.db" | Path to the tags database file |
tags_dir | string | "tags" | Directory containing .txt tag list files |
required_fields | string[] | [] | Frontmatter fields that every .md file must have |
required_field_groups | string[][] | [] | Alternative field groups; at least one group must be fully present |
required_values | string[] | [] | Scalar fields whose values must exist in tags/<field>.txt |
unique_fields | string[] | [] | Fields whose values must be unique across all files |
field_types | map | {} | Expected types per field: "list", "scalar", or "number" |
sorted_tags | bool | false | Require list items in sorted order within each file |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
Each input file is processed individually, producing its own output file.
Subcommands
All subcommands require a prior rsconstruct build to populate the database
(except check which reads files directly).
All support --json for machine-readable output.
Querying
| Command | Description |
|---|---|
rsconstruct tag list | List all unique tags (sorted) |
rsconstruct tag files TAG [TAG...] | List files matching all given tags (AND) |
rsconstruct tag files --or TAG [TAG...] | List files matching any given tag (OR) |
rsconstruct tag grep TEXT | Search for tags containing a substring |
rsconstruct tag grep -i TEXT | Case-insensitive tag search |
rsconstruct tag for-file PATH | List all tags for a specific file (supports suffix matching) |
rsconstruct tag frontmatter PATH | Show raw parsed frontmatter for a file |
rsconstruct tag count | Show each tag with its file count, sorted by frequency |
rsconstruct tag tree | Show tags grouped by key (e.g. level= group) vs bare tags |
rsconstruct tag stats | Show database statistics (file count, unique tags, associations) |
Reporting
| Command | Description |
|---|---|
rsconstruct tag matrix | Show a coverage matrix of tag categories per file |
rsconstruct tag coverage | Show percentage of files that have each tag category |
rsconstruct tag orphans | Find files with no tags at all |
rsconstruct tag suggest PATH | Suggest tags for a file based on similarity to other tagged files |
Validation
| Command | Description |
|---|---|
rsconstruct tag check | Run all validations without building (fast lint pass) |
rsconstruct tag unused | List tags in tags_dir that no file uses |
rsconstruct tag unused --strict | Same, but exit with error if any unused tags exist (for CI) |
rsconstruct tag validate | Validate indexed tags against tags_dir without rebuilding |
Clean behavior
This processor is a Generator — rsconstruct clean outputs removes each declared output file individually with no directory recursion. After all per-product cleans complete, the orchestrator removes any parent directories that are now empty. Pass --no-empty-dirs to keep them. See Clean behavior and rsconstruct clean.
Tera Processor
Purpose
Renders Tera template files into output files, with support for loading configuration variables from Python or Lua files.
How It Works
Files matching configured extensions in tera.templates/ are rendered and written
to the project root with the extension stripped:
tera.templates/app.config.tera → app.config
tera.templates/sub/readme.txt.tera → sub/readme.txt
Templates use the Tera templating engine and can call
load_python(path="...") or load_lua(path="...") to load variables from config files.
Loading Lua config
{% set config = load_lua(path="config/settings.lua") %}
[app]
name = "{{ config.project_name }}"
version = "{{ config.version }}"
Lua configs are executed via the embedded Lua 5.4 interpreter (no external
dependency). All user-defined globals (strings, numbers, booleans, tables) are
exported. Built-in Lua globals and functions are automatically filtered out.
dofile() and require() work relative to the config file’s directory.
Loading Python config
{% set config = load_python(path="config/settings.py") %}
[app]
name = "{{ config.project_name }}"
version = "{{ config.version }}"
Source Files
- Input:
tera.templates/**/*{src_extensions} - Output: project root, mirroring the template path with the extension removed
Configuration
[processor.generator.tera]
src_extensions = [".tera"] # File extensions to process (default: [".tera"])
dep_inputs = ["config/settings.py"] # Additional files that trigger rebuilds when changed
| Key | Type | Default | Description |
|---|---|---|---|
src_extensions | string[] | [".tera"] | File extensions to discover |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
Each input file is processed individually, producing its own output file.
Clean behavior
This processor is a Generator — rsconstruct clean outputs removes each declared output file individually with no directory recursion. After all per-product cleans complete, the orchestrator removes any parent directories that are now empty. Pass --no-empty-dirs to keep them. See Clean behavior and rsconstruct clean.
Yaml2json Processor
Purpose
Converts YAML files to JSON. Native (in-process, no external tools required).
How It Works
Discovers YAML files in the configured directories and converts each to a pretty-printed JSON file.
Source Files
- Input:
**/*.yml,**/*.yaml - Output:
out/processor.generator.yaml2json/{relative_path}.json
Configuration
[processor.generator.yaml2json]
src_dirs = ["yaml"]
output_dir = "out/processor.generator.yaml2json" # Output directory (default)
| Key | Type | Default | Description |
|---|---|---|---|
output_dir | string | "out/processor.generator.yaml2json" | Output directory for JSON files |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch Support
Each input file is processed individually, producing its own output file.
Clean behavior
This processor is a Generator — rsconstruct clean outputs removes each declared output file individually with no directory recursion. After all per-product cleans complete, the orchestrator removes any parent directories that are now empty. Pass --no-empty-dirs to keep them. See Clean behavior and rsconstruct clean.
Explicit
An explicit processor takes inputs and outputs declared in the configuration rather than discovered by scanning, and aggregates them into one product. See Processor Types for how explicit processors work.
The page in this section documents the built-in explicit processor.
Run rsconstruct processor list for the full list with each processor’s type.
Explicit Processor
Why “explicit”?
Other processor types discover their inputs by scanning directories for files matching certain extensions. The explicit processor is different: the user declares exactly which files are inputs and which are outputs. Nothing is discovered or inferred.
Names considered:
- explicit — chosen. Directly communicates the key difference: everything is declared rather than discovered.
- custom — too generic. Doesn’t say what makes it different from the
existing
generatorprocessor (which is also “custom”). - rule — precise (Bazel/Make terminology for a build rule with explicit inputs/outputs), but carries baggage from other build systems and doesn’t fit the rsconstruct naming convention (processors, not rules).
- aggregate — describes the many-inputs-to-few-outputs pattern, but not all uses are aggregations.
- task — too generic. Could mean anything.
Purpose
Runs a user-configured script or command with explicitly declared inputs and outputs. Unlike scan-based processors (which discover one product per source file), the explicit processor creates a single product with all declared inputs feeding into all declared outputs.
This is ideal for build steps that aggregate many files into one or a few outputs, such as:
- Generating an index page from all HTML files in a directory
- Building a bundle from multiple source files
- Creating a report from multiple data files
How It Works
The processor resolves all inputs (literal paths) and input_globs (glob
patterns) into a flat file list. It creates a single product with these files
as inputs and the outputs list as outputs.
Rsconstruct uses this information for:
- Rebuild detection: if any input changes, the product is rebuilt
- Dependency ordering: if an input is an output of another processor,
that processor runs first (automatic via
resolve_dependencies()) - Caching: outputs are cached and restored on cache hit
Invocation
The command is invoked as:
command [args...] --inputs <input1> <input2> ... --outputs <output1> <output2> ...
Input ordering
Inputs are passed in a deterministic order:
inputsentries first, in config file orderinput_globsresults second, one glob at a time in config file order, files within each glob sorted alphabetically
This ordering is stable across builds (assuming the same set of files exists).
Configuration
[processor.explicit.generic.site]
command = "scripts/build_site.py"
args = ["--verbose"]
inputs = [
"resources/index.html",
"resources/index.css",
"resources/index.js",
"tags/level.txt",
"tags/category.txt",
"tags/audiences.txt",
]
input_globs = [
"docs/courses/**/*.html",
"docs/tracks/*.html",
]
outputs = [
"docs/index.html",
]
Fields
| Key | Type | Required | Description |
|---|---|---|---|
command | string | yes | Script or binary to execute |
args | array of strings | no | Extra arguments passed before --inputs |
inputs | array of strings | no | Literal input file paths |
input_globs | array of strings | no | Glob patterns resolved to input files |
outputs | array of strings | yes | Output file paths produced by the command |
At least one of inputs or input_globs must be specified.
Glob patterns
input_globs supports standard glob syntax:
*matches any sequence of characters within a path component**matches any number of path components (recursive)?matches a single character[abc]matches one of the listed characters
Glob results that match no files are silently ignored (the set of matching files may grow as upstream generators produce outputs via the fixed-point discovery loop).
Cross-Processor Dependencies
The explicit processor works naturally with the fixed-point discovery loop.
If input_globs matches files that are outputs of other processors (e.g.,
pandoc-generated HTML files), rsconstruct automatically:
- Injects those declared outputs as virtual files during discovery
- Resolves dependency edges so upstream processors run first
- Rebuilds the explicit processor when upstream outputs change
This means you do not need to manually order processors or wait for a second build — everything is handled in a single build invocation.
Comparison with Other Processor Types
| Checker | Generator | Explicit | |
|---|---|---|---|
| Products | one per input file | one per input file | one total |
| Outputs | none (pass/fail) | one per input | explicitly listed |
| Discovery | src_dirs + src_extensions | src_dirs + src_extensions | declared inputs/globs |
| Use case | lint/validate files | transform files 1:1 | aggregate many → few |
Clean behavior
This is the Explicit processor — rsconstruct clean outputs removes each declared output_files entry individually and each declared output_dirs entry recursively. After all per-product cleans complete, the orchestrator removes any parent directories that are now empty. Pass --no-empty-dirs to keep them. See Clean behavior and rsconstruct clean.
Mass Generators
A mass generator wraps a tool that produces many files in one invocation
and can say in advance which files those will be. Its planner computes a
manifest at discovery time; every entry becomes a product of its own, with its
own inputs and blob cache entry, and the tool’s command runs at most once per
build. See Processor Types for how
mass generators work.
Built-in mass generators include processor.mass_generator.generic (driven
entirely by config — any tool that honors the manifest contract plugs into it
without a new processor file), static site and documentation builders
(mdbook, jekyll, sphinx, zola), project compilers (cargo, cc,
linux_module), and package installers (gem, npm, pip).
Run rsconstruct processor list for the full list with each processor’s type.
Cargo Processor
Purpose
Builds Rust projects using Cargo. Each crate target artifact (<bin>, lib<name>.rlib, lib<name>.so/.dylib, lib<name>.a) under target/<profile>/ is planned in advance as its own cached product, while leaving Cargo’s target/<profile>/deps incremental directory intact.
How It Works
Discovers files named Cargo.toml in the project. For each Cargo.toml found,
the processor parses the manifest (and workspace members) to plan the output
artifacts for each configured profile, and runs cargo build (or a configured
command) at most once per (Cargo.toml, profile) when any planned product is dirty.
Input Tracking
The cargo processor tracks all .rs and .toml files, plus Cargo.lock, in the Cargo.toml’s
directory tree as inputs. This includes:
Cargo.tomlandCargo.lock- All Rust source files (
src/**/*.rs) - Test files, examples, benches
- Workspace member Cargo.toml files
When any tracked file changes, rsconstruct will re-run cargo.
Workspaces
For Cargo workspaces, each Cargo.toml (root and members) is discovered as a
separate anchor. To build only the workspace root, use src_exclude_paths to skip
member directories, or configure src_dirs / src_files to limit discovery.
Source Files
- Input:
Cargo.tomlplus all.rsand.tomlfiles andCargo.lockin the project tree - Output: Planned crate target artifacts in
target/<profile_dir>/
Configuration
[processor.mass_generator.cargo]
cargo = "cargo" # Cargo binary to use
command = "build" # Cargo command (build, check, etc.)
args = [] # Extra arguments passed to cargo
profiles = ["dev", "release"] # Cargo profiles to build
src_dirs = [""] # Directory to scan ("" = project root)
src_extensions = ["Cargo.toml"]
dep_inputs = [] # Additional files that trigger rebuilds
| Key | Type | Default | Description |
|---|---|---|---|
cargo | string | "cargo" | Path or name of the cargo binary |
command | string | "build" | Cargo subcommand to run |
args | string[] | [] | Extra arguments passed to cargo |
profiles | string[] | ["dev", "release"] | Cargo profiles to build |
src_dirs | string[] | [""] | Directory to scan for Cargo.toml files |
src_extensions | string[] | ["Cargo.toml"] | File names to match |
src_exclude_dirs | string[] | ["/.git/", "/target/", ...] | Directory patterns to exclude |
src_exclude_paths | string[] | [] | Paths (relative to project root) to exclude |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
Runs once per (Cargo.toml, profile) when any planned artifact for that profile is dirty.
Examples
Basic Usage
[processor.mass_generator.cargo]
Release Only
[processor.mass_generator.cargo]
profiles = ["release"]
Dev Only
[processor.mass_generator.cargo]
profiles = ["dev"]
Use cargo check Instead of build
[processor.mass_generator.cargo]
command = "check"
Workspace Root Only
[processor.mass_generator.cargo]
src_exclude_paths = ["crates/"]
Notes
- Each top-level crate artifact in
target/<profile_dir>/is cached as a blob in the object store, whiletarget/<profile_dir>/depsstays untouched so Cargo retains its own incremental compilation state - The
target/directory is automatically excluded from input scanning - For monorepos with multiple Rust projects, each Cargo.toml is built separately
Clean behavior
This processor is a Mass Generator — rsconstruct clean outputs removes each planned output file (product.outputs) and then sweeps any parent directories that are now empty. Pass --no-empty-dirs to keep them. See Clean behavior and rsconstruct clean.
CC Project Processor
Purpose
Builds full C/C++ projects with multiple targets (libraries and executables)
defined in a cc.yaml manifest file. Unlike the CC Single File
processor which compiles each source file into a standalone executable, this
processor supports multi-file targets with dependency linking.
How It Works
The processor scans for cc.yaml files. Each manifest defines libraries
and programs to build. All paths in the manifest (sources, include directories)
are relative to the cc.yaml file’s location and are automatically resolved
to project-root-relative paths before compilation. All commands run from the
project root.
Every file written under out/processor.mass_generator.cc/<path-to-cc.yaml-dir>/
(obj/<target>/*.o, lib/lib<name>.a, lib/lib<name>.so, and bin/<name>)
is planned in advance as its own cached product, built into a staging directory,
and verified against the plan via install_staged.
Source files are compiled to object files, then linked into the final targets:
src/exercises/foo/cc.yaml defines:
library "mymath" (static) from math.c, utils.c
program "main" from main.c, links mymath
Build produces:
out/processor.mass_generator.cc/src/exercises/foo/obj/mymath/math.o
out/processor.mass_generator.cc/src/exercises/foo/obj/mymath/utils.o
out/processor.mass_generator.cc/src/exercises/foo/lib/libmymath.a
out/processor.mass_generator.cc/src/exercises/foo/obj/main/main.o
out/processor.mass_generator.cc/src/exercises/foo/bin/main
cc.yaml Format
All paths in the manifest are relative to the cc.yaml file’s location.
# Global settings (all optional). A field left unset inherits its value
# from [processor.mass_generator.cc] in rsconstruct.toml; a field set explicitly — even
# to an empty list — overrides the config default for this manifest.
cc: gcc # C compiler (unset: inherit config, default gcc)
cxx: g++ # C++ compiler (unset: inherit config, default g++)
cflags: [-Wall] # Global C flags (unset: inherit config)
cxxflags: [-Wall] # Global C++ flags (unset: inherit config)
ldflags: [] # Global linker flags (unset: inherit config)
include_dirs: [include] # Global -I paths (relative to cc.yaml location; unset: inherit config)
# Library definitions
libraries:
- name: mymath
lib_type: shared # shared (.so) | static (.a) | both
sources: [src/math.c, src/utils.c]
include_dirs: [include] # Additional -I for this library
cflags: [] # Additional C flags
cxxflags: [] # Additional C++ flags
ldflags: [-lm] # Linker flags for shared lib
- name: myhelper
lib_type: static
sources: [src/helper.c]
# Program definitions
programs:
- name: main
sources: [src/main.c]
link: [mymath, myhelper] # Libraries defined above to link against
ldflags: [-lpthread] # Additional linker flags
- name: tool
sources: [src/tool.cc] # .cc -> uses C++ compiler
link: [mymath]
Library Types
| Type | Output | Description |
|---|---|---|
shared | lib/lib<name>.so | Shared library (default). Sources compiled with -fPIC. |
static | lib/lib<name>.a | Static library via ar rcs. |
both | Both .so and .a | Builds both shared and static variants. |
Language Detection
The compiler is chosen per source file based on extension:
| Extensions | Compiler |
|---|---|
.c | C compiler (cc field) |
.cc, .cpp, .cxx, .C | C++ compiler (cxx field) |
Global cflags are used for C files and cxxflags for C++ files.
Output Layout
Output is placed under out/processor.mass_generator.cc/<cc.yaml-relative-dir>/:
out/processor.mass_generator.cc/<cc.yaml-dir>/
obj/<target_name>/ # Object files per target
file.o
lib/ # Libraries
lib<name>.a
lib<name>.so
bin/ # Executables
<program_name>
Build Modes
Compile + Link (default)
Each source is compiled to a .o file, then targets are linked from objects.
This provides incremental rebuilds — only changed sources are recompiled.
Single Invocation
When single_invocation = true in rsconstruct.toml, programs are built by passing
all sources directly to the compiler in one command. Libraries still use
compile+link since ar requires object files.
Configuration
[processor.mass_generator.cc]
enabled = true # Enable/disable (default: true)
cc = "gcc" # Default C compiler (default: "gcc")
cxx = "g++" # Default C++ compiler (default: "g++")
cflags = [] # Additional global C flags
cxxflags = [] # Additional global C++ flags
ldflags = [] # Additional global linker flags
include_dirs = [] # Additional global -I paths
single_invocation = false # Use single-invocation mode (default: false)
loose_manifest = false # Warn instead of failing on plan mismatch (default: false)
dep_inputs = [] # Extra files that trigger rebuilds
The compiler and flag fields are project-wide defaults for every
cc.yaml: a manifest that leaves a field unset inherits the config value,
and a manifest that sets it explicitly (even to an empty list) overrides it
for that directory. Compilers overridden per manifest are picked up by tool
checking during discovery.
Configuration Reference
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Enable/disable the processor |
cc | string | "gcc" | Default C compiler |
cxx | string | "g++" | Default C++ compiler |
cflags | string[] | [] | Global C compiler flags |
cxxflags | string[] | [] | Global C++ compiler flags |
ldflags | string[] | [] | Global linker flags |
include_dirs | string[] | [] | Global include directories |
single_invocation | bool | false | Build programs in single compiler invocation |
loose_manifest | bool | false | Warn instead of failing when staged outputs do not match the plan |
dep_inputs | string[] | [] | Extra files that trigger rebuilds when changed |
src_dirs | string[] | [""] | Directory to scan for cc.yaml files |
src_extensions | string[] | ["cc.yaml"] | File patterns to scan for |
Batch support
Runs once per cc.yaml manifest when any of its planned output files is dirty.
Example
Given this project layout:
myproject/
rsconstruct.toml
exercises/
math/
cc.yaml
include/
math.h
math.c
main.c
With exercises/math/cc.yaml:
include_dirs: [include]
libraries:
- name: math
lib_type: static
sources: [math.c]
programs:
- name: main
sources: [main.c]
link: [math]
Running rsconstruct build produces:
out/processor.mass_generator.cc/exercises/math/obj/math/math.o
out/processor.mass_generator.cc/exercises/math/lib/libmath.a
out/processor.mass_generator.cc/exercises/math/obj/main/main.o
out/processor.mass_generator.cc/exercises/math/bin/main
Clean behavior
This processor is a Mass Generator — rsconstruct clean outputs removes each planned output file (product.outputs) and then sweeps any parent directories that are now empty. Pass --no-empty-dirs to keep them. See Clean behavior and rsconstruct clean.
Gem Processor
Purpose
Installs Ruby dependencies from Gemfile files using Bundler.
How It Works
Discovers Gemfile files in the project and runs bundle install in each
directory with GEM_HOME and GEM_PATH set to gem_home, so gems install
into a directory next to the Gemfile. Sibling .rb and .gemspec files
are tracked as inputs. After a successful install it writes an install stamp,
out/processor.mass_generator.gem/root.stamp for a root Gemfile (the directory
path with / turned into _ otherwise). The stamp is what another
processor lists as an input to run after the install — the
mdl processor’s gem_stamp does exactly that.
Source Files
- Input:
**/Gemfile(plus sibling.rb,.gemspecfiles) - Output: the install stamp
out/processor.mass_generator.gem/<dir>.stamp
Configuration
[processor.mass_generator.gem]
command = "bundle" # The bundler command to run
args = [] # Additional arguments to pass to bundler install
gem_home = "gems" # Where gems are installed (GEM_HOME/GEM_PATH)
dep_inputs = [] # Additional files that trigger rebuilds when changed
| Key | Type | Default | Description |
|---|---|---|---|
command | string | "bundle" | The bundler executable to run |
args | string[] | [] | Extra arguments passed to bundler install |
gem_home | string | "gems" | Directory, relative to the Gemfile, where gems are installed; exported as GEM_HOME and GEM_PATH |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
Runs once per Gemfile when its install stamp product is dirty.
Clean behavior
This processor is a Mass Generator — rsconstruct clean outputs removes each planned install stamp (product.outputs) and sweeps any parent directories that become empty. See Clean behavior and rsconstruct clean.
Jekyll Processor
Purpose
Builds Jekyll static sites by planning the site outputs via Jekyll’s own read/generate phase and running jekyll build into a staging directory.
How It Works
Discovers _config.yml files in the project (excluding common build tool
directories). During discovery, runs an embedded Ruby planner that loads
Jekyll::Site, runs reset, read, and generate (without rendering), and
emits the planned destination paths. Each planned file becomes a cached product
under output_dir (_site by default, relative to _config.yml). When any
product is dirty, runs jekyll build --disable-disk-cache -d <staging> once
and verifies the staged outputs against the plan via install_staged.
Source Files
- Input:
**/_config.yml(plus sibling site files) - Output: Planned site files under
_site/(or configuredoutput_dir)
Configuration
[processor.mass_generator.jekyll]
command = "jekyll"
ruby = "ruby"
output_dir = "_site"
loose_manifest = false
args = []
dep_inputs = []
| Key | Type | Default | Description |
|---|---|---|---|
command | string | "jekyll" | Path to the jekyll executable |
ruby | string | "ruby" | Ruby interpreter for the discovery-time planner (must have the jekyll gem) |
output_dir | string | "_site" | Output directory for generated site files, relative to _config.yml |
loose_manifest | bool | false | Warn instead of failing when jekyll build writes files not in the plan |
args | string[] | [] | Extra arguments passed to jekyll build |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
Runs once per _config.yml site when any of its planned output files is dirty.
Clean behavior
This processor is a Mass Generator — rsconstruct clean outputs removes each planned output file (product.outputs) and sweeps any parent directories that are now empty. Pass --no-empty-dirs to keep them. See Clean behavior and rsconstruct clean.
Linux Module Processor
Purpose
Builds Linux kernel modules (.ko files) from source, driven by a
linux-module.yaml manifest. The processor generates a temporary Kbuild
file, invokes the kernel build system (make -C <kdir> M=<src> modules),
captures the resulting .ko, cleans up build artifacts from the source tree,
and installs the staged .ko files into the output directory via install_staged.
How It Works
The processor scans for linux-module.yaml files. Each manifest lists one
or more kernel modules to build, and each .ko file is planned as its own
cached product. When any module for a manifest is dirty, the processor:
- Generates a
Kbuildfile in the source directory (next to the yaml). - Runs
make -C <kdir> M=<absolute-source-dir> modulesto compile. - Reads the resulting
.kointo a staging directory. - Runs
make ... cleanand removes the generatedKbuildso the source directory stays clean. - Verifies the staged
.kofiles against the plan and installs them toout/processor.mass_generator.linux_module/<yaml-relative-dir>/.
Steps 3–5 are ordered deliberately: kbuild’s make clean recursively deletes
every .ko under M=<source-dir>. When the manifest sits at the repo root the
source directory is the repo root, so the output directory (out/…) lives
inside the clean’s scope. Capturing the module before cleaning and installing it
afterwards keeps the output intact regardless of where it lands relative to
the source directory.
Because the kernel build system requires M= to point at an absolute path
containing the sources and Kbuild, the make command runs in the yaml
file’s directory — not the project root.
linux-module.yaml Format
All source paths are relative to the yaml file’s directory.
# Global settings (all optional)
make: make # Make binary (default: "make")
kdir: /lib/modules/6.8.0-generic/build # Kernel build dir (default: running kernel)
arch: x86_64 # ARCH= value (optional, omitted if unset)
cross_compile: x86_64-linux-gnu- # CROSS_COMPILE= value (optional)
v: 0 # Verbosity V= (default: 0)
w: 1 # Warning level W= (default: 1)
# Module definitions
modules:
- name: hello # Module name -> produces hello.ko
sources: [main.c] # Source files (relative to yaml dir)
extra_cflags: [-DDEBUG] # Extra CFLAGS (optional, becomes ccflags-y)
- name: mydriver
sources: [mydriver.c, utils.c]
Minimal Example
A single module with one source file:
modules:
- name: hello
sources: [main.c]
Output Layout
Output is placed under out/processor.mass_generator.linux_module/<yaml-relative-dir>/:
out/processor.mass_generator.linux_module/<yaml-dir>/
<module_name>.ko
For example, a manifest at src/kernel/hello/linux-module.yaml defining
module hello produces:
out/processor.mass_generator.linux_module/src/kernel/hello/hello.ko
KDIR Detection
If kdir is not set in the manifest, the processor runs uname -r to
detect the running kernel and uses /lib/modules/<release>/build. This
requires the linux-headers-* package to be installed (e.g.,
linux-headers-generic on Ubuntu).
Generated Kbuild
The processor writes a Kbuild file with the standard kernel module
variables:
obj-m := hello.o
hello-objs := main.o
ccflags-y := -DDEBUG # only if extra_cflags is non-empty
This file is removed after building (whether the build succeeds or fails).
Configuration
[processor.mass_generator.linux_module]
enabled = true # Enable/disable (default: true)
loose_manifest = false # Warn instead of failing on plan mismatch (default: false)
dep_inputs = [] # Extra files that trigger rebuilds
Configuration Reference
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Enable/disable the processor |
loose_manifest | bool | false | Warn instead of failing when staged outputs do not match the plan |
dep_inputs | string[] | [] | Extra files that trigger rebuilds when changed |
src_dirs | string[] | [""] | Directory to scan for linux-module.yaml files |
src_extensions | string[] | ["linux-module.yaml"] | File patterns to scan for |
src_exclude_dirs | string[] | common excludes | Directories to skip during scanning |
Batch support
Runs once per linux-module.yaml manifest when any of its planned .ko products is dirty.
Caching
The .ko outputs are cached in the rsconstruct object store. After rsconstruct clean,
a subsequent rsconstruct build restores .ko files from cache (via hardlink or
copy) without invoking the kernel build system. A rebuild is triggered when
any source file or the yaml manifest changes.
Prerequisites
makemust be installed- Kernel headers must be installed for the target kernel version
(
apt install linux-headers-genericon Ubuntu) - For cross-compilation, the appropriate cross-compiler toolchain must be
available and specified via
cross_compileandarchin the manifest
Example
Given this project layout:
myproject/
rsconstruct.toml
drivers/
hello/
linux-module.yaml
main.c
With drivers/hello/linux-module.yaml:
modules:
- name: hello
sources: [main.c]
And drivers/hello/main.c:
#include <linux/module.h>
#include <linux/init.h>
MODULE_LICENSE("GPL");
static int __init hello_init(void) {
pr_info("hello: loaded\n");
return 0;
}
static void __exit hello_exit(void) {
pr_info("hello: unloaded\n");
}
module_init(hello_init);
module_exit(hello_exit);
Running rsconstruct build produces:
out/processor.mass_generator.linux_module/drivers/hello/hello.ko
The module can then be loaded with sudo insmod out/processor.mass_generator.linux_module/drivers/hello/hello.ko.
Clean behavior
This processor is a Mass Generator — rsconstruct clean outputs removes each planned .ko output file (product.outputs) and sweeps any parent directories that are now empty. Pass --no-empty-dirs to keep them. See Clean behavior and rsconstruct clean.
Mass Generator Processor
Runs a tool that enumerates its outputs in advance. The tool’s plan command prints a manifest; rsconstruct turns every entry into a product and runs the tool’s build command at most once per build. The design rationale is in Output Prediction.
Why “mass generator”?
Existing processor types cover a matrix of “how many outputs” and “are they known in advance”:
| Type | Outputs known? | Example |
|---|---|---|
| Generator | Yes, 1 per input | tera: template → file |
| Explicit | Yes, user-declared | custom build step |
| Checker | None (pass/fail) | ruff |
| Creator | No, opaque (output_dirs) | mkdocs → _site/ |
| Mass generator | Yes — tool enumerates them | rssite → _site/* |
A mass generator is the transparent creator: it produces many output files (like a creator), but the tool itself answers “what will you produce?” before running. Each predicted file becomes a declared product with its own inputs, cache entry, and dependency edges.
Names considered:
- mass_generator — chosen. Says what it does: “generator” (per-file outputs like the generator type), “mass” (many products from one tool invocation).
- transparent_creator — accurate but awkward.
- predicting_creator — describes the mechanism, not the result.
- site_generator — too narrow; the type is useful beyond static sites.
Purpose
Wraps a tool that:
- Produces many output files from a set of source files (a static site generator, say).
- Can enumerate its outputs in advance via a separate plan command.
- Builds all its outputs in a single invocation.
Once wired as a mass generator, the tool gets a cache entry per file, shares its output directory safely with other processors, and lets downstream processors depend on individual outputs.
Configuration
[processor.mass_generator.generic.site]
command = "rssite"
args = ["build"]
predict_command = "rssite"
predict_args = ["plan"]
output_dirs = ["_site"]
# Files the plan is computed from; while none changes, the plan is reused.
src_dirs = ["docs", "templates"]
src_extensions = [".md", ".html"]
src_files = ["mysite.toml"]
# loose_manifest = true # report plan/build mismatches as warnings; default false
# dep_inputs = ["mysite.toml"]
Commands are executed directly, never through a shell (see No-Shell Policy), so a command and its arguments are separate fields: command + args for the build, predict_command + predict_args for the plan. They may be the same binary with different arguments or two separate scripts.
Fields
| Key | Type | Required | Description |
|---|---|---|---|
command | string | yes | The tool’s build command. Runs at most once per build. |
args | array of strings | no | Arguments for command. |
predict_command | string | yes | The tool’s plan command. Must print the JSON manifest to stdout. |
predict_args | array of strings | no | Arguments for predict_command. |
output_dirs | array of strings | yes | Directories the tool writes into. Every manifest path must fall inside one. |
loose_manifest | bool | no | Default false. If true, plan/build mismatches are warnings, not errors. |
dep_inputs | array of strings | no | Extra files added to the inputs of every product. |
src_dirs, src_extensions, src_files, src_exclude_* | arrays | no | The files the plan is computed from; they key the plan cache. |
required_tools | array of strings | no | Tools the commands shell out to, for tool install and version locking. |
The scan fields discover no products — the manifest’s sources decide every product’s inputs — but they declare which files the plan is computed from; see Plan cache. src_dirs without src_extensions is rejected, because it would match no file and silently key the cache on nothing.
Plan cache
predict_command runs at every graph build — build, status, graph, clean outputs. When the config declares the plan’s inputs through src_dirs (with src_extensions) and/or src_files, rsconstruct caches the manifest in .rsconstruct/mass_generator/<instance>.json, keyed by the rsconstruct version, the instance’s whole config, and the path and content of every declared input file. A later build whose key matches reuses the cached manifest and does not run predict_command; a new, removed or edited input file — or any config change — runs it again. Without declared inputs, nothing is cached and the plan runs every time.
The declaration is a promise: the plan must be a function of those files alone. A file the plan reads that is not declared (a template directory, a config file) can change the plan without invalidating the cache, so declare everything the tool’s plan mode reads. A cache file that does not parse is an error naming the file; delete it to re-plan.
Manifest format
{
"version": 1,
"outputs": [
{
"path": "_site/index.html",
"sources": ["docs/index.md", "templates/default.html", "mysite.toml"]
},
{
"path": "_site/about/index.html",
"sources": ["docs/about.md", "templates/default.html", "mysite.toml"]
}
]
}
version— integer. Schema version; only1is accepted.outputs[].path— plain relative path (no leading/, no.or..components). Must fall within one of the processor’soutput_dirs. Listed once.outputs[].sources— the files whose content determines this output. At least one; the tool’s own config file counts. These become the product’s inputs, so keep the list minimal: an output that names every source rebuilds whenever any source changes.
Unknown keys are rejected. rsconstruct sorts entries by path, so the tool’s output order does not matter.
Several outputs may list exactly the same sources — a tag’s index page, its page/1/ redirect and its feed all depend on the same posts. Each is still cached under its own path: the predicted path is part of the product’s cache key, where for other processor types the output path follows from the input and needs no such entry.
How it works
Plan phase (at graph-build time)
rsconstruct runs predict_command once per instance and parses its stdout. A non-zero exit, malformed JSON, an unsupported version, or a path outside output_dirs fails the graph build with the tool’s own message. For each manifest entry a product is added:
inputs= the entry’ssources(plusdep_inputs)outputs=[entry.path]processor= this instance
Predicted outputs join the file index as virtual files, so a downstream processor can discover them even before they exist on disk.
Build phase
Products are classified individually, like any generator’s: unchanged inputs skip, a known input checksum restores from the blob cache, anything else builds. The first product that needs building runs command; every later one in the same build finds the tool already run and does nothing but cache its file. If the tool fails, every remaining product of the instance fails with the same message rather than re-running a build that just failed.
Before the tool runs, rsconstruct unlinks every predicted path, including the ones whose products are clean. A clean file may be a read-only hardlink left by an earlier cache restore, which the tool could not overwrite. The tool then regenerates everything; the clean products keep their cache entries, so the next build matches them again as long as the tool is deterministic (same inputs, same bytes).
Verification
After the tool exits, rsconstruct compares what it wrote against the plan:
- missing — a predicted path that does not exist afterwards.
- unexpected — a file under
output_dirsthat is new or was rewritten during the run, is not in the plan, and is not another product’s declared output.
Either is a build error. Files the tool left untouched are not its business and are not reported: a neighbor processor’s output, a CNAME committed into _site/, or a page from an older plan that nobody cleans up. A file another processor declares (an explicit writing _site/about.html, say) is never reported even if the tool rewrote it — that is the shared-directory contract of Shared Output Directory.
loose_manifest = true turns both findings into a warning on stderr. Even then a product whose own predicted file is absent fails: there is nothing to cache.
Restore phase
When every product of an instance is cache-clean, each is restored from its blob independently and the tool does not run at all. rsconstruct clean outputs followed by rsconstruct build therefore never invokes the tool.
Clean behavior
This processor is a mass generator — rsconstruct clean outputs removes each predicted output file individually with no directory recursion, exactly as for a generator. After all per-product cleans complete, the orchestrator removes any parent directories that are now empty. Pass --no-empty-dirs to keep them. See Clean behavior and rsconstruct clean.
A page the tool no longer predicts has no product and is neither rebuilt nor cleaned; delete it by hand.
Cross-processor dependencies
Every output file is a declared product, so downstream processors wire up through the normal graph:
[processor.mass_generator.generic.site]
command = "rssite"
args = ["build"]
predict_command = "rssite"
predict_args = ["plan"]
output_dirs = ["_site"]
[processor.explicit.generic.sitemap]
command = "./sitemap.sh"
inputs = ["_site/index.html", "_site/about/index.html"]
output_files = ["_site/sitemap.xml"]
The sitemap runs after the site is built and rebuilds when either page changes. No ordering knobs are involved; the topological sort handles it.
Tool author contract
For a tool to work as a mass generator, its plan command must uphold these invariants:
- Pure function of config + source tree. Same inputs → same manifest, bit for bit. No network, no timestamps, no environment peeking (unless the file read is declared as a source).
- Cheap, or cacheable. rsconstruct runs it at every graph build —
build,status,clean outputs,graph— unless the config declares its inputs, in which case it runs only when one of them changed (see Plan cache). - Exact match with the build. Predicted paths equal the paths
commandwrites, no more and no fewer. Violations are errors. - Deterministic build. Same inputs → same bytes, or clean products will mismatch their cache after every rebuild of a neighbor.
- Deterministic variable outputs. Content-derived pages (tag indexes, archives, feeds) must be enumerable from the same parsing pass that plan does.
Both modes should be driven by the same internal function that enumerates outputs — otherwise the plan and the build drift apart, and verification turns every build red.
See rssite for a tool built to this contract.
Comparison with other processor types
| Creator (opaque) | Mass generator (transparent) | Generator (1:1) | |
|---|---|---|---|
| Outputs known in advance? | No | Yes | Yes |
| Tool invocations per build | 1 if dirty | 1 if any product is dirty | N (one per dirty input) |
| Cache unit | Whole tree | Per file | Per file |
| Downstream deps | Only on declared files | On every predicted file | On every produced file |
| Shared-folder safety | Via path_owner filter | Via declared outputs (normal) | Via declared outputs |
| Use case | mkdocs, Sphinx | rssite, cooperative tools | tera, mako, compilers |
Migration from a creator
If a tool exists first as a creator and later grows a plan mode, the migration is config-only:
# Before
[processor.creator.generic.mysite]
command = "mysite"
args = ["build"]
src_extensions = ["mysite.toml"]
src_dirs = ["."]
output_dirs = ["_site"]
# After
[processor.mass_generator.generic.mysite]
command = "mysite"
args = ["build"]
predict_command = "mysite"
predict_args = ["plan"]
output_dirs = ["_site"]
The anchor-file scan goes away: the manifest names the product inputs (declare the plan’s own inputs with the scan fields if you want the plan cache). Downstream processors start getting per-file dependencies without changes on their side.
See also
- Output Prediction — design rationale, invariants, and what the implementation left for later
- Shared Output Directory — the ownership rules for opaque creators, which mass generators inherit
- Processor Ordering — sibling discussion about explicit ordering knobs
- rssite — a static site generator built to the manifest contract
zola planpatch — adds a plan mode to zola v0.23.3 so a Zola site can run as a mass generator; verified on an 800-page site
Mdbook Processor
Purpose
Builds mdbook documentation projects, planning every HTML page, theme asset, font, and static asset natively in Rust and caching each file as a product of its own.
How It Works
Discovers book.toml files indicating mdbook projects, parses book.toml and SUMMARY.md along with static assets to compute the output plan under output_dir, and runs mdbook build with MDBOOK_OUTPUT__HTML__HASH_FILES=false into a staging directory when any planned product is dirty. Staged outputs are verified against the plan via install_staged.
Source Files
- Input:
**/book.toml(plusSUMMARY.md, chapter.mdfiles, and static assets) - Output: Planned HTML pages, theme/search/font assets, and copied static files under
book(or configuredoutput_dir)
Configuration
[processor.mass_generator.mdbook]
command = "mdbook" # The mdbook command to run
output_dir = "book" # Output directory for generated docs
loose_manifest = false # Warn instead of failing on plan mismatch
args = [] # Additional arguments to pass to mdbook
dep_inputs = [] # Additional files that trigger rebuilds when changed
| Key | Type | Default | Description |
|---|---|---|---|
command | string | "mdbook" | The mdbook executable to run |
output_dir | string | "book" | Output directory for generated documentation (relative to book.toml unless build.build-dir is set in book.toml) |
loose_manifest | boolean | false | Warn instead of failing when mdbook build writes files not in the plan |
args | string[] | [] | Extra arguments passed to mdbook |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
Runs once per book.toml when any of its planned output files is dirty.
Clean behavior
This processor is a Mass Generator — rsconstruct clean outputs removes each planned output file (product.outputs) and sweeps any parent directories that are now empty. Pass --no-empty-dirs to keep them. See Clean behavior and rsconstruct clean.
Npm Processor
Purpose
Installs Node.js dependencies from package.json files using npm.
How It Works
Discovers package.json files in the project and runs npm install in each
directory. Sibling .json, .js, and .ts files are tracked as inputs so
changes trigger reinstallation. After a successful install it writes a
deterministic install stamp, out/processor.mass_generator.npm/root.stamp for a
root package.json (or <dir>.stamp with / turned into _ otherwise).
Source Files
- Input:
**/package.json(plus sibling.json,.js,.tsfiles) - Output: the install stamp
out/processor.mass_generator.npm/<dir>.stamp
Configuration
[processor.mass_generator.npm]
command = "npm" # The npm command to run
args = [] # Additional arguments to pass to npm install
dep_inputs = [] # Additional files that trigger rebuilds when changed
| Key | Type | Default | Description |
|---|---|---|---|
command | string | "npm" | The npm executable to run |
args | string[] | [] | Extra arguments passed to npm install |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
Runs once per package.json when its install stamp product is dirty.
Clean behavior
This processor is a Mass Generator — rsconstruct clean outputs removes each planned install stamp (product.outputs) and sweeps any parent directories that become empty. See Clean behavior and rsconstruct clean.
Pip Processor
Purpose
Installs Python dependencies from requirements.txt files using pip.
How It Works
Discovers requirements.txt files in the project and runs pip install -r
in each file’s directory. After a successful install it writes a deterministic
install stamp, out/processor.mass_generator.pip/root.stamp for a root
requirements.txt (or <dir>.stamp with / turned into _ otherwise), so
dependencies are only reinstalled when requirements.txt (or a dep_inputs
file) changes.
Source Files
- Input:
**/requirements.txt - Output: the install stamp
out/processor.mass_generator.pip/<dir>.stamp
Configuration
[processor.mass_generator.pip]
command = "pip" # The pip command to run
args = [] # Additional arguments to pass to pip
dep_inputs = [] # Additional files that trigger rebuilds when changed
| Key | Type | Default | Description |
|---|---|---|---|
command | string | "pip" | The pip executable to run |
args | string[] | [] | Extra arguments passed to pip |
dep_inputs | string[] | [] | Extra files whose changes trigger rebuilds |
Batch support
Runs once per requirements.txt when its install stamp product is dirty.
Clean behavior
This processor is a Mass Generator — rsconstruct clean outputs removes each planned install stamp (product.outputs) and sweeps any parent directories that become empty. See Clean behavior and rsconstruct clean.
Sphinx Processor
Purpose
Builds Sphinx HTML documentation with sphinx-build, and gives every file
Sphinx writes a product of its own: its own cache entry, its own dependency
edges, its own place in the graph for downstream processors.
It is the mass generator contract with the plan supplied by Sphinx itself. Sphinx has no plan mode, but it knows every file before it writes one: after its read phase the environment holds the documents, the domain indices, the pages extensions add and the theme’s static files.
How It Works
- Plan. At graph-build time the processor runs a small planner with
python— a program that drives Sphinx through its read phase and asset copying into a scratch directory under.rsconstruct/, never rendering a page, and prints the files the HTML builder would write: one page per document,_sources/copies,genindex.html, the domain indices (py-modindex.html), every page an extension contributes onhtml-collect-pages(viewcode’s_modules/),search.html,searchindex.js,objects.inv,.buildinfo,_static/,_images/,_downloads/,html_extra_pathfiles, and.nojekyllwhensphinx.ext.githubpagesis on. Each becomes a product. Nothing about Sphinx’s layout is reimplemented in Rust. - Build. The first product that needs building runs
sphinx-build -b html -E -a -d <doctrees> <root> <staging>into private directories under.rsconstruct/; the others find it done. rsconstruct compares what Sphinx wrote with the plan — exactly, both ways — and moves the planned files into the output directory. - Restore. When every product is clean, nothing runs; after
rsconstruct clean outputs, every file is restored from cache without Sphinx.
The plan is cached in .rsconstruct/mass_generator/<instance>.json, keyed
on the instance config and the content of every file under root and in
dep_inputs; the planner runs again only when one of them changes.
What a rendered file depends on
A Sphinx page can change when any other source changes: a toctree lists the
other documents’ titles, cross-references resolve against every document,
the general index and the search index cover all of them, and autodoc pages
render the package’s docstrings. Every product therefore lists as its
sources every file under root plus dep_inputs, and Sphinx runs once per
build whenever any of them changed.
Name in dep_inputs what Sphinx reads outside root: the package autodoc
imports, and whatever conf.py reads (a pyproject.toml it takes the
version from, the lock file that pins the Sphinx version — a Sphinx upgrade
changes _static/ and every page).
Arguments the processor owns
args goes to sphinx-build as given, except that the processor refuses
the options it sets itself or that would change the configuration behind the
planner’s back: -b/-M (the builder is always html), -E, -a, -d,
-D, -A, -t, -c and -C. Configuration lives in conf.py, which the
planner and the build both read. -W, -n, -q, -j and the like pass
through.
A disagreement between plan and build — an extension writing a file the planner does not know about — fails the build with the exact list of missing and unexpected files, and leaves the staging directory in place for inspection.
Source Files
- Input: the Sphinx source directory (
conf.py, the documents,_static/,_templates/) anddep_inputs - Output: every file
sphinx-build -b htmlwrites, underoutput_dir
Configuration
[processor.mass_generator.sphinx]
# root = "sphinx" # The source directory holding conf.py
# output_dir = "docs" # Where the HTML goes
dep_inputs = ["src/mypkg/**/*.py", "pyproject.toml", "uv.lock"]
# args = ["-W"] # Extra sphinx-build arguments
# python = "python3" # The interpreter that runs the planner
# loose_manifest = false # true: report plan/build mismatches as warnings
| Key | Type | Default | Description |
|---|---|---|---|
command | string | "sphinx-build" | The sphinx-build executable |
args | string[] | [] | Extra arguments for sphinx-build (see the owned options above) |
root | string | "sphinx" | The Sphinx source directory, relative to the project root |
output_dir | string | "docs" | Where the HTML is written |
python | string | "python3" | The interpreter the planner runs with; it must import the same Sphinx as command |
dep_inputs | string[] | [] | Extra sources of every product (the autodoc’d package, files conf.py reads) |
loose_manifest | boolean | false | Report plan/build mismatches as warnings instead of errors |
Both command and python resolve from the active environment, the
repository’s own venv; the processor never switches environments itself.
Batch support
Sphinx runs once per build, for all products together.
Clean behavior
This processor is a mass generator — rsconstruct clean outputs removes each planned output file individually with no directory recursion. After all per-product cleans complete, the orchestrator removes any parent directories that are now empty. Pass --no-empty-dirs to keep them. See Clean behavior and rsconstruct clean.
A file Sphinx stops writing (a deleted document) has no product and is neither rebuilt nor cleaned; delete it by hand.
See also
- Mass Generator — the generic,
predict_command-driven form of the same contract - Zola — the other native mass generator, with its plan computed in Rust
Zola Processor
Purpose
Builds a zola site with the official zola binary, and gives every file zola writes a product of its own: its own cache entry, its own dependency edges, its own place in the graph for downstream processors.
Zola has no plan mode, so this processor computes the list of files zola will
write itself, by reading config.toml and the content front matter the way
zola 0.23.3 does. It is the mass generator contract, with the
plan computed natively instead of by a predict_command.
How It Works
- Plan. At graph-build time the processor reads the site and lists every
output: pages (slug,
slug/pathoverrides, dates in file names, language suffixes), sections, pagination, taxonomy lists and terms (with their pagination and feeds), site and section feeds, aliases, colocated and section assets,404.html,sitemap.xml,robots.txt, the search index, highlighting stylesheets, compiled Sass, and the static tree. Each becomes a product. - Build. The first product that needs building runs
zola build --output-dir <staging> --forceinto a private directory under.rsconstruct/; the others find it done. rsconstruct compares what zola wrote with the plan — exactly, both ways — and moves the planned files into the output directory. - Restore. When every product is clean, nothing runs; after
rsconstruct clean outputs, every file is restored from cache without zola.
zola builds into staging rather than into the output directory because
zola build deletes its output directory first: building into _site/
directly would delete the files other processors put there.
What a rendered file depends on
A zola template can read any page or section (get_page, get_section,
sibling links, taxonomy listings, the paginator), so a rendered file’s
content can change when another file changes. Every rendered output
therefore lists as its sources the whole content/ tree, every template, the
config file, and every data file a template or content file loads with a
literal load_data(path = "..."). A file loaded through a computed path is
invisible to the planner; list it in dep_inputs.
Static files and compiled Sass keep precise sources (the file itself, the
sass/ tree), so editing a static file rebuilds one product.
zola runs once per build whenever anything it reads changed, regardless of how many products are dirty.
What is not supported
The planner refuses, by name, what it cannot predict, instead of guessing:
- a zola other than 0.23.3 (
zola --versionis checked on every plan; a zola upgrade means re-verifying the planner, which the fixture test does); theme,ignored_content,ignored_static, a[languages]table for the default language;resize_imagein a template or content file (processed images are only known by rendering);- sites rendering 30,000 or more HTML files (zola then splits the sitemap).
A disagreement that slips through anyway — a rule the planner gets wrong — fails the build with the exact list of missing and unexpected files, and leaves the staging directory in place for inspection.
Source Files
- Input: the zola site (
config.toml/zola.toml,content/,templates/,sass/,static/) - Output: every file
zola buildwrites, under the output directory
Configuration
[processor.mass_generator.zola]
output_dir = "_site" # Default: the site config's output_dir ("public")
# root = "site" # The site directory, if not the project root
# dep_inputs = ["data/computed.toml"]
# loose_manifest = false # true: report plan/build mismatches as warnings
| Key | Type | Default | Description |
|---|---|---|---|
command | string | "zola" | The zola binary (must be 0.23.3) |
args | string[] | [] | Extra arguments for zola build |
root | string | "" | The site directory, relative to the project root |
output_dir | string | "" | Where the site is written; empty means the site config’s output_dir |
dep_inputs | string[] | [] | Extra sources of every product (data files loaded through computed paths) |
loose_manifest | boolean | false | Report plan/build mismatches as warnings instead of errors |
Batch support
zola runs once per build, for all products together.
Clean behavior
This processor is a mass generator — rsconstruct clean outputs removes each planned output file individually with no directory recursion. After all per-product cleans complete, the orchestrator removes any parent directories that are now empty. Pass --no-empty-dirs to keep them. See Clean behavior and rsconstruct clean.
A file zola stops writing (a deleted page) has no product and is neither rebuilt nor cleaned; delete it by hand.
See also
- Mass Generator — the generic,
predict_command-driven form of the same contract zola planpatch — the alternative: a plan mode inside zola itself, not used by this processor
GitHub Actions
How to run rsconstruct in a GitHub Actions workflow.
Recommended flags
- name: Build
run: rsconstruct build -q -j0
| Flag | Why |
|---|---|
-q (quiet) | Suppresses the progress bar and status messages. The progress bar uses terminal escape codes that produce garbage in CI logs. Only errors are shown. |
-j0 | Auto-detect CPU cores. GitHub-hosted runners have 4 cores (ubuntu-latest) — using them all speeds up the build significantly vs the default of -j1. |
Full workflow example
name: Build
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install rsconstruct
run: cargo install rsconstruct
- name: Install tools
run: rsconstruct tool install
- name: Build
run: rsconstruct build -q -j0
Runner sizing
| Runner | Cores | RAM | Notes |
|---|---|---|---|
ubuntu-latest | 4 | 16 GB | Good for most projects. Use -j0 or -j4. |
ubuntu-latest (private repo) | 4 | 16 GB | Same hardware as public repos. |
| Large runners | 8-64 | 32-256 GB | For large projects. -j0 scales automatically. |
-j0 always does the right thing — it detects the available cores at runtime.
There is no benefit to setting -j higher than the core count.
Caching
Cache the .rsconstruct/ directory between runs to skip unchanged products:
- uses: actions/cache@v4
with:
path: .rsconstruct
key: rsconstruct-${{ hashFiles('rsconstruct.toml') }}-${{ github.sha }}
restore-keys: |
rsconstruct-${{ hashFiles('rsconstruct.toml') }}-
rsconstruct-
This restores cached build products from previous runs. Only products whose inputs changed will be rebuilt.
GitHub Pages deployment
A repo that publishes its build output to GitHub Pages (with the Pages source
set to “GitHub Actions”) declares the published directory in rsconstruct.toml:
[pages]
dir = "out/web"
The workflow then asks rsconstruct page dir whether to deploy. The command
prints the directory when [pages] is configured and prints nothing (exit 0)
when it isn’t, so the exact same workflow file works in Pages and non-Pages
repos — the upload step and the deploy job simply skip when the output is
empty:
jobs:
build:
runs-on: ubuntu-24.04
permissions:
contents: read
# needed by the stale-artifact cleanup below
actions: write
outputs:
pages-dir: ${{ steps.pages.outputs.dir }}
steps:
# ... checkout, install rsconstruct, build ...
- name: pages dir
id: pages
run: echo "dir=$(rsconstruct page dir)" >> "$GITHUB_OUTPUT"
- name: delete stale pages artifact
# Artifacts survive re-run attempts, and both the upload and
# deploy-pages fail hard when a "github-pages" artifact already exists
# in the run. Delete any leftover from a previous attempt first.
if: steps.pages.outputs.dir != ''
run: |
gh api --paginate "repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts" \
--jq '.artifacts[] | select(.name == "github-pages") | .id' |
while read -r artifact_id; do
echo "deleting stale artifact $artifact_id"
gh api -X DELETE "repos/${{ github.repository }}/actions/artifacts/$artifact_id"
done
env:
GH_TOKEN: ${{ github.token }}
- uses: actions/upload-pages-artifact@v5
if: steps.pages.outputs.dir != ''
with:
path: ${{ steps.pages.outputs.dir }}
deploy:
needs: build
if: needs.build.outputs.pages-dir != ''
runs-on: ubuntu-24.04
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- uses: actions/deploy-pages@v5
id: deployment
Permissions are scoped at the job level, so non-Pages repos running this
workflow never grant pages: write or id-token: write to anything that
executes, and the skipped deploy job does not create a github-pages
environment.
Tips
- Don’t use
--timingsin CI unless you need the data. It adds overhead. - Use
--jsoninstead of-qif you want machine-readable output for downstream processing. - Use
-k(keep-going) to see all failures at once instead of stopping at the first one. - Use
--verify-tool-versionsto catch tool version drift between local and CI environments.
Advanced Usage
Parallel builds
RSConstruct can build independent products concurrently. Set the number of parallel jobs:
rsconstruct build -j4 # 4 parallel jobs
rsconstruct build -j0 # Auto-detect CPU cores
Or configure it in rsconstruct.toml:
[build]
parallel = 4 # 0 = auto-detect
The -j flag on the command line overrides the config file setting.
Watch mode
Watch source files and automatically rebuild on changes:
rsconstruct watch
This monitors all source files and triggers an incremental build whenever a file is modified.
Dependency graph
Visualize the build dependency graph in multiple formats:
rsconstruct graph # Default text format
rsconstruct graph --format dot # Graphviz DOT format
rsconstruct graph --format mermaid # Mermaid diagram format
rsconstruct graph --format json # JSON format
rsconstruct graph --view # Open in browser or viewer
The --view flag opens the graph using the configured viewer (set in rsconstruct.toml):
[graph]
viewer = "google-chrome"
Ignoring files
RSConstruct respects .gitignore files automatically. Any file ignored by git is also ignored by all processors. Nested .gitignore files and negation patterns are supported.
For project-specific exclusions that should not go in .gitignore, create a .rsconstructignore file in the project root with glob patterns (one per line):
/src/experiments/**
*.bak
The syntax is the same as .gitignore: # for comments, / prefix to anchor to the project root, / suffix for directories, and */** for globs.
Processor verbosity levels
Control the detail level of build output with -v N:
| Level | Output |
|---|---|
| 0 (default) | Target basename only: main.elf |
| 1 | Target path: out/processor.generator.cc_single_file/main.elf; cc_single_file processor also prints compiler commands |
| 2 | Adds source path: out/processor.generator.cc_single_file/main.elf <- src/main.c |
| 3 | Adds all inputs: out/processor.generator.cc_single_file/main.elf <- src/main.c, src/utils.h |
Dry run
Preview what would be built without executing anything:
rsconstruct build --dry-run
rsconstruct build --dry-run --explain # with the reason for each product
rsconstruct build --dry-run -p processor.checker.ruff
The dry run plans exactly what build with the same flags would: -p, -x,
--target, --dir, --force and --iset/--pset apply as they do to a
build. It uses the build’s own prediction, so a product whose input another
product is about to rewrite is shown as BUILD (“a dependency will be rebuilt
or restored”), not SKIP. Nothing is executed and the stored config baseline
is not advanced, so the next build still reports a config change.
rsconstruct status answers a different question: what the cache says about
each product as it stands now, without predicting what other products would
do first.
Keep going after errors
By default, RSConstruct stops on the first error. Use --keep-going to continue building other products:
rsconstruct build --keep-going
Build timings
Show per-product and total timing information:
rsconstruct build --timings
Shell completions
Generate shell completions for your shell:
rsconstruct complete bash # Bash completions
rsconstruct complete zsh # Zsh completions
rsconstruct complete fish # Fish completions
Configure which shells to generate completions for:
[completions]
shells = ["bash"]
Extra inputs
By default, each processor only tracks its primary source files as inputs. If a product depends on additional files that aren’t automatically discovered (e.g., a config file read by a linter, a suppressions file used by a static analyzer, or a Python settings file loaded by a template), you can declare them with dep_inputs.
When any file listed in dep_inputs changes, all products from that processor are rebuilt.
[processor.generator.tera]
dep_inputs = ["config/settings.py", "config/database.py"]
[processor.checker.ruff]
dep_inputs = ["pyproject.toml"]
[processor.checker.pylint]
dep_inputs = ["pyproject.toml"]
[processor.checker.cppcheck]
dep_inputs = [".cppcheck-suppressions"]
[processor.generator.cc_single_file]
dep_inputs = ["Makefile.inc"]
[processor.checker.zspell]
dep_inputs = ["custom-dictionary.txt"]
Paths are relative to the project root. Missing files cause a build error, so all listed files must exist.
The dep_inputs paths are included in the processor’s config hash, so adding or removing entries triggers a rebuild even if the files themselves haven’t changed. The file contents are also checksummed as part of the product’s input set, so any content change is detected by the incremental build system.
All processors support dep_inputs.
Graceful interrupt
Pressing Ctrl+C during a build stops execution promptly:
- Subprocess termination — All external processes (compilers, linters, etc.) are spawned with a poll loop that checks for interrupts every 50ms. When Ctrl+C is detected, the running child process is killed immediately rather than waiting for it to finish. This keeps response time under 50ms regardless of how long the subprocess would otherwise run.
- Progress preservation — Products that completed successfully before the interrupt are cached. The next build resumes from where it left off rather than starting over.
- Parallel builds — In parallel mode, all in-flight subprocesses are killed when Ctrl+C is detected. Each thread’s poll loop independently checks the global interrupt flag.
Environment Variables
The problem
Build tools that inherit the user’s environment variables produce non-deterministic builds. Consider a C compiler invoked by a build tool:
- If the user has
CFLAGS=-O2in their shell, the build produces optimized output. - If they unset it, the build produces debug output.
- Two developers on the same project get different results from the same source files.
This breaks caching (the cache key doesn’t account for env vars), breaks reproducibility (builds differ across machines), and makes debugging harder (a build failure may depend on an env var the developer forgot they set).
Common examples of environment variables that silently affect build output:
| Variable | Effect |
|---|---|
CC, CXX | Changes which compiler is used |
CFLAGS, CXXFLAGS, LDFLAGS | Changes compiler/linker flags |
PATH | Changes which tool versions are found |
PYTHONPATH | Changes Python module resolution |
LANG, LC_ALL | Changes locale-dependent output (sorting, error messages) |
HOME | Changes where config files are read from |
RSConstruct’s approach
RSConstruct does not use environment variables from the user’s environment to control build behavior. All configuration comes from explicit, versioned sources:
rsconstruct.toml— all processor configuration (compiler flags, linter args, scan dirs, etc.)- Source file directives — per-file flags embedded in comments (e.g.,
// EXTRA_COMPILE_FLAGS_BEFORE=-pthread) - Tool lock file —
.tools.versionslocks tool versions so changes are detected
This means:
- The same source tree always produces the same build, regardless of the user’s shell environment.
- Cache keys are computed from file contents and config values, not ambient env vars.
- Remote cache sharing works because two machines with different environments still produce identical cache keys for identical inputs.
Rules for processor authors
When implementing a processor:
-
Never read
std::env::var()to determine build behavior. If a value is configurable, add it to the processor’s config struct inrsconstruct.toml. -
Never call
cmd.env()to pass environment variables to external tools, unless the variable is derived from explicit config (not fromstd::env). The user’s environment is inherited by default — the goal is to avoid adding env-based configuration on top. -
Tool paths come from
PATH— RSConstruct does inherit the user’sPATHto find tools likegcc,ruff, etc. This is acceptable because each tool’s identity is part of the cache key of everything it produces: resolving a different binary, or the same binary with different content, invalidates that processor’s cached results. Seehash_tool_versions.rsconstruct tool lockadditionally pins exact versions and makes drift a hard error. -
Config values, not env vars — if a tool needs a flag that varies per project, put it in
rsconstruct.tomlunder the processor’s config section. Config values are hashed into cache keys automatically.
What RSConstruct does inherit
RSConstruct inherits the full parent environment for subprocess execution. This is unavoidable — tools need PATH to be found, HOME to read their own config files, etc. The key design decision is that RSConstruct itself never reads env vars to make build decisions, and processors never add env vars derived from the user’s environment.
The exceptions are:
NO_COLOR— RSConstruct respects this standard env var to disable colored output, which is a display concern and does not affect build output.RSCONSTRUCT_THREADS— Sets the number of parallel jobs (equivalent to-j). Priority: CLI-jflag >RSCONSTRUCT_THREADSenv var >[build] parallelconfig. This is a performance tuning concern and does not affect build correctness or output.
Release Info
This page is generated automatically by the docs deployment workflow.
- Version:
0.9.138 - Git tag:
v0.9.138 - Git commit:
b054a9740be7f916bf37f10b0554c621c839953e - Released at:
2026-10-22 09:22:38 UTC - Workflow:
CIrun #294
Internal Documentation
This section collects documentation aimed at rsconstruct’s contributors and maintainers — people who modify the codebase itself, not end users who configure rsconstruct for their projects.
If you are using rsconstruct to build a project, you can stop reading now. Everything below is about how rsconstruct works internally: data structures, design decisions, invariants, coding style, and the reasoning behind non-obvious choices.
What belongs here
A chapter belongs in “For Maintainers” if it answers at least one of these questions:
- How is rsconstruct implemented? (Architecture, cache layout, execution model)
- Why did we make this design choice? (Design notes, rejected alternatives, tradeoffs)
- What contract must my code uphold? (Processor contract, invariants, coding standards)
- What’s the right way to extend rsconstruct? (Adding processors, adding analyzers)
- What’s the non-obvious implementation detail I need to know? (Checksum cache layers, descriptor keys, shared-output-directory semantics)
A chapter does NOT belong here if it answers:
- How do I install rsconstruct?
- How do I configure a processor for my project?
- How do I use processor X on file type Y?
Those are user-facing and live in the main section above.
How to use this section
Read in roughly this order if you’re new to the codebase:
- Architecture — 10-minute tour of the major modules and their responsibilities.
- Coding Standards — conventions you’ll be held to in code review.
- Strictness — how the compiler is configured to reject lax code, and the rules for opting out.
- Processor Contract — the interface every processor must satisfy. Read before adding a new processor.
- Testing — how the test suite is structured and how to add new tests.
- Cache System and Checksum Cache — how incremental builds actually work.
After that, read topic-specific chapters as the work demands:
- Building cache features → Cache System, Processor Versioning
- Adding a processor that writes into a shared directory → Shared Output Directory
- Adding cross-processor dependencies → Cross-Processor Dependencies
- Thinking about ordering and enumeration → Processor Ordering, Output Prediction
Links to individual chapters
See the table of contents in the sidebar. Brief one-line summaries:
- Architecture — module map and major data flows.
- Design Notes — collected rationale for design decisions.
- Coding Standards — naming, file layout, error handling conventions.
- Strictness — crate-level
#![deny(warnings)], rules for#[allow]. - Testing — integration test structure and philosophy.
- Parameter Naming — canonical names for the same concept in different places.
- Processor Contract — what every processor must implement and uphold.
- Cache System — content-addressed object store, descriptor keys.
- Checksum Cache — mtime-based content hash caching.
- Dependency Caching — caching of source-file dependency scans (e.g. C/C++ headers).
- Processor Versioning — how processors invalidate caches when their behavior changes.
- Cross-Processor Dependencies — how one processor’s outputs become another’s inputs.
- Shared Output Directory — handling multiple processors that write into the same folder.
- Processor Ordering — why rsconstruct does NOT have explicit ordering primitives.
- Output Prediction — the MassGenerator design: tools that enumerate their outputs in advance.
- Per-Processor Statistics — why cache stats can’t group by processor today, options for fixing it.
- Profiling — recorded profiling runs with date + rsconstruct version, plus how-to for rerunning.
- Unreferenced Files — detecting files on disk that no product references.
- Internal Processors — pure-Rust processors that do not shell out.
- Missing Processors — tools we don’t yet wrap but should.
- Crates.io Publishing — release process.
- Per-Processor max_jobs — design note for per-processor parallelism limits.
- Rejected Audit Findings — audit issues deliberately rejected, kept to prevent re-flagging.
- Suggestions — ideas for future work.
- Suggestions Done — archive of completed suggestions.
- TODO — ongoing and completed task list.
Architecture
This page describes RSConstruct’s internal design for contributors and those interested in how the tool works.
Core concepts
Processors
Processors implement the Processor trait (src/processor/mod.rs). Each processor:
- Auto-detects whether it is relevant for the current project
- Scans the project for source files matching its conventions
- Creates products describing what to build
- Executes the build for each product
Run rsconstruct processor list to see all available processors and their auto-detection results.
Auto-detection
Every processor implements auto_detect(), which returns true if the processor appears relevant for the current project based on filesystem heuristics. This allows RSConstruct to guess which processors a project needs without requiring manual configuration.
Only two Processor methods are required — scan_config() and
execute(ctx, product). Everything else has a default implementation driven
by the processor’s StandardConfig. The main methods:
| Method | Purpose |
|---|---|
auto_detect(&FileIndex) -> bool | Return true if the project looks like it needs this processor |
discover(&mut BuildGraph, &FileIndex, instance_name) | Query the file index and add products to the build graph |
execute(&BuildContext, &Product) | Build a single product |
clean(&Product, verbose) -> Result<usize> | Remove a product’s outputs, returning how many files were removed |
Batch execution (execute_batch) and tool declaration (required_tools,
tool_version_commands) round out the trait.
Both auto_detect and discover receive a &FileIndex — a pre-built index of all non-ignored files in the project (see File indexing below).
Detection heuristics are per-processor filesystem checks (e.g. ruff
detects when the project contains .py files, cargo when it contains
Cargo.toml). The authoritative, always-current list is
rsconstruct processor list, which shows every processor together with its
auto-detection result for the current project.
Products
A product represents a single build unit with:
- Inputs — source files that the product depends on
- Outputs — files that the product generates
- Output directory (optional) — for creators, the directory whose entire contents are cached and restored as a unit
BuildGraph
The BuildGraph manages dependencies between products. It performs a topological sort to determine the correct build order, ensuring that dependencies are built before the products that depend on them.
Executor
The executor runs products in dependency order. It supports:
- Sequential execution (default)
- Parallel execution of independent products (with
-jflag) - Dry-run mode (show what would be built)
- Keep-going mode (continue after errors)
- Batch execution (group multiple products into one tool invocation)
Incremental rebuild after partial failure
Each product is cached independently after successful execution. If a build is interrupted or fails partway through, the next run only rebuilds products that don’t have valid cache entries:
-
Non-batch mode: Each product executes and is cached individually. If the build stops after 400 of 800 products, the next run skips the 400 cached successes and rebuilds the remaining 400. Products go down this path when batching is disabled (
--batch-size -1), when the processor doesn’t support batching, or when fewer than two of its products need rebuilding. -
Batch mode with external tools (the default for batch-capable processors): The external tool receives all files in the batch in one invocation — with no
--batch-sizelimit set, that is every rebuilding product of the processor in the level. If the tool exits with an error, all products in that batch are marked failed — there is no way to determine which outputs are valid from a single exit code. On the next run, all products from the failed batch are rebuilt. Use--batch-size Nto bound the blast radius, or--batch-size -1for per-product execution and caching. -
Batch mode with internal processors (e.g.,
imarkdown2html,isass,ipdfunite): These process files sequentially in-process and return per-file results, so partial failure is handled correctly even in batch mode — only the failed products are rebuilt.
Interrupt handling
External subprocess execution goes through the runner family in
src/processor/mod.rs — run_command(), run_command_capture(),
run_command_with_timeout(), run_command_with_stdin() — which share one
inner implementation. It spawns children via tokio::process::Command
with kill_on_drop(true) and then awaits a biased tokio::select! racing
the child’s exit against a tokio::sync::watch interrupt receiver obtained
from BuildContext::interrupt_receiver() (plus an optional timeout arm).
Routing through it is also what gives a call site log_command()
(--show-child-processes) and, in debug builds, the declared-tools
assertion that a processor only runs tools its required_tools() names.
Hand-rolled spawns silently opt out of all four, which is why they are held
to exactly two exceptions:
- Tool installation (
tools::run, the binary installer) inherits the terminal sosudocan prompt for a password and apt/dnf can render progress. Capturing would hang on the prompt. - Opening a viewer (
Builder::open_file) launches a detached process that must outlive rsconstruct —kill_on_dropand waiting are both exactly wrong.
Both are commented as such at the call site. Anything else that spawns a child is a bug.
A command that needs to feed stdin uses run_command_with_stdin(), which
pumps the write concurrently with draining stdout/stderr. Writing all of
stdin first deadlocks as soon as the child fills its output pipe — this is
why the aspell checker used to carry a hand-rolled spawn with a writer
thread.
The Ctrl+C handler — a dedicated thread in main.rs awaiting
tokio::signal::ctrl_c() — calls BuildContext::interrupt(), which sets an
AtomicBool and broadcasts on the watch channel. Every waiting subprocess
wakes immediately (there is no polling interval) and its child is killed on
drop. The executor also consults is_interrupted() between products and
levels to stop scheduling new work. A second Ctrl+C force-exits with
status 130.
File indexing
RSConstruct walks the project tree once at startup and builds a FileIndex — a sorted list of all non-ignored files. The walk is performed by the ignore crate (ignore::WalkBuilder), which natively handles:
.gitignore— standard git ignore rules, including nested.gitignorefiles and negation patterns.rsconstructignore— project-specific ignore patterns using the same glob syntax as.gitignore
Two kinds of directory are never walked, regardless of ignore files:
.rsconstruct/— the tool’s own state directory (cache descriptors, objects, databases).- Configured output roots — the global
[build] output_dirplus every instance’soutput_dir/output/output_dirsvalues (Config::file_index_walk_dirs). Generated files must not be discovered as project source: otherwise build 1 generates a file and build 2 lints it, and fresh vs. incremental builds discover different graphs. Downstream processors still see upstream outputs — as virtual files injected by declaration during the fixed-point discovery loop, which works identically whether or not the files exist on disk.
The opt-in: naming a directory under an output root in a processor’s src_dirs (e.g. src_dirs = ["out/processor.generator.generic"]) force-walks it unconditionally — ignoring gitignore and the output-root exclusion. That is the deliberate way to scan generated files. output_dir = "" (in-place output at the project root) cannot be excluded; in-place outputs remain indexed.
Processors never walk the filesystem themselves. Instead, auto_detect and discover receive a &FileIndex and query it with their scan configuration (src_extensions, exclude directories, exclude files). This replaces the previous design where each processor performed its own recursive walk.
Build pipeline
This is the core algorithm — every rsconstruct build follows these phases
in order. Use --phases to see timing for each phase.
Phase 1: File indexing
The project tree is walked once to build the FileIndex — a sorted list of
all non-ignored files. This is the only filesystem walk; all subsequent file
lookups go through the index. See File indexing below.
Phase 2: Discovery (fixed-point loop)
Each enabled processor queries the file index and adds products to the
BuildGraph. Discovery runs in a fixed-point loop to handle
cross-processor dependencies:
file_index = walk filesystem
loop (max 10 passes):
for each processor:
processor.discover(graph, file_index)
if no new products were added → break
collect outputs from new products
inject them as virtual files into file_index
if no new virtual files were injected → break
On each pass, processors may re-declare existing products (silently deduplicated) or discover new products whose inputs are virtual files from upstream generators. The loop converges when a full pass adds nothing new. Most projects converge in 1 pass; projects with generator → checker/generator chains converge in 2.
See Cross-Processor Dependencies for details on deduplication and the virtual file mechanism.
Phase 3: Dependency analysis
Dependency analyzers (e.g., the C/C++ header scanner) run against the graph
to add additional input edges. For example, if main.c includes util.h,
the analyzer adds util.h as an input to the main.c product. Results are
cached in deps.redb for incremental builds.
Phase 4: Tool version hashing
For each processor with a tool lock entry (rsconstruct tool lock), the
locked tool version hash is appended to the product’s config hash. This
ensures that upgrading a tool (e.g., ruff 0.4 → 0.5) triggers rebuilds
even if source files haven’t changed.
Tool detection
The TOOLS registry in src/tools.rs lists every external tool
rsconstruct knows about. Each entry’s name is the detection key: it is
passed directly to which::which by builder/tools.rs and tool_lock.rs, so
it decides whether rsconstruct tool list reports installed or missing,
and which binary rsconstruct tool lock records a version for.
Registry names must be bare binary names, never paths. which switches
behaviour on the presence of a path separator:
| Name form | Resolution | Depends on cwd? |
|---|---|---|
mdl | searched on $PATH | no |
gems/bin/mdl | relative to the current working directory | yes |
./gems/bin/mdl | relative to the current working directory | yes |
A path-shaped entry therefore resolves only when rsconstruct is invoked from
the one directory that has that subtree beneath it, and reports missing
everywhere else — including for a user who has the tool installed and on
$PATH at, say, ~/install/gems/bin/mdl. The registry once carried
gems/bin/mdl and node_modules/.bin/markdownlint alongside correct bare
mdl / markdownlint entries; both path forms were removed.
In both modes which also requires the executable bit — a file that exists but
is not executable reads as missing.
To pin a project-local binary, set the per-processor command config field
(src/config/processor_configs.rs), which is the value actually executed:
[markdownlint]
command = "node_modules/.bin/markdownlint"
The registry name stays bare so detection works from any working directory;
command is where a vendored path belongs.
Phase 5: Dependency resolution
resolve_dependencies() scans the graph for products whose inputs match
other products’ outputs. When found, it creates a dependency edge — the
producer must complete before the consumer can start. This is how
cross-processor ordering works automatically (e.g., pandoc runs before the
explicit site generator because pandoc’s HTML outputs are the site
generator’s inputs).
After resolution, the graph is topologically sorted to produce the execution order.
Phase 6: Classify
Each product is classified as one of:
- Skip (up-to-date) — input checksum matches the cache entry and all outputs exist on disk. No work needed.
- Restore — input checksum matches a cache entry but outputs are missing
(e.g., after
rsconstruct clean). Outputs are restored from cache via hardlink or copy. - Build (stale) — input checksum doesn’t match any cache entry. The product must be rebuilt.
Input checksums are computed by hashing all input files (SHA-256). The mtime
pre-check (mtime_check = true, default) skips rehashing files whose mtime
hasn’t changed since the last build.
Phase 7: Execute
Products are executed in topological order, respecting dependency edges.
Independent products at the same dependency level run in parallel (controlled
by -j / RSCONSTRUCT_THREADS). Batch-capable processors group their
products into a single tool invocation.
Batch chunk sizing: By default a batch group is sent as one chunk — all
of a batch-capable processor’s rebuilding products that became ready
together go to the tool in a single invocation. With --batch-size N,
chunks are limited to N products. --batch-size -1 disables batching
entirely, giving per-product execution and caching — the best incremental
recovery after partial failure, at the cost of one tool invocation per file.
In fail-fast mode (no --keep-going), a failing chunk stops later chunks
from being dispatched.
Held checkers: products become ready as their own dependencies finish,
so a batching checker whose files partly depend on generated files would
form several batches, one per wave, running at the same time. That breaks
tools that assume one process over the whole set: pytest given only
__init__.py files collects no tests and exits 5, and two mypy processes
sharing .mypy_cache crash with INTERNAL ERROR. The ReadyTracker
therefore holds a batching checker’s ready products back until none of its
products still waits on a dependency, and releases them all in one wave.
Only checkers are held: nothing depends on a checker’s products, so the hold
delays nothing else, while a generator’s products may feed one another.
For each product:
- Compute input checksum (if not already done in classify)
- Check cache — skip or restore if possible
- Execute the processor’s command
- On success: store outputs in the cache (content-addressed under
.rsconstruct/objects/) - On failure: report error (or continue if
--keep-going)
Processor source layout
All processor code lives under src/processor/. The folder structure mirrors processor type:
src/processor/
├── mod.rs # Processor trait, shared helpers (run_command, run_checker,
│ # SimpleChecker, SimpleGenerator, ProcessorBase, …)
├── checker/ # One file per checker (ruff.rs, pylint.rs, cppcheck.rs, …)
│ └── mod.rs # mod declarations
├── generator/ # One file per generator (generator.rs, marp.rs, sass.rs, tags.rs, …)
│ └── mod.rs # Shared helpers: find_templates, output_path, discover_single_format, …
├── creator/ # One file per creator (cargo.rs, cc.rs, gem.rs, jekyll.rs,
│ │ # mdbook.rs, npm.rs, pip.rs)
│ ├── mod.rs # mod declarations
│ └── creator.rs # Generic creator processor
├── explicit/ # Explicit processor (user-defined command with declared outputs)
│ ├── mod.rs
│ └── explicit.rs
└── mass_generator/ # Mass generator (tool that enumerates its outputs via predict_command)
├── mod.rs
└── mass_generator.rs
Category directories are singular (checker/, not checkers/), like every other module in the crate; a category that holds a processor named after itself (generator::generator, creator::creator, explicit::explicit, mass_generator::mass_generator) carries a #[allow(clippy::module_inception)] on that one mod line.
Conventions
- Every file in
src/processor/is a real single processor — no utility-only files anywhere in the tree. Shared helpers live inmod.rsorgenerator/mod.rs; processor-specific data tables (e.g. the requirements generator’s stdlib list) live in the processor’s own file. The tool registry (src/tools.rs) and build statistics (src/stats.rs) live at the crate root for this reason. - Checkers use
SimpleChecker(data-driven, no boilerplate) or implementProcessordirectly for checkers with custom discovery logic (e.g.,clippy,script). - Generators use
SimpleGenerator(data-driven with a customexecute_fn) orGeneratorProcessorfor the generic pass-through generator. - Creators use
CreatorProcessorfor the generic case, or their own struct for creators with special discovery (cargo profiles, npm siblings, etc.). - Explicit is a singleton processor type with its own folder because it is neither a checker nor a generator.
- Lua is the only processor type that hosts external scripts rather than wrapping a fixed external tool. It has its own folder because it carries significant runtime state (the Lua VM).
- A processor is one file. Everything that defines a processor lives in its file: the
Processorimpl (or SimpleChecker/SimpleGenerator params), its config struct, and oneinventory::submit!plugin entry carrying the whole schema —fields(aFieldSpeclist from which known/checksum/must fields, descriptions, and expected types all derive),scan_defaults,defaults, andomit_standard_fields. Tools missing from the central registry are submitted asToolInfofrom the processor file. There are no central per-processor tables: the former 92/71/~120-arm match tables inconfig/mod.rsare deleted, and every default value is encoded exactly once. The remaining out-of-file touch-points (the categorymoddeclaration, the docs page, the test file) are enforced by completeness tests inconfig/tests.rs.
Determinism
Build order is deterministic:
- File discovery is sorted
- Processor iteration order is sorted
- Topological sort produces a stable ordering
This ensures that the same project always builds in the same order, regardless of filesystem ordering.
Caching
See Cache System for full details on cache keys, storage format, rebuild classification, and per-processor caching behavior.
Subprocess execution
RSConstruct uses two internal functions to run external commands:
-
run_command()— by default captures stdout/stderr via OS pipes and only prints output on failure (quiet mode). Use--show-outputflag to show all tool output. Use for compilers, linters, and any command where errors should be shown. -
run_command_capture()— always captures stdout/stderr via pipes. Use only when you need to parse the output (dependency analysis, version checks, Python config loading). Returns the output for processing.
Parallel safety
When running with -j, each thread spawns its own subprocess. Each subprocess gets its own OS-level pipes for stdout/stderr, so there is no interleaving of output between concurrent tools. On failure, the captured output for that specific tool is printed atomically. This design requires no shared buffers or cross-thread output coordination.
Path handling
All paths are relative to project root. RSConstruct assumes it is run from the project root directory (where rsconstruct.toml lives).
Internal paths (always relative)
Product.inputsandProduct.outputs— stored as relative pathsFileIndex— returns relative paths fromscan()andquery()- Cache keys (
Product::descriptor_key()) — contain no machine-specific paths (the key hashes processor identity, config, and input content), enabling cache sharing across different checkout locations - Cache entries (
CacheEntry.outputs[].path) — stored as relative paths
Processor execution
- Processors pass relative paths directly to external tools
- Processors set
cmd.current_dir(project_root)to ensure tools resolve paths correctly fs::read(),fs::write(), etc. work directly with relative paths since cwd is project root
Exception: Processors requiring absolute paths
If a processor absolutely must use absolute paths (e.g., for a tool that doesn’t respect current directory), it should:
- Store the
project_rootin the processor struct - Join paths with
project_rootonly at execution time - Never store absolute paths in
Product.inputsorProduct.outputs
Why relative paths?
- Cache portability — cache keys don’t include machine-specific absolute paths
- Remote cache sharing — same project checked out to different paths can share cache
- Simpler code — no need to strip prefixes for display or storage
Architecture Observations
Observations about rsconstruct’s high-level structure — the shapes that
determine how the system behaves when you try to change or extend it. Kept
separate from suggestions.md (which is tactical features and bugs) because
these are about how the code is put together, not about what it does.
Each entry has:
- A short title naming the pattern or tension.
- What the current code does.
- What that implies for changes / extensions / users.
- Load-bearing: how much of the system this shape dictates. High = touching it ripples everywhere. Low = localized quirk.
The entries are roughly ordered by how much they shape the rest of the codebase.
The central four
1. The graph is the universal coupling point
Every phase — discovery, analysis, classification, execution — reads and/or
mutates the BuildGraph. Processors receive &mut BuildGraph in their
discover() method and are trusted to add products correctly. There’s no
invariant enforcement at insertion time: empty inputs are allowed, bad dep
references are allowed, duplicate outputs are caught but duplicate inputs
aren’t. Cycles are only detected during topological sort, late.
The graph’s shape also leaks into the executor: the executor knows about
output_dirs (creators), variant (multi-format generators), config_hash
(cache keys), and product IDs. Adding a new product category (say, a
“phantom” product that exists for scheduling but produces no outputs)
requires touching both graph and executor.
Implication: the graph is the lingua franca. Any architectural change
that touches the product model — adding fields, changing what counts as a
dependency, supporting alternate execution orders — ripples into every
consumer. A healthy graph layer would have validation (reject ill-formed
products at insertion), opaque access (consumers see a trait-shaped view,
not the struct), and observer hooks (something watching mutations so
--graph-stats and graph show don’t duplicate traversal logic).
Load-bearing: very high.
2. Plugin registration at link time
Every processor and analyzer submits an inventory::submit! entry. The
registry is populated at binary link time, and enumeration is a runtime
iteration over those entries. This is elegant for modularity — adding a
processor means adding one file, no central list to update — but it has
consequences:
- No compile-time enumeration: you can’t write a match statement over all processor names, so the processor-count gets rediscovered on every run, and static checks (e.g. “every processor has a corresponding config struct”) have to be runtime assertions.
- No runtime registration: the registry is frozen at link time, so a processor that is not compiled in does not exist. (The Lua plugin host that used to arrive at runtime after the freeze was removed on 2026-10-17.)
- Ordering is alphabetical everywhere: because
inventorydoesn’t preserve submission order, every code path that touches plugins has to sort by name. This is a minor tax but it’s baked in everywhere. - Testing requires the whole binary: you can’t instantiate a stripped-down registry for tests; they pull the full set. Most tests don’t mind, but ones that want a controlled plugin set have to filter rather than inject.
Implication: the registration model favors modularity over introspectability. If rsconstruct ever wants a “declarative build” representation (think Bazel’s static action graph) the plugin layer will have to expose more schema information than it does today.
Load-bearing: high.
3. Config defaults are scattered, not composed — PARTIALLY ADDRESSED
Three sources of defaults apply in sequence:
- Per-processor defaults (e.g.
ruff→command = "ruff") in a giant match-or-registry lookup. - Scan defaults (src_dirs, src_extensions) via a separate mechanism
(
ScanDefaultsData). - User TOML overrides both.
The order matters, but it’s encoded across apply_processor_defaults,
apply_scan_defaults, and the serde deserialization.
Update: config provenance tracking (src/config/provenance.rs) now
records where each field came from (UserToml { line }, ProcessorDefault,
ScanDefault, OutputDirDefault, SerdeDefault). rsconstruct config show
annotates every field with its source. The defaults pipeline still applies
layers across multiple functions, but the provenance map makes it possible
to answer “where did this value come from?” without tracing the code.
The remaining gap: adding a new defaults layer (env-derived, user-global) still means inserting into the existing function chain rather than a declarative resolver.
Load-bearing: medium.
4. The executor owns too much policy — RESOLVED
Update: a BuildPolicy trait has been extracted to src/executor/policy.rs.
classify_products now delegates per-product decisions to &dyn BuildPolicy.
IncrementalPolicy implements the current skip/restore/rebuild logic.
Alternate policies (dry-run, always-rebuild, time-windowed) are now a single
trait implementation away — no executor changes needed.
Correction (2026-10-06): the extraction was only half done. The
executor decided every product a second time, at dispatch, by calling
ObjectStore::needs_rebuild_descriptor directly — so the policy governed
the printed counts but not what ran, and a non-default policy would have
been ignored by execution. Now the dispatch-time decision is the policy’s
(prepare_level_work calls BuildPolicy::classify), and
classify_products is documented as a prediction. See finding R1 below.
Load-bearing: very high, but the tension is resolved.
Structural tensions
5. Processor trait assumes StandardConfig, but allows bypass
The Processor trait has a scan_config() -> &StandardConfig method that
every processor must implement. The default implementations of discover(),
auto_detect(), and supports_batch() use this config. But processors
with richer configs (e.g. ClippyConfig, CcConfig) don’t expose those
richer fields through the trait — they store them privately and access
them internally. The outside world only sees StandardConfig.
Implication: there’s no way to ask “what config does processor X
accept?” through the trait. Introspection goes through the registry
(known_fields, must_fields, field_descriptions) instead, which means
the processor has to register the metadata separately from implementing
the trait. The two representations can drift: someone adds a field to
ClippyConfig and forgets to add it to known_fields.
A healthier shape would have one source of truth per processor — the
config struct itself — with a derive macro or trait-based reflection
generating the known_fields list. Or go the other direction: make the
trait parameterized (Processor<Config>) so introspection goes through the
type system.
Load-bearing: medium. Doesn’t break anything today but is the root cause of several “remembered to update both places?” bugs we’ve fixed.
6. Analyzers are inputs-only; they can’t add products
An analyzer scans the sources of existing products, and the framework
(analyzers::Analysis) adds what it finds as inputs. (Analyzers used to
walk the graph themselves through DepAnalyzer::analyze(); since R3/R5
they only implement scan().)
It cannot:
- Create new products (the cpp analyzer can’t spawn a product for a header it discovered).
- Remove products.
- Change processor assignments.
This is a deliberate simplification — analyzers run in a single pass after discovery and don’t need fixed-point semantics of their own. But it means the “dependency graph” isn’t really discovered by analyzers; it’s refined by them. The actual discovery of what exists lives entirely in processors.
Implication: if a use case arises where an analyzer legitimately needs
to produce a product — e.g. “for every .proto import I find, ensure
there’s a product for generating the .pb.cc” — the analyzer interface
doesn’t support it. You’d have to turn the analyzer into a processor, or
add a “synthesize” callback. The asymmetry between processors (can add
products) and analyzers (can only add inputs) is currently invisible but
will bite eventually.
Load-bearing: medium. Not a bug, but a limitation that shapes what kinds of features are easy vs. hard.
7. Processor instance ↔ typed processor mapping is one-way — PARTIALLY ADDRESSED
A ProcessorInstance in the config holds (type_name, instance_name, config_toml). Builder::create_processors() deserializes the TOML and
produces a Box<dyn Processor>. Afterwards, the TOML blob is discarded.
Update: ProcessorInstance now carries a provenance: ProvenanceMap
that records where each field came from (user TOML with line number,
processor default, scan default, etc.). This means config show can
annotate fields with their source without reparsing TOML, and smart
commands can distinguish user-set from defaulted fields.
The remaining gap: a running Box<dyn Processor> still can’t navigate
back to its ProcessorInstance or the originating TOML section. The
provenance lives on the config side, not the runtime processor side.
Load-bearing: medium.
8. Global state in the processor runtime — RESOLVED
Update: all mutable process globals have been moved into BuildContext
(src/build_context.rs):
- The three processor globals (
INTERRUPTED,RUNTIME,INTERRUPT_SENDER) are replaced and deleted.run_commandtakes&BuildContextexplicitly. - The three checksum globals (
CACHE,MTIME_DB,MTIME_ENABLED) are moved intoBuildContext.combined_input_checksumandchecksum_fasttake&BuildContext.
Remaining process-wide state is all immutable or correctly scoped:
RuntimeFlags— immutable after startup, doesn’t vary between contexts.DECLARED_TOOLS—thread_local!, debug-only.- Compiled regexes —
LazyLock<Regex>, stateless.
Load-bearing: resolved. Multiple BuildContext instances can now run
independently (daemon mode, LSP, testing).
Broader patterns
9. Supply-driven model everywhere
The whole pipeline — discover, classify, execute — walks every product
unconditionally. There’s no demand-driven path (like make foo which
visits only the subgraph producing foo). The --target <glob> flag
filters after discovery; it doesn’t trim the work that discovery itself
does.
This is a deliberate design — rsconstruct’s typical workload is “build everything incrementally,” and supply-driven matches that well. But it means a user asking “just build X” still pays the cost of discovering all 5000 other products.
Implication: for projects at a certain scale, or for tooling that wants to quickly answer “which products would I run for this file?” (IDE integration, pre-commit hooks), the supply-driven model becomes a bottleneck. A demand-driven shortcut would require either pre-built reverse indexes (input path → product) persisted between runs, or an analytical model of each processor’s output paths (hard — processor output is computed procedurally).
Load-bearing: very high. Changing this means a fundamentally different build-system shape.
10. “Run on every build” is the default stance
Every configured processor discovers and classifies on every invocation.
There’s no concept of “processor X is slow, only run when asked.” The
-p/-x mechanism works per-invocation but not as a declarative
property. See suggestions.md for the proposed build_by_default = false
pattern — that’s a tactical fix. The architectural observation is that
rsconstruct’s model biases hard toward “all processors together,”
whereas the user mental model often has lifecycle phases (lint vs.
package vs. deploy).
Implication: adding a “goals” layer (cargo-style subcommands, or npm-style named scripts) is a natural extension direction. It would introduce a new concept — a goal is a named selection of processors — and likely requires CLI reorganization. Bigger than it sounds.
Load-bearing: medium. Shapes the CLI surface and user mental model.
11. Object store as a multi-responsibility module — RESOLVED
Update: ObjectStore has been decomposed into focused submodules:
blobs.rs— content-addressed blob storage (store, read, restore, checksum)descriptors.rs— cache descriptor CRUD (store_marker, store_blob, store_tree)restore.rs— cache query and restoration (restore_from_descriptor, needs_rebuild, can_restore, explain)management.rs— cache management (size, trim, remove_stale, list, stats)operations.rs— remote cache push/fetchconfig_diff.rs— processor config change tracking
mod.rs went from ~664 to ~223 lines (struct definition, types, constructor).
Each concern is now a focused 100–150 line file.
Load-bearing: very high, but the monolith is resolved.
What’s absent that one might expect
12. No abstraction for “tool invocation”
Every processor that shells out to a subprocess rolls its own Command
building: env vars, arg construction, timeout, output capture, error
classification. Shared helpers (run_command, check_command_output)
exist but are minimal. Processor implementations still have to know about:
- How to pass files (positional args vs.
--file=Xvs. stdin vs. response file when argv is too long). - How to interpret exit codes (some tools return 1 for “found issues”, some return 0 and print to stderr, some return 2 for config errors).
- How to parse output for structured errors.
Implication: processor implementations have roughly 30-80 lines of
boilerplate each, and they’re inconsistent. A ToolInvocation abstraction
with pluggable arg-passing strategies would shrink most processors to a
few lines of declaration. This also makes adding a new processor harder
than it needs to be.
Load-bearing: medium.
13. No pluggable reporting / event stream
Today reporting is hardcoded: println! during execution, colored summary
at the end, --json mode emits structured events, --trace emits Chrome
tracing format. Each reporting path is a separate code path threading
through the executor.
Implication: adding a new output format (JUnit XML for CI, GitHub Actions annotations, custom Slack webhook) means threading another code path through the executor. A proper event-bus model — executor emits events, subscribers render them — would make this a two-file change (subscribe + format).
Load-bearing: medium.
14. No formal dry-run execution — RESOLVED
Resolution (2026-10-07): --dry-run now runs the build’s own pipeline
up to the point of execution: plan_build (the same -p/-x/--target
selection, overrides and tool preflight) and classify_products (the same
prediction, including downstream propagation, reported as
RebuildReason::DependencyChanged). Before, it built its own graph,
ignored the selection flags and looked each product up in isolation, so a
product downstream of a change showed SKIP while the build rebuilt it.
plan_build no longer advances the config baseline; build does that
itself. --stop-after classify stays as a phase-debugging stop, conflicting
with --dry-run, and status stays a current-state view
(Builder::current_action). Tests: tests/tests_mod/dry_run.rs.
Original observation: there’s --stop-after classify, which stops after classification, and
there’s dry_run() (different from --dry-run which is a flag on build),
and there’s --explain which annotates per-product decisions. Three
partially-overlapping mechanisms. The user-facing story is “to see what
would happen, use X or Y or Z depending on what you want.”
Implication: these evolved separately. A unified “simulation mode” that fully runs the classify pipeline and outputs what would happen — including what cache entries would be produced — would subsume the three. Likely a small refactor, but requires aligning on the output shape.
Load-bearing: low-medium.
Review of 2026-10-06: dependency management and execution stages
A read-through of the build pipeline end to end (driver, graph, discovery, analyzers, deps cache, executor, checksums, object store). R1–R5 were correctness defects and are fixed, as are R6–R10 (R8 to the extent it can be: see its entry), and so are the minor items found alongside them.
R1. Two decisions per product, only one of them the policy’s — RESOLVED
classify_products decided skip/restore/build through BuildPolicy; the
executor then decided again at dispatch with its own call to
needs_rebuild_descriptor, using the classification only to size the
progress bar. The execute() doc claimed classify-time checksums were
reused; the code recomputed them. dep_changed was effectively ignored at
execution (a checker predicted BUILD skipped if its marker matched), so
the “N to build” counts were wrong.
Fix: one decision per product, made at dispatch (all dependencies have
run, inputs are final) by the policy. dep_changed is gone from the
policy: with a content-addressed key the checksum is the dependency
signal. classify_products is now documented as a pessimistic prediction
that only sizes the bar, prints the counts and picks what to unlink.
R2. The cache key was taken after the tool ran — RESOLVED
handle_success hashed the inputs after execution and cached the outputs
under that key, to cope with products whose dep_inputs glob matched their
own output. An input edited while its tool ran was cached as if the old
output belonged to the new content: the next build skipped, stale.
Fix: a product’s own outputs are never its inputs
(graph::without_own_outputs). Outputs are cached under the dispatch-time
checksum; when the post-run checksum differs, the outputs are not cached
and a warning says so, so the next build reruns the product. Limitation:
the in-session checksum caches assume content is stable within a build —
input lists of 32+ files are memoized, and with --no-mtime-cache every
file is — so a mid-run edit is only detected through the mtime check.
R3. Analyzers wrote inputs behind the graph’s indexes — RESOLVED
Analyzers mutated product.inputs through get_product_mut, bypassing
input_to_products, so products_consuming (and graph lookups built on
it) never saw analyzer-found dependencies. Fix: get_product_mut is
gone; inputs grow only through BuildGraph::add_inputs, which keeps the
index. resolve_dependencies recomputes edges instead of appending, so it
can run again mid-build.
R4. The deps cache ignored configuration and missing files — RESOLVED
Entries were keyed by (analyzer, path) alone and validated only against
file contents. Changing include_paths (or pkg-config output) kept the old
lists; a new src/x.h shadowing include/x.h went unnoticed. Fix:
entries carry the analyzer’s config fingerprint and the paths resolution
probed and found missing (ScanResult::absent). See
dependency-caching.md.
R5. Generated sources were never analyzed in the build that wrote them — RESOLVED
A source that did not exist at graph time was skipped (clean checkout), and one that existed was analyzed in its stale state. Its consumer could run before a generated header it included (no edge), and its cache key differed between a clean and an incremental build — so a CI-filled remote cache never hit locally, and the build after a clean build rebuilt.
Fix: analyzers resolve against the post-discovery file index, which
includes declared outputs, so references to not-yet-generated files become
edges. analyzers::Analysis outlives graph construction: after each level
the executor reports what built or restored (executor::GraphRefresh),
and every product whose analysis read a rewritten file is reset to its
declared inputs and re-analyzed in graph-time order. The executor then
re-links and re-plans the remaining levels. The analyzer trait shrank to
scan(); iteration, caching and graph updates live in the framework.
R6. Levels were barriers — RESOLVED
execute_parallel ran level by level; one slow product held up the whole
next level. Fix: a ready-queue scheduler. ReadyTracker counts each
product’s unfinished dependencies; the coordinator thread prepares every
product the moment that count reaches zero and queues it for a pool of
workers. R5’s re-analysis runs between completions; when it adds edges the
tracker recounts which products are ready. Test:
a_product_does_not_wait_for_unrelated_products deadlocks (and times out)
under the old executor.
R7. Work was split statically; -j was not a global cap — RESOLVED
Non-batch items were cut into parallel fixed chunks up front (no load
balancing, and a chunk blocked on a max_jobs semaphore stalled the rest
of it). Each batch group added a thread on top of those, and a batching
processor ran its chunks serially. Fix: exactly -j workers take units
from one queue — a single product, or a chunk of at most batch_size of
one batching processor’s products, chunks running in parallel. A worker
takes the first unit whose processor is under its max_jobs cap, so a
capped processor never parks a worker; the semaphores are gone. Test:
jobs_cap_every_invocation_and_max_jobs_caps_a_processor.
R8. Discovery re-ran every processor on every pass — RESOLVED
The fixed-point loop rediscovered all processors each pass, not just those whose extensions match the newly added virtual files. Fix: Routing Generated Files — per-file processors (80 of 97) are handed only the newly declared files; processors that look at more than one file still rescan the whole index each pass. Verified identical on all 222 fleet configs. Making those processors wait for their producers instead was set aside: the ordering cannot be computed without output prediction (see “Why whole-index processors do not wait” in that chapter), so the loop’s re-declaration handling stays.
R9. The deps cache committed once per scanned file — RESOLVED
Each DepsCache::set was its own durable redb transaction — one fsync per
file on a cold scan. Fix: set stages and DepsCache::flush writes the
batch in one transaction at the end of each analysis pass. A cold scan of
2,000 sources: 2,031 fsyncs before, 32 after.
R10. The mtime cache trusted mtime alone — RESOLVED
MtimeEntry held (mtime, checksum). Tools that preserve mtimes (tar x,
cp -p, rsync -a) changed content without changing it, and the build
skipped with stale outputs. Fix: entries hold a platform::FileStamp
(mtime, ctime, size, inode); ctime cannot be set from user space. The
“changed too recently to store” check uses the later of mtime and ctime.
Minor — RESOLVED
pkg-config andResolved: every failure is an error at analyzer setup, naming the package or command.include_path_commandsfailures print a warning and continue, silently dropping dependencies — against “strict by default”.Resolved:build_graph_filteredis a hand-copied fourth graph-building path that skips tool-version hashing.processor filesbuilds the normal graph and filters the listing, which also fixedprocessor files <name>failing when another processor generates that processor’s inputs.Resolved:-p Bdoes not pull in the producers of B’s inputs;--targetdoes.-pselects from the fully discovered graph with the same upstream closure as--target;-xdrops what depends on an excluded processor.Resolved:@<name>aliases:@ruffworks only because ruff is also a tool name; the five type aliases are copy-pasted branches.@xmatches short names (with instances) as well as tools, and the type aliases are one lookup overProcessorType, which also adds the missing@explicit.FiveResolved:add_product*constructors;src/config/mod.rsis 3,100+ lines.add_productplus oneadd_product_with(…, variant, output_dirs);config/mod.rs(about 1,100 lines) keeps config loading and the schema types, withdependencies,processors,settings,validationandoverridessplit out beside it.Products predicted to build only because a dependency changes still have their outputs unlinked up front, so an unchanged rebuild ends as a restore rather than a skip.Resolved: stale outputs are removed per product right before it restores or rebuilds, and at the end of the run for predicted changes that never ran; an unchanged dependency now means a skip.
Summary of architectural recommendations
All four highest-leverage refactors are now complete:
Extract a— done.BuildPolicytrait from the executorclassify_productsdelegates per-product skip/restore/rebuild decisions to a&dyn BuildPolicy.IncrementalPolicyimplements the current logic. Future policies (dry-run, always-rebuild, time-windowed) are a single trait impl. Seesrc/executor/policy.rs.Decompose— done.ObjectStoremod.rssplit from 664 → 223 lines into focused submodules:blobs.rs(content-addressed storage),descriptors.rs(cache descriptor CRUD),restore.rs(restore/needs_rebuild/can_restore/explain). Existingmanagement.rs,operations.rs,config_diff.rsunchanged.Consolidate config resolution with provenance tracking— done. Config fields now carryFieldProvenance(user TOML with line number, processor default, scan default, serde default).config showannotates every field with its source. Seesrc/config/provenance.rs.Introduce a— done. The three process globals (BuildContextstruct replacing process globalsINTERRUPTED,RUNTIME,INTERRUPT_SENDER) are replaced by aBuildContextstruct threaded through theProcessortrait, executor, analyzers, and remote cache. Seesrc/build_context.rs.
Entries 3, 7, and 8 are partially addressed — the core issues are resolved but minor gaps remain (see individual entries above).
Entry 14 is resolved. Entries 1, 2, 5, 6, 9, 10, 12, 13 are observations about the code’s shape — not necessarily problems to fix, but constraints a new contributor should understand before making structural changes.
The technical observations (code duplication in discovery helpers, dead
fields in ProcessorPlugin, scattered error handling) are recorded in
suggestions.md as tactical items.
Design Notes
This page has been merged into Architecture. See that page for RSConstruct’s internal design, subprocess execution, path handling, and caching behavior.
Coding Standards
Rules that apply to the RSConstruct codebase and its documentation.
Always add context to errors
Every ? on an IO operation must have .with_context() from anyhow::Context. A bare ? on fs::read, fs::write, fs::create_dir_all, Command::spawn, or any other syscall-wrapping function is a bug. It produces error messages like “No such file or directory” with no indication of which file or which operation failed.
Good:
#![allow(unused)]
fn main() {
fs::read(&path)
.with_context(|| format!("Failed to read config file: {}", path.display()))?;
}
Bad:
#![allow(unused)]
fn main() {
fs::read(&path)?; // useless error message
}
The error chain should read like a stack trace of intent: “Failed to build project > Failed to execute ruff on src/main.py > Failed to spawn command: ruff > No such file or directory”.
Fail hard, never degrade gracefully
When something fails, it must fail the entire build. Do not try-and-fallback, do not silently substitute defaults for missing resources, do not swallow errors. If a processor is configured to use a file and that file does not exist, that is an error. The user must fix their configuration or their project, not the code.
Optional features must be opt-in via explicit configuration (default off). When the user enables a feature, all resources it requires must exist.
Processor naming conventions
Every processor has a single identity string (e.g. ruff, clang_tidy,
mdbook). All artifacts derived from a processor must use that same string
consistently:
| Artifact | Convention | Example (clang_tidy) |
|---|---|---|
| Name constant | pub const UPPER: &str = "name"; in processor::names | CLANG_TIDY: &str = "clang_tidy" |
| Source file | src/processor/checker/{name}.rs or generator/{name}.rs | checker/clang_tidy.rs |
| Processor struct | {PascalCase}Processor | ClangTidyProcessor |
| Config struct | {PascalCase}Config | ClangTidyConfig |
Field on ProcessorConfig | pub {name}: {PascalCase}Config | pub clang_tidy: ClangTidyConfig |
Match arm in processor_enabled_field() | "{name}" => self.{name}.enabled | "clang_tidy" => self.clang_tidy.enabled |
Entry in default_processors() | names::UPPER.into() | names::CLANG_TIDY.into() |
Entry in validate_processor_fields() | processor_names::UPPER => {PascalCase}Config::known_fields() | processor_names::CLANG_TIDY => ClangTidyConfig::known_fields() |
Entry in expected_field_type() | ("{name}", "field") => Some(FieldType::...) | ("clang_tidy", "compiler_args") => ... |
Entry in src_dirs() | &self.{name}.scan | &self.clang_tidy.scan |
Entry in resolve_scan_defaults() | self.{name}.scan.resolve(...) | self.clang_tidy.scan.resolve(...) |
Registration in create_builtin_processors() | Builder::register(..., proc_names::UPPER, {PascalCase}Processor::new(cfg.{name}.clone())) | Builder::register(..., proc_names::CLANG_TIDY, ClangTidyProcessor::new(cfg.clang_tidy.clone())) |
Re-export in processor/mod.rs | pub use checker::{PascalCase}Processor | pub use checker::ClangTidyProcessor |
Install command in tool_install_command() | "{tool}" => Some("...") | "clang-tidy" => Some("apt install clang-tidy") |
When adding a new processor, use the identity string everywhere. Do not
abbreviate, rename, or add suffixes (Gen, Bin, etc.) to any of the
derived names.
Never use a _check suffix in processor names. Name the processor after the
tool or library it wraps — do not abstract or rename it (e.g. zspell not
spellcheck, ruff not python_lint).
Processor new() must be infallible
Every processor’s fn new(config: XxxConfig) -> Self must return Self, not
Result<Self>. This is enforced at compile time by the registry macro. If
construction can fail, defer the failure to execute() or discover().
Processor directory layout
Each processor category directory (src/processor/checker/,
src/processor/generator/, src/processor/creator/) must contain
only processor implementation files — one processor per .rs file (plus
mod.rs). Shared utilities, helpers, or supporting code used by multiple
processors must live in src/processor/ directly, not inside a category
subdirectory. This keeps each category directory a flat, scannable list of
processors.
Test naming for processors
Test functions for a processor must be prefixed with the processor name.
For example, tests for the cc_single_file processor must be named
cc_single_file_compile, cc_single_file_incremental_skip, etc.
No indented output
All println! output must start at column 0. Never prefix output with spaces
or tabs for visual indentation unless when printing some data with structure.
No shell when spawning subprocesses
Subprocesses must be spawned via direct argv execution
(Command::new(prog).args([...])), not via sh -c or any other shell.
Routing user-controlled or config-controlled strings through a shell turns
ordinary characters like <, >, |, &, ;, $, and spaces into
code-injection surface. A shell is also unnecessary — every package manager
and tool we invoke accepts argv directly.
A small number of features (Tera shell_output(), C source pragmas like
EXTRA_COMPILE_SHELL, the icpp analyzer’s include_path_commands, and the
free-form binary install methods in the static tool registry) are
contractually shell-shaped and are listed as named exceptions. See
No-Shell Policy for the full rationale, the exception
table, and how to add a new exception if one is genuinely needed.
Suppress tool output on success
External tool output (compilers, linters, etc.) must be captured and only
shown when a command fails. On success, only rsconstruct’s own status messages appear.
Users who want to always see tool output can use --show-output. This keeps
build output clean while still showing errors when something goes wrong.
Never hard-code counts of dynamic sets
Documentation and code must never state the number of processors, commands, or any other set that changes as the project evolves. Use phrasing like “all processors” instead of “all seven processors”. Enumerating the members of a set is acceptable; stating the cardinality is not.
Use well-established crates
Prefer well-established crates over hand-rolled implementations for common functionality (date/time, parsing, hashing, etc.). The Rust ecosystem has mature, well-tested libraries for most tasks. Writing custom implementations introduces unnecessary bugs and maintenance burden. If a crate exists for it, use it.
No trailing newlines in output
Output strings passed to println!, pb.println(), or similar macros must not
contain trailing newlines. These macros already append a newline. Adding \n
inside the string produces unwanted blank lines in the output.
Include processor name in error messages
Error messages from processor execution must identify the processor so the
user can immediately tell which processor failed. The executor’s
record_failure() method automatically wraps every error with
[processor_name] before printing or storing it, so processors do not need
to manually prefix their bail! messages. Just write the error naturally
(e.g. bail!("Misspelled words in {}", path)) and the executor will produce
[aspell] Misspelled words in README.md.
Never silently ignore user configuration
Every field a user can write in rsconstruct.toml (or in any YAML/TOML
manifest we load: cc.yaml, linux-module.yaml, etc.) must produce an
observable effect in the engine. The two failure modes to prevent are:
- Schema-level silent-ignore — serde accepts an unknown field because
the struct doesn’t reject it. A user typos
enabeld = false, we accept it, nothing happens, they wonder why their setting had no effect. - Runtime silent-ignore — serde stores the field in a struct, but no
code in the engine ever reads it. This is exactly how the
[analyzer.X] enabled = falsebug shipped: the CLI subcommand wrote the field, the config loader happily deserialized it, and the analyzer runner ignored it. A half-wired feature is worse than no feature.
Rule 1: reject unknown fields at the schema level
Every struct that deserializes user input must use one of:
#[serde(deny_unknown_fields)]— preferred for plain structs (no#[serde(flatten)]). Serde enforces the reject at deserialize time.KnownFieldstrait +validate_processor_fields()— for top-level processor configs that use#[serde(flatten)]to embedStandardConfig. Serde’sdeny_unknown_fieldsdoesn’t see throughflatten(known limitation), so we implement the check ourselves inConfig::load().
Nested structs inside a flattened parent (e.g. CcLibraryDef inside
CcManifest) must use deny_unknown_fields — they don’t flatten, so the
direct mechanism works.
The only legitimate exception: a struct that intentionally captures unknown
fields. None exists today (ProcessorConfig.extra, which held Lua plugin
sections, went with the Lua processor type); one that is added must be
documented at the field.
Rule 2: every accepted field must be read
When you add a field to any config struct, add the engine code that consumes it in the same change. Don’t ship the schema first and the behaviour “soon.” If the field is a toggle, the runner must check it. If it’s a path, something must open or scan that path. If it’s a value, a code path must branch on it.
When you add a CLI subcommand that writes a field (like analyzer disable
writing enabled = false), verify the runtime reads it by writing an
integration test that exercises the toggle end-to-end — config → build →
observable effect. A passing write-the-config test is not enough; the effect
must be asserted.
When you remove or rename a field, grep the codebase and docs to catch
stragglers. A field that exists in defconfig_toml but no longer affects
behaviour is a regression of Rule 2, even if no user reports it.
When reviewing
Reject a patch that adds a new Deserialize struct without either
deny_unknown_fields or a KnownFields impl. Reject a patch that adds a
config field without the runtime code that reads it. Both failure modes
cost users time in exactly the same way — they write something sensible,
get no feedback, and conclude the tool is broken.
Rule 3: validate before constructing
Schema validation must run inside Config::load(), before any processor or
analyzer is instantiated. Builder::new() should never be the first place
that surfaces an unknown-field or unknown-type error, because by the time
Builder::new() runs it has already opened redb databases, walked the
filesystem to build the FileIndex, and created CPU-bound infrastructure
the user doesn’t need just to see “you typoed a field name.”
The validators are validate_processor_fields_raw and
validate_analyzer_fields_raw in src/config/validation.rs. They return
Vec<String> so Config::load() can surface errors from both validators
together under a single Invalid config: header. If you add a new config
surface (a new top-level section with its own registered plugins), add a
matching validator and call it from Config::load() alongside the
existing two.
Unit-test the validators directly (see src/config/tests.rs) — not only
through rsconstruct toml check. Direct tests pin down the contract that
validation is a pure function of the parsed TOML, independent of
filesystem or plugin instantiation.
No “latest” git tag
Never create a git tag named latest. Use only semver tags (e.g. v0.3.0).
A latest tag causes confusion with container registries and package managers
that use the word “latest” as a moving pointer, and it conflicts with GitHub’s
release conventions.
Book layout mirrors the filesystem
The book (docs/src/) is divided into two sections by SUMMARY.md:
- A top-level user-facing section (introduction, commands, configuration, processors, etc.) — for people who use rsconstruct to build their projects.
- A “For Maintainers” section — for contributors modifying rsconstruct itself: architecture, design decisions, coding standards, cache internals, and so on.
The filesystem must mirror this split. A reader glancing at a path should be able to tell which audience the document is for:
- User-facing chapters live at the top level of
docs/src/— e.g.docs/src/configuration.md,docs/src/commands.md. - Maintainer chapters live under
docs/src/internal/— e.g.docs/src/internal/architecture.md,docs/src/internal/cache.md. - Per-processor reference docs live under
docs/src/processor/<type>/— one subdirectory per processor type (checker,generator,creator,explicit,mass_generator), each with aREADME.mdintroducing the type. These are user-facing (they document how to configure each processor).
When adding a new doc, decide first whether it’s user-facing or internal, then place it accordingly. Moving a doc across the boundary requires moving the file too — don’t leave an internal document at the top level just because its links would break.
When cross-referencing:
- Inside
internal/→ link to sibling files directly ([X](other.md)). - From a top-level doc to an internal doc →
[X](internal/other.md). - From
processor/to an internal doc →[X](../internal/other.md). - From
internal/to a user-facing doc →[X](../other.md).
This rule is enforced by convention, not by tooling. Reviewers should reject PRs that add a maintainer-only document at the top level (or vice versa).
No-Shell Policy
Rule
When rsconstruct spawns a subprocess, it MUST do so via direct argv execution
— Command::new(prog).args([...]) — and not via a shell interpreter such as
sh -c, bash -c, zsh -c, or any equivalent. Routing a command through a
shell is forbidden by default. New code that adds a Command::new("sh")
(or other shell binary) call WILL be rejected in code review unless it falls
under one of the named exceptions in this document.
Why
A shell exists to interpret a string as a program: it splits on whitespace, expands variables, performs glob matching, evaluates pipelines, substitutes backticks, redirects file descriptors, and so on. None of that behavior is free — every metacharacter in the input is a hazard if any part of the input was not authored by us.
There are three concrete problems with routing subprocess calls through a shell.
1. It is a code-injection surface
If any segment of the command string came from a config file, an environment variable, a file the user wrote, or even something that looks benign like a package name, a single shell metacharacter in that segment lets the substring break out and run arbitrary code with rsconstruct’s privileges.
This is not theoretical. The bug that motivated the policy was this:
Running: [pip] setuptools<82, manim, manim_voiceover (pip install setuptools<82 manim manim_voiceover)
setuptools<82 is a perfectly normal pip version specifier. When we ran the
install command via sh -c "pip install setuptools<82 manim ...", the shell
interpreted <82 as a redirection from a file named 82, the command
failed, and a “harmless” config string had effectively become a
shell-redirection request. With argv execution, setuptools<82 is one
argv element and reaches pip verbatim — no ambiguity, no injection
surface.
The same pattern shows up with package names containing <, >, |, &,
;, $, `, (, ), *, ?, [, ], newlines, or spaces. A
defense-in-depth project does not enumerate “the dangerous characters”; it
removes the interpreter that gives them meaning.
2. It hides bugs as features
When a shell silently expands a glob, evaluates a variable, or splits an argument, the surrounding Rust code has no way to know that happened. The program continues, and the side effect is invisible until something downstream notices the wrong files were processed, the wrong package was installed, or the wrong path was deleted. Argv execution makes the actual bytes that reach the target program identical to the bytes our code constructed.
3. We do not need it
Every structured package manager rsconstruct talks to (apt, dnf, pacman, brew, pip, npm, gem, cargo, snap) accepts package names and flags as separate argv elements. A shell adds nothing to those calls except risk.
Exceptions
This policy is “argv by default” not “argv always”. A small number of features in rsconstruct are contractually shell-shaped: the user is deliberately writing a shell snippet and expects shell semantics. Removing the shell from those features would break the feature.
The complete list of allowed shell call sites, as of this writing, is:
| Location | Why a shell is required |
|---|---|
src/processor/generator/cc_single_file.rs (two) | EXTRA_COMPILE_SHELL / EXTRA_LINK_SHELL and backtick expansion in C source pragmas. Users write things like pkg-config --cflags gtk+-3.0 in source comments and expect shell semantics. |
src/analyzers/mod.rs | include_path_commands config in the icpp analyzer. Documented to run via sh -c so users can write commands like gcc -print-file-name=plugin. |
src/processor/generator/tera.rs | The shell_output() Tera template function. The function name is the contract — it executes a shell command and returns the output for use in templates. |
src/processor/mod.rs (run / run_binary in tool install) | Not a shell exception: installers run argv-style via Command, never through sh -c. They are the documented exception to the central-runner rule instead — they inherit the terminal so sudo can prompt and apt/dnf can render progress. |
Each exception above is opt-in from the user’s perspective: the user writes a string into a place documented as “this is shell syntax”. None of them route untrusted names (package names, file paths, identifiers) through a shell.
Adding a new exception
Don’t, if you can help it. Before adding Command::new("sh") to the
codebase, ask:
- Can I express this as argv? Almost always yes. A shell is rarely the shortest path; it is usually the most familiar one.
- Is the input a single argv token? Then argv is strictly simpler than shelling out.
- Do I need pipes, redirects, or
&&? Then argv is harder, but Rust gives youStdio::piped(),Stdio::from(), and full control over fds without invoking a shell. Use those. - Is the input a user-authored shell snippet (template function, source pragma, config field documented as shell syntax)? Then a shell is legitimate — but document the contract at the call site and add the call site to the table above in the same PR.
If the answer to (4) is yes, the call site MUST be commented to reference this document, e.g.:
#![allow(unused)]
fn main() {
// Shell required: <feature name> contract — user writes shell syntax.
// See docs/src/internal/no-shell-policy.md.
Command::new("sh").arg("-c").arg(user_snippet).status()?
}
The reviewer’s job is to decide whether the new exception is justified, and the comment is what they read first.
Mechanics
For the common case, prefer the structured types in src/processor/mod.rs:
InstallPlan::Argv(Vec<String>)— direct argv execution. Default.InstallPlan::Shell(String)— explicit shell execution. Used only by the registry entries that store full shell pipelines as static data.
The variant tags the intent at construction time, not at execution time.
A reviewer reading InstallPlan::Shell(...) knows immediately that this
plan was authored as shell syntax and understands the trust boundary; a
reviewer reading InstallPlan::Argv(...) knows nothing went through an
interpreter. This separation is the whole point — it makes the rare,
audited shell case visually distinct from the safe default.
When constructing argv, do not interpolate untrusted data into a single
string and then split on whitespace. Push each logical argument as a
separate Vec<String> element. Spaces, quotes, version specifiers, and
glob characters in those elements are then carried verbatim to the target
program with no further interpretation.
Audit
The repository is audited periodically with:
rg -n 'Command::new\("(sh|bash|zsh|/bin/sh)"\)' src/
Every match must correspond to a row in the exceptions table above. If a match exists that is not in the table, either remove the shell call or add the row (with a justification) in the same PR.
Download Policy
Rule
Every network download rsconstruct performs MUST go through src/download.rs.
Call sites MUST NOT invoke curl directly, and MUST NOT call ureq without
wrapping the call in download::with_retry. New code containing a naked
Command::new("curl") WILL be rejected in code review.
The three entry points are:
| Helper | Use for |
|---|---|
download::curl_argv(url, dest) | Downloading a URL to a file. Returns argv; the caller executes it. |
download::apply_retry_args(&mut cmd) | A curl Command needing flags this module does not own (--head, -X PUT, -w %{http_code}). |
download::with_retry(closure) | HTTP performed in-process via ureq. |
Why
1. curl does not retry, and neither does ureq
Neither tool retries by default. curl needs explicit --retry flags;
ureq has connection pooling, which is easy to mistake for retry, but a
transport failure surfaces straight to the caller. A naked call at either
layer is a call with no resilience at all — and nothing about the call site
makes that visible to a reader.
2. One place to change policy
Before this module there were four independent curl -fsSL invocations
across tools.rs and remote_cache.rs, plus two bare ureq calls. Changing
retry behavior, adding a timeout, or setting a header meant finding all six
and editing them consistently. Anything less than all six produces a
codebase where robustness depends on which code path you happen to hit.
3. Preview must match execution
tool install renders its steps for preview (describe_binary) and
separately executes them (run_binary). Those were two hand-written argv
literals that had to be kept in sync by hand. Both now call curl_argv, so
the preview cannot drift from what actually runs.
What the retry is and is not for
This policy exists for connection-level failures: a connection reset,
refused, or dropped during handshake. The motivating incident was a
tool install run that died on:
curl: (35) Recv failure: Connection reset by peer
FAILED [binary] rumdl, taplo: curl -fsSL -o /tmp/taplo.dl https://... exited with code 35
The connection died about half a second in. Re-running the identical job minutes later succeeded in 43 seconds. That is the failure shape a retry fixes: the request never got started, so trying again costs almost nothing and usually works.
It is explicitly not for a slow-but-progressing transfer. The 2026-08-19 incidents involved a mirror serving at roughly 60 KB/s — slow, but making progress. Against that, a retry accomplishes nothing (each attempt is equally slow) and a total-transfer timeout is actively harmful: one such timeout killed a healthy release build. This is why:
--connect-timeoutis set,--max-timeis not. Bounding the handshake catches a dead endpoint. Bounding the whole transfer kills a working download of a large asset over a slow link. A unit test insrc/download.rsasserts--max-timestays absent.- The attempt count is 3, not 10. Three attempts absorb a transient reset. A larger number just means a genuinely broken endpoint takes longer to report a failure that was never going to resolve.
The remedy for a degraded runner remains what it was: cancel and re-run on fresh hardware, in the consuming repo’s CI. This module does not change that and cannot.
Flags, and why each is there
--retry 3 --retry-delay 2 --retry-connrefused --retry-all-errors --connect-timeout 30
--retry-connrefused and --retry-all-errors carry the weight. Plain
--retry covers HTTP 5xx responses and a narrow set of transport errors —
it does not cover a connection reset during handshake, which is exit
code 35 and precisely the case this policy addresses. Without those two
flags the retry count would have made no difference to the incident above.
Rate limits are fixed by authenticating, not by retrying
A rate limit is not a connection failure: GitHub answers 429 (or 403 on the
API) to anonymous requests per source IP, and hosted runners share egress
IPs, so another job may have spent the budget before ours starts. Retrying
within seconds hits the same exhausted budget. CI run 37439740429 failed
exactly so, fetching checkpatch.pl from raw.githubusercontent.com
after all three retries.
The remedy is the account’s own, much larger, limit. When GITHUB_TOKEN
or GH_TOKEN is set (the CI workflow forwards GITHUB_TOKEN to the
install step), curl_argv authenticates downloads from
raw.githubusercontent.com:
--variable %GITHUB_TOKEN --expand-header 'Authorization: Bearer {{GITHUB_TOKEN}}'
curl reads the token from the environment itself (curl 8.3+), so argv —
printed by tool install -i and visible in ps — names the variable and
never holds the secret. Other hosts get no header. The releases API lookup
(tools::resolve_latest_deb_asset) is authenticated the same way, from the
same variable choice (download::github_token_var).
Adding a new download
- Downloading a URL to a file? Use
curl_argv. Do not write acurlargv by hand. - Need curl flags the module does not own? Build the
Command, then callapply_retry_argson it before adding your flags. - Fetching in-process with
ureq? Wrap the call inwith_retry, and keep the whole request-and-read sequence inside the closure — a retry that re-reads a consumed body is not a retry. - Tempted to add a timeout knob? Read “What the retry is and is not for” above first. A total-transfer timeout has already broken a release build in this project once.
Any new download call site should carry a comment pointing at this document,
as the ureq sites in src/tools.rs and src/webcache.rs do.
Audit
rg -n 'Command::new\("curl"\)' src/
rg -n 'ureq::' src/
Every Command::new("curl") match must be followed by an
apply_retry_args call, and every ureq:: match must sit inside a
with_retry closure. As of this writing that is three curl sites (all in
src/remote_cache.rs) and two ureq sites (src/tools.rs,
src/webcache.rs). The binary installer in src/tools.rs builds its argv
through curl_argv and so does not spawn curl by name at all.
Licensing of Ported Engines
Strictness
This project holds itself to a strict compiler baseline and treats every relaxation as a deliberate, documented choice. This chapter explains the baseline, the rules for opting out, and the history of the most recent strictness pass.
Crate-level baseline
src/main.rs starts with:
#![allow(unused)]
#![deny(clippy::all)]
#![warn(clippy::pedantic)]
#![warn(clippy::nursery)]
#![deny(warnings)]
fn main() {
}
Effect:
- Every warning is a compile error. Unused imports, dead code, unused variables, deprecated APIs — all stop the build. There is no “warning fatigue” because there are no warnings.
- All of Clippy’s default lint group (
clippy::all) is enforced atdenylevel. This covers ~500 lints spanning correctness, complexity, style, and perf. clippy::pedanticandclippy::nurseryare enforced atwarnlevel. Combined with#![deny(warnings)], this means any pedantic or nursery hit also stops the build, unless it is one of the named lints in the per-lint allow block at the top ofsrc/main.rs. New code that triggers a pedantic/nursery lint not in that block will fail to compile.
This is one step short of forbid. forbid cannot be overridden per-item; deny allows a per-item #[allow(...)] escape hatch. We chose deny so that principled exceptions remain possible, but each one is an obvious, grep-able act.
Per-lint allow block
The crate root carries a block of #![allow(clippy::*)] attributes — these are pedantic and nursery lints that the codebase deliberately permits. Each falls into one of three groups (the source comments tag them):
- Numeric/cast lints (3 lints).
cast_possible_truncation,cast_precision_loss,cast_sign_loss. These fire on progress-percentage computations and similar internal arithmetic where the values are bounded and not user-controlled. Per-site allows would add noise without adding safety. - Stylistic / debatable (~32 lints). Lints where clippy’s preferred shape is not obviously better. Examples:
option_if_let_else(map_orform is often less readable),match_same_arms(kept distinct for readability),doc_markdown(over-eager about backticking ordinary words),naive_bytecount(would require adding thebytecountcrate for one call site). - Mutex guard tightening (1 lint).
significant_drop_tightening. Flags every guard whose scope extends past the last use, even by one statement. In practice our guards are held for short cache lookups and tightening produces busier code without measurable contention reduction. Deliberate policy allow.
There is no “to-do” group remaining: the previous strictness pass either fixed each pedantic/nursery lint in code or moved it to one of the three buckets above with rationale.
The rule for #[allow(...)]
Every #[allow(...)] in the codebase MUST:
- Be necessary. If the compiler accepts the code without the allow, remove the allow. The compiler is cleverer than you think — dead code that looks dead to a human is sometimes reachable, and vice versa.
- Be scoped minimally. Attach the allow to the smallest item (a single field, a single function, a single import) that requires it — never to a whole struct or module when one member is the culprit.
- Carry a comment explaining why. The comment answers: “what feature/workflow keeps this thing around despite looking dead?” A silent
#[allow(dead_code)]is a bug. - Be periodically re-audited. Scaffolding becomes production code (allow removed) or is abandoned (code deleted). Long-lived allows are a code smell.
Current #[allow] attributes (at time of writing)
After the strictness pass, 5 allows remain. Each is documented in the source and reproduced here with rationale.
src/object_store/mod.rs — remote_pull field
#![allow(unused)]
fn main() {
/// Whether to pull from remote cache.
/// Wired into the constructor but not yet consulted by any read path —
/// remote-pull integration is scaffolded in `operations.rs` (the
/// `try_fetch_*` helpers) but not yet called from the executor.
#[allow(dead_code)]
remote_pull: bool,
}
Why kept: remote-pull is a real, partially-implemented feature. The try_fetch_* helpers exist; they’re just not wired into classify_products / the restore path yet. Removing the field now would mean re-adding it when we wire up the feature. Keeping it with a comment documents what’s missing.
When to remove: when remote-pull read paths are wired up, or when we formally abandon remote-pull.
src/object_store/operations.rs — three try_fetch_* / try_push_descriptor_* helpers
#![allow(unused)]
fn main() {
// Scaffolding for remote-pull: wired into the API surface but not yet
// called from any read path. Intentional; tracked under remote-pull WIP.
#[allow(dead_code)]
pub(super) fn try_fetch_object_from_remote(&self, checksum: &str) -> Result<bool> { ... }
// Scaffolding for remote-pull (for paired fetch-after-push semantics).
// Not yet called from any write path; tracked under remote-pull WIP.
#[allow(dead_code)]
pub(super) fn try_push_descriptor_to_remote(&self, descriptor_key: &str, data: &[u8]) -> Result<()> { ... }
/// Try to fetch a descriptor from remote cache.
/// Scaffolding for remote-pull; not yet called from any read path.
#[allow(dead_code)]
pub(super) fn try_fetch_descriptor_from_remote(&self, descriptor_key: &str) -> Result<Option<Vec<u8>>> { ... }
}
Why kept: same feature as above. These are the building blocks the eventual remote-pull implementation will call. They’re tested (implicitly via the types that check they compile), and they work when called — they just aren’t called yet.
When to remove: same trigger as the remote_pull field.
src/registries/processor.rs — ProcessorPlugin.processor_type field
#![allow(unused)]
fn main() {
pub struct ProcessorPlugin {
pub name: &'static str,
/// Processor type. Declared by every plugin but not yet queried by any
/// runtime code path — kept as plugin metadata so future features
/// (e.g. `processor list --type=checker`) can filter without touching
/// every registration.
#[allow(dead_code)]
pub processor_type: ProcessorType,
...
}
}
Why kept: Every inventory::submit! for a processor declares a type (Checker, Generator, Creator, Explicit). The runtime currently reads processor_type() from the Processor trait, never from the plugin. But the static plugin metadata is the right place for filtering features like rsconstruct processor list --type=checker. Removing the field now would mean adding 93 processor_type: ... lines back later when we want the filter.
When to remove: never, once the first feature queries it. Until then, the allow is the cheap price of preserving optionality.
What the pass removed
Seven allows were removed during the most recent strictness sweep. Three of them masked genuine dead code, which was then deleted:
checksum::invalidate()— never called; deleted.checksum::clear_cache()— never called; deleted.ProcessorBase.namefield +ProcessorBase::auto_detect()helper — never read, never called; deleted.
Four were stale — the code they guarded was actually used, and the allow no longer made the compiler quieter:
remote_cache::RemoteCache::download— used byoperations.rs; allow removed.exit_code::IoError— used in match arms and by theerrorCLI command; allow removed.ProcessorPluginstruct-level#[allow(dead_code)]— only theprocessor_typefield needed it; scoped down.builder/mod.rs—#[allow(unused_imports)]onuse crate::config::*;— the compiler wasn’t flagging the glob at all; allow removed.
What this pass did NOT change
The sweep was focused on #[allow] attributes. Broader strictness knobs were left as-is, by choice:
.unwrap()and.expect()counts. Many are on internal invariants where panicking is correct (contract violation, not user error). An audit could tighten some to?, but this is a separate pass with its own judgment calls.missing_docs,missing_debug_implementations, etc. Enabling these would require documenting every public item — a much larger change.clippy::cargo. 23 warnings, all about transitive dependency duplication driven by upstream Windows target crates. Not actionable from this repo.- The
use crate::config::*;glob import inbuilder/mod.rs. Narrowing it would require enumerating ~15 symbols and risks churn. Left as-is.
Subsequent pedantic/nursery sweep
A later pass (see doc/strictness-pass.md) enabled clippy::pedantic and clippy::nursery at warn level. That sweep autofixed about 830 occurrences across ~13 lints (notably uninlined_format_args, redundant_pub_crate, redundant_closure_for_method_calls, missing_const_for_fn) and added ~45 explicit per-lint allows for the remainder. See the per-lint allow block in src/main.rs and the design doc for the breakdown.
Adding a new #[allow]
When you find yourself wanting to add an #[allow(...)], follow this checklist:
- Can the compiler complaint be fixed instead? Remove the unused import, inline the unused function, prove the variable is live. Most of the time the answer is yes.
- Is this the minimum scope? Put the allow on the single field, not the whole struct. On the single function, not the whole impl. On the single import, not the whole
useblock. - Did you write a comment? One sentence answering “what feature / workflow justifies this?” is enough. “Reserved for future use” is NOT enough — say what future use, and what would trigger the deletion.
- Did you open a tracking concern? If the allow is for WIP scaffolding, the WIP should be tracked somewhere (a TODO comment with a
// wip:tag, an issue, a feature flag) so future maintainers know it’s temporary.
A reviewer who sees a new #[allow] should read the comment, check the rationale, and ask “could we just fix this instead?” before approving.
Running the audit
A quick sweep to find all current allows:
grep -rn '#\[allow(' src/
For each hit, read the surrounding context and the comment. If the comment is missing or weak, or the code it guards has become truly used, the allow should come out.
See also
- Coding Standards — the style rules beyond strictness.
- Processor Contract — the invariants each processor must uphold.
src/main.rs— the crate-level#![deny(...)]directives.
Testing
RSConstruct uses two kinds of tests:
- Integration tests in
tests/— the primary test suite. These exercise the compiledrsconstructbinary as a black box, building fake projects in temp directories and asserting on CLI output and side effects. - Unit tests in
src/(#[cfg(test)] mod tests) — used sparingly, only for self-contained modules whose internals cannot be exercised adequately through the CLI. Currently this issrc/graph.rs(dedup and topological-sort logic).
Running tests
cargo test # Run all tests
cargo test rsconstructignore # Run tests matching a name
cargo test -- --nocapture # Show stdout/stderr from tests
Why unit tests live in src/ (not tests/)
There is a recurring question: should unit tests move to tests/ to keep source files shorter and more readable? The short answer is no, for a structural reason specific to this crate.
This crate is a binary only — there is no src/lib.rs. Integration tests under tests/ can only link against a library crate; against a binary crate they can only do what tests/main.rs does today: spawn the rsconstruct binary as a subprocess and assert on its output. So there are only three real options for testing internal logic like BuildGraph:
| Option | Cost |
|---|---|
Unit tests inline in src/ (current) | Longer source files (mitigated by #[cfg(test)] stripping them from release builds, and by editor folding) |
Move tests to tests/ as end-to-end tests | Far more code per test, much slower, indirect — can’t isolate a specific dedup branch without building a whole fake project |
Add a src/lib.rs exposing modules | Architectural change — the crate becomes both a library and a binary. Forces decisions about what is public API |
The third option is the “clean” fix but it has ongoing costs (API surface to maintain, semver implications if we ever publish the library). The first option has only a readability cost, and it’s the idiomatic Rust approach for binary crates.
Rule: default to writing integration tests in tests/. Only add a #[cfg(test)] mod tests block in src/ when the thing under test is genuinely hard to exercise through the CLI (e.g. a specific branch of a dedup helper that requires setting up graph state that would take dozens of real products to reproduce end-to-end). When a source file grows large enough that its inline test module dominates the file, split the tests into a sibling file via #[cfg(test)] mod tests; + src/MODULE/tests.rs, rather than moving them out of src/ entirely.
Test directory layout
tests/
├── common/
│ └── mod.rs # Shared helpers (not a test binary)
├── build.rs # Build command tests
├── cache.rs # Cache operation tests
├── complete.rs # Shell completion tests
├── config.rs # Config show/show-default tests
├── dry_run.rs # Dry-run flag tests
├── graph.rs # Dependency graph tests
├── init.rs # Project initialization tests
├── processor_cmd.rs # Processor list/auto/files tests
├── rsconstructignore.rs # .rsconstructignore / .gitignore exclusion tests
├── status.rs # Status command tests
├── tools.rs # Tools list/check tests
├── watch.rs # File watcher tests
├── main.rs # The one test binary; declares every module
└── processor/
├── cc_single_file.rs # C/C++ compilation tests
├── zspell.rs # Zspell processor tests
└── template.rs # Template processor tests
tests/main.rs is the single test binary. It declares the tests_mod/ files by path and the processor/ subdirectory modules in a mod processor block:
#![allow(unused)]
fn main() {
mod common;
mod processor {
pub mod cc_single_file;
pub mod zspell;
pub mod template;
}
}
This is the standard Rust pattern for grouping related integration tests into subdirectories without creating a separate binary per file.
Shared helpers
tests/common/mod.rs provides utilities used across all test files:
| Helper | Purpose |
|---|---|
setup_test_project() | Create an isolated project in a temp directory with rsconstruct.toml and basic directories |
setup_cc_project(path) | Create a C project structure with the cc_single_file processor enabled |
run_rsconstruct(dir, args) | Execute the rsconstruct binary in the given directory and return its output |
run_rsconstruct_with_env(dir, args, env) | Same as run_rsconstruct but with extra environment variables (e.g., NO_COLOR=1) |
All helpers use env!("CARGO_BIN_EXE_rsconstruct") to locate the compiled binary, ensuring tests run against the freshly built version.
Every test creates a fresh TempDir for isolation. The directory is automatically cleaned up when the test ends.
Test categories
Command tests
Tests in build.rs, clean, dry_run.rs, init.rs, status.rs, and watch.rs exercise CLI commands end-to-end:
#![allow(unused)]
fn main() {
#[test]
fn force_rebuild() {
let temp_dir = setup_test_project();
// ... set up files ...
let output = run_rsconstruct_with_env(temp_dir.path(), &["build", "--force"], &[("NO_COLOR", "1")]);
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("[template] Processing:"));
}
}
These tests verify exit codes, stdout messages, and side effects (files created or removed).
Processor tests
Tests under processor/ verify individual processor behavior: file discovery, compilation, linting, incremental skip logic, and error handling. Each processor test module follows the same pattern:
- Set up a temp project with appropriate source files
- Run
rsconstruct build - Assert outputs exist and contain expected content
- Optionally modify a file and rebuild to test incrementality
Ignore tests
rsconstructignore.rs tests .rsconstructignore pattern matching: exact file patterns, glob patterns, leading / (anchored), trailing / (directory), comments, blank lines, and interaction with multiple processors.
Common assertion patterns
Exit code:
#![allow(unused)]
fn main() {
assert!(output.status.success());
}
Stdout content:
#![allow(unused)]
fn main() {
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("Processing:"));
assert!(!stdout.contains("error"));
}
File existence:
#![allow(unused)]
fn main() {
assert!(path.join("out/processor.generator.cc_single_file/main.elf").exists());
}
Incremental builds:
#![allow(unused)]
fn main() {
// First build
run_rsconstruct(path, &["build"]);
// Second build should skip
let output = run_rsconstruct_with_env(path, &["build"], &[("NO_COLOR", "1")]);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("Skipping (unchanged):"));
}
Mtime-dependent rebuilds:
#![allow(unused)]
fn main() {
// Modify a file and wait for mtime to differ
std::thread::sleep(std::time::Duration::from_millis(100));
fs::write(path.join("src/header.h"), "// changed\n").unwrap();
let output = run_rsconstruct(path, &["build"]);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("Processing:"));
}
Writing a new test
- Add a test function in the appropriate file (or create a new
.rsfile undertests/for a new feature area) - Use
setup_test_project()orsetup_cc_project()to create an isolated environment - Write source files and configuration into the temp directory
- Run
rsconstructwithrun_rsconstruct()orrun_rsconstruct_with_env() - Assert on exit code, stdout/stderr content, and output file existence
If adding a new processor test module, declare it in the mod processor block of tests/main.rs (a test every_test_file_is_registered fails the build for any file left out):
#![allow(unused)]
fn main() {
mod processor {
pub mod cc_single_file;
pub mod zspell;
pub mod template;
pub mod my_new_processor; // add here
}
}
Test coverage by area
| Area | File | Tests |
|---|---|---|
| Build command | build.rs | Force rebuild, incremental skip, clean, deterministic order, keep-going, timings, parallel -j flag, parallel keep-going, parallel all-products, parallel timings, parallel caching |
| Cache | cache.rs | Clear, size, trim, list operations |
| Complete | complete.rs | Bash/zsh/fish generation, config-driven completion |
| Config | config.rs | Show merged config, show defaults, annotation comments |
| Dry run | dry_run.rs | Preview output, force flag, short flag, downstream prediction, -p, config baseline |
| Graph | graph.rs | DOT, mermaid, JSON, text formats, empty project |
| Init | init.rs | Project creation, duplicate detection, existing directory preservation |
| Processor command | processor_cmd.rs | List, all, auto-detect, files, unknown processor error |
| Status | status.rs | UP-TO-DATE / STALE / RESTORABLE reporting |
| Tools | tools.rs | List tools, list all, check availability |
| Watch | watch.rs | Initial build, rebuild on change |
| Ignore | rsconstructignore.rs | Exact match, globs, leading slash, trailing slash, comments, cross-processor |
| Template | processor/template.rs | Rendering, incremental, dep_inputs |
| CC | processor/cc_single_file.rs | Compilation, headers, per-file flags, mixed C/C++, config change detection |
| Zspell | processor/zspell.rs | Correct/misspelled words, code block filtering, custom words, incremental |
Parameter Naming Conventions
This document establishes the canonical names for configuration parameters across all processors, and the reasoning behind each name. Use this as the reference when adding new processors or renaming existing ones.
Taxonomy
Parameters fall into four categories:
| Category | Purpose |
|---|---|
| Source discovery | Which files are the primary targets to process |
| Dependency tracking | Which additional files affect the checksum / trigger rebuilds |
| Tool configuration | What command/tool to run and how |
| Execution control | Batching, parallelism, output location |
Source Discovery Parameters
These parameters determine which files are the primary inputs — the files that get processed, linted, or transformed.
| Parameter | Type | Description |
|---|---|---|
src_dirs | string[] | Directories to scan recursively for source files. |
src_extensions | string[] | File extensions to match during scanning (e.g. [".py", ".pyi"]). |
src_exclude_dirs | string[] | Directory path segments to skip during scanning. |
src_exclude_files | string[] | File names to skip during scanning. |
src_exclude_paths | string[] | Exact relative paths to skip during scanning. |
src_files | string[] | Explicit list of source files to process. When set, bypasses src_dirs, src_extensions, and all exclude filters entirely. |
src_files vs scanning
src_dirs + src_extensions is the default discovery mechanism — the processor
walks directories and finds matching files automatically.
src_files is for when you know exactly which files you want processed and
don’t want any scanning. Setting src_files disables all scan-based
discovery for that processor instance.
Dependency Tracking Parameters
These parameters declare files that the processor depends on but does not process directly. A change to any of these files invalidates the cache and triggers a rebuild, but the files are not passed as arguments to the tool.
| Parameter | Type | Description |
|---|---|---|
dep_inputs | string[] | Explicit dependency files (e.g. config files, schema files). Globs are supported. Fails if a listed file does not exist. |
dep_auto | string[] | Like dep_inputs, but a processor’s default list is skipped where the files do not exist. Used for optional config files (e.g. .pylintrc, pyproject.toml). A list written in the config replaces the default and is checked like dep_inputs unless [build] allow_missing_dep_auto is set. |
Why two parameters?
dep_inputs is strict — it errors if a file is missing, which catches
mistakes in configuration. dep_auto is where a processor declares the
well-known config files it honours when present: a project without
.pylintrc is fine, so those defaults are lenient. The leniency stops at
the processor’s defaults. Once a user writes a dep_auto list, each entry
is a claim about the project, and a missing one is a typo or a stale copy
of a shared config — a dependency that tracks nothing — so it fails at
config load like a missing dep_inputs entry would. [build] allow_missing_dep_auto = true restores skip-if-absent for listed entries
too.
Tool Configuration Parameters
command and args always appear together. Every processor that has command
must also have args. They are treated as a unit: both participate in the
config checksum (computed from each processor’s checksum_fields()), so
changing either the command or any argument invalidates the cache and triggers
a rebuild.
| Parameter | Type | Description |
|---|---|---|
command | string | The executable to run. Required when the processor is active. If the value is a path to a local file, its content checksum is also tracked as a dependency. |
args | string[] | Arguments passed to the command before file paths. Always present alongside command. Both command and args values are included in the config checksum. |
command dependency tracking
For the script and generator processors, if command points to a file that
exists on disk (e.g. command = "scripts/my_linter.sh"), rsconstruct
automatically adds it as an input dependency. This means that if the script
itself changes, all affected products are rebuilt. System tools (e.g. bash,
python3) are not files in the project and are not tracked.
Execution Control Parameters
| Parameter | Type | Description |
|---|---|---|
batch | bool | When true, pass all files to the command in a single invocation. When false, invoke once per file. Default: true for most processors. |
max_jobs | int | Maximum parallel jobs for this processor. Overrides the global --jobs flag. |
output_dir | string | Directory where output files are written (generator processors). |
output_extension | string | File extension for generated output files. |
Processor Contract
Rules that all processors must follow.
Fail hard, never degrade gracefully
When something fails, it must fail the entire build. Do not try-and-fallback, do not silently substitute defaults for missing resources, do not swallow errors. If a processor is configured to use a file and that file does not exist, that is an error. The user must fix their configuration or their project, not the code.
Optional features must be opt-in via explicit configuration (default off). When the user enables a feature, all resources it requires must exist.
No work without source files
An enabled processor must not fail the build if no source files match its file patterns. Zero matching files means zero products discovered; the processor simply does nothing. This is not an error — it is the normal state for a freshly initialized project.
Single responsibility
Each processor handles one type of transformation or check. A processor discovers its own products and knows how to execute, clean, and report on them.
Deterministic discovery
discover() receives an instance_name parameter identifying the processor
instance (e.g., "ruff" or "script.lint_a" for multi-instance processors).
Use this name when calling graph.add_product() — do not use hardcoded
processor type constants.
discover() must return the same products given the same filesystem state.
File discovery, processor iteration, and topological sort must all produce
sorted, deterministic output so builds are reproducible.
Incremental correctness
Products must declare all their inputs. If any declared input changes, the product is rebuilt. If no inputs change, the cached result is reused. Processors must not rely on undeclared side inputs for correctness (support files read at execution time but excluded from the input list are acceptable only when changes to those files can never cause a previously-passing product to fail).
Execution isolation
A processor’s execute() must only write to the declared output paths
(or, for creators, to the expected output directory).
It must not modify source files, other products’ outputs, or global state.
Cleaning
A processor’s clean(product, verbose) is responsible only for removing
what that product produced. Concretely:
- Checkers override nothing — the default
clean()returns 0 (no outputs). - Generators must remove every file in
product.outputsand only those files. Theclean_outputs()helper does exactly this withfs::remove_file. Generators must not recurse into directories. - Creators must remove every directory in
product.output_dirsrecursively (the contents are unenumerated by design — the external build tool produces an unknown set of files inside). Theclean_output_dir()helper handles this. Creators must not delete anything outside their declaredoutput_dirs. - Explicit processors run both — files for
output_files, recursive removal for any user-declaredoutput_dirs. - Mass generators remove every predicted file in
product.outputs, like Generators. Theiroutput_dirsonly bound where predicted files may live and are never removed: the directory is shared by design.
Recursive directory removal is reserved for Creators (and the user-declared
output_dirs of Explicit). All other processor types remove individually
declared files and nothing else.
After every product’s clean() has run, the executor performs an
empty-directory sweep: it collects every parent directory of every
removed output (and every parent of every removed output_dir), sorts
deepest-first, and calls fs::remove_dir on each one. remove_dir is
non-recursive, so it only succeeds on already-empty directories — anything
still containing files is left in place. The sweep climbs upward through
parents until it hits a non-empty directory or the project root.
The sweep is part of the orchestrator, not the per-processor clean().
Processors should not pre-empt it by removing parent directories themselves
— let the sweep handle directory cleanup so the rule “files I produced,
nothing else” stays clean. Users can disable the sweep with
rsconstruct clean outputs --no-empty-dirs.
Output directory caching (creators)
Creators that set output_dir on their products get automatic
directory-level caching. After successful execution, the executor walks
the output directory, stores every file as a content-addressed object,
and records a manifest with paths, checksums, and Unix permissions.
On restore, the entire directory is recreated from cache.
The cache_output_dir config option (default true) controls this.
When disabled, creators fall back to stamp-file or empty-output
caching (no directory restore on rsconstruct clean && rsconstruct build).
Creators that use output_dir caching must implement clean() to
remove the output directory so it can be restored from cache.
Error reporting
On failure, execute() returns an Err with a clear message including
the relevant file path and the nature of the problem. The executor
decides whether to abort or continue based on --keep-going.
Batch execution and partial failure
A processor declares whether it can take several inputs in one invocation
with the supports_batch flag on its ProcessorPlugin (every plugin sets
it; processor list and processor info show it). A batch-capable one
overrides execute_batch(), which receives multiple products and must return
one Result per product, in the same order as the input. The executor calls
it only when the flag is true and the stanza’s batch field (default true)
has not switched batching off.
External tool processors that invoke a single subprocess for the entire
batch typically use execute_generator_batch(), which maps a single exit code
to all-success or all-failure. If the tool fails, all products in the batch
are marked failed — there is no way to determine which outputs are valid.
Internal processors (e.g., imarkdown2html, isass, ipdfunite) that process
files in-process should return per-file results so that partial failure is
handled correctly — only the actually-failed products are rebuilt on the next run.
Chunk sizing: By default a batch-capable processor’s rebuilding products
go to the tool in one chunk, whether or not --keep-going is given. An
explicit --batch-size N limits chunks to N products; --batch-size -1
disables batching, so each product is cached individually, which gives the
best incremental recovery. A batch-capable checker is also held: its
products are dispatched only when the last of them is ready, so files that
depend on a generated file do not split the checker into two overlapping
invocations (pytest over __init__.py files alone collects no tests; two
mypy processes race on .mypy_cache).
Cache System
RSConstruct uses a content-addressed cache to enable fast incremental builds. This page describes the cache architecture, storage format, and rebuild logic.
Overview
The cache lives in .rsconstruct/ and consists of:
objects/— content-addressed blob store, sharded by the first two hex characters of the content hashdescriptors/— cache descriptors (marker/blob/tree JSON), sharded the same way but addressed by the descriptor key rather than by contentdb.redb— redb database holding theprocessor_configstable used for config-change diffing and theproduct_last_treetable used to unlink stale outputs (see Stale outputs before a rebuild)deps.redb— source file dependency cache (see Dependency Caching)mtime.redb— mtime→checksum pre-check cache that lets unchanged files skip re-hashingwebcache.redb— HTTP request cache for processors that fetch remote resources
Data model
The object store contains three kinds of objects, inspired by git:
Blobs
A blob is a file’s raw content, addressed by its SHA-256 content hash. Blobs are optionally zstd-compressed and made read-only to prevent corruption when restored via hardlinks.
Blobs are stored content-addressed — two products producing identical output share the same blob. This enables deduplication and hardlink-based restoration.
Why blobs don’t store output paths
A blob is pure content — it has no knowledge of where it will be restored. This is critical for two reasons:
-
Rename survival. If you rename
foo.mdtobar.mdwithout changing its content, the cache key (which is content-addressed) is the same. The blob is reused and restored to the new output path (bar.txtinstead offoo.txt). If the blob stored its output path, this wouldn’t work. -
Deduplication across trees. Multiple tree entries can point to the same blob under different paths. For example, if two files in a creator’s output have identical content, they share the same blob object in the store. The tree records the path; the blob just holds the content.
Trees
A tree is a serialized list of (path, mode, blob_checksum) entries describing a set of output files. Trees are stored as descriptors under descriptors/, addressed by the descriptor key (not by content hash). A tree maps relative file paths to content-addressed blobs. Multiple trees can point to the same blobs — deduplication happens at the blob level.
Markers
A marker is a minimal descriptor indicating that a check passed. Markers are stored under descriptors/, addressed by the descriptor key, and reference no blobs.
Cache entries
A cache entry is a small JSON descriptor stored under .rsconstruct/descriptors/ at the path derived from the descriptor key. It contains:
{"type": "blob", "checksum": "abc123...", "mode": 493}
Note: the blob descriptor has no path — the product knows where its output goes.
Or:
{"type": "tree", "entries": [{"path": "dir/file.txt", "checksum": "def456...", "mode": 493}]}
or:
{"type": "marker"}
The actual file content lives in separate content-addressed blob objects. The cache entry is just a pointer (for generators) or a manifest (for creators).
On-disk layout
.rsconstruct/objects/
a1/b2c3d4... # blob (raw file content), addressed by content hash
cd/ef5678... # another blob (".zst" suffix when compressed)
.rsconstruct/descriptors/
ff/0011aa... # descriptor (JSON), addressed by descriptor key
Descriptors and blobs live in separate directories with the same two-hex-char
sharding. Keeping them separate is what lets cache trim delete unreferenced
blobs by scanning the descriptors.
Cache keys
The descriptor key identifies a product build. It is computed as
(CacheKey::descriptor_key in src/cache_key.rs):
hash(processor_name, processor_version, cache_key_digest, input_content_hash)
Where:
processor_name— the processor type (e.g.,pandoc,ruff)processor_version— the processor’s cache version from the plugin registry; bumping it invalidates every entry the processor ever produced (see Processor versioning)cache_key_digest— a digest of every piece of non-input state that affects the output (below)input_content_hash— combined SHA-256 hash of all input file contents
The key is content-addressed: it depends on what the inputs contain, not what they’re named. Renaming a file without changing its content produces the same cache key.
Cache key components
CacheKey is the single owner of key composition. Key material used to be
assembled in five uncoordinated places, all splicing into one opaque
config_hash: Option<String> with three different separator conventions —
so a component that silently failed to be mixed in was invisible, and a
changed key could not be attributed to a cause. Contributors now append a
named, typed component and one function folds them into the digest:
| Component | Contributed by | Covers |
|---|---|---|
config | discovery, via output_config_hash over the processor’s checksum_fields() | compiler flags, args, output declarations |
variant | Product::with_variant | multiple products from one input (e.g. one per output format) |
analyzer | Product::extend_config_hash | non-content state an analyzer resolves, e.g. the sorted set of paths matching a glob |
tool | apply_tool_version_hashes, gated on hash_tool_versions | identity of the external tools the processor invokes |
The component kind is part of the digest, so a config value and a tool
value that happen to be equal do not collide. Order is significant and
deterministic (discovery, then analyzers, then tool versions).
rsconstruct product show <path> prints the components individually, so any
descriptor-key change is attributable to exactly one of them (or to
input_checksum).
Multi-format processors
For processors that produce multiple output formats from the same input (e.g., pandoc producing PDF, HTML, and DOCX), each format is a separate product with a separate cache key. The output format is mixed into the key as the variant component, so each format gets its own key naturally.
Output depends on input name
Most processors produce output that depends only on input content, so the descriptor key hashes input content, not input path. A rename with identical content is a cache hit by design: the blob is path-free and is restored to the product’s current output path (see the cache_survives_input_rename test), and two identical files at different paths share one entry.
That is wrong for a processor whose result depends on the path: one that
embeds the input filename in its output (a // Generated from foo.c header)
would restore output still naming the old file, and a checker with per-path
rules (ruff’s per-file-ignores) could reuse another path’s verdict. Such a
processor sets output_depends_on_input_name = true, a universal field like
enabled. ruff, pylint and mypy default it to true through their plugin’s
ProcessorDefaults (their verdicts depend on module names, per-file rules and
the presence of __init__.py); a user’s explicit value wins. The builder
reads it from the declared config after discovery and marks the instance’s
products (BuildGraph::key_by_input_paths); Product::descriptor_key then
hashes the input paths together with the input checksum. The paths are taken
from the product’s current inputs at key time, so inputs an analyzer adds
mid-build are covered too. Turning the field on yields new keys, so the first
build after it rebuilds. Tests: renamed_input_restores_by_default and
output_depends_on_input_name_rebuilds_renamed_input
(tests/tests_mod/incremental.rs).
Flows
Lookup
- Compute the cache key from processor name + config + input contents
- Look up the object at that key in the object store
- If not found: cache miss, product must be built
- If found: read the descriptor, act based on type
Cache (after successful build)
Checker:
- Store a
{"type": "marker"}entry at the cache key
Generator (single output):
- Store the output file content as a content-addressed blob
- Store a
{"type": "blob", "checksum": "..."}entry at the cache key
Creator (multiple outputs):
- Walk all output directories and files
- Store each file as a content-addressed blob
- Build the tree entries:
[{"path": "...", "checksum": "...", "mode": ...}, ...] - Store a
{"type": "tree", "entries": [...]}entry at the cache key
Restore
Checker: Nothing to restore. Cache entry exists = check passed.
Generator:
- Read the cache entry, get the blob checksum
- Hardlink or copy the blob to the output path
Creator:
- Read the cache entry, get the tree entries
- For each
(path, checksum, mode): restore the blob to the path, set permissions
Skip
If the cache entry exists AND all output files are present on disk, no work is needed.
Rebuild classification
| Classification | Condition | Action |
|---|---|---|
| Skip | Cache key found AND all outputs exist on disk | No work needed |
| Restore | Cache key found BUT some outputs are missing | Restore from object store |
| Build | No cache entry for this key | Execute the processor |
Because the cache key incorporates input content, a changed input produces a different key. There’s no “stale entry” — either the key exists or it doesn’t.
Stale outputs before a rebuild
Cache objects are read-only and a hardlink restore shares their inode, so a restored output cannot be overwritten in place by the tool that regenerates it. Before a product is built or restored, the files of its previous tree are unlinked. That tree cannot be found through the current key (the input changed, so the key did too): the product_last_tree table in db.redb maps a product’s owner key — processor instance, primary input and output paths, nothing that changes with content or config — to the descriptor it last built or restored. Outputs left by an rsconstruct older than that table are recognised by the restore’s own signature instead: read-only files with a link count above one.
Config-aware caching
Processor configuration is hashed into cache keys. Changing a config value triggers rebuilds even if source files haven’t changed.
Cache restoration methods
| Method | Behavior | Best for |
|---|---|---|
hardlink | Links output to cached blob (same inode, read-only) | Local development (fast, no disk space) |
copy | Copies cached blob to output path (writable) | CI runners, cross-filesystem setups |
auto (default) | Uses copy when CI=true, hardlink otherwise | Most setups |
Hardlinks work because blob objects contain raw file content (not wrapped in a descriptor). Only cache entries (which point to blobs) contain JSON metadata.
Cache commands
| Command | Description |
|---|---|
rsconstruct cache size | Show cache size and object count |
rsconstruct cache list | List all cache entries as JSON |
rsconstruct cache stats | Show per-processor cache statistics |
rsconstruct cache trim | Remove unreferenced objects |
rsconstruct cache clear | Delete the entire cache |
Clean vs Clear
rsconstruct clean removes build outputs but preserves the cache:
- Generators: Output files deleted. Next build restores via hardlink/copy.
- Checkers: Nothing to delete. Next build skips.
- Creators: Output directories deleted. Next build restores from tree.
rsconstruct cache clear wipes everything — descriptors and blobs. A cleared cache means “forget everything, rebuild from scratch.” The entire .rsconstruct/ directory is removed, which also takes the auxiliary caches (mtime.redb, deps.redb, webcache.redb) with it. If only blobs were cleared but descriptors survived, the cache would think outputs are available but fail to restore them. Clearing both together avoids this inconsistency.
Incremental rebuild after partial failure
Each product is cached independently after successful execution. If a build fails partway through, the next run only rebuilds products without valid cache entries.
Remote caching
See Remote Caching for sharing cache between machines and CI.
Checksum Cache
RSConstruct uses a centralized checksum system (src/checksum.rs) for all file hashing. It has two layers of caching to avoid redundant I/O and computation.
Architecture
All file checksum operations go through a single entry point: checksum::file_checksum(path). This function never computes the same hash twice.
Layer 1: In-memory cache (per build run)
A global HashMap<PathBuf, String> stores checksums computed during the current build. When a file is checksummed for the first time, the result is cached. Any subsequent request for the same file returns the cached value without reading the file again.
This handles the common case where the same file appears as an input to multiple products (e.g., a shared header file), or when the checksum is needed both for classification (skip/restore/build) and for cache storage.
The in-memory cache lives for the duration of the process and is not persisted.
Layer 2: Mtime database (across builds)
A persistent redb database at .rsconstruct/mtime.redb maps file paths to (stamp, checksum) pairs, where the stamp is the file’s mtime, ctime, size and inode (platform::FileStamp). Before reading a file to compute its checksum, the system checks:
- Has this file been checksummed in a previous build?
- Has any part of its stamp changed since then?
If the stamp matches, the cached checksum is returned without reading the file. This avoids I/O for files that haven’t been modified between builds — the common case in incremental builds where most files are unchanged.
When the stamp differs (file was modified), the file is read, the new checksum is computed, and both the in-memory cache and the mtime database are updated.
mtime alone is not enough: cp -p, tar x and rsync -a write new content and then set mtime back, which used to leave the old checksum valid and the build skipping with stale outputs. ctime cannot be set from user space and moves on every write and every mtime change, so the stamp catches those; size and inode catch most rewrites and replacements cheaply. This is the same set git’s index compares.
No entry is stored for a file whose mtime or ctime is less than two seconds old: on a filesystem with coarse timestamps, a second write in the same tick after hashing would otherwise leave a valid-looking entry for stale content. Using ctime as well matters for cp -p, whose freshly written file carries an old mtime.
Dirty mtime entries are flushed to the database in a single batch transaction at the end of each checksum computation pass, minimizing database writes.
Why two layers
| Layer | Scope | Avoids | Cost |
|---|---|---|---|
| In-memory cache | Single build run | Re-reading + re-hashing the same file | HashMap lookup |
| Mtime database | Across builds | Reading unchanged files from disk | stat() + DB lookup |
For the first build, every file must be read and hashed. The mtime database is populated as a side effect. On subsequent builds, most files are unchanged — the mtime check skips reading them entirely, and the in-memory cache prevents redundant lookups within the run.
Configuration
The persistent mtime database can be disabled via rsconstruct.toml:
[cache]
mtime_check = false
Or via the command-line flag:
rsconstruct build --no-mtime-cache
When disabled, every file is read and hashed on every build. The in-memory cache still prevents redundant reads within a single run, but there is no cross-build benefit.
When to disable: In CI/CD environments with a fresh checkout, the mtime database has nothing cached from previous builds and just adds write overhead. The in-memory cache is sufficient. Use --no-mtime-cache (or mtime_check = false in config) to skip the database entirely.
The rsconstruct status command also disables mtime checking internally to ensure accurate classification.
Database location
The mtime database is stored at .rsconstruct/mtime.redb, separate from the build cache (objects/ and descriptors/) and the config tracking database. This separation means:
rsconstruct cache clearremoves the entire.rsconstruct/directory — build cache AND mtime database (seecache.md; the clear must work even when a database is corrupted, so it deletes the whole state dir)- The mtime database can be deleted independently without affecting cached build outputs
Combined input checksum
The combined_input_checksum(inputs) function computes a single hash representing all input files for a product. It:
- Checksums each input file (using the two-layer cache)
- Hashes the checksums as length-prefixed parts (not a separator join —
see
hash_checksums, which has an injection-proofing test)
Missing files get a MISSING:<path> sentinel so that different sets of missing files produce different combined checksums.
See also
- Fast
staton Linux — notes on batching the per-inputfs::metadatacall (relevant for very large projects, cold cache, or network filesystems).
Dependency Caching
RSConstruct includes a dependency cache that stores source file dependencies (e.g., C/C++ header files) to avoid re-scanning files that haven’t changed. This significantly speeds up the graph-building phase for projects with many source files.
Overview
When processors like cc_single_file discover products, they need to scan source files to find dependencies (header files). This scanning can be slow for large projects. The dependency cache stores the results so subsequent builds can skip the scanning step.
The cache is stored in .rsconstruct/deps.redb using redb, an embedded key-value database.
Cache Structure
Each cache entry consists of:
- Key: Analyzer instance name and source file path (e.g.,
icpp+src/main.c) - Value:
source_checksum— SHA-256 hash of the source file contentdependencies— list of dependency paths (header files)dependency_checksums— SHA-256 hash of each dependency when it was scannedabsent— paths the scan probed and found missing on the way to its answerconfig_fingerprint— hash of the analyzer’s configuration (and run-time resolved search paths) the scan ran under
Cache Lookup Algorithm
When looking up dependencies for a source file:
- Look up the entry by source file path
- If not found → cache miss, scan the file
- If its
config_fingerprintdiffers from the analyzer’s current one → cache miss, re-scan - If found, compute the current SHA-256 checksum of the source file
- Compare with the stored checksum:
- If different → cache miss (file changed), re-scan
- If same → check every cached dependency against its stored checksum
- If any dependency is missing or its content changed → cache miss, re-scan
- If any
absentpath now exists → cache miss, re-scan - Otherwise → cache hit, return cached dependencies
Step 5 is what keeps transitive lists correct. When a.h gains
#include "b.h", main.c (which includes a.h) has not changed, but its
cached list [a.h] is now incomplete. a.h’s checksum no longer matches,
so main.c is rescanned and b.h joins its dependencies. Checking only
the source’s own checksum, as the cache once did, left b.h out for good:
later edits to it never rebuilt main.c.
Checksums go through the mtime cache, so an unchanged dependency costs a
stat, not a read.
Steps 3 and 7 cover what decides a dependency list without being file
content. A resolution depends on which files are missing:
#include "x.h" resolved to include/x.h because src/x.h did not
exist, and once src/x.h appears it is the one the compiler finds. The
analyzer reports those probes (ScanResult::absent), and an entry is only
valid while all of them are still missing. It also depends on the
analyzer’s configuration: adding an include path can change what every
#include resolves to, without any file changing. The cpp analyzer only
sees the compiler’s answer, not its probes, so it reconstructs them: for
each header and each search directory it lies under, the same relative
spelling under every earlier directory (a superset of the real probes).
An entry is not stored when a dependency does not exist yet (a file another product will generate): it has no checksum to validate against. The source is scanned again once the file is generated.
Why Path as Key (Not Checksum)?
An alternative design would use the source file’s checksum as the cache key instead of its path. This seems appealing because you could look up dependencies directly by content hash. However, this approach has significant drawbacks:
Problems with Checksum as Key
-
Mandatory upfront computation: With checksum as key, you must compute the SHA-256 hash of every source file before you can even check the cache. This means reading every file on every build, even when nothing has changed.
With path as key, you do a fast O(1) lookup first. Only if there’s a cache hit do you compute the checksum to validate freshness.
-
Orphaned entries accumulate: When a file changes, its old checksum entry becomes orphaned garbage. You’d need periodic garbage collection to clean up stale entries.
With path as key, the entry is naturally updated in place when the file changes.
-
No actual benefit: The checksum is still needed for validation regardless of the key choice. Using it as the key just moves when you compute it, without reducing total work.
Current Design
The current design is optimal:
Path (key) → O(1) lookup → Checksum validation (only on hit)
This minimizes work in the common case where files haven’t changed.
Cache Statistics
During graph construction, RSConstruct displays cache statistics:
[cc_single_file] Dependency cache: 42 hits, 3 recalculated
This shows how many source files had their dependencies retrieved from cache (hits) versus re-scanned (recalculated).
Viewing Dependencies
Use the rsconstruct analyzer command to view the dependencies stored in the cache:
rsconstruct analyzer show all # Show all cached dependencies
rsconstruct analyzer show files src/main.c # Show dependencies for a specific file
rsconstruct analyzer show files src/a.c src/b.c # Show dependencies for multiple files
rsconstruct analyzer clean # Clear the dependency cache
Example output:
src/main.c: [icpp] (no dependencies)
src/test.c: [icpp]
src/utils.h
src/config.h
analyzer show reads directly from the dependency cache without building the graph. If the cache is empty (e.g., after rsconstruct analyzer clean or on a fresh checkout), run rsconstruct analyzer build or a build first to populate it.
This is useful for debugging rebuild behavior or understanding the include structure of your project.
Cache Invalidation
The cache automatically invalidates entries when:
- The source file content changes (checksum mismatch)
- Any cached dependency file no longer exists, or its content changes
- A path the scan found missing now exists
- The analyzer’s configuration changes
You can manually clear the entire dependency cache by removing the .rsconstruct/deps.redb file, or by running rsconstruct clean all which removes the entire .rsconstruct/ directory.
Processors Using Dependency Caching
Currently, the following processors use the dependency cache:
- cc_single_file — caches C/C++ header dependencies discovered by the include scanner
Implementation
The dependency cache is implemented in src/deps_cache.rs:
#![allow(unused)]
fn main() {
pub struct DepsCache {
db: redb::Database,
stats: DepsCacheStats,
pending: HashMap<String, DepsEntry>,
}
impl DepsCache {
pub fn open() -> Result<Self>;
pub fn get(&mut self, ctx, analyzer: AnalyzerId, source: &Path) -> Option<Vec<PathBuf>>;
pub fn set(&mut self, ctx, analyzer: AnalyzerId, source: &Path, source_checksum: String,
dependencies: &[PathBuf], absent: &[PathBuf]) -> Result<()>;
pub fn flush(&mut self) -> Result<()>;
pub fn stats(&self) -> &DepsCacheStats;
}
}
The cache is opened once per graph build and driven by analyzers::Analysis.
set only stages an entry (lookups see it at once); flush writes every
staged entry in one transaction, at the end of each analysis pass — the
graph-time pass and each mid-build re-analysis. A redb commit is durable, so
the old one-commit-per-set cost one fsync per scanned file: a cold scan of
2,000 sources went from 2,031 fsyncs to 32.
Processor Versioning and Cache Invalidation
When a processor’s implementation changes in a way that produces different output for the same input, every cached entry it produced becomes potentially stale. This chapter documents the problem, the design alternatives we considered, and the chosen approach.
The problem
rsconstruct’s cache is content-addressed on a key derived from:
- Primary input file checksums
dep_inputs/dep_autofile checksumsoutput_config_hash(the processor’s relevant config fields)- Tool version hash (optional — e.g.
ruff --versionoutput)
Crucially absent: the implementation of the processor itself.
Consider: a user upgrades rsconstruct to a version where the ruff wrapper now passes a new flag by default. Inputs haven’t changed. Config hasn’t changed. Ruff’s binary version hasn’t changed. But the output is different — the new flag changes behavior.
rsconstruct sees a cache hit on the old descriptor and restores the stale result. The user gets incorrect output from “fresh” caches.
Design alternatives considered
Option A: Hash the binary at startup
Compute a SHA of the rsconstruct binary itself at program start. Mix that hash into every product’s cache key.
How it works: Any change to any part of rsconstruct — processors, core executor, cache code, even comments — invalidates every cache entry.
Pros:
- Trivially correct. If any code changed, caches are invalidated.
- Zero developer action.
- No risk of forgotten invalidation.
Cons:
- Massively over-invalidates. Fixing a typo in a docstring or reformatting the
cleancommand wipes every user’s cache across every processor. - Makes iterating on rsconstruct itself painful — developers constantly rebuild everything.
- Version bumps of unrelated dependencies (regex bumps, anyhow bumps) change the binary and also invalidate.
Option B: Per-file source hash (automatic)
build.rs hashes each processor’s .rs file at compile time. The hash is embedded as a &'static str into that processor’s plugin entry. Cache key includes this hash.
How it works: Modify src/processor/checker/ruff.rs, next build picks up a new hash, ruff’s caches invalidate. Other processors are unaffected.
Pros:
- Zero developer action — hashes are automatic.
- More precise than Option A — only the changed processor invalidates.
- Never forget to bump.
Cons:
- Too sensitive. Whitespace changes, comment fixes, rustfmt reformats, renames of private helpers — all invalidate the cache even though behavior is identical.
- Doesn’t catch indirect changes. If a processor calls shared helpers in
processor/mod.rsand those change, the processor’s file hash hasn’t changed but its behavior has. We need to hash transitive dependencies, and Rust doesn’t give us an easy way. - Non-deterministic sources of churn: different rustfmt versions produce different hashes for the same intent, CI vs. local editor differences cause spurious invalidation.
- Signal dilution: users stop paying attention to “this rebuilt” because it happens even for cosmetic changes. The signal loses meaning.
Option C: Whole src/processor/ subtree hash
Hash the entire processors directory at compile time. Any change to anything under src/processor/ invalidates every processor’s cache.
How it works: Middle ground between A and B.
Pros:
- Catches shared-helper changes automatically (since helpers are in the same subtree).
- Less aggressive than A — core-executor tweaks don’t invalidate.
Cons:
- Still over-invalidates — a fix to processor X wipes processor Y’s cache.
- Still vulnerable to formatting/comment churn.
Option D: Explicit per-processor version (manual)
Each processor declares a version: u32 in its plugin entry. The developer bumps it when making a behavior-changing modification. Cache key includes the version.
How it works:
#![allow(unused)]
fn main() {
inventory::submit! { ProcessorPlugin {
name: "ruff",
version: 1, // bump when behavior changes
...
}}
}
Commit Processor ruff: change default flags becomes the same commit as version: 1 → version: 2.
Pros:
- Precise. Only bumps when the developer decides behavior actually changed.
- Stable. Reformats, comment edits, renames do not invalidate caches.
- Auditable. Every version bump is visible in git history as a deliberate one-line change with its own rationale.
- Cross-platform deterministic — a number, not a hash sensitive to file encoding.
- Signal stays meaningful — users see a rebuild only when something actually changed.
Cons:
- Relies on developer discipline. Forgetting to bump after a behavior change leaves stale caches surviving — a silent correctness bug, arguably worse than no invalidation (because it creates a false sense of safety).
- Requires a documented bump rule so the convention is followed.
- Can be mitigated by code review (diffs show version bumps) and optional CI checks (warn when a processor file changes without a version bump).
Option E: Hybrid — manual version OR automatic hash, whichever is larger
Both fields exist. The cache key includes max(manual version, auto hash). Belt-and-suspenders.
Pros: Catches both forgotten bumps and behavior changes.
Cons: Complexity. Two systems doing nearly the same thing. Users don’t know which one is “the” trigger. Debugging cache misses becomes harder. Loses the “explicit and predictable” property of Option D.
Decision: Option D (explicit per-processor version)
For a build system that cares about cache correctness, deliberate is better than automatic:
- Cache stability is a feature. Users expect their caches to survive a refactor, a
cargo fmt, a whitespace cleanup. An automatic hash violates this expectation constantly. - A version bump documents intent.
git blameon theversion:line shows why behavior changed. An auto hash leaves no such record. - The discipline cost is low. Each behavior-changing commit already requires care — adding a one-line version bump to that care is trivial. Forgetting to bump is caught by code review, same as forgetting a changelog entry or a test.
- The discipline failure mode is recoverable. Worst case: a version bump is forgotten, users report stale caches, we bump the version retroactively in the next release. This is better than the Option B failure mode (constant spurious invalidation drives users to distrust the system).
The bump rule
Bump a processor’s version when ANY of:
- The processor would produce different output files for the same inputs.
- The processor would include different content in an output file for the same inputs.
- The processor changes which inputs are discovered (e.g. a new glob pattern, a changed default).
- The processor changes which paths are declared as outputs.
- The processor’s interpretation of a config field changes (e.g. what a flag means, how a default is resolved).
Do NOT bump for:
- Refactors with identical behavior.
- Comment / docstring changes.
- Reformatting.
- Renaming of internal helpers.
- Performance improvements that don’t change output.
- Bug fixes in error messages (but DO bump if the fix changes which inputs succeed/fail).
When in doubt, bump. A bump is cheap (rebuild all products of one processor once); a missed bump is a correctness bug.
Implementation outline
- Add a required
version: u32field toProcessorPlugin(no default — every processor must declare it). - Include the version in the cache key via
output_config_hashordescriptor_key. - Initialize all existing processors to
version: 1. - Document the bump rule in a prominent comment near the field definition.
- (Optional, future) CI check: if a processor file’s git diff touches logic but not the
version:line, post a warning comment on the PR.
Migration
On the first release after this change ships, every existing cache entry is invalidated (the cache key schema changed). This is a one-time cost, same as any cache-key schema evolution. Users will see a full rebuild once, then cache behavior resumes normally.
See also
- Cache System — how the cache is organized and keyed
- Checksum Cache — the mtime-based content checksum layer
- Processor Contract — the broader contract each processor must uphold
Cross-Processor Dependencies
This chapter discusses the problem of one processor’s output being consumed as input by another processor, and the design options for solving it.
The Problem
Consider a template that generates a Python file:
tera.templates/config.py.tera → (template processor) → config.py
Ideally, ruff should then lint the generated config.py. Or a template might
generate a C++ source file that needs to be compiled by cc_single_file and
linted by cppcheck. Chains can be arbitrarily deep:
template → generates foo.sh → shellcheck lints foo.sh
template → generates bar.c → cc_single_file compiles bar.c → cppcheck lints bar.c
Currently this does not work. Each processor discovers its inputs by querying
the FileIndex, which is built once at startup by scanning the filesystem.
Files that do not exist yet (because they will be produced by another processor)
are invisible to downstream processors. No product is created for them, and no
dependency edge is formed.
Why It Breaks
The build pipeline today is:
- Walk the filesystem once to build
FileIndex - Each processor runs
discover()against that index resolve_dependencies()matches product inputs to product outputs by path- Topological sort and execution
Step 3 already handles cross-processor edges correctly: if product A declares
output foo.py and product B declares input foo.py, a dependency edge from
A to B is created automatically. The problem is that step 2 never creates
product B in the first place, because foo.py is not in the FileIndex.
How Other Build Systems Handle This
Bazel
Bazel uses BUILD files where rules explicitly declare their inputs and outputs.
Dependencies are specified by label references, not by filesystem scanning.
However, Bazel does use glob() to discover source files during its loading
phase. The key insight is that during the analysis phase, both source files
(from globs) and generated files (from rule declarations) are visible in a
unified view. A rule’s declared outputs are known before any action executes.
Buck2
Buck2 takes a similar approach with a single unified dependency graph (no
separate phases). Rules call declare_output() to create artifact references
and return them via providers. Downstream rules receive these references through
their declared dependencies. For cases where the dependency structure is not
known statically, Buck2 provides dynamic_output — a rule can read an artifact
at build time to discover additional dependencies.
Common Pattern
In both systems, the core principle is the same: a rule’s declared outputs are visible to the dependency resolver before execution begins. The dependency graph is fully resolved at analysis time.
Proposed Solutions
A. Multi-Pass Discovery (Iterative Build-Scan Loop)
Run discovery, build what is ready, re-scan the filesystem, discover again. Repeat until nothing new is found.
- Pro: Simple mental model, handles arbitrary chain depth
- Con: Slow (re-scans filesystem each pass), hard to detect infinite loops, execution is interleaved with discovery
B. Virtual Files from Declared Outputs (Two-Pass)
After the first discovery pass, collect all declared outputs from the graph and inject them as “virtual files” visible to processors. Run discovery a second time so downstream processors can find the generated files.
- Pro: No filesystem re-scan, single build execution phase, deterministic
- Con: Limited to chains of depth 1 (producer → consumer). A three-step chain (template → compile → lint) would require three passes, making the fixed two-pass design insufficient.
C. Fixed-Point Discovery Loop
Generalization of Approach B. Run discovery in a loop: after each pass, collect newly declared outputs and feed them back as known files for the next pass. Stop when a full pass adds no new products. Add a maximum iteration limit to catch cycles.
known_files = FileIndex (real files on disk)
loop {
run discover() for all processors, with known_files visible
new_outputs = outputs declared in this pass that were not in known_files
if new_outputs is empty → break
known_files = known_files + new_outputs
}
resolve_dependencies()
execute()
A chain of depth N requires N iterations. Most projects would converge in 1-2 iterations.
- Pro: Fully general, handles arbitrary chain depth, no filesystem re-scan, deterministic, path-based matching (no reliance on file extensions)
- Con: Processors must be able to discover products for files that do not exist on disk yet (they only know the path). This works for stub-based processors and compilers but might be an issue for processors that inspect file contents during discovery.
D. Explicit Cross-Processor Wiring in Config
Let users declare chains in rsconstruct.toml:
[[pipeline]]
from = "template"
to = "ruff"
rsconstruct then knows that template outputs matching ruff’s scan configuration should become ruff inputs.
- Pro: Explicit, no magic, user controls what gets chained
- Con: More configuration burden, loses the “convention over configuration” philosophy
Revisited in Routing Generated Files, which keeps
Approach C’s automatic connections and offers explicit wiring only as an
optional inputs_from, for producers that cannot predict their outputs.
E. Make out/ Visible to FileIndex
The simplest mechanical fix: stop excluding out/ from the FileIndex. Since
.gitignore contains /out/, the ignore crate skips it. This could be
overridden in the WalkBuilder configuration.
- Pro: Minimal code change, works on subsequent builds (files already exist from previous build)
- Con: Does not work on the first clean build (files do not exist yet). Processors would also see stale outputs from deleted processors, and stub files from other processors (though extension filtering would exclude most of these).
F. Two-Phase Processor Trait (Declarative Forward Tracing)
Split the ProductDiscovery trait so that each processor can declare what
output paths it would produce for a given input path, without performing full
discovery:
#![allow(unused)]
fn main() {
trait ProductDiscovery {
/// Given an input path, return the output paths this processor would
/// produce. Called even for files that don't exist on disk yet.
fn would_produce(&self, input_path: &Path) -> Vec<PathBuf>;
/// Full discovery (as today)
fn discover(&self, graph: &mut BuildGraph, file_index: &FileIndex) -> Result<()>;
// ...
}
}
The build system first runs discover() on all processors to get the initial
set of products and their outputs. Then, for each declared output, it calls
would_produce() on every other processor to trace the chain forward. This
repeats transitively until no new outputs are produced. Finally, discover()
runs once more with the complete set of known paths (real + virtual).
Unlike Approach C, this does not require a loop over full discovery passes. The chain is traced declaratively by asking each processor “if this file existed, what would you produce from it?” — a lightweight query that does not modify the graph.
- Pro: Single discovery pass plus lightweight forward tracing. No loop, no convergence check, no iteration limit. Each processor defines its output naming convention in one place. The full transitive closure of outputs is known before the main discovery runs.
- Con: Adds a method to the
ProductDiscoverytrait that every processor must implement. Some processors have complex output path logic (e.g.,cc_single_filechanges the extension and directory), sowould_produce()must replicate that logic — meaning the output path computation exists in two places (inwould_produce()and indiscover()). Keeping these in sync is a maintenance risk.
G. Hybrid: Visible out/ + Fixed-Point Discovery
Combine Approach E (make out/ visible) with Approach C (fixed-point loop) or
Approach F (forward tracing).
On subsequent builds, existing files in out/ are already in the index. On
clean builds, the fixed-point loop discovers them from declared outputs.
- Pro: Most robust — works for both clean and incremental builds
- Con: Combines complexity of two approaches, risk of discovering stale outputs
Recommendation
Approach C (fixed-point discovery loop) is the most principled solution. It is fully general, handles arbitrary chain depth, requires no configuration, and matches the core insight from Bazel and Buck2: declared outputs should be visible during dependency resolution before execution begins.
The main implementation requirement is extending the FileIndex (or creating a
wrapper) to accept “virtual” entries for paths that are declared as outputs but
do not yet exist on disk. Processors already declare their outputs during
discover(), so the information needed to populate these virtual entries is
already available.
Current Status
Cross-processor dependencies are implemented using Approach C (fixed-point
discovery loop). After each discovery pass, newly declared outputs are injected
as virtual files into the FileIndex. Discovery re-runs with the expanded index
until no new products are found (up to 10 iterations).
Since 2026-10-06 the later passes route the new files instead of re-running
everything: a processor that discovers one product per file
(Discovery::PerFile) is handed an index of only the newly declared files,
and only the other processors rescan the whole index. Connections are still
inferred from the same scan settings. See
Routing Generated Files.
Key implementation details:
FileIndex::add_virtual_files()inserts declared output paths into the index so downstream processors can discover them viascan().BuildGraph::add_product()handles re-declarations during multi-pass discovery (see below).- The loop runs in both discovery sites: the main build graph builder and the deps builder.
--phasesoutput shows per-pass statistics when multiple passes are needed.- Most projects converge in 1 pass (no cross-processor chains). Projects with generator → checker chains converge in 2 passes.
Deduplication during multi-pass discovery
When processors re-run on subsequent passes, they may try to add products that
already exist. add_product() detects this via two separate dedup paths,
depending on whether the product declares outputs:
Products with outputs (generators)
Dedup is keyed on output paths. When a product with the same outputs is re-declared by the same processor:
-
Identical re-declaration — Same inputs. The product is silently skipped.
-
Expanded inputs — The new inputs are a superset of the existing inputs. This happens when a processor like
tagscollects all matching files into a single product. On pass 2, virtual files from generator outputs are now in theFileIndex, sotagsdiscovers the same product with additional inputs. The existing product’s inputs are updated to the expanded set, and theinput_to_productsindex is updated accordingly.
Both cases account for instance name remapping: a product may have been
remapped from cc_single_file to cc_single_file.clang after pass 1, but
discover() still passes the type name cc_single_file on pass 2. The
dedup check accepts processor names where one is a qualified instance of the
other (e.g., cc_single_file matches cc_single_file.clang).
Genuinely conflicting products — different processors (or the same processor
with different inputs that are not a superset) declaring the same output —
still produce an Output conflict error.
Products without outputs (checkers, explicit processors with output_dirs)
Products with no declared output files (e.g., checkers, or explicit processors
that only declare output_dirs) cannot be deduped by output path. Instead,
they are deduped by the tuple (processor_name, primary_input, variant) via
the checker_dedup index.
This path also supports expanded inputs. When a later pass re-declares the
same product with a superset of inputs, the existing product’s inputs are
updated. This is critical for processors like explicit that use input_globs:
on pass 0, the globs may match nothing (the target files don’t exist yet); on
pass 1, virtual files from upstream generators are available and the globs
resolve to additional inputs. Without the input update, the product would be
frozen with its pass-0 inputs, no dependency edges would be created to the
upstream producers, and the product would execute too early (before its actual
inputs exist).
Glob-aware Tera dependencies
The problem
Some Tera templates depend not on the contents of any one file, but on the set of files that satisfy a pattern. The motivating example is from teaching-slides:
- Currently there are {{ shell_output(command="find marp/courses -type f -name '*.md' -printf '%h\n' | sort -u | wc -l") }} courses in this repo.
- Currently there are {{ shell_output(command="find marp/lectures -type f -name '*.md' | wc -l") }} lectures in this repo.
- Currently there are {{ git_count_files(pattern="marp/**/*.md") }} marp files in this repo.
- Currently there are {{ shell_output(command="python3 scripts/count_slides.py") }} marp slides in this repo.
- Currently there are {{ git_count_files(pattern="svg/**/*.svg") }} SVG diagrams in this repo.
- Currently there are {{ shell_output(command="grep -r '^|.*---.*|' marp/ --include='*.md' | wc -l") }} tables in this repo.
The rendered output changes when files under marp/ and svg/ are
added, removed, or renamed, even though no specific file has been
edited and no path is listed as a dependency. rsconstruct today tracks
content-addressed dependencies: a product is rebuilt when the contents of
its declared inputs change. Add or remove a .md file under marp/ and
the template product is happily served from cache, even though its
rendered output is now stale.
This is a fundamental “the build action depends on the directory listing, not on a fixed set of files” problem. We need a way to express that.
How other tools solve it
| Tool | Approach |
|---|---|
| make | Doesn’t solve it. Globs in Makefiles are evaluated once, when the Makefile is parsed. Re-running make after adding a file picks up the new file because the Makefile is re-parsed. Make’s dependency model = file content + mtime, never directory contents. |
| ninja | Same as make — relies on the build-graph generator (cmake, gn) to re-run and produce new build.ninja. |
| bazel / buck | Solve it cleanly by forbidding implicit globs inside actions. Globs are evaluated by the build tool itself at graph-construction time, producing a fixed list of files baked into the action. Add a file → BUILD-file evaluation produces a different action with a different cache key. The action itself never reads the directory. This works because Bazel owns the glob() function — it’s not a side effect inside the user’s tool. |
| ninja + depfiles | The tool emits a .d file at build time listing every input it actually read. Doesn’t help here because pandoc/tera/sh don’t know they’re “reading the directory”. |
| tup | Hooks the build with FUSE/ptrace and observes every open() and readdir() call. Captures directory listings as true dependencies. Heavyweight; cross-platform pain. |
| directory mtime | Cheap and approximate. A directory’s mtime updates when entries are added or removed. Catches add/remove. Does not catch rename within a directory if the new name still hashes the same number of files; does not catch content-dependent queries (grep -r). |
Chosen approach: Bazel-style, ported to Tera
Stop letting templates make uncontrolled directory queries. Promote directory queries to first-class rsconstruct constructs that participate in the dependency graph.
Three layers:
1. New Tera function: glob(pattern=...)
Templates that today say:
{{ shell_output(command="find marp/courses -type f -name '*.md' | wc -l") }}
{{ git_count_files(pattern="marp/**/*.md") }}
are migrated to:
{{ glob(pattern="marp/courses/**/*.md") | length }}
{{ glob(pattern="marp/**/*.md") | length }}
glob() is implemented inside rsconstruct’s Tera engine. It does two things:
- Expands the glob using rsconstruct’s
FileIndex— the same mechanism the rest of the build uses, so it respects.gitignoreand.rsconstructignoreand behaves consistently withsrc_dirs. - Records the pattern as a graph-level dependency on the calling
product. Specifically:
- Every matched file is added to
product.inputs(its content checksum contributes to the product’s input checksum). - The sorted list of resolved paths is hashed into a glob-set
fingerprint which is mixed into the product’s
config_hash.
- Every matched file is added to
The fingerprint matters because rsconstruct’s cache key is content-addressed (Cache System) — renaming a file with identical content otherwise wouldn’t bust the cache. Mixing the sorted-path fingerprint into the config hash makes add / remove / rename all flip the cache key deterministically.
Returns a list of file paths as strings. Templates can use Tera’s built-in
length, sort, iteration, etc. on the result.
2. Tightened shell_output(command=..., depends_on=[...])
For users who genuinely need to shell out for content-dependent results
(grep -r '...' marp/), require an explicit depends_on list of glob
patterns:
{{ shell_output(
command="grep -r '^|.*---.*|' marp/ --include='*.md' | wc -l",
depends_on=["marp/**/*.md"]
) }}
shell_output then:
- Resolves every pattern in
depends_on, adding matched files toproduct.inputs. - Mixes the sorted union of resolved paths into the product’s config hash.
- Mixes the literal command string into the product’s config hash so command edits also rebuild.
- Runs the command and returns its trimmed stdout (current behavior).
A shell_output call with no depends_on is rejected at template-render
time:
shell_output() requires a
depends_on=[...]list of glob patterns. rsconstruct cannot otherwise tell when its output should be invalidated. If your command genuinely has no file dependencies, passdepends_on=[]explicitly to acknowledge that.
The empty-list escape hatch is for commands like date or whoami whose
output rsconstruct can never track — the user takes responsibility.
3. The Tera analyzer extends to scan for these calls
src/analyzers/tera.rs
already scans templates for {% include %}, {% import %}, {% extends %},
and load_lua/load_data/... directives. Extend it to also recognize:
glob(pattern="...")— capturespattern, expands to file list.shell_output(..., depends_on=[...])— captures the literal pattern list, expands each to file list. The command string itself is captured intoconfig_hash(notinputs).
Captured patterns and their resolved file lists become per-product
dependency contributions, fed through the same machinery used today for
include/import dependencies. The analyzer cache (.rsconstruct/deps.redb)
gets one entry per template per pattern, so re-runs on unchanged templates
are O(1).
Why this is the right shape
- Composes with existing machinery. No new caching layer; everything
flows through
product.inputs,config_hash, and the existing content-addressed cache key. The only new piece is the glob-set fingerprint, which is a one-linersha256(sorted_paths.join("\n")). - Catches all three failure modes. Add → new path in glob set → new
fingerprint. Remove → path missing → new fingerprint. Rename → both
fingerprint and
inputschange. - User opt-in is local. The user adds a function call in one place;
rsconstruct does the rest. Compare with
dep_inputs = [...]inrsconstruct.toml, which scales poorly because every template that uses globbing would need a config update. - Failure of the old idiom is loud.
shell_outputwithoutdepends_onbecomes an error, not a silent stale build. Existing templates surface immediately when first re-rendered. - No filesystem instrumentation. No FUSE, no ptrace, no eBPF. Stays cross-platform and inside the “simple, cross-platform” philosophy in CLAUDE.md.
Migration
The change is backwards-incompatible for shell_output calls without
depends_on. Two options:
- Hard cut-over. Bump the processor version on
tera. First build after upgrade fails fast on every legacyshell_outputcall with the error above. User migrates toglob(...)(preferred) orshell_output(..., depends_on=[...]). - Soft transition. Add a new function
tracked_shell_output(..., depends_on=...)and leaveshell_outputworking but emit a deprecation warning at render time. Removeshell_outputafter one release cycle.
We pick option 1. The library is pre-1.0 and the existing shell_output
is the source of incremental-build bugs we’re fixing — making it loud is
the point. Per
feedback_no_backcompat
in our memory, on-disk and config back-compat aren’t a concern at this
stage.
Lower-effort fallback (not chosen)
Track the mtime of each src_dir as an automatic input for every
product whose processor scans that directory. Catches add/remove and most
rename cases, zero user-facing API change. Doesn’t catch content-dependent
queries like grep -r '...'. Roughly 30 lines of code, no template-engine
changes. Could ship as a stop-gap if the full design proves too disruptive.
We chose against it because:
- mtime is unreliable on some filesystems and tar/git restores set it arbitrarily.
- It would silently miss the
grep -rcase from the motivating example. - It papers over the real bug (
shell_outputis unsafe) without forcing users to think about what their templates actually depend on.
Implementation
Status: shipped. Implemented as two pieces:
Static analysis (the cache-key path) — src/analyzers/tera.rs reads each
template file as text and regex-matches:
{% include "..." %},{% import "..." %},{% extends "..." %}(existing)load_lua/load_data/...(path="...")(existing)glob(pattern="...")(new)shell_output(command="...", depends_on=[...])(new)
For each match, the analyzer:
- Resolves any glob patterns via the
globcrate. - Adds matched files to the product’s
inputs(so file-content changes are caught by the existing input-checksum mechanism). - Mixes a deterministic string into the product’s
config_hashviaProduct::extend_config_hash(). The string is built from the literal pattern plus the sorted list of resolved paths plus, forshell_output, the literal command text. Adding/removing/renaming a matching file flips the path list; editing the command text flips the command piece.
The analyzer rejects shell_output(...) without depends_on at
graph-construction time. The error message includes the source file path,
the offending command, and migration advice.
A new helper analyzers::analyze_with_full_scanner was added to support
analyzers whose scanner returns a [ScanResult] (deps + config-hash piece)
rather than just deps. The existing C/C++/Python/markdown analyzers
continued to use the simpler analyze_with_scanner. (Both helpers have
since been folded into analyzers::Analysis: every analyzer returns a
ScanResult, and one that contributes hash pieces sets always_rescan.)
Runtime evaluation (the rendered-output path) — src/processor/generator/tera.rs
registers two functions on the Tera engine:
glob(pattern="...")— runs the same expansion the analyzer did and returns the sorted list as a Tera string array. Templates can uselength, iterate, orjoinit.shell_output(command="...", depends_on=[...])— runs the command and returns its trimmed stdout. Validates thatdepends_onis present and is an array. Empty list is permitted (explicit user opt-out of file-level dependency tracking).
The runtime validation in shell_output is a defense-in-depth check: the
analyzer enforces depends_on at graph-construction time, but if for any
reason a template were rendered without going through the analyzer (e.g.,
during early prototyping or a misconfigured project) the render itself
would still surface the missing argument with a clear error.
Caching note. The path list returned by scan_template is not cached
in .rsconstruct/deps.redb. Tera analysis is cheap (regex + glob + path
hashing) and the config-hash piece needs to be recomputed on every build
anyway — its value depends on filesystem state, not on the template file’s
content. A richer cache could be added later if profiling demands it.
Files changed
src/graph.rs— addedProduct::extend_config_hash().src/analyzers/mod.rs— addedScanResultandanalyze_with_full_scanner.src/analyzers/tera.rs— replacedscan_includeswithscan_template, which handles all five constructs; addedexpand_globhelper.src/processor/generator/tera.rs— addedGlobFunction; tightenedShellOutputFunctionto requiredepends_on.tests/processor/tera.rs— 8 new tests covering count, add/remove/rename invalidation, missingdepends_onrejection, content-change invalidation, command-edit invalidation, and empty-glob handling.
Known limitations
- The
shell_output(...)body regex stops at the first). Commands or argument values that contain a literal)could confuse the analyzer. None of the existing teaching-slides commands hit this, but a quote-and-paren-aware parser would be a small follow-up if the limitation becomes painful. - Globs are evaluated with the
globcrate’s syntax (POSIX-ish with**for recursive) and do not currently honor.gitignore/.rsconstructignore. For most users this is fine because the patterns are scoped to a known directory. If unwanted matches appear, exclude them with a more specific pattern.
Open questions
- What about
git_count_files? The existing function usesgit ls-files, which is essentially a glob restricted to git-tracked files. We can keep it for backwards compatibility and have it record the same kind of dependency under the hood (the resolved file list fromgit ls-files), or we can just deprecate it in favor ofglob(). The former is friendlier; do the former. - Caching the glob expansion itself.
FileIndexis already cached per-build. For repeatedglob()calls inside one render the resolution is cheap. Across builds, the analyzer cache keys deps by source file, so if the.terafile hasn’t changed and the source tree hasn’t changed, the cached dep list is reused. No extra caching needed. - What if
glob()matches zero files? Return an empty list. Don’t error — empty results are a legitimate state, and an error would force awkwardif-glob-empty-ortemplate logic.
Shared Output Directory
Multiple processors can write into the same directory — a website _site/, a dist/, a build/ folder. This document explains how rsconstruct keeps each processor’s cache correct when they share an output directory, and the exact rules that make it work.
The scenario
A common case:
- mkdocs (a Creator) builds a whole site. It produces many files under
_site/and declares the directory as itsoutput_dir. It cannot enumerate individual outputs in advance. - pandoc (a Generator / Explicit) converts one specific markdown file into
_site/about.html. It declares that file explicitly as itsoutput_files.
Both contribute to the same directory. A website IS a single folder by design.
[processor.creator.generic.mkdocs]
command = "mkdocs build --site-dir _site"
output_dirs = ["_site"]
[processor.explicit.generic.pandoc]
command = "./pandoc-page.sh"
inputs = ["about.md"]
output_files = ["_site/about.html"]
The problem
Naive implementations break in at least three places:
- Over-claiming at cache store time. If mkdocs’s cache entry walks
_site/and records every file, it will wrongly claimabout.htmlas its own. On cache restore, pandoc’s file gets restored from mkdocs’s cache — with whatever content mkdocs last saw there — even if pandoc hasn’t run. - Clobbering at build time. If mkdocs wipes
_site/before running (so stale outputs from a previous build don’t linger), it will also delete pandoc’sabout.htmlwhenever mkdocs runs after pandoc. - Clobbering at restore time. If restoring mkdocs’s cache wipes
_site/before writing cached files, it will again destroy pandoc’s output.
Each problem leads to silent cache corruption: stale content appears to be fresh, or recently-built files vanish.
Ownership rule
Every declared output path has exactly one owner — the single product that lists it in
outputs,output_files, or produces it as a named product output.A directory declared as
output_diris not an ownership claim on the whole subtree. The Creator only owns the files it itself produces that no other product has declared.
This is enforced by a single graph query, BuildGraph::path_owner(path) -> Option<usize>, which returns the id of the unique product that declares path as one of its outputs (or None if nobody does).
Pseudocode:
path_owner(path):
for each product P in graph:
if path in P.outputs:
return P.id
return None
A declared output path has at most one owner by construction — if two products declare the same literal output, that is detected as an output conflict at graph-build time and the build aborts.
How each of the three hazards is handled
1. Over-claiming at cache store time
When a Creator’s tree descriptor is being built in ObjectStore::store_tree_descriptor, the walker visits every file under each output_dir. For each file, it asks the graph: “Is this path owned by a different product?”
is_foreign(path) = graph.path_owner(path) is Some(owner) and owner != my_product_id
If is_foreign(path) is true, the file is skipped — it does not appear as a tree entry. The Creator’s cache then contains only files the Creator actually created and that nobody else has laid claim to.
When pandoc writes _site/about.html and mkdocs later caches _site/, mkdocs’s tree will not contain about.html because path_owner("_site/about.html") == pandoc.id != mkdocs.id.
2. Clobbering at build time
Before a product’s command runs, remove_stale_outputs removes stale outputs so the command can rewrite them fresh (important when a cache restore left read-only hardlinks in place).
The rule for Creators:
- Do NOT wipe
output_dirwholesale. - Read the previous tree descriptor from the object store.
- Remove only the files recorded in that previous tree.
- Re-create the
output_dir(so the command can assume it exists). - Leave any file not in the previous tree alone — it belongs to somebody else.
Pseudocode:
remove_stale_outputs(product, input_checksum):
if product has output_dirs:
previous = object_store.previous_tree_paths(descriptor_key(product, input_checksum))
for file in previous:
if file exists: remove it
for dir in product.output_dirs:
create dir if missing
for file in product.outputs:
if file exists: remove it
Because the previous tree only ever contained paths the Creator owned, this removal cannot touch files owned by other processors.
3. Clobbering at restore time
Cache restore for a tree descriptor iterates entries and writes each one in place. It never calls remove_dir_all on the output_dir. If a file already exists with the correct checksum, the restore skips it (saving I/O).
When mkdocs restores its tree:
_site/index.htmland_site/assets/style.cssare written from the object store._site/about.htmlis NOT in mkdocs’s tree, so it is neither written nor removed.- If pandoc has also restored, pandoc’s blob descriptor wrote
_site/about.htmlseparately.
The two restores compose correctly regardless of order.
Invariants
The system relies on these invariants; each is enforced in code:
| # | Invariant | Where enforced |
|---|---|---|
| 1 | Every declared output path has at most one owner. | add_product / graph validation (output conflict check) |
| 2 | A Creator’s tree descriptor contains only paths not owned by any other product. | store_tree_descriptor with is_foreign predicate |
| 3 | Pre-run cleanup removes only files the Creator previously owned. | remove_stale_outputs reads previous_tree_paths |
| 4 | Cache restore never deletes files it did not cache. | restore_tree_descriptor writes in place; no remove_dir_all |
When all four hold, processors can freely share an output directory.
Worked example
Starting from an empty project, both processors are declared as above and both get to run on a fresh build.
First build
- pandoc runs first.
remove_stale_outputs: pandoc has nooutput_dirs; removes_site/about.htmlif it exists (it doesn’t). No-op.- Runs
./pandoc-page.sh, which creates_site/about.html. - Caches a blob descriptor for
_site/about.html.
- mkdocs runs next.
remove_stale_outputs: mkdocs hasoutput_dirs; looks up its previous tree (none — first build). Creates_site/to ensure it exists.- Runs
mkdocs build, which writes_site/index.html,_site/assets/style.css, and may (harmlessly) touch_site/about.html. - Caches a tree descriptor. The walker skips
_site/about.htmlbecausepath_ownersays pandoc owns it. Tree =[index.html, assets/style.css].
Final state on disk: index.html, assets/style.css, about.html. All three files exist with correct content.
Incremental build, no changes
- pandoc: input checksum matches; descriptor already exists; skipped.
- mkdocs: input checksum matches; descriptor already exists; skipped.
Clean outputs + rebuild
rsconstruct clean outputsdeletes_site/entirely.- Next build:
- pandoc’s input checksum matches its cached descriptor → restore blob → writes
_site/about.html. - mkdocs’s input checksum matches its cached descriptor → restore tree → writes only the files in the tree (
index.html,assets/style.css), leavesabout.htmlalone.
- pandoc’s input checksum matches its cached descriptor → restore blob → writes
Final state is the same as after the first build, without either tool having actually run.
Building only the Creator (-p processor.creator.generic.mkdocs)
- pandoc is not in the run set;
_site/about.htmlstays wherever it was (absent if cleaned, present otherwise). - mkdocs runs or restores its tree.
If _site/ was clean, about.html remains absent — which is correct, because the Creator does not claim to produce it. The regression test creator_tree_does_not_include_foreign_outputs verifies exactly this.
Non-goals
- Runtime conflict detection for paths the Creator actually wrote but didn’t declare. If a Creator happens to write a file that another Generator also declares, the declared owner wins; the Creator’s tree simply won’t include that file. We do not error on this.
- Ordering constraints. rsconstruct does not enforce “Generators run before Creator” or vice versa. The snapshot/walk is done after each product finishes, and
path_owneris a static graph query independent of run order. - Partial-directory caching like git trees with subtrees. The tree descriptor is a flat list of
(path, checksum)entries, which is enough for this use case.
Quick reference for processor authors
If you are writing a new processor:
- Generator / Explicit: declare every output file in
output_files. rsconstruct keeps each of your files safe from Creators that share the directory. - Creator: declare the shared directory in
output_dirs. Do NOT assume the directory is empty when your command runs — other processors may have already contributed files to it. Your command should overwrite only what it produces; it should not wipe the directory. - Conflict: never declare the same path as an output in two different products. That is a graph-build-time error regardless of directory sharing.
Processor Ordering
When two processors touch the same files or cooperate on a shared workspace, the question of “which runs first?” inevitably comes up. This chapter explains how rsconstruct answers that question today, how other build systems approach it, the dilemmas that show up in practice, and why rsconstruct has deliberately avoided adding explicit ordering knobs so far.
How rsconstruct orders today
rsconstruct has no explicit cross-processor ordering configuration. Ordering is derived entirely from the data-flow graph:
- Each product (a unit of work from a processor) declares
inputsandoutputs. - If product A’s
inputscontains a path that product B’soutputsalso contains, A depends on B — B runs first. - Products with no such relationship are considered independent and may run in parallel.
That’s the whole mechanism. The executor runs each product as soon as every product it depends on has finished, up to -j at a time. See Cross-Processor Dependencies for the data-flow story.
There is no depends_on, mustRunAfter, before, after, priority, or stage field anywhere in rsconstruct.toml. If two processors write into the same directory without any file dep between them, their order is undefined and may vary between runs.
How other tools handle it
Bazel, Buck2
No explicit ordering. Rules declare srcs, deps, and outs. The scheduler orders actions strictly by the DAG of declared inputs/outputs. Hermeticity is a first-class value — if you need something to run before something else, you model it as a data dependency. If a rule B needs rule A’s side effect but not its output, you fabricate a marker file: A outputs a.done, B takes a.done as an input.
Bazel’s design intent: if you need ordering without data flow, you’re modeling the problem wrong. The graph should tell the truth about what depends on what.
Make, Ninja
Data-flow ordering via rules (foo.o: bar.h). Ninja adds order-only dependencies — the || separator in build.ninja. An order-only dep means “run A before B” without “rebuild B when A changes”. This is useful for things like “create out/ before any rule tries to write into it”. It’s the minimum viable ordering primitive: pure ordering, no rebuild semantics.
Gradle
Has explicit ordering primitives, three of them:
dependsOn— real dependency: running B automatically runs A first (even if A would otherwise be skipped).mustRunAfter— ordering constraint: if both A and B are in the scheduled set, A runs first; but running B does NOT pull A in.shouldRunAfter— soft ordering hint: honored when possible, may be violated to enable parallelism.
Gradle’s ecosystem (Android, JVM tooling, packaging/signing pipelines) has more real-world “unrelated tasks that still need ordering” cases — e.g., signing must happen after packaging even though they don’t share a file output. The three-level hierarchy lets users pick the right strength.
CMake
add_dependencies(targetA targetB) enforces ordering at the target level, beyond file-level rules. Used mostly for custom targets that don’t produce tracked output files — the bridge when file-based ordering isn’t sufficient.
Cargo, SBT
No explicit cross-crate ordering. Everything flows from [dependencies] / library deps → data flow → topological sort. Same posture as Bazel.
Summary table
| Tool | Explicit ordering knobs | Philosophy |
|---|---|---|
| Make / Ninja | Order-only deps (||) | Bridge when file deps aren’t enough |
| Bazel, Buck2 | None | Hermeticity; all ordering comes from data flow |
| Cargo, SBT | None | Same as Bazel |
| Gradle | dependsOn, mustRunAfter, shouldRunAfter | Real-world tasks have non-data ordering needs |
| CMake | add_dependencies | Bridge for “phantom” custom targets |
| rsconstruct | None (currently) | Same as Bazel |
The dilemmas
Adding explicit ordering feels useful but carries real risks. Here are the tradeoffs.
Dilemma 1: does ordering imply rebuild?
Say [processor.b] after = ["a"]. If A’s output changes, should B rebuild?
- If yes,
afteris justdependsOn— which we already have through data flow. It’s redundant. - If no,
afteris pure ordering (mustRunAfter). But then it silently lies about the true dependency graph: a user might addafter = ["a"]because they “know” B consumes A’s side effect, but rsconstruct won’t invalidate B’s cache when A changes. Stale caches follow.
Gradle copes because it has three flavors. Adding one flavor is usually wrong; adding three is complexity creep.
Dilemma 2: declared vs. inferred
rsconstruct already infers ordering from inputs/outputs. Adding another channel means:
- Two sources of truth for the dependency graph.
- Debugging “why did B run after A?” now requires checking both the data flow AND the explicit config.
- Mistakes compound: a user adds
after = ["a"]but forgets that they ALSO removed the data dep; now B runs after A but doesn’t actually consume anything from it.
Dilemma 3: encourages side-effects
If ordering knobs exist, they become the path of least resistance for modeling side effects:
“My script also writes to
/tmp/cache_seed.json, just declareafterand it’ll work.”
Side-effectful processors are an anti-pattern in any incremental build system — the cache can’t know when they changed, when to rerun them, or what invalidates them. Every ordering primitive that doesn’t touch the cache makes side effects easier to introduce.
Dilemma 4: the “fix-up pass” case
The one case where data flow struggles: a processor that runs after everything else has written to a shared directory and modifies the result. Examples:
- Minification: take everything in
dist/and minify it after all generators have produced their outputs. - Post-processing: add cache-busting hashes to filenames, rewrite links, compress.
In Bazel, you model this as a rule with srcs = glob(["dist/**"]). But with lazy generators (outputs that didn’t exist when the scan ran), globs can miss things.
Reasonable fixes without adding ordering knobs:
- Have the fix-up processor declare its inputs explicitly as the output files of the generators. Works but requires enumeration.
- Re-scan globs after each dependency level so the fix-up step sees newly-generated files. Correct, but costlier.
- Make the fix-up a Creator with the whole
dist/as itsoutput_dir. Our shared-output-directory logic handles this cleanly (see that chapter), but now the fix-up operates in-place on files owned by others, which touches the “files owned by other products” rule.
None of these is wonderful, but none requires a new ordering primitive.
Dilemma 5: parallelism is already constrained
If ordering becomes a first-class concept, users will sprinkle after = [...] for safety and the scheduler will serialize work that could have run in parallel. Bazel’s aggressive parallelism comes partly from refusing to accept unprincipled ordering constraints.
Why rsconstruct hasn’t added ordering
The posture we’ve picked (for now):
- Data flow is the truth. Every time ordering matters, there is a real data dependency. Expose it as an input/output rather than as a separate ordering rule.
- Shared output directories are handled without ordering. The Shared Output Directory design lets multiple processors contribute to one folder in any order; the cache stays correct per-processor.
- The cost of adding explicit ordering is high: it creates a second channel for dependencies, invites side-effect-oriented thinking, and rarely solves a problem that couldn’t be solved by modeling the data flow properly.
When we would add explicit ordering
If a real use case appears where:
- Data flow genuinely cannot express the dependency (no file is consumed, only a side effect).
- The alternative (adding a marker file or input_glob re-scan) is significantly worse than adding a knob.
- The feature can be specified with clear rebuild semantics (pick one of: forces rerun / does not force rerun; do not leave it ambiguous).
Then the most likely shape is a single after = ["processor_name"] field with Gradle’s mustRunAfter semantics:
- Affects ordering only when both processors are already scheduled.
- Does NOT add a rebuild trigger.
- Does NOT force the referenced processor to run.
This is the smallest, most honest knob. It doesn’t pretend to be a data dependency; it doesn’t change cache invalidation; it only constrains scheduling.
Until that case is concrete, the answer is: model ordering through data flow. The graph should tell the truth.
Alternative: Output Prediction
Another way to close the gap without adding ordering knobs: make opaque Creators (mkdocs, Sphinx, Jekyll) transparent by discovering their outputs in advance.
Instead of the Creator declaring output_dirs = ["_site"] (opaque — “something goes in here”), it would declare (or generate) the exact file list it will produce:
[processor.creator.generic.mkdocs]
command = "mkdocs build --site-dir _site"
predict_command = "./list-mkdocs-outputs.sh" # prints one output path per line
output_dirs = ["_site"]
The mass generator does exactly this: rsconstruct runs predict_command at graph-build time, turns each printed path into a declared outputs entry, and the opaque Creator becomes a set of per-file products. After that, the entire “how do we order two processors that both write into _site/?” question dissolves — every file has exactly one declared owner, and the normal Generator/data-flow rules apply.
Why this is an alternative to ordering knobs:
- Explicit ordering says “we can’t model this; let the user pin the order manually.”
- Output prediction says “we can model this if we know the outputs; let’s discover them.”
Prediction is the more principled answer — the graph ends up telling the truth about what depends on what — but it is far more expensive to do well (predictor drift, plugin ecosystems, partial-build support, validation). Ordering knobs are cheap but lie about the dependency graph.
The full tradeoff is explored in the Output Prediction chapter. Short version: neither is obviously better; they solve different problems and could coexist.
See also
- Cross-Processor Dependencies — how data-flow dependencies work between processors
- Shared Output Directory — how multiple processors can cooperate on one directory without ordering
- Output Prediction — a different approach that makes opaque Creators transparent
- Design Notes — broader design principles
Output Prediction & MassGenerator
A Creator (mkdocs, Sphinx, Jekyll, Hugo, etc.) declares output_dirs = ["_site"] — “I produce something in here, don’t ask me what until I’ve run.” This chapter specifies a new processor type, MassGenerator, that makes those tools transparent: the tool is asked in advance what it will produce, and each planned file is promoted to a declared product output.
Once outputs are known up front, per-file caching, precise incremental rebuilds, cross-processor dependencies on generated files, and safe output-conflict detection all come for free.
Status
Implemented as the processor.mass_generator.generic processor (src/processor/mass_generator/generic.rs); the user-facing contract is in Mass Generator. This document is the design spec it was built from. Where the implementation departs from the text below, the departure is called out in a Status note; the main ones are: no synthetic phase product (a once-per-build guard inside the processor does that job), loose_manifest is a config field rather than a CLI flag, verification compares a before/after snapshot of output_dirs instead of walking the whole directory, and the manifest cache is opt-in, keyed on the plan inputs the config declares.
Related designs:
- Shared Output Directory — the fallback mechanism for tools that can’t predict outputs.
- Processor Ordering — the sibling design discussion about explicit ordering knobs.
The core idea
Today we treat tools like mkdocs as a black box:
[processor.creator.generic.mkdocs]
command = "mkdocs build --site-dir _site"
output_dirs = ["_site"] # opaque — we only know the directory
The new approach asks the tool to emit a manifest before running:
[processor.mass_generator.generic.mkdocs]
command = "mkdocs build --site-dir _site"
predict_command = "mkdocs-plan" # prints a JSON manifest on stdout
output_dirs = ["_site"]
rsconstruct invokes predict_command at graph-build time, parses its JSON output, and creates one product per planned file. Each product has its own inputs (taken from the manifest’s sources field) and a single outputs entry (the planned path). From that point on, the product is a regular per-file Generator — caching, dependency tracking, and cross-processor wiring all work uniformly.
Manifest format
predict_command must print a single JSON document to stdout in this shape:
{
"version": 1,
"outputs": [
{
"path": "_site/index.html",
"sources": ["docs/index.md", "templates/default.html", "mysite.toml"]
},
{
"path": "_site/about/index.html",
"sources": ["docs/about.md", "templates/default.html", "mysite.toml"]
},
{
"path": "_site/assets/style.css",
"sources": ["assets/style.scss", "assets/_vars.scss"]
}
]
}
version— integer. Schema version (1 for now). Allows future evolution without breaking existing tools.outputs— array, one entry per file the tool will produce.outputs[].path— output file path relative to the project root. Must fall within one of the processor’soutput_dirs(enforced).outputs[].sources— array of input paths whose changes should trigger rebuilding this output. Used as the product’sinputs, which feed into cache-key computation.
Order within outputs must be deterministic (sorted by path). The sources array should be minimal — only the files whose content genuinely affects this specific output.
Lifecycle
1. Plan phase (at graph-build time)
Once per MassGenerator instance declared in rsconstruct.toml:
- Run
predict_command. Capture stdout and exit status. - Exit status non-zero → fail the graph build with the tool’s stderr in the error message.
- Parse stdout as JSON. Malformed → fail the graph build.
- Reject manifest if any
outputs[].pathfalls outside the declaredoutput_dirs. - For each manifest entry, add one product to the build graph:
inputs= entry’ssourcesoutputs= [entry’spath]processor= this instance’s name
- Cache the manifest itself in the object store, keyed on a hash of
(config + input_checksum_of(source_tree)). Re-planning is skipped when the hash matches.
The plan phase runs BEFORE the existing product-discovery phase, so predicted outputs are known to all downstream processors (linters, compressors, etc.) via the normal file-index/cross-processor-dependency mechanisms.
2. Build phase
When one or more MassGenerator products are dirty:
- rsconstruct groups all dirty products belonging to the same MassGenerator instance into a single execution batch.
- It invokes
commandexactly once per batch (not per product). - The tool produces all its output files in that one invocation.
- Each product caches its own output file as a blob descriptor, independently.
- In strict mode (default): after the tool exits, rsconstruct verifies that every predicted file in the batch was produced and no unexpected files appeared in
output_dirs. A mismatch fails the build. - In loose mode (
loose_manifest = truein the instance’s config — a per-instance knob, not a CLI flag, since drift is a property of one tool): divergence is a warning only.
Status note. “No unexpected files appeared in output_dirs” is implemented as a before/after snapshot: a file counts as unexpected when it is new or has a changed mtime since the snapshot taken just before the tool ran, is not in the plan, and is not another product’s declared output (the executor hands the set of declared outputs to the BuildContext for exactly this query). Files the tool left untouched — a neighbor’s output, a committed CNAME, an orphan from an older plan — are not reported. That is narrower than “every file in output_dirs is in the manifest or owned by someone”, and deliberately so: the check is for predictor drift, and only writes the tool just made can be drift.
The “one invocation, many products” idiom is this type’s defining execution shape — distinct from both Generator (one invocation per product) and Creator (one invocation, one product).
3. Restore phase
When all MassGenerator products for an instance are cache-clean:
- Each product is restored from its blob descriptor independently — no tool invocation at all.
- Partial restoration is natural: if 47 of 50 files are clean, only 3 products go through the build phase (which still triggers one tool invocation, but the 47 unchanged files are either untouched on disk or silently overwritten with identical content).
4. Verification (strict mode)
After build:
- Every manifest entry → file exists with the right path.
- Every file in
output_dirs→ appears in the manifest OR belongs to another processor (via the existingpath_ownerquery).
Violations are hard errors; partial output is left on disk for debugging.
Graph shape
With a MassGenerator producing N planned files, the graph looks like this:
source files (markdown, templates, config)
|
| (as inputs to each planned file's product)
v
[product: _site/index.html]
[product: _site/about/index.html]
[product: _site/assets/style.css]
... (N products, all with processor = "mass_generator.mkdocs")
Each product is a first-class citizen in the graph. A downstream linter can depend on _site/index.html like any other generated file.
Execution: one tool invocation for many products
Today’s executor assumes “one product = one invocation of processor.execute(product).” MassGenerator violates that. The cleanest implementation (per the design discussion) uses a two-level graph:
- Phase product (internal, not user-visible): one synthetic product per MassGenerator instance whose
executeis the actual tool invocation. It has no declared outputs; its job is to populate the output_dir. - File products (the N planned files): each depends on the phase product, meaning the tool must have run before any file product can be cached/restored. Each file product’s
executeis a no-op (tool already ran); it just caches its output.
The dependency system then naturally orders: phase product runs once (if any file product is dirty), then every dirty file product caches its output. Clean file products skip both phases.
This shape keeps the executor simple and reuses all existing caching, skipping, and restore logic without modification.
Status note. The implementation reaches the same end without the phase
product. Every file product is an ordinary product (inputs = its sources,
outputs = its path), and the processor keeps a per-instance tool_run state
behind a mutex. execute(product) asks that state: not run yet → run the
tool, verify, record the outcome; already succeeded → do nothing; already
failed → fail this product with the recorded message. So the tool runs at most
once per build, clean products never trigger it, and a failure is reported
once per product without re-running a build that just failed. The plugin caps
the instance at max_jobs = 1, so products of one instance execute one at a
time instead of parking threads on the mutex. The reasons the phase product
lost: a product with no outputs cannot be depended upon through the graph
(edges come from inputs matching outputs), so it would have needed a stamp
output, and a restored stamp would have marked every file product
dep_changed → build on a fresh checkout with a warm remote cache, where the
right answer is restore.
Before the tool runs, the processor unlinks every predicted path, clean ones included: a clean file may be a read-only hardlink from an earlier restore, which the tool could not overwrite. The executor’s own pre-run unlink only covers the products it is about to build.
Config reference
[processor.mass_generator.<INSTANCE>]
# The tool's build command and its arguments. Runs at most once per build,
# when any predicted file is dirty. (No shell: command and args are separate.)
command = "mkdocs"
args = ["build", "--site-dir", "_site"]
# The tool's plan command. Must print the JSON manifest to stdout.
# May be the same binary with different arguments or a separate script.
predict_command = "mkdocs-plan"
predict_args = []
# Where the tool will produce its outputs. Every manifest entry's path
# must fall inside one of these directories. Used for verification.
output_dirs = ["_site"]
# Optional: extra files added to the inputs of every predicted product.
dep_inputs = ["mkdocs.yml"]
# Optional: report plan/build mismatches as warnings instead of errors.
# Useful during development of the tool itself. Default: false.
loose_manifest = false
Status note. As designed, the standard scan fields bound which source changes trigger a replan: their files key the manifest cache (step 6 below). They discover no products.
Interaction with the shared-output-directory design
This new processor type does not replace the Creator / shared-output-directory mechanism. Both coexist:
| User declares | Treated as | Caching | Cross-processor deps |
|---|---|---|---|
output_dirs only | Creator (opaque) | One tree per build | Only via declared files |
output_dirs + predict_command | MassGenerator | Per file | Full — all files known |
Choose Creator when the tool can’t enumerate its outputs. Choose MassGenerator when it can.
Design invariants (for tool authors)
For a tool to be consumed as a MassGenerator, predict_command must uphold:
- Pure function of config + source tree. Same inputs → same manifest, bit for bit.
- Cheap or cached. rsconstruct calls this on every graph build. Slow predict_command means slow rsconstruct invocations.
- Matches the build command’s actual outputs. Predicted paths = actual paths. Violations are hard errors in strict mode.
- Deterministic variable outputs. If the tool produces tag pages or archive pages or anything else content-derived,
predict_commandmust compute them from the same source inspection pass.
The rssite README spells out a concrete contract that meets these invariants.
Advantages
1. Shared-directory ownership becomes trivial
Every generated file has a declared owner at graph-build time. The existing output-conflict check catches overlaps instantly:
Output conflict: _site/about.html is produced by both [mass_generator.mkdocs] and [explicit.pandoc]
The complex path_owner + tree filtering + previous-tree cleanup mechanism (see Shared Output Directory) is still there as a safety net, but for MassGenerators it’s mostly unnecessary.
2. True cross-processor dependencies
Downstream processors (linters, compressors, sitemap builders) can declare the MassGenerator’s outputs as inputs. The graph connects properly. Impossible with opaque Creators.
3. Per-file caching
Change docs/tutorial.md → rebuild only _site/tutorial.html. On a large site this is the difference between “rebuild in 50ms” and “rebuild in 30s.”
Note: the per-file caching on the rsconstruct side only saves the tool invocation when ALL file products are clean. If any one is dirty, the tool runs once and produces everything — then clean files are still cached individually (useful across different invocations). True per-file build speed requires the tool itself to support partial builds. rssite will; most existing tools won’t.
4. Parallel file caching
With per-file products, different files can be cached to the object store in parallel after the build. Minor win, but free.
5. Precise clean, precise restore, real dep graphs
Every downstream feature that relies on declared outputs — clean outputs <path>, graph visualization, dry-run, watch mode — works correctly for MassGenerator outputs without special cases.
Disadvantages
1. Predictor drift
If predict_command lies (or gets out of sync with the tool), the cache can be corrupted silently: predicted paths get restored, actual build produces different paths, orphan files accumulate. Strict-mode verification after each build is the guardrail — it catches drift at build time rather than at next-restore time.
2. Predict-time cost
Every graph build runs predict_command. For large sites this may mean parsing every source file to enumerate outputs. The manifest cache (keyed on source-tree hash) mitigates but doesn’t eliminate this.
3. Partial build support
The per-product caching model wants “rebuild just this one file” but most tools rebuild everything per invocation. With mkdocs, hugo, jekyll, you pay full build cost whenever anything is dirty, regardless of how many files changed. rssite is being designed to support partial builds from day one; existing tools would need patches.
4. Engineering cost
The MassGenerator type is a new processor class with new execution semantics (“one invocation for many products”). That’s real implementation work in the executor, plus a new config schema, plus manifest parsing, plus verification logic.
5. Variable outputs may require heavy parsing
Tag pages, archive indices, RSS feeds — all content-derived. predict_command has to do enough source parsing to enumerate them. For well-designed tools this is cheap (the same parsing feeds both plan and build). For retrofitted tools it’s often duplicate work.
Open questions
These should be resolved during implementation:
- Single-pass mode: should we support a
--print-manifestflag oncommanditself, so one invocation does both plan and build? Faster for full rebuilds, slightly uglier config. Probably yes, optional. - Manifest schema evolution: how do we handle
version: 2? Support both for a transition period, or hard-require upgrade? Probably both-for-N-releases. - Incremental invalidation: when the manifest changes between builds (e.g., a new page added), how is the old cache cleaned? The existing descriptor-based cache handles this automatically (unreferenced cache entries are eventually pruned), but the behavior deserves explicit documentation.
- Interaction with
file_index: predicted outputs need to appear in the file index so downstream processors can discover them during their own scan phases. Must be registered before discover_products runs. - Watch mode: when a source file changes, do we re-run
predict_commandor reuse the last manifest? The hash-based cache mostly handles this, but edge cases around plugin-rewritten outputs need thinking.
Recommendation
Build this once rssite (or any other cooperating tool) is far enough along to drive concrete requirements. Implementing it against a hypothetical tool wastes work — we’d guess at features. Implementing against rssite (where we control both sides) grounds the design in reality.
The order it was built in, and where each step stands:
- New processor type
mass_generatorregistered in the plugin registry. Done. - Config schema (
predict_command,predict_args,output_dirs,loose_manifest). Done. Commands and arguments are separate fields, per the No-Shell Policy; thecommand = "mkdocs build --site-dir _site"spelling in the examples above is design shorthand. - Plan phase: invoke
predict_command, parse JSON, create products. Done. The plan is memoized for the process, so the fixed-point discovery loop runs it once per build, not once per pass. - Execution phase: one invocation per instance, per build. Done, via the once-per-build guard described in the status note above.
- Strict verification after build. Done, as a before/after snapshot (status note above).
- Manifest caching (skip re-plan when source tree unchanged). Done. The cache is opt-in: when the config declares the plan’s inputs (
src_dirswithsrc_extensions, and/orsrc_files), the raw manifest is stored in.rsconstruct/mass_generator/<instance>.jsonunder a key of the rsconstruct version, the instance config, and the path and content of every declared input; a matching key skipspredict_command. Undeclared inputs mean no cache, because nothing else can tell rsconstruct what the plan depends on.src_dirswithoutsrc_extensionsis rejected (it would key the cache on nothing). - Documentation in
docs/src/processor/mass_generator/generic.md. Done.
Open question 1 (single-pass --print-manifest mode) is not implemented. Open question 3 (a page the tool stops predicting) is resolved by documentation: the orphan has no product and is neither rebuilt nor cleaned; the user deletes it.
See also
- Mass Generator — the processor’s user-facing documentation
- Shared Output Directory — how we handle opaque Creators today
- Processor Ordering — the sibling discussion about explicit ordering
- Cross-Processor Dependencies — why per-file outputs enable proper dependency graphs
- rssite — static site generator built to implement the MassGenerator contract
Routing Generated Files Between Processors
Status: routing implemented (2026-10-06). Making whole-index processors
wait was set aside (see Why whole-index processors do not
wait); inputs_from and the
creator tiers are deferred until a config needs them. See
Implementation status at the end.
This chapter proposes replacing the fixed-point discovery loop with
routing: every file a processor declares as an output is handed to exactly
the processors that accept it, and only those processors do any further
discovery. Connections between processors stay automatic — inferred from
the scan settings users already write — and an optional inputs_from field
covers the cases inference cannot. It grew out of finding R8 in
Architecture Observations (“discovery re-runs
every processor on every pass”).
Today: the fixed-point loop
How a generated file reaches the processor that consumes it today (see Cross-Processor Dependencies):
- Every processor discovers products by scanning the
FileIndexwith its scan settings (src_dirs,src_extensions,src_files, globs). - The outputs declared in that pass are added to the index as virtual files.
- Every processor runs discovery again over the whole index, in case one of the new paths matches its settings.
- Repeat until a pass adds nothing, up to
max_discovery_passes.
What is good about it, and must be kept: connections are automatic. If
processor A generates gen/doc.md and processor B’s settings accept .md
files under gen/, B consumes it. Nobody has to say so.
What is wrong with it:
- Every processor re-runs on every pass, over the whole index, whether or not anything new can concern it (R8).
- It needs machinery that exists only for re-running: virtual files,
re-declaration handling in
BuildGraph::add_product(the superset input update intry_update_inputs, thechecker_dedupindex), the convergence check and its limit. - The resulting wiring is invisible. Which processor feeds which is a side effect of path matching; nothing shows it.
- Producers that don’t know their outputs are invisible. A creator
declares only an output directory, so nothing it writes ever becomes a
virtual file. A processor whose
src_dirspoints into that directory finds nothing on a clean build, and the build stays green.
Proposal: route each new file to the processors that accept it
The rule for whether B consumes a file is unchanged: B’s scan settings accept its path. What changes is that the rule is applied per file, at the moment the file is declared, instead of by re-scanning everything:
- Pass 0. Every processor discovers products from the files on disk, as today.
- Route. Each output declared by a new product is checked against every processor’s scan settings. Each processor that accepts the path is handed that one file.
- Discover the file. A processor handed a file creates the products for that file — and nothing else; it does not rescan.
- Repeat with the outputs those products declare, until no new file is declared.
Routing is a path match against each processor’s settings — cheap compared with a discovery run, and done once per new file instead of once per processor per pass. A processor that accepts nothing new never runs again.
What it keeps and what it removes
- Kept: automatic connections, with exactly today’s matching rule, so every existing config behaves the same.
- Removed: re-running every processor, scanning the whole index per pass, virtual files as a mechanism for discovery, and the re-declaration handling for per-file processors (a per-file processor is handed each file once).
- Added: the wiring is a computed fact. Discovery records which
processor’s outputs went to which processor, so it can be shown
(
rsconstruct graph processors, or similar) and checked byinputs_fromassertions (below).
The convergence limit (max_discovery_passes) can stay as a guard against a
runaway chain, but is no longer how discovery ends in the normal case: it
ends when the routing queue is empty.
Processors that need all their inputs at once
Most processors are per-file: one product per input file (generators, most checkers). Handing them one file at a time is natural.
Some build one product from a whole set of files: tags (one index over
all sources), duplicate_files (a whole-set comparison), pdfunite and
ipdfunite (many PDFs into one), explicit processors (inputs given as
globs), and creators whose product depends on a glob of files. These are
aggregate processors. Handing them one file at a time produces a product
over a partial set, which today’s code repairs by growing the product’s
inputs on later passes.
Under routing, an aggregate processor waits instead of being repaired:
- Files routed to it are collected, not discovered one by one.
- It discovers once, with its full set, when no processor that could still route a file to it has work left — every per-file chain that could feed it has drained.
- Whether a processor could still feed it is known: the set of producers whose declared outputs it accepted so far, plus the processors upstream of those in the routing graph.
So discovery alternates: drain the per-file routing queue, then run the aggregate processors whose producers have all settled, route their outputs, drain again. An aggregate processor’s outputs that route (directly or through others) back to an aggregate processor that feeds it is a cycle, reported as a config error naming the processors on it.
Whether a processor is per-file or aggregate is a property of the processor, declared in its plugin entry next to the rest of its schema — one file per processor, as the coding rules require. A processor that does not declare it is treated as aggregate: always correct (it waits for its full set), at worst later than necessary.
This waiting is the part of the design that needs the most care and the
most tests: chains that alternate per-file and aggregate stages
(templates → pages → tags → generated index page → checker), and the
cycle report.
inputs_from: optional, for what inference cannot do
[processor.checker.linkcheck]
inputs_from = ["processor.creator.mdbook"]
inputs_from names processors by instance name — the name in the config
header and the -p argument (processor.generator.generic.derive_metadata;
processor.generator.tera for a processor declared without an instance
name). A type name with named instances is an error listing them. Most
configs never need it. It exists for three cases:
- Connect what cannot be inferred. A creator declares an output
directory and learns what is in it only by running, so its files cannot
be routed in advance. Naming it in
inputs_fromis the only way to consume it; see the next section. - Restrict. With
inputs_fromset, B accepts generated files only from the processors it names, instead of from every processor whose outputs happen to match its settings. For two producers writing similar paths when only one should feed B. - Assert. When the connection would be inferred anyway, naming it
turns “B consumes A’s files” into a checked fact: the build fails if
discovery routed nothing from A to B — a moved directory or a changed
extension is caught instead of silently checking less. A producer with
no outputs (disabled, or nothing to do in this repo) does not fail the
assertion: one shared
rsconstruct.tomlserves repos of different layouts.
Variants: a producer with variants (compiler profiles) offers the outputs of
every variant. Variants write to distinct paths, so a consumer that wants
one variant filters with its existing src_dirs.
Producers that don’t know their outputs
A creator (sphinx, mdbook, jekyll, cargo, pip, npm, generic creators) declares an output directory and learns what is in it only by running. Three tiers:
Tier 1 — make it predictable
A mass generator runs predict_command before the
build and declares every planned file, then checks after running that the
tool wrote what it planned. Its outputs route like any generator’s. Any tool
that can say in advance what it will produce should be wired this way.
Tier 2 — hand over the directory as one input
Many consumers of a creator’s output want the whole tree: a link checker
over _site, an HTML validator run once, an archive, a deploy. Naming a
creator in inputs_from gives B one product whose input is the
creator’s output tree.
- Order is known before anything runs: the connection is declared.
- The input checksum is taken at dispatch, after the creator ran (the
executor decides every product at dispatch — R1). It is computed from the
creator’s tree descriptor, which the object store records on every build
or restore (
ObjectStore::record_last_tree). That list is exact: it excludes files other processors contribute to a shared directory, which a directory walk would not (see Shared Output Directory). - This needs a new kind of product input — a producer’s tree rather than a
path — in the graph, the checksum code and
product show.
Tier 3 — one product per file, discovered after the creator runs
Per-file checking of a creator’s output (one bad page fails one product, an
unchanged page is skipped) needs products that can only be created once the
creator has run. The executor’s GraphRefresh hook, added for mid-build
re-analysis (R5), is where it would happen: after the creator’s level, its
actual files are routed like declared outputs, products join the graph, and
the remaining levels are re-planned.
Costs: status, --dry-run and the predicted counts cannot see these
products before the creator runs (they could show the last recorded tree,
marked provisional); it is the only place products are created during
execution; clean and stale-output removal must learn these products from
the recorded tree.
Tier 3 is opt-in on the consumer (e.g. inputs_from_mode = "per_file") and
is built only when a real config needs it.
Reporting the gap
Today a processor whose src_dirs points into a creator’s output directory
silently finds nothing on a clean build. Under routing that becomes a
config error: a search setting reaches into a directory owned by a creator
that is not named in inputs_from. The message names the creator and the
tiers above. This is the one place routing asks the user for anything, and
only where inference is impossible.
Analyzers are unchanged
Routing decides which products exist. Dependencies found inside files —
an #include, an image reference — stay with the analyzers: they resolve
references against every declared output and re-analyze sources regenerated
mid-build (R5).
Migration
Census of the 223 rsconstruct.toml files under ~/git (textual match of
search and path settings against out/ and other processors’ declared
output directories; approximate):
| Repo | Sites | Kind |
|---|---|---|
| book-openbook | 25 | src_dirs into tera / generic outputs; input_globs over out/derived; literal inputs of generated .ly/.pdf |
| teaching-syllabi | 6 | five processors with src_dirs on one generic generator’s output; one glob over _site/tracks |
| rsslide | 2 | src_dirs = ["out/import"] |
| teaching-slides | 2 | globs over _site/pdfunite, _site/marp |
| veltzer.github.io | 2 | src_dirs on isass output; literal input from a mass generator |
| teaching-animations | 1 | glob over _site/animations |
Every producer found is a generator or mass generator with known outputs, so every one of these connections is inferred by routing exactly as it is by the loop today. No config changes are needed, and no config consumes a creator’s output directory, so the new error above fires nowhere today.
These repos are the regression suite for the change: each must discover the
same products before and after (rsconstruct graph output compared), with
book-openbook’s mixed per-file and glob-based chains the main exercise for
the aggregate waiting.
Implementation plan
- A per-file/aggregate property on each processor; default aggregate. Done, as a trait method (see below).
- A routing function: path → processors whose scan settings accept it. Reuses the existing scan-setting matching so the rule cannot drift. Done, by construction (see below).
- Discovery as a routing queue: pass 0, per-file discovery of routed files, aggregate processors once their producers have settled. Partly done: per-file processors are routed; aggregate processors still rediscover every round rather than waiting — and will, see below.
- Record the routing graph; show it in
graph; cycle errors. Already there:rsconstruct processor graph(text, dot or JSON) prints which processor feeds which, from the product edges routing creates. A cycle between products is a graph error at topological sort; between processors it is not an error (two processors may feed each other different files). - Delete the loop, virtual files as a discovery mechanism, and the
re-declaration handling in
add_product. Set aside with step 3’s waiting, which it depends on. inputs_from(restrict and assert), and the creator-output config error.- Tier 2 (tree inputs from creators). Tier 3 only when a config needs it.
Why whole-index processors do not wait
The plan had each WholeIndex processor wait until no processor that could
still feed it had work left, then discover once — which would let the loop
and its re-declaration handling go. That ordering cannot be computed:
- A processor is
WholeIndexprecisely because “which files does it take” is not a path test on its scan settings — explicit globs, sibling lookups, a whole set, a plan. So whether a file could reach it is not known in advance. - Whether one whole-index processor feeds another is only known after the first has discovered (its outputs come from its own discovery: tera’s templates, a mass generator’s plan).
Ordering them soundly needs every processor to predict its outputs before discovering — Approach F in Cross-Processor Dependencies, set aside there because it duplicates each processor’s output-path logic. Without it, a whole-index processor that ran “too early” would miss files and nothing would notice.
What exists is correct and cheap: per-file processors (80 of 97) are routed
only the new files; whole-index processors rescan the index in each round
that declared new files, exactly as before, and their re-declarations are
merged by add_product. Discovery was never the expensive phase. This is
the endpoint unless output prediction is ever added for another reason.
Implementation status
What is built. Processor::discovery() returns Discovery::PerFile or
Discovery::WholeIndex. In every round after the first,
Builder::discover_products hands a per-file processor a FileIndex of
only the files the previous round declared, and runs every other processor
over the whole index as before. The processor’s own discover runs on that
smaller index, so “which new files does it accept” is decided by its own
FileIndex::scan — the routing rule and the scan rule cannot drift apart,
because they are the same code. scan judges each path on its own, which is
what makes discovering over a subset exact.
Departures from the plan above:
- A trait method, not a plugin-entry field. A field would have to be
added to all ~100 plugin entries, and a field cannot default safely: the
wrong value for a processor whose
discoverlooks at more than one file would silently lose products. The trait method defaults toWholeIndex, which is correct for every processor;PerFileis an opt-in optimization. - Aggregate processors re-run, they do not wait (yet). A
WholeIndexprocessor rediscovers over the whole index in every round that declared new files — exactly the old loop’s behavior — so the re-declaration handling inadd_productstays. Making them wait for their producers (step 3) is what would let step 5 delete it. - No switch back to the old loop. Instead of shipping both mechanisms,
the new discovery was compared against the released 0.9.106 binary on
copies of all 222 repos under
~/gitwith anrsconstruct.toml(all butvirtual-windows, which is VM images), with build outputs removed so every chain is a clean-checkout chain:processor files --json, sorted, identical in every repo — 50,930 products, all exit codes 0.
Marked PerFile: every processor whose discovery qualifies, each
audited to touch the index only through scan, make one product per
scanned file, and store nothing taken from the index:
- the
SimpleCheckerandSimpleGeneratorwrappers (most checkers and generators); - the checkers using the default
discover:ascii,encoding,iactionlint,icpplint,idockerfile,ijq,ijsonlint,isvglint,itaplo,itidy,ixmllint,iyamllint,iyq,json_schema,marp_images; - custom checkers:
aspell,clang_tidy,iyamlschema,license_header,markdownlint,mdl,script,terms,zspell; - custom generators:
cc_single_file,generic,ijinja2,jinja2,mako,marp,pdflatex,rust_single_file; - creators:
cc,generic,linux_module,pip. (ccandlinux_moduleread their manifest from disk, which both index views share;ccalso collects compiler names, but only adds to that set.)
Left WholeIndex, and why:
| Processor | Its discovery depends on |
|---|---|
generator.tera | the set of includable templates, recorded from the whole index |
generator.requirements, generator.tags, checker.duplicate_files | one product over the whole set of scanned files |
generator.pdfunite, generator.ipdfunite | files grouped by directory |
explicit.generic | globs resolved against the index |
creator.mdbook, creator.jekyll, checker.clippy, checker.make, creator.cargo, creator.npm, creator.gem | sibling files collected around an anchor |
mass_generator.generic, mass_generator.sphinx, mass_generator.zola | a predicted plan over the source set |
Guard. per_file_discovery_over_a_subset_matches_the_full_index
(in src/processor/mod.rs) configures every PerFile processor — 80 of
the 97 — to scan a fixture written to a temporary directory, and checks
that discovering over half of it gives exactly the full index’s products
for that half. Every PerFile processor must produce products from the
fixture, so none passes vacuously. Marking mdbook (which collects sibling
files) PerFile makes it fail. It compares products, not state a
discover stores for later — the reason tera stays WholeIndex has to be
caught in review.
Effect. Discovery-only runs (build --stop-after discover, release
builds, best of seven) on the chain-heavy repos: teaching-syllabi 43 → 32
ms, teaching-slides 89 → 78 ms, book-openbook unchanged at ~15 ms (its
consumers are tera and explicit processors, which still rediscover).
Discovery was never the expensive phase; the gain is real but small.
Decisions
- Inference is the default;
inputs_fromis optional. Users connect nothing by hand unless a producer cannot predict its outputs. - Instances, not types, in
inputs_from. - Variants: all of them, filtered by path when needed.
Relation to earlier designs
- Cross-Processor Dependencies: the
fixed-point loop (its Approach C) is kept in behavior — same automatic
connections — and replaced in mechanism. Its Approach D, explicit wiring
in config, survives only as the optional
inputs_from. - Processor Ordering rejects ordering-only knobs;
inputs_fromis a data dependency with rebuild semantics. - Output Prediction is tier 1.
- Shared Output Directory’s ownership rule is what makes a tier 2 tree input exact.
Undeclared Reads
Status: researched (2026-10-06), not started — deferred. This chapter records what was found while considering a check for files a tool reads without them being declared inputs. Pick it up from here.
The problem
rsconstruct does not declare dependencies; it discovers them. Processors
find their inputs by scanning, analyzers add what source files reference
(#include, import, @use, template includes), and dep_inputs /
dep_auto add the rest. When all of that misses a file a tool actually
reads, nothing notices: the file is not part of the product’s cache key,
editing it rebuilds nothing, and the build stays green with a stale result.
A check would run each tool, see which files it opened, and report any file inside the project that is not among the product’s inputs.
How other build systems handle it
Two families: prevent undeclared reads by running each action where only its declared inputs exist, or observe what each action reads and compare it with what it declared.
Prevent — sandboxes
- Bazel runs every action in a sandbox containing only its declared
inputs. On Linux,
linux-sandboxbuilds a private mount namespace with each input bind-mounted or symlinked into a fresh execution root (network off by default); on macOS,sandbox-execprofiles; elsewhere, a weaker symlink-forest sandbox. An undeclared file is simply absent, so the tool fails with “not found” — Bazel never sees the read itself. Remote execution gives the same guarantee: only declared inputs are uploaded to the worker. - Buck2 gets hermeticity chiefly from remote execution, with the same only-declared-inputs property. Local execution is, as far as we know, not sandboxed by default, so a local Buck2 build can read an undeclared file unnoticed; the guarantee holds where builds run remotely, typically CI.
- Pants runs each process in a temporary directory into which only its declared inputs are materialized.
- Please isolates each process with Linux namespaces.
- Nix builds in a namespace sandbox holding only the declared store paths, without network.
Observe — tracing
- tup watches every file a command opens (a FUSE filesystem on Linux, library interposition elsewhere) and fails the build when a command read a file it did not declare, or wrote one it did not declare. The closest match to what rsconstruct would want.
- BuildXL (Microsoft) intercepts every file access of every process —
Detours on Windows; on Linux,
LD_PRELOADinterposition backed by ptrace for static binaries — and uses the observations both to enforce declarations and for caching. - fabricate.py and similar tools run commands under
straceand infer dependencies from what was opened, rather than checking declarations. - Ninja / Make take dependency lists from the compiler (
-MD,deps = gcc) and trust them; nothing is enforced.
Which family fits rsconstruct
Observation. Sandboxing works for Bazel because a BUILD file declares
every input of every action before it runs. rsconstruct runs tools in the
real working tree, at real paths, where they find config by walking up
directories, and many read files nobody would think to declare — Sphinx’s
conf.py imports, book.toml, Cargo.lock, node_modules, a whole
.venv. A sandbox would break most processors until every such file was
declared, inverting the design (discovery over declaration).
Tracing leaves tools running exactly as now and adds a report on the side, so it can be opt-in and used in CI, as tup uses it. It also catches the read even when the undeclared file exists; a sandbox only fails when the file is missing from it.
Mechanisms considered
| Mechanism | Sees | Cost | Notes |
|---|---|---|---|
strace -f -e trace=openat,open,execve around each tool run | every syscall, including static binaries and Go programs | traced runs are much slower; needs the strace tool | Linux only — on macOS the mode would fail with a clear “needs strace” error, consistent with the platform rules (no #[cfg]). Leading candidate. |
LD_PRELOAD shim intercepting open/openat | dynamically linked programs only | fast | Misses static binaries, Go, direct syscalls: it would under-report silently, against strict-by-default. Also a .so to build and ship. |
| fanotify, in-process | all opens under the project | no external tool | Attributing an open to the product that caused it needs the process tree; unprivileged fanotify is limited on older kernels; Linux-only code to keep in platform.rs. |
| ptrace / seccomp user-notify, in-process | every syscall | moderate | Precise, but a substantial amount of Linux-specific code. |
Open questions
- Mechanism.
straceis the leading candidate (complete, simple to attach — tool runs already go through central helpers insrc/processor/mod.rs), at the price of slow traced runs and a tool dependency for this mode. - What a finding does. Fail the product with a message naming the file (consistent with how a missing tool is handled), or warn. Failing needs an escape hatch: a per-processor field listing reads that are known and accepted (e.g. a tool’s own config file outside the project).
- What counts. Only reads inside the project tree, excluding
.rsconstruct/and the tool’s own installation; reads of a product’s own outputs and of declared output directories are expected. - Batch runs. A batched invocation reads files for many products at once; a read is undeclared only if no product of the batch declares it.
Suggested next step
Before designing the feature, measure the noise: trace one build of a few
fleet repositories with strace by hand and count undeclared project-file
reads per processor. If most processors read nothing undeclared, the check
can fail by default with a small allow-list; if many do (config files,
caches), it starts as a report.
See also Suggestions (“Sandboxed execution”) and Strictness.
Per-Processor Statistics
rsconstruct shows several “per-processor” or “per-analyzer” statistics tables
(cache stats, analyzer stats, graph stats, build summaries). These all
look similar on the surface, but the data source differs, and that changes
what we can cheaply show.
This document explains:
- The three data sources that feed per-X statistics.
- The per-processor grouping problem in
cache stats. - Options for fixing it, with tradeoffs.
- Secondary cleanup — graph-level helpers.
The three data sources
| Question | Lives where | Cost of grouping by X |
|---|---|---|
| “How many products does pylint have in this build config?” | graph (in-memory) | free |
| “How many products were built / skipped / restored this run?” | executor stats (in-memory) | free |
| “How many files did each analyzer find?” | .rsconstruct/deps.redb (on disk, keyed by analyzer) | fast — single DB scan, key is already the analyzer name |
| “How big is my on-disk cache, per processor?” | .rsconstruct/cache/descriptors/ (on disk) | see below — this is the problem |
Graph (in-memory, rebuilt each run)
Every Product carries its processor: String field. Grouping is a simple
iteration over Vec<Product>, constructing a HashMap<String, T> on the spot.
Every caller that wants per-processor stats does this inline — see
builder/graph.rs:111, builder/build.rs:323,436,467,
executor/execution.rs:180,479,524,540.
Analyzer dependency cache (deps.redb)
The redb schema stores each entry keyed by (source path → dependencies) and
tagged with the analyzer that produced it. DepsCache::stats_by_analyzer()
scans the DB once and returns HashMap<analyzer, (file_count, dep_count)>.
Grouping is effectively free because the analyzer name is a first-class field.
Object-store descriptors (.rsconstruct/cache/descriptors/)
Each descriptor file is a small JSON blob describing one cached product — its outputs, their checksums, etc. The filename is a hash of the product’s cache key; the file’s location tells us nothing about which processor created it.
Today’s code in object_store/management.rs:169:
#![allow(unused)]
fn main() {
pub fn stats_by_processor(&self) -> BTreeMap<String, ProcessorCacheStats> {
// walk every file in descriptors_dir
// read the file
// parse the JSON
// ...
// "We can't extract processor name from a hashed descriptor key.
// Use 'all' as a single bucket for now."
let processor = "all".to_string();
}
}
Two things are wrong with this:
- It’s a white lie. The function is named
stats_by_processor, but it returns a single"all"bucket. There is no per-processor grouping. - It’s slow. Even to produce that single bucket, it reads and parses every descriptor file. For 10,000 cached products that’s 10,000 syscalls and 10,000 JSON parses, just to count entries.
Why this matters: declared-but-empty processors
In analyzer stats, if a user declares [analyzer.cpp] in rsconstruct.toml
but the analyzer never matches anything, the table shows a cpp 0 0 row
(implemented 2026-04-12). This is a useful signal: “you configured it, but it
is silently doing nothing.”
We’d like the same in cache stats: show every enabled processor, including
those with zero cached entries, so that users notice mis-configurations.
We cannot implement this today. If we listed declared processors with
zeros, real entries would still be lumped into "all", so the table would
show:
all: 50 entries, 58 outputs, 3.2 MiB
ruff: 0 entries, 0 outputs, 0 bytes ← misleading
pylint: 0 entries, 0 outputs, 0 bytes ← misleading
Total: 50 entries, 58 outputs, 3.2 MiB
That’s worse than the current output — it tells the user “pylint produced nothing” when pylint may actually have plenty. Fixing the 0-rows UX requires first fixing the grouping itself.
Options to fix per-processor cache grouping
Option A — embed the processor name inside each descriptor
Add a processor: String field to CacheDescriptor. The cache-insert path
populates it (already known at that point). stats_by_processor reads the
field instead of hard-coding "all".
- ✅ Small, localized change — ~100–150 lines including a backward-compat fallback for old descriptors.
- ❌ Does not fix the slowness. We still read and parse every descriptor to learn the grouping.
- ❌ Cache format change requires either a migration step, a “legacy entries
show up as
unknown” fallback, or a cache wipe on upgrade.
Option B — encode the processor name in the descriptor’s path
Layout changes from:
.rsconstruct/cache/descriptors/
ab/
cd/
abcd1234…json
to:
.rsconstruct/cache/descriptors/
ruff/
abcd.json
ef01.json
pylint/
9876.json
stats_by_processor becomes:
#![allow(unused)]
fn main() {
for each subdir of descriptors/:
name = subdir.file_name() // free — already a String in the dir entry
count = number of files in subdir // one readdir per processor
}
- ✅ Fixes grouping and speed simultaneously. 30
readdirs instead of 10,000reads is two to three orders of magnitude faster. - ✅ Trivially answers “does this processor have any cached entries at all?”
with
exists(descriptors/NAME/). - ❌ Changes on-disk cache layout. Requires migration.
Since descriptors are a cache by definition (regenerable from a build), the simplest migration is: detect the old layout on startup and wipe it. Next build repopulates under the new layout. No data loss beyond a slower first build post-upgrade.
Option C — maintain a processor→count index in a redb sidecar
Keep a small redb database (e.g. .rsconstruct/cache/stats.redb) with a table
mapping processor_name → (entry_count, output_count, output_bytes). The
cache insert / evict paths update this index transactionally alongside the
descriptor write.
stats_by_processor becomes:
#![allow(unused)]
fn main() {
let db = redb::Database::open("cache/stats.redb")?;
let table = db.begin_read()?.open_table(STATS_TABLE)?;
// One DB read per processor — counts are pre-aggregated.
}
- ✅ Answers
cache statsin O(P) where P = number of processors, independent of cache size. Even faster than Option B at scale. - ✅ No on-disk layout change to the descriptors themselves — the sidecar sits alongside the existing directory structure.
- ✅ Bytes / output counts are maintained eagerly, so the “bytes” axis is also
free (unlike Option B, which still needs to
stateach blob for bytes). - ❌ Two sources of truth. If the sidecar and the descriptor directory
ever disagree (crash mid-write, manual
rmof a descriptor, remote-cache sync, a bug in an insert path), the UI lies. Requires either transactional atomicity across two stores (hard — redb transaction + filesystem write) or a periodic reconciliation pass. - ❌ Every cache-insert path needs to update the sidecar. Miss one, and the counts drift silently. Options B and A put the source-of-truth physically next to the cache entry, so there’s no drift to manage.
- ❌ Cache invalidation logic gets more complex: evicting a descriptor now means “delete the file AND decrement the counter AND handle the decrement failing.” More moving parts, more places for bugs.
- ❌ Doesn’t help with any future “list all entries for processor X” query — you’d still need Option B’s path layout for that, or fall back to a full walk.
Verdict: Option C is the fastest for this one specific query, but it pays for it with a consistency problem that didn’t exist before. Options A and B keep the cache self-describing — the descriptor itself (or its path) IS the fact — so they’re immune to drift.
Option comparison
| Aspect | A (field in descriptor) | B (processor in path) | C (redb sidecar) |
|---|---|---|---|
| Grouping correctness | yes | yes | yes (if kept in sync) |
| Scan cost | O(N) reads | O(P) readdirs | O(P) DB reads |
| Bytes count free | no | no (still stat blobs) | yes (pre-aggregated) |
| On-disk layout change | descriptor format | directory layout | new sidecar file |
| Source of truth | descriptor | descriptor path | two stores |
| Drift risk | none | none | real — needs reconciliation |
| Migration cost | wipe or dual-read | wipe | initial scan to populate |
| Code complexity | low | low | medium-high |
| Helps other queries | no | yes (list-by-processor) | no |
Recommendation
Option B. The extra invasiveness is one-time (migration). The speed and correctness wins are permanent; the path layout is self-describing, so no drift risk; and it also unlocks fast “list entries for processor X” queries that Options A and C don’t.
Option C is attractive if the only query we cared about was a single summary, but the sidecar’s consistency burden is real and tends to surface as bugs in edge cases (remote-cache sync, partial writes, manual cleanup).
On Option B’s “cost”
The only new artifact on disk is N extra directory entries at the top level
of descriptors/, where N is the number of distinct processors that have
ever cached anything. In practice that’s 10–30 directories. Filesystems handle
that trivially — both ext4 and btrfs are fine with thousands of top-level
entries, let alone tens.
In return we get:
stats_by_processorin O(N readdirs) instead of O(cache_size reads).- Honest “declared-but-empty” rows in
cache stats(empty dir = 0 entries, and there is no drift to reconcile). - Fast “list cache entries for processor X” — a single
readdir. - A self-describing cache:
ls .rsconstruct/cache/descriptors/tells you at a glance which processors have cached anything.
The cost is negligible; the payoff is across the board.
Implementation plan (Option B)
-
Cache insert path. Change the descriptor write to
descriptors/<processor>/<hash>.json(replacing the currentdescriptors/<hash-prefix>/<hash-suffix>/<hash>.jsonsharding). The processor name is already known at insert time — it’s on the product. -
Cache read path. Descriptor lookups happen by cache key. If the lookup caller already has the processor name, read directly. Otherwise scan the processor subdirs (rare path — most lookups come from a build graph where the processor is known).
-
stats_by_processorrewrite. Iterate subdirs ofdescriptors/; each subdir name is a processor. Count files within. For the “bytes” axis, continue to stat the corresponding blob objects. -
Migration. On startup, if old-layout descriptor files exist (files directly under sharded
ab/cd/subdirs, or anywhere that isn’t a recognized processor name), wipedescriptors/. Cache is regenerable by definition; next build repopulates under the new layout. Users pay one slower build post-upgrade, no data loss. -
cache statsUX. Once grouping is real, enumerate declared processors fromrsconstruct.tomland union them with processors present indescriptors/. Show a 0-row for anything declared-but-empty (mirrors theanalyzer statstreatment already implemented inbuilder/analyzers.rs).
Scope
Most of the work lives in src/object_store/:
management.rs—stats_by_processorrewrite.- The insert/read paths (split across
object_store.rsand neighbors) — path-construction change. - The cache-clean / trim paths — updated to walk the new layout.
Followed by a small change in src/main.rs (CacheAction::Stats) to
consume the new grouped output and render a table with the declared-union
treatment.
Estimated: a couple hundred lines, concentrated in a single module.
Secondary cleanup — graph-level helpers
Every caller that wants per-processor grouping over the current graph
currently writes the same HashMap pattern inline:
#![allow(unused)]
fn main() {
let mut per_processor: HashMap<&str, _> = HashMap::new();
for product in graph.products() {
per_processor.entry(&product.processor).or_default() += ...;
}
}
We could add BuildGraph::products_by_processor() -> &HashMap<String, Vec<ProductId>>
as a lazily-computed cached view (computed on first access, invalidated only
when the graph is mutated).
- Benefit: de-duplicates the pattern in ~5 call sites.
- Cost: caching / invalidation logic.
- Priority: low. The inline grouping is O(N) over RAM iteration and is not a performance bottleneck.
Don’t do this unless a sixth call site shows up.
Current state (2026-04-12)
analyzer stats: fixed. Shows declared-but-empty rows. Separator between data and Total.cache stats: unchanged. Still uses single-bucket"all"grouping. Documented as a known limitation here; fix is pending Option B.- Graph helpers: not added. Inline pattern remains across call sites.
See also
- Cache System — object-store layout, descriptor keys.
- Checksum Cache — mtime-based content-hash caching.
- Dependency Caching — analyzer dependency cache (which does have per-analyzer grouping built in).
Profiling
This chapter records concrete profiling runs on rsconstruct, with methodology and findings pinned to a specific version. Add new runs as new sections with date + version headers so historical data stays intact.
How to profile locally
Build a profile-friendly binary
The default release profile strips symbols, so stack traces come out as raw
addresses. Cargo.toml defines a profiling profile that inherits release
but keeps full debug info:
[profile.profiling]
inherits = "release"
strip = false
debug = true
Build with:
cargo build --profile profiling
# binary lands in target/profiling/rsconstruct
Prerequisite: relax perf_event_paranoid
Kernel sampling (perf, samply) requires kernel.perf_event_paranoid <= 1. On
a personal dev machine, persist it:
echo 'kernel.perf_event_paranoid = 1' | sudo tee /etc/sysctl.d/60-perf.conf
sudo sysctl --system
Record with perf (text-pipeline-friendly)
On CPUs without LBR (most laptops), DWARF unwinding is very slow to
post-process — don’t use --call-graph dwarf unless you’re patient. Without a
call graph you still get reliable self-time attribution:
perf record -F 999 -o /tmp/rsc.perf.data -- \
target/profiling/rsconstruct --quiet --color=never status
perf report -i /tmp/rsc.perf.data --stdio --no-children \
--sort symbol --percent-limit 0.1
Alternative: samply (Firefox-Profiler UI)
cargo install samply
samply record -r 4000 -o /tmp/rsc.json.gz -- \
target/profiling/rsconstruct --quiet --color=never status
Default behavior opens a local UI. Use --save-only to just write the file.
Hardware counters
perf stat -d -- target/profiling/rsconstruct --quiet --color=never status
Gives IPC, cache miss rates, branch miss rates — useful for “is this CPU-bound, memory-bound, or branch-mispredict-bound.”
Run: 2026-04-12 — rsconstruct 0.8.1 — status on teaching-slides
Target
- Command:
rsconstruct --quiet --color=never status - Project:
../teaching-slides(10,027 products across 10 processors). - Product breakdown: explicit (1), ipdfunite (55), markdownlint (824), marp (824), ruff (19), script.check_md (824), script.check_svg (3327), svglint (3327), tera (2), zspell (824).
Methodology
- Binary:
target/profiling/rsconstruct(release + debug info). - Sampler:
perf record -F 999(no call-graph — LBR unavailable, DWARF too slow to post-process on this host). - Counters:
perf stat -d.
Wall-clock and counters
| Metric | Value |
|---|---|
| Wall time | 1.08 s |
| User time | 0.99 s |
| System time | 0.08 s |
| CPU utilization | 98.7 % of 1 core |
| RSS peak | 28 MB |
| Instructions | 21.10 B |
| Cycles | 5.30 B |
| IPC | 3.98 (very high) |
| Frontend stall | 12.8 % |
| Branches | 5.11 B |
| Branch miss rate | 0.60 % |
| L1-dcache loads | 7.03 B |
| L1-dcache miss rate | 4.13 % |
Interpretation: high IPC, low miss rates, low branch mispredictions. The CPU pipeline is fully utilized — slowness comes from doing too many instructions, not from cache thrash or branch mispredicts.
Hot spots (self-time)
| % of CPU | Function |
|---|---|
| 48.79 % | std::path::Components::parse_next_component_back |
| 12.90 % | <std::path::Components as DoubleEndedIterator>::next_back |
| 10.84 % | rsconstruct::graph::BuildGraph::add_product_with_variant |
| 8.43 % | <std::path::Components as PartialEq>::eq |
| 1.41 % | __memcmp_evex_movbe |
| 1.04 % | core::str::converts::from_utf8 |
| 0.89 % | _int_malloc |
| 0.78 % | std::fs::DirEntry::file_type |
| 0.61 % | <std::path::Path as Hash>::hash |
| 0.60 % | <std::path::Components as Iterator>::next |
| 0.38 % | std::sys::fs::metadata |
| 0.38 % | <sip::Hasher as Hasher>::write |
| 0.37 % | sha2::sha256::x86::digest_blocks |
| 0.34 % | <core::str::lossy::Utf8Chunks as Iterator>::next |
| 0.31 % | _int_realloc |
| 0.29 % | _int_free_chunk |
| 0.19 % | rsconstruct::graph::Product::cache_key |
| 0.19 % | std::path::compare_components |
| 0.19 % | serde_json::read::SliceRead::parse_str |
| 0.19 % | statx |
| 0.19 % | malloc |
| 0.19 % | cfree |
| 0.18 % | core::hash::BuildHasher::hash_one |
| rest | scattered < 0.15 % each |
Findings
~70 % of CPU is in PathBuf iteration / comparison. Specifically
parse_next_component_back + next_back + Components::eq, all invoked
from PathBuf equality and hashing. Filesystem I/O (readdir, stat, open)
is under 2 %. Hashing (SHA-256 + SipHash) is under 1 %.
The callsite is BuildGraph::add_product_with_variant in src/graph.rs
(lines 221–307). It contains three loops whose path-equality cost
dominates the whole run:
-
Lines 232–242 — checker dedup loop. For every checker product (outputs empty), scans every existing product and compares
existing.inputs[0] == inputs[0](fullPathBufequality, which iterates components). With 7,000+ checker products in teaching-slides (script.check_md+script.check_svg+svglint+markdownlint+zspell), this is an O(P²) pass per processor over the course of discovery. -
Lines 252–253 — superset check for generator re-declarations. Includes
existing.inputs.iter().all(|i| inputs.contains(i))— an O(M²) call, again per-insertion, again comparingPathBufs component-by-component. -
Lines 246–285 — output conflict check. Fast path (HashMap lookup); not the bottleneck.
Graph mutation itself (add_product_with_variant self-time, 10.84 %) is
modest. The quadratic scans inside it are where the time goes — they just
happen to be attributed to the stdlib path-iteration functions.
Suggested fix (not yet implemented)
Index the checker-dedup and generator-superset lookups via a HashMap keyed on
(processor, primary_input, variant) so the linear scans become O(1). For
10,027 products, the expected improvement is ~3×–5× on status wall time.
Scope: additions to BuildGraph (two new HashMap index fields, kept in
sync with add_product_*), a small change to add_product_with_variant to
do HashMap lookups instead of linear scans. No cache-layout or
on-disk-format changes.
Raw data
/tmp/rsc.perf.datawas recorded and analyzed to produce the tables above. Removed afterwards — regenerate via the methodology section if needed.
Run: 2026-04-12 (later) — HEAD after HashMap dedup fix
Wall-clock and counters
| Metric | Value | vs. 0.8.1 tag |
|---|---|---|
| Wall time | 0.265 s | 4.1× faster |
| Instructions | 2.05 B | -90 % |
| Cycles | 0.88 B | -83 % |
| IPC | 2.34 | was 3.98 |
| L1-dcache miss rate | 1.34 % | was 4.13 % |
The quadratic path-equality peak is gone. What remains is the normal cost
of using PathBuf as HashMap keys.
Hot spots (self-time, user-space, 9,948 samples, 10 iterations)
| % | Function | Category |
|---|---|---|
| 5.42 | core::str::converts::from_utf8 | UTF-8 validation |
| 3.52 | sip::Hasher::write | HashMap hashing |
| 3.51 | <Path as Hash>::hash | HashMap hashing |
| 3.39 | sha2::sha256::digest_blocks | Checksumming |
| 2.09 | Components::next | Path iteration |
| 2.00 | _int_malloc | Allocator |
| 1.92 | parse_next_component_back | Path iteration |
| 1.60 | compare_components | Path comparison |
| 1.19 | combined_input_checksum | Checksumming |
| 1.12 | Product::cache_key | Cache keys |
Run: 2026-04-12 (later still) — HEAD after path interning
Context
BuildGraph’s three hot HashMaps (output_to_product, input_to_products,
checker_dedup) switched from PathBuf keys to a private PathId(u32)
backed by an in-memory PathInterner. See
Path Interning for design.
Wall-clock and counters
| Metric | Value | vs. previous |
|---|---|---|
| Wall time | 0.245 s | -8 % |
| Instructions | 2.04 B | ~flat |
| Cycles | 0.91 B | ~flat |
Hot spots (self-time, user-space, 9,925 samples, 10 iterations)
| % | Function | Notes |
|---|---|---|
| 4.34 | core::str::converts::from_utf8 | unchanged |
| 3.21 | sha2::sha256::digest_blocks | unchanged |
| 2.60 | Components::next | unchanged |
| 2.39 | sip::Hasher::write | down from 3.52 % |
| 2.25 | <Path as Hash>::hash | down from 3.51 % |
| 2.06 | _int_malloc | unchanged |
| 1.52 | resolve_dependencies | new — attribution shift |
| 1.19 | compare_components | down from 1.60 % |
| 1.09 | combined_input_checksum | unchanged |
Interning paid off exactly where predicted — the hashing/compare columns
dropped, and resolve_dependencies appears because its inner loop is now
small enough to self-attribute rather than vanish inside the stdlib path
functions. The total gain is modest (~8 %) because after the HashMap dedup
fix, HashMap key cost was only ~7 % of total, and interning cuts that in
half.
Candidate next targets (not yet implemented)
- UTF-8 validation (~6 %) — from
display().to_string()in cache-key building. Cache the string form per product or build keys from raw bytes. Product::cache_key+ hex encoding (~2 % combined) — precompute and memoize per product.- SHA-256 (~3 %) — already hardware-accelerated; the only lever is
fewer calls, via memoized
input_checksumor better batch reuse.
See also
- Path Interning — the optimization applied in the most recent run.
- Per-Processor Statistics — the previous perf
discussion; describes why
cache statsis slow (O(N descriptor reads)). That’s independent of this graph-construction finding. - Architecture — overview of the graph and how products are added.
- Fast
staton Linux — background on batchingstatxvia io_uring; relevant if the per-inputfs::metadatacall ever climbs out of the noise (currently ~0.19% of CPU).
Fast, Scalable stat on Linux
Background notes on batching stat-style metadata calls. Relevant to
rsconstruct because combined_input_checksum (see Checksum
Cache) calls fs::metadata once per input file to read
the mtime before consulting the persistent cache. For projects with tens of
thousands of products, that’s tens of thousands of serial statx syscalls
per build. This doc records what’s available on Linux, when it actually
matters, and what a Rust implementation would look like — so the option is
on file when profiling shows the stat path becoming the bottleneck.
Is there a stat(2) syscall that gives info about lots of files?
No. There is no batch stat(2) on Linux. Each call covers exactly one
path. The closest options:
statx(2)(Linux 4.11+): richer info thanstat, still one file per call.getdents(2)/readdir(3): returns directory entries in bulk. On most filesystems each entry includesd_type(file/dir/symlink), so you can classify without astatcall. But you don’t get size, mtime, or permissions — for those you still need per-filestat.fstatat(2)with a dirfd: avoids re-resolving the parent path repeatedly when scanning one directory tree. Still one syscall per file.- io_uring (Linux 5.6+): submit many
statxops in one syscall and reap them together. Not a “batch stat” in the API sense, but in practice it gives the throughput win — fewer syscall round-trips, async completion. This is what you want for “stat 100k files fast.” - BSD
getattrlistbulk(2)(macOS/FreeBSD): genuinely returns stat-like info for many entries in one call. Linux has no equivalent.
For “I need stat data for many files on Linux”, the standard answer is
getdents for what you can get cheaply, then io_uring with
IORING_OP_STATX for the rest.
What io_uring actually gives you
You submit N statx operations to a ring buffer, the kernel processes
them (often in parallel), and you reap N results. From userspace it can be
just 2 syscalls total regardless of N — one io_uring_enter to
submit/wait, one to drain completions. With SQPOLL mode it can be
zero syscalls in steady state.
It is not a single stat_many(paths[], results[]) call. The kernel still
does one statx’s worth of work per file internally — it just doesn’t
make you pay the syscall-boundary cost for each one.
Empirical speedups:
- Cold cache (real disk I/O): big win — kernel parallelizes the lookups,
~5–10× over serial
statx. - Warm cache: smaller win (~2×), since each
statxwas already cheap; you are mostly saving syscall overhead. - Submission queue depth matters — keep it deep (256–4096) to let the kernel reorder.
For 1M files: serial statx is ~1M syscalls. With io_uring at ring depth
4096, it’s ~244 io_uring_enter calls, or zero with SQPOLL. The
syscall-count saving is real but in most workloads parallelism is the
bigger deal — they happen to come from the same mechanism.
When it actually matters
| Workload | Serial cost (5,000 files) | io_uring win |
|---|---|---|
| Warm cache (dentries/inodes in memory) | 2–5 ms | 1.5–2× |
| Cold cache, SSD | 250 ms – 1 s | 5–10× |
| Cold cache, HDD | 5–10 s | ~10× |
| Network filesystem (NFS, SMB) | 5 s+ (RTT-bound) | 5–10× |
Warm cache: each statx is ~0.5–1µs. The absolute time is small enough
that the code complexity probably is not justified.
Cold cache or network filesystem: io_uring is genuinely worth it.
Simpler alternatives to try first
- Threaded parallel
metadata()—rayon::par_iter().map(fs::metadata)with ~16 threads. Far less code than io_uring, competitive on warm cache. On cold cache it is worse than io_uring (threads still block one syscall at a time) but still 5–10× faster than serial. getdents+d_type— if all you need is “is it a file/dir/symlink” and not size/mtime, scan a directory and get type info essentially for free. This is already whatignore::WalkBuilderdoes insrc/file_index.rs.fstatatwith a dirfd — if statting many files in the same directory, opening the dir once and usingfstatatshaves path-resolution cost. ~1.5× over plainstat.
Rust options
Three realistic choices:
tokio-uring
Easiest if already using Tokio. As of writing, tokio-uring does not
expose statx as a first-class op in all versions — may need to drop to
raw ops.
rio
Simple, blocking-style API over io_uring. Less maintained but works.
io-uring crate (direct binding)
Most control, most code:
#![allow(unused)]
fn main() {
use io_uring::{IoUring, opcode, types};
let mut ring = IoUring::new(4096)?;
let mut results: Vec<libc::statx> = vec![unsafe { std::mem::zeroed() }; paths.len()];
for (i, path) in paths.iter().enumerate() {
let sqe = opcode::Statx::new(
types::Fd(libc::AT_FDCWD),
path.as_ptr(),
&mut results[i] as *mut _ as *mut _,
)
.mask(libc::STATX_BASIC_STATS)
.build()
.user_data(i as u64);
unsafe { ring.submission().push(&sqe)?; }
}
ring.submit_and_wait(paths.len())?;
for cqe in ring.completion() {
let idx = cqe.user_data() as usize;
if cqe.result() < 0 {
// -errno
}
// results[idx] is filled
}
}
For 1M files, loop: submit ~4096, wait, drain, repeat. Don’t try to push all 1M into the ring at once — the SQ has finite size.
Pitfalls in Rust
- Path lifetimes: the kernel reads the path string asynchronously. The
&CStrpassed must outlive the operation. Withtokio-uringthis is handled by ownership transfer; with rawio-uringyou must keep buffers alive until the CQE arrives. Easiest pattern: aVec<CString>that lives for the whole batch. statxbuffer: same — must be valid until completion. Pre-allocateVec<statx>of the right size.- Runtime setup:
tokio-uringrequires explicittokio_uring::start; it is not a drop-in replacement fortokio::main. - Linux vs macOS: io_uring is Linux-only, and macOS is a supported
target, so this is the one place a real platform split would be needed.
Any usage must live behind
src/platform.rs, which owns every OS call (see Coding Standards). The macOS fallback would be parallelfs::metadata.
Relevance to rsconstruct
Today, combined_input_checksum in src/checksum.rs calls fs::metadata
serially per input file to read mtime. The latest profiling run (see
Profiling) shows statx at ~0.19% of CPU on a 10k-product
project — not currently a bottleneck.
This becomes interesting when:
- A project has 100k+ products, or
- Builds run on cold cache (CI without warm dentries), or
- Builds run against a network filesystem.
Recommended sequence if this ever becomes the hot path:
- Confirm via profiling that
statx/fs::metadataactually dominates (not graph construction, not hashing, not subprocess spawn). - Try
rayon::par_iter+fs::metadatafirst — minimal code, big enough win for most cases. - Only if step 2 is insufficient and the workload is cold-cache or
network-FS bound, reach for
io-uringbehindplatform.rs.
Code-scan: how easy would the integration be today?
A scan of every fs::metadata / mtime call site in src/ (recorded here
so future work doesn’t have to redo it) showed the following:
Where mtime is read
Hot path — would benefit from batching:
combined_input_checksum(ctx, &product.inputs)insrc/checksum.rs— onefs::metadataper input viafast_checksum. Called from four sites, all per-product:src/builder/build.rs(status pass —for product in products)src/executor/mod.rs(planning pass —for &id in order)src/executor/execution.rs(execution pass —for &id in level)src/builder/product.rs(single-product diagnostics)
checksum_fast(ctx, source)insrc/deps_cache.rs— one stat per (analyzer, source) pair from insideget/classify/set.
Incidental — not worth batching:
src/object_store/{management,descriptors,blobs,operations}.rs— single-file metadata reads (permission bits, sizes for cache stats). Low volume, no batching opportunity.src/platform.rs— single permission read.
Why a swap is harder than it looks
-
Call sites are per-product, not per-batch. Every hot caller has the shape
for product in products { combined_input_checksum(ctx, &product.inputs) }. The function only ever sees one product’s 1–10 inputs. To get io_uring’s win you need to feed it hundreds-to-thousands of paths at once, which means inverting the loop: collect every input across every product first, batch-stat them, then do the per-product classification using the pre-populated cache. That’s a structural refactor of the status / planning / execution loops, not a one-line change inchecksum.rs. -
The mtime cache is read mid-
fast_checksum. Today it does: stat → read mtime DB → compare → maybe read file → maybe write DB. Batching the stat means splitting this into phases: (1) batched stat for all paths, (2) batched mtime DB read, (3) per-path decide-and-rehash. Doable, but it changes the function’s contract (no longer “pure per-path”) and shifts the access patterns of the in-memorychecksum_cacheandmtime_db. -
Result-shape change ripples into stats.
ChecksumPath::MtimeShortcutvsFullReadis reported per call and feeds deps cache stats insrc/builder/mod.rs. A batch API needs to return a parallelVec<ChecksumPath>and callers need to attribute results back to products. -
Linux/macOS constraint. Per
CLAUDE.md, every OS call lives insrc/platform.rs. The io_uring bits would be a platform shim — a thin wrapper likepub fn batch_mtimes(paths: &[&Path]) -> Vec<io::Result<(i64, u32)>>whose only job is to expose one OS-agnostic API to callers, with a Linux impl using theio-uringcrate and a non-Linux fallback (serialfs::metadataor rayon). Callers stay clean; the#[cfg]lives in exactly one place. This part of the refactor is mechanical — the abstraction boundary already exists (seeplatform::set_permissions_modefor the existing pattern). -
Deps cache has no batch to give it.
deps_cache::getis called one (analyzer, source) at a time from inside the analyzer scan loop. Batching it would require pre-collecting all sources for an analyzer before scanning — another structural change. Probably leave this on the serial path for v1.
Sketch of a realistic refactor
- Add
platform::batch_mtimes(paths: &[&Path]) -> Vec<io::Result<(i64, u32)>>(Linux: io_uring; else: serial or rayon). - Add
checksum::warm_mtime_cache(ctx, paths: &[PathBuf])that callsbatch_mtimes, reads the mtime DB once for all paths, and pre-populatesctx.checksum_cachefor any path whose mtime matches the cached entry. - Modify the three hot loops (
builder/build.rs,executor/mod.rs,executor/execution.rs) to callwarm_mtime_cacheonce with the union of allproduct.inputsbefore entering the per-product loop. The existingcombined_input_checksumthen mostly hits the in-memory cache and skips the per-file stat entirely. - Leave deps cache on the serial path for v1.
Effort estimate: medium. Roughly 200–400 lines, the bulk of which
is the loop inversion in the three hot files plus the platform shim.
The actual io_uring code is small (~50 lines). Existing
combined_input_checksum tests would mostly still apply since the
per-product API can stay unchanged.
Verdict
Not worth doing today. Profiling shows statx at ~0.19 % of CPU on the
10k-product teaching-slides project; this refactor would buy almost
nothing on warm cache, which is the common case for incremental builds.
It only pays off on cold-cache CI, network filesystems, or 100k+
products. If we ever hit one of those, start with rayon::par_iter —
it would deliver most of the win for ~50 lines and no platform shim.
See also
- Checksum Cache — describes the mtime-stat path that would benefit from this.
- Profiling — current stat cost measurements.
- Suggestions — tracking entry for this optimization.
Path Interning
Interning is a data-structure optimization that replaces PathBuf HashMap
keys with small integer IDs. It exists to cut the cost of hashing, comparing,
and cloning paths during graph construction.
Motivation
The Profiling run on teaching-slides (10,027 products)
pointed at three quadratic scans inside BuildGraph::add_product_with_variant.
Replacing those scans with HashMap<PathBuf, _> indexes took status from
1.08 s to 0.26 s.
The remaining 0.26 s is dominated, by category:
| Category | % of CPU |
|---|---|
Path iteration (Components) | ~10 % |
| HashMap hashing (SipHash + Path) | ~7 % |
| Allocator churn (malloc/free) | ~6 % |
| UTF-8 validation/decoding | ~7 % |
| Checksumming (SHA-256 + keys) | ~6 % |
A lot of that is the cost of using PathBuf as a HashMap key. Every insert
and lookup does:
- Hash the path — walks every component, hashes each byte. O(path length).
- On collision, compare paths — walks both paths component-by-component.
- Clone the path to store as key —
PathBufallocation + copy.
With ~10,000 products participating in multiple maps (output_to_product,
input_to_products, checker_dedup), this work dominates what remains.
The idea
Assign each unique path a u32 ID once, then use the ID everywhere the path
is used as a HashMap key or for comparison. Hashing a u32 is one
instruction. Comparing two u32s is one instruction. No allocation.
#![allow(unused)]
fn main() {
#[derive(Copy, Clone, Eq, PartialEq, Hash)]
pub struct PathId(u32);
pub struct PathInterner {
to_id: HashMap<PathBuf, u32>, // used during insertion
from_id: Vec<Arc<PathBuf>>, // id -> path (for display / FS ops)
}
impl PathInterner {
pub fn intern(&mut self, p: &Path) -> PathId { /* ... */ }
pub fn get(&self, id: PathId) -> &Path { /* ... */ }
}
}
Every hot HashMap that currently keys on PathBuf switches to PathId.
In-memory only
Interned IDs are per-process. They are assigned fresh at the start of
every rsconstruct invocation and dropped when the process exits. They
never touch disk.
| Data | Lives in | IDs used? |
|---|---|---|
BuildGraph HashMaps | RAM, this process | Yes |
| On-disk cache (redb descriptors, etc.) | Disk, persistent | No |
| Config files, discovered files | Disk | No |
The path foo/bar.md might be PathId(42) today and PathId(17) tomorrow.
That is fine because nothing persistent ever referred to 42.
The boundary rule: PathId must not leak into anything persistent.
Specifically:
- Cache keys on disk (
Product::cache_key,descriptor_key) must keep using real paths or content checksums. - Logs and error messages must print real paths, not IDs.
- Nothing serializes the interner state.
Why it helps here
- Paths are reused heavily. One
.mdfile feedsmarkdownlint,zspell,script.check_md,marp. Interning collapses four HashMap key clones into one. - The same path appears as a lookup key in every dedup map during graph
construction. Each lookup becomes
hash(u32) + compare(u32)instead of walking a path’s components. - Product inputs/outputs can still be stored as
PathBufpublicly — the optimization targets the HashMap keys, not the product data itself. This keeps the refactor’s blast radius small.
Scope of the change
Narrow scope — only the three hot HashMaps in BuildGraph:
output_to_product: HashMap<PathBuf, usize>→HashMap<PathId, usize>input_to_products: HashMap<PathBuf, Vec<usize>>→HashMap<PathId, Vec<usize>>checker_dedup: HashMap<(String, PathBuf, Option<String>), usize>→HashMap<(String, PathId, Option<String>), usize>
The interner lives on BuildGraph. Callers still pass PathBuf/&Path to
add_product* — the interner is a private implementation detail. Public
access to Product.inputs/outputs/output_dirs remains unchanged.
Non-goals
- No on-disk format change. Cache entries keep using real paths.
- No API change to
Product. Inputs and outputs stay asVec<PathBuf>. - No plugin-facing change. Lua processors keep seeing paths.
Risks
- The interner’s own
to_idmap still hashes aPathBufonce per unique path. Unavoidable — this is the cost of asking “have I seen this path before?” - Every call site that hashes a
&Pathinto aBuildGraphmap now callsinterner.intern()orinterner.get_id(). Must be careful not to callintern()(mutating) on read-only paths, or lookups may create spurious entries.
See also
- Profiling — the measurement that motivated this.
- Architecture — how
BuildGraphfits into the overall design.
Unreferenced Files
Purpose
Find files on disk that are not referenced by any product in the build graph. This helps identify forgotten assets, stale files, or files accidentally excluded from the build configuration.
How It Works
When rsconstruct builds its graph, every product has an inputs list. This list
contains all files the product depends on:
- Primary inputs — the source files being processed (e.g.
foo.svgthat mermaid converts to a PNG) - Dependency inputs — files that affect the output but are not the primary
source (e.g. a C header file
utils.hthatmain.cincludes, a config file like.ruff.toml, or a script passed viadep_inputs)
A file is unreferenced if it does not appear in the inputs list of any
product in the graph — neither as a primary input nor as a dependency input.
Why both primary and dependency inputs?
Consider a C header file utils.h. It is not a primary input (the compiler does
not produce output directly from it), but it appears in dep_inputs because
changes to it must trigger a rebuild of any .c file that includes it. Such a
file is clearly referenced and should not be reported as unreferenced.
Only files that appear in no product’s inputs list — not primary, not dependency — are reported.
Usage
rsconstruct graph unreferenced --extensions .svg[,.png,...] [--rm]
Options
| Option | Description |
|---|---|
--extensions | Comma-separated list of file extensions to check (required) |
--rm | Delete the unreferenced files immediately (no confirmation) |
Examples
Find unreferenced SVG files:
rsconstruct graph unreferenced --extensions .svg
Find unreferenced images of any type:
rsconstruct graph unreferenced --extensions .svg,.png,.jpg
Delete unreferenced SVG files:
rsconstruct graph unreferenced --extensions .svg --rm
Output
Plain list of file paths, one per line, relative to the project root:
assets/old_diagram.svg
docs/unused_figure.svg
scratch/test.svg
Design Notes
- Extensions are required — defaulting to all files would produce excessive noise (READMEs, Makefiles, config files, etc. are intentionally not in the graph).
- Finding unreferenced files does not mean they are useless. The user decides
what to do. Common reasons a file might be unreferenced:
- It was part of a processor whose
src_dirsorsrc_extensionsexcludes it - It was intentionally left out of the build
- It is a leftover from a renamed or deleted processor instance
- It is a scratch/draft file
- It was part of a processor whose
--rmdeletes without confirmation. Use with care.- The command requires a
rsconstruct.toml(the graph must be buildable).
Distributed Execution
This document explores what distributed execution would mean for RSConstruct — the problems it solves, the problems it creates, how other build tools approach it, and what a design might look like.
What distributed execution means
Today RSConstruct runs all products on the local machine, optionally in parallel
across multiple cores (-j). Distributed execution means offloading individual
products to remote workers — other machines on a network — so that the build
exploits more CPU than any single machine has.
This is distinct from remote caching (which RSConstruct already has). Remote caching avoids re-running a product whose result was already computed by someone else. Distributed execution runs products remotely even when no cached result exists. The two features compose: a distributed build that also has remote caching can share results across runs and across users.
The problems it solves
- Slow builds on large codebases. When thousands of C files need checking
or hundreds of PDFs need rendering, a single machine is the bottleneck even
with
-j. A cluster of workers can run all of them truly in parallel. - CI latency. CI machines are often single-core or have limited parallelism. Distributing work across a pool of CI agents cuts wall-clock time.
- Memory pressure. Some tools (Chromium, LibreOffice, heavy linters) are memory-hungry. Spreading them across machines avoids OOM conditions.
The problems it creates
Input availability
Every product needs its inputs on the worker. For a checker that reads a single
source file, this means uploading that file to the worker (or having it available
via a shared filesystem). For a generator with many dep_inputs, it may mean
uploading dozens of files. This is a non-trivial data transfer problem.
The content-addressed object store already solves this at the output side — outputs are stored by SHA-256. The same mechanism can serve inputs: if the worker has a local object store, the coordinator only needs to send checksums, and the worker fetches missing objects from the remote cache. Products whose inputs are already cached require zero transfer.
Output collection
After execution, the worker’s outputs must be pushed back to the coordinator (or directly to the remote cache) so local build phases and downstream products can use them. This is essentially the existing remote cache push path.
Hermeticity
Distributed workers only produce correct results if builds are hermetic — the
product’s output depends only on its declared inputs, not on ambient machine
state (installed tools, environment variables, filesystem layout). RSConstruct
does not enforce hermeticity today. A worker with a different version of ruff
or cppcheck than the local machine will produce different results.
This is the hardest problem. Options:
- Ignore it — document that workers must have identical tool versions;
use tool locking (
rsconstruct tool lock) to detect divergence. - Containers — run each product in a container image that includes all required tools. Bazel and BuildBuddy do this. Heavy but correct.
- Nix/flakes — pin tools via Nix derivations on all workers. Correct but requires Nix infrastructure.
Scheduling and load balancing
Which products go to which worker? A central coordinator must:
- Know the graph (dependency order).
- Dispatch products whose dependencies are already satisfied.
- Avoid overloading any single worker.
- Handle worker failure (retry on another worker).
This is a distributed systems problem. Even a simple greedy scheduler requires a reliable heartbeat, a work queue, and failure detection.
Latency overhead
For fast products (a Python lint check on a 50-line file takes ~50ms), the overhead of serializing inputs, sending them over the network, waiting for the worker, and receiving results can exceed the actual execution time. Distributed execution only pays off for products that take seconds or more, or when there are so many products that local parallelism is saturated.
How other tools do it
Bazel (Remote Execution API)
Bazel defines the Remote Execution API
(REAPI), a gRPC protocol for distributed execution. Workers implement the
Execution service; the coordinator submits Action objects (a command +
input digest tree). Workers fetch inputs from a Content Addressable Storage
(CAS) service, execute the action, and push outputs back to CAS.
Strengths: hermetic by design (actions are pure functions of their inputs),
well-specified protocol, many implementations (BuildBuddy, EngFlow, NativeLink,
self-hosted buildfarm).
Weaknesses: requires all actions to be declared with precise input sets; dynamic dependencies (header includes discovered at compile time) need special handling; heavy infrastructure to stand up.
RSConstruct’s object store is conceptually similar to CAS. The Product struct
already declares all inputs explicitly. Implementing REAPI would make
RSConstruct compatible with the existing Bazel remote execution ecosystem
without building a proprietary scheduler.
distcc
distcc distributes C/C++ compilation by intercepting gcc/clang invocations
and forwarding the preprocessed source to a pool of workers. It works at the
invocation level, not the build graph level — the local machine still runs the
build tool (make/ninja) and distcc is transparent to it.
Strengths: simple, no build tool integration required, widely deployed.
Weaknesses: only works for compilation (not linters, generators, etc.); requires preprocessing locally (partial hermeticity); no caching.
Incredibuild / Xtensa
Commercial tools that intercept process spawning at the OS level (Windows job
objects, Linux LD_PRELOAD) to virtualize and distribute arbitrary commands.
No build tool integration required; any tool that runs a subprocess can be
distributed.
Strengths: transparent to the build tool; works with any compiler or tool.
Weaknesses: proprietary; expensive; the OS-level interception is fragile.
Pants / Buck2
Both use a daemon-based architecture with a local scheduler that knows the full build graph. Distributed execution is an extension of local execution — the scheduler dispatches actions to remote workers using REAPI or a proprietary protocol. Input digests and output digests flow through a central CAS.
Pants calls this “remote execution”; Buck2 calls it “remote actions”. Both require the build rules to declare all inputs precisely (no dynamic deps).
Ninja + a distributed wrapper
Some teams wrap Ninja with distributed backends (ninja-build + icecc,
ninja + sccache, or ninja + a custom scheduler). The wrapper intercepts
compiler invocations from the Ninja process. This is similar to the distcc
approach but can handle caching (sccache) alongside distribution.
A possible design for RSConstruct
A minimal distributed execution design that fits RSConstruct’s architecture:
1. Worker protocol
Workers expose a simple HTTP API:
POST /execute
body: { product_id, command, args, input_checksums: {path: sha256, ...} }
response: { exit_code, stdout, stderr, output_checksums: {path: sha256, ...} }
Before executing, the worker fetches any inputs it doesn’t already have from the shared remote cache. After executing, it pushes outputs to the remote cache and returns their checksums.
2. Input availability via shared cache
The coordinator (local RSConstruct) ensures all inputs are in the remote cache before dispatching a product to a worker. For source files, this means uploading them once at build start. For intermediate outputs (products that are inputs to other products), they flow through the cache automatically — the producer pushes to remote, the consumer fetches from remote.
This avoids a separate “input upload” step for most products: source files are small and stable; once uploaded they stay cached across builds.
3. Coordinator changes
The executor’s product dispatch loop currently runs products locally. With distributed execution:
- Each dispatchable product is classified as local or remote based on a configurable predicate (e.g., processor type, estimated duration, worker availability).
- Remote products are submitted to a work queue.
- A pool of worker connections consumes the queue, tracking in-flight products.
- When a remote product completes, its outputs are pulled from cache and the downstream products are unblocked.
The dependency graph and topological sort are unchanged — distribution is purely an execution-layer concern.
4. Hermeticity via tool locking
Without containers, workers must have the same tool versions as the local
machine. rsconstruct tool lock already records tool version hashes.
Distributed execution should verify that each worker’s tool hashes match the
lock file before accepting products of that type. A worker with a mismatched
ruff version refuses ruff products and logs a warning.
5. What stays local
Some products cannot or should not be distributed:
- Always-rebuild products (e.g., timestamp generators) — would need a per-processor opt-out from caching, which does not exist today.
- Products that depend on the local filesystem state beyond declared inputs
(e.g.,
git logstyle operations). - Creators that manage local directories (
npm install,cargo build) — their outputs are directory trees, not files, and their side effects are local. - Products faster than the round-trip overhead (most lint checks on small files).
A distributed = false config field (analogous to enabled) would let users
pin specific processors to local execution.
Current status
Not implemented. RSConstruct runs all products locally. Remote caching (push/pull of outputs) is the only cross-machine feature today.
The design above is a sketch for future consideration. The most natural first step would be implementing a minimal REAPI-compatible worker, since that would make RSConstruct interoperable with existing distributed build infrastructure (BuildBuddy, EngFlow, self-hosted buildfarm) without requiring RSConstruct- specific worker deployments.
Internal Processors
Processors that can be reimplemented in pure Rust, eliminating external tool dependencies. Internal processors are faster (no subprocess overhead), require no installation, and work on any platform with rsconstruct.
The naming convention is to prefix with i (for internal), e.g., ipdfunite replaces pdfunite.
Both the original and internal variants coexist — users choose which to use.
An internal processor that ports a whole tool keeps the port in its own
module under src/engines/, named after the tool it reproduces
(src/engines/hadolint/, src/engines/xmllint/, …); the processor file
in src/processor/ is only the front end that drives it.
Implemented
ipdfunite
Replaces: pdfunite (external pdfunite binary from poppler-utils)
Merges PDFs from subdirectories into course bundles using lopdf in-process.
Same config as pdfunite minus the pdfunite_bin field. Batch-capable.
Crate: lopdf
Candidates
ijq / ijsonlint — JSON validation
Replaces: jq (checks JSON parses) and jsonlint (Python JSON linter)
Both tools ultimately just validate that files are well-formed JSON.
serde_json is already a dependency — parse each file and report errors.
Crate: serde_json (already in deps)
Complexity: Low — parse file, report error with line/column
iyq — YAML checked with a jq filter (done)
Replaces: yq FILTER file (the jq wrapper; the fleet’s yq stanzas ran yq .)
src/processor/checker/iyq.rs reads every document of the file with
serde_norway (a leading byte order mark skipped, as PyYAML does), turns
each into JSON and evaluates the configured filter over it with jaq
(jaq-core, jaq-std, jaq-json); a YAML error or a filter error fails the
file, outputs are discarded. Checked against yq over the fleet’s 1015 YAML
files under two filters: the same pass/fail verdicts.
Crate: jaq-core, jaq-std, jaq-json, serde_norway
Complexity: Low — the engines are the crates; the work is yq’s document
handling
iprotobuf — Protocol Buffers to Rust (done)
Replaces: the protobuf generator (protoc --cpp_out), for Rust output
src/processor/generator/iprotobuf.rs compiles a stanza’s .proto files
with protox (imports against include_paths or the src_dirs; the
well-known types built in) and generates Rust with prost-build, one
<package>.rs per package into the output directory, so the stanza is one
product whose outputs are known from the files’ package statements. The
descriptor set can be written too. protox’s descriptor set was compared
with protoc 3.21’s over proto2/proto3 files covering the syntax (nested and
map fields, oneofs, reserved ranges, groups, extensions, defaults, options,
streaming services, imports): byte-identical without source info.
Crate: protox, prost-build, prost-types, prost
Complexity: Low — the compiler is the crate; the work is the per-package
output contract
iyamllint — YAML linting (done)
Replaces: yamllint (Python YAML linter)
A full port of yamllint lives in src/engines/yamllint/: its config format, its 23
rules and its directives, verified identical to yamllint 1.38.0 over the
fleet’s YAML files. Tokens come from libyaml-safer, a safe port of libyaml,
whose scanner matches PyYAML’s token for token (both are Kirill Simonov’s
design), which is what lets the rules be ported line for line.
Crate: libyaml-safer
Complexity: Medium — the rules are mechanical ports; the token-stream
details (marks, comment extraction, implicit block sequences) are where
fidelity is won or lost
ixmllint — XML well-formedness and XSD validation (done)
Replaces: xmllint --noout [--schema x.xsd] (libxml2)
src/engines/xmllint/ tokenizes with xmlparser (roxmltree’s tokenizer) and implements
above it what xmllint checks: tag matching, duplicate attributes, entity and
character references against the internal DTD subset, namespace
declarations and prefixes, document shape; then an XSD validator for the
schema constructs in use (sequence/choice/all content models with
occurrence bounds, mixed and simple content, attributes, facets, lists,
unions, extension, include). Messages, lines and verdicts are libxml2’s,
including its quirks: a duplicate attribute is reported where the start tag
ends, “premature end” only when nothing else failed, a relative namespace
URI is warned about only on the default namespace. Checked against xmllint
over the fleet’s 6256 XML/SVG files plus hand-made edge cases: same
verdicts, same lines. A schema using an unsupported construct is a build
failure naming it, never a partial check.
Crate: xmlparser
Complexity: Medium — the tokenizer gives the lexical level for free; the
libxml2 behaviours above are what the differential run against xmllint
pinned down
iactionlint — GitHub Actions workflow linting (done)
Replaces: actionlint (Go), except its shellcheck and pyflakes rules,
which run external tools over run: scripts.
src/engines/actionlint/ is a file-by-file port of actionlint v1.7.12: the YAML
tree with go-yaml’s tag resolution and alias handling, the workflow parser
with its syntax-check messages, the ${{ }} lexer, parser, type system
and semantics checker (including untrusted-input tracking), and the fifteen
rules. The popular-actions data set actionlint ships is embedded as JSON,
generated from popular_actions.go by
scripts/gen-actionlint-popular-actions.py. Verified against actionlint’s
own test corpus (testdata/err, examples, ok, projects): all 740
reported problems identical in position and wording; the fleet’s 343
workflow files are clean under both tools.
Crate: libyaml-safer (events with marks, like go-yaml’s), regex,
serde_json
Complexity: High — the expression type checker and the position rules
(go-yaml’s node positions, text/scanner columns inside expressions) are
where fidelity is won or lost
icpplint — C/C++ style linting (done)
Replaces: cpplint (cpplint 2.0.2, Python)
src/engines/cpplint/ is a function-by-function port of cpplint: its four line
views (CleansedLines: raw, raw strings blanked, comments removed, strings
collapsed), the cross-line brace and template matchers (CloseExpression
and its reverse), the nesting stack (namespaces, classes, extern "C",
constructors and member-initializer lists, with the #if/#else
checkpoints), the include-order and function-length state, NOLINT
suppressions in all four forms, the --filter semantics (defaults, flag,
CPPLINT.cfg chain in cpplint’s priority order, cat:file:line
selectors), header-guard derivation (FileInfo.RepositoryName with its
.git/.hg/.svn search, --root, --repository) and every check, with
cpplint’s regular expressions kept verbatim (fancy-regex, for the
lookarounds and backreferences) behind helpers that give re.match and
re.search their Python shapes. Verified against cpplint over its own
sample corpus (22 files under 12 option sets), the 1245 snippets its unit
tests lint, demos-os-linux’s 1231 files with and without the repo’s
.cpplint, and 3000 mutants: identical findings. Not reproduced, on
purpose: the “Unexpected \r” warning (dead in cpplint, which reads in
Python text mode) and the leak of one directory’s linelength/root/
headers/extensions into later files of the same run.
Crate: fancy-regex (cpplint’s patterns as they are), unicode-width,
unicode-normalization (GetLineWidth)
Complexity: High — no parser to port, but 7.9k lines of state-carrying
regex code where every [i-1] and [:5] is a decision
idockerfile — Dockerfile linting (done)
Replaces: hadolint (hadolint 2.15.1, Haskell), except its ShellCheck
findings (SC codes), which come from a separate program.
src/engines/hadolint/ ports hadolint’s own half: the Dockerfile parser
(language-docker 16.0.0’s grammar, combinator by combinator: escaped line
breaks, heredocs, --mount arguments, exec-form arrays, pragmas), the view
of RUN scripts the rules query (commands, arguments, flags, pipes; a shell
reader written for those queries, with the quirks of hadolint’s walk over
ShellCheck’s tree copied: every command is yielded twice, an unparsable
script yields nothing), the # hadolint ignore= / stage ignore= / global ignore= pragmas, .hadolint.yaml (ignored, override, trustedRegistries,
label-schema, strict-labels, failure-threshold), all 71 DL rules with
their messages, and hadolint’s exit rule (fail at or above the threshold).
Verified against hadolint over the fleet’s 154 Dockerfiles with and without
the fleet’s config and 2000 mutants: identical DL findings. Known
divergence: hadolint 2.15.1’s parser rejects a heredoc followed directly by
the next instruction (fixed upstream); idockerfile accepts it.
Crate: serde_norway (the configuration file)
Complexity: High — 71 rules is the easy part; the shell view and the
parser’s whitespace rules are where fidelity is won or lost
itidy — HTML checking (done)
Replaces: tidy -errors -q [-config x] [--option value] (HTML Tidy 5.8.0, C)
src/engines/tidy/ is a port of tidy’s checking half: the UTF-8 stream reader
(streamio.c, with its tab expansion and 64-entry column ring for pushed-back
characters), the lexer (lexer.c: tokens, entities, attributes, doctype
declarations, the inline stack), the element parsers (parser.c, one routine
per content model), the attribute and element checks (attrs.c, tags.c), the
default clean-up passes (clean.c: style elements, nested emphasis, the meta
charset, the doctype, HTML5-removed and proprietary elements) and the
message table (message.c, language_en.h). The tag, attribute and entity
tables are generated from tidy’s sources by scripts/gen-itidy-tables.py.
The pretty-printer is not ported. Every tidy option is parsed and validated;
options that only shape the output are ignored and the few whose effect is
not reproduced (clean, gdoc, bare, word-2000, logical-emphasis,
input-xml, accessibility-check, mute, non-UTF-8 encodings) are
refused. Verified against tidy over the fleet’s 599 HTML files and 6000
mutated copies, under the fleet’s option sets: identical report lines in
the same order. The quirks that had to be copied: an empty attribute value
is treated as absent (tmbstrndup returns NULL for length 0), an ESC
character does not count as a column, and whether an emptied text node was
blank is decided from a stale byte past its end.
Crate: none (serde_json for the generated tables) Complexity: High — tidy’s parser repairs as it reads, so every report’s position depends on the exact token stream; the differential run is the only way to know the port is right
isvglint — SVG linting (done)
Replaces: svglint (svglint 4.2.1, Node), except its custom rules,
which are JavaScript functions.
src/engines/svglint/ ports the three built-in rules and what they run on: the
lenient parser (htmlparser2 3.10.1 in XML mode without entity decoding,
with its chained node start positions, over UTF-16 code units so columns
agree), a CSS selector engine with css-select’s XML-mode semantics (the
seven attribute operators, four combinators, :not/:is/:has, the
-child/-of-type families, :empty, :contains), fast-xml-parser
5.10.1’s validator for valid, and the elm and attr rules with their
messages. Rules come from TOML (valid, an elm table, attr tables)
instead of .svglintrc.js; selectors or rule values svglint would only
warn about are configuration errors. Verified against svglint over the
fleet’s 5776 SVG files and 3000 mutants under the default rules and two
rule sets covering every selector and attribute feature: identical
findings. Positions are printed 1-based (svglint prints them 0-based and
miscounts after a leading newline).
Crate: regex (for { regex = ... } attribute values)
Complexity: Medium — three small libraries to port faithfully rather
than one large one; the lenient parser’s position chaining and the
validator’s blind spots (self-closing roots, declarations after a tag) are
what the differential run pinned down
itaplo — TOML validation
Replaces: taplo (TOML formatter/linter)
Validate that TOML files parse correctly. The toml crate is already a dependency.
taplo also reformats — a pure validation-only internal processor covers the common case.
Crate: toml (already in deps)
Complexity: Low
ijson_schema — JSON Schema validation
Replaces: json_schema (Python jsonschema)
Validate JSON files against JSON Schema definitions. The jsonschema Rust crate
supports JSON Schema draft 2020-12, draft 7, and draft 4.
Crate: jsonschema
Complexity: Medium — need to load schema files and validate against them
imarkdown2html — Markdown to HTML
Replaces: markdown2html (external markdown CLI)
Convert Markdown files to HTML. pulldown-cmark is a fast, CommonMark-compliant
Markdown parser written in Rust.
Crate: pulldown-cmark
Complexity: Low — parse and render to HTML string, write to output file
iyamlschema — YAML Schema Validation
Validates YAML files against JSON schemas referenced by $schema URLs.
Fetches and caches schemas via the webcache, validates data against the schema
(including remote $ref resolution), and checks property ordering.
Crate: jsonschema, ureq, serde_norway
Complexity: Medium — HTTP fetching, schema compilation, recursive ordering checks
yaml2json — YAML to JSON Conversion
Convert YAML files to pretty-printed JSON.
Crate: serde_norway, serde_json
Complexity: Low — parse YAML, serialize as JSON
ijinja2 — Jinja2 templates (done)
Replaces: the jinja2 generator (Python Jinja2 through python3 -c)
src/processor/generator/ijinja2.rs keeps the generator’s contract (the
shared find_templates discovery, the output path with the extension
removed, the project root as loader root, the environment as context) and
renders with minijinja set up like a plain Jinja2 Environment: no
autoescaping, trailing newline dropped, line endings normalised, the
Python string and dict methods from minijinja-contrib’s pycompat, and
Jinja2’s argument lists for truncate, replace, round, int, sum,
center and forceescape supplied where minijinja’s are shorter. Checked
against Python Jinja2 over 34 templates and 59 single-construct probes:
identical except keyword-only wordwrap(width=) and truncate(leeway=),
numeric-only sum, 2 ** -1, dict keys named like Python methods, the
attr filter on dicts, reverse on a list (Jinja2 prints an iterator) and
+ vs %20 in urlencode of a dict.
Crate: minijinja, minijinja-contrib
Complexity: Low — the engine is the crate; the work is matching Jinja2’s
defaults and knowing the gaps
isass — Sass/SCSS to CSS
Replaces: sass (Dart Sass CLI)
Compile Sass/SCSS files to CSS. The grass crate is a pure-Rust Sass compiler
with good compatibility.
Crate: grass
Complexity: Low — compile input file, write CSS output
Not Suitable for Internal Implementation
These processors wrap tools with complex, evolving behavior that would be impractical to reimplement:
- ruff, pylint, mypy, pyrefly — Python linters/type checkers with deep language understanding
- eslint, jshint, stylelint — JavaScript/CSS linters with plugin ecosystems
- clippy, cargo — Rust toolchain components
- marp — Presentation framework (spawns Chromium)
- mdbook, jekyll — Full documentation/site generators
- shellcheck — Shell script analyzer with extensive rule set
- aspell — Spell checker with language dictionaries
- chromium, libreoffice, drawio — GUI applications used for rendering
- protobuf — Protocol buffer compiler
- pdflatex — LaTeX to PDF (entire TeX distribution)
Binary Plugin System
As of now, rsconstruct does not have a binary plugin system. This section documents the approach for future consideration.
Rust applications can dynamically load plugins written in Rust via dlopen/dlsym on shared libraries (.so on Linux, .dylib on macOS, .dll on Windows). The plugin compiles as a cdylib crate, exports extern "C" functions, and the host loads them at runtime using a crate like libloading.
The main constraint is that Rust has no stable ABI. You cannot use Rust traits, generics, or standard library types across the dynamic library boundary. The plugin interface must be C-compatible: extern "C" functions returning opaque pointers, with a vtable or function-pointer struct defining the plugin API.
Crates like abi_stable attempt to provide a stable ABI layer for Rust-to-Rust dynamic loading, but they add significant complexity.
The Lua plugin host that used to exist avoided this problem — Lua has a stable, simple FFI — and was removed unused. A binary plugin system would offer better performance but at the cost of a much more complex plugin interface and build process (plugins would need to be compiled separately and matched to the host’s ABI).
Missing Processors
Tools found in Makefiles across ../*/ sibling projects that rsconstruct does not yet have processors for.
Organized by category, with priority based on breadth of usage.
High Priority — Linters and Validators
eslint
- What it does: JavaScript/TypeScript linter (industry standard).
- Projects: demos-lang-js
- Invocation:
eslint $(ALL_JS)ornode_modules/.bin/eslint $< - Processor type: Checker
jshint
- What it does: JavaScript linter — detects errors and potential problems.
- Projects: demos-lang-js, gcp-gemini-cli, gcp-machines, gcp-miflaga, gcp-nikuda, gcp-randomizer, web-schemas, veltzer.github.io
- Invocation:
node_modules/.bin/jshint $< - Processor type: Checker
tidy (HTML Tidy)
- What it does: HTML/XHTML validator and formatter.
- Projects: demos-lang-js, gcp-gemini-cli, gcp-machines, gcp-miflaga, gcp-nikuda, gcp-randomizer, book-openbook, book-riddles
- Invocation:
tidy -errors -quiet -config .tidy.config $< - Processor type: Checker
check-jsonschema
- What it does: Validates YAML/JSON files against JSON Schema (distinct from rsconstruct’s json_schema which validates JSON against schemas found via
$schemakey). - Projects: data, web-schemas, veltzer.github.io
- Invocation:
check-jsonschema --schemafile $(yq -r '.["$schema"]' $<) $< - Processor type: Checker
cpplint
- What it does: C++ linter enforcing Google C++ style guide.
- Projects: demos-os-linux
- Invocation:
cpplint $< - Processor type: Checker
checkpatch.pl
- What it does: Linux kernel coding style checker.
- Projects: kcpp
- Invocation:
$(KDIR)/scripts/checkpatch.pl --file $(C_SOURCES) --no-tree - Processor type: Checker
standard (StandardJS)
- What it does: JavaScript style guide, linter, and formatter — zero config.
- Projects: demos-lang-js
- Invocation:
node_modules/.bin/standard $< - Processor type: Checker
jslint
- What it does: JavaScript code quality linter (Douglas Crockford).
- Projects: demos-lang-js
- Invocation:
node_modules/.bin/jslint $< - Processor type: Checker
jsl (JavaScript Lint)
- What it does: JavaScript lint tool.
- Projects: java-keynote, myworld-php
- Invocation:
jsl --conf=support/jsl.conf --quiet --nologo --nosummary --nofilelisting $(SOURCES_JS) - Processor type: Checker
gjslint (Google Closure Linter)
- What it does: JavaScript style checker following Google JS style guide.
- Projects: java-keynote, myworld-php
- Invocation:
$(TOOL_GJSLINT) --flagfile support/gjslint.cfg $(JS_SRC) - Processor type: Checker
checkstyle
- What it does: Java source code style checker.
- Projects: demos-lang-java, java-keynote
- Invocation:
java -cp $(scripts/cp.py) $(MAINCLASS_CHECKSTYLE) -c support/checkstyle_config.xml $(find . -name "*.java") - Processor type: Checker
pyre
- What it does: Python type checker from Facebook/Meta.
- Projects: archive.apiiro.TrainingDataLaboratory, archive.work-amdocs-py
- Invocation:
pyre check - Processor type: Checker
High Priority — Formatters
black
- What it does: Opinionated Python code formatter.
- Projects: archive.apiiro.TrainingDataLaboratory, archive.work-amdocs-py
- Invocation:
black --target-version py36 $(ALL_PACKAGES) - Processor type: Checker (using
--checkmode) or Formatter
uncrustify
- What it does: C/C++/Java source code formatter.
- Projects: demos-os-linux, xmeltdown
- Invocation:
uncrustify -c support/uncrustify.cfg --no-backup -l C $(ALL_US_C) - Processor type: Formatter
astyle (Artistic Style)
- What it does: C/C++/Java source code indenter and formatter.
- Projects: demos-os-linux
- Invocation:
astyle --verbose --suffix=none --formatted --preserve-date --options=support/astyle.cfg $(ALL_US) - Processor type: Formatter
indent (GNU Indent)
- What it does: C source code formatter (GNU style).
- Projects: demos-os-linux
- Invocation:
indent $(ALL_US) - Processor type: Formatter
High Priority — Testing
pytest
- What it does: Python test framework.
- Projects: 50+ py* projects (pyanyzip, pyapikey, pyapt, pyawskit, pyblueprint, pybookmarks, pyclassifiers, pycmdtools, pyconch, pycontacts, pycookie, pydatacheck, pydbmtools, pydockerutils, pyeventroute, pyeventsummary, pyfakeuse, pyflexebs, pyfoldercheck, pygcal, pygitpub, pygooglecloud, pygooglehelper, pygpeople, pylogconf, pymakehelper, pymount, pymultienv, pymultigit, pymyenv, pynetflix, pyocutil, pypathutil, pypipegzip, pypitools, pypluggy, pypowerline, pypptkit, pyrelist, pyscrapers, pysigfd, pyslider, pysvgview, pytagimg, pytags, pytconf, pytimer, pytsv, pytubekit, pyunique, pyvardump, pyweblight, and archive.*)
- Invocation:
pytest testsorpython -m pytest tests - Processor type: Checker (mass, per-directory)
High Priority — YAML/JSON Processing
yq
- What it does: YAML/JSON processor (like jq but for YAML).
- Projects: data, demos-lang-yaml, web-schemas, veltzer.github.io
- Invocation:
yq < $< > $@(format/validate) oryq -r '.key' $<(extract) - Processor type: Checker or Generator
Medium Priority — Compilers
javac
- What it does: Java compiler.
- Projects: demos-lang-java, java-jenable, java-keynote
- Invocation:
javac -Werror -Xlint:all $(JAVA_SOURCES) -d out/classes - Processor type: Generator
go build
- What it does: Go language compiler.
- Projects: demos-lang-go
- Invocation:
go build -o $@ $< - Processor type: Generator (single-file, like cc_single_file)
kotlinc
- What it does: Kotlin compiler.
- Projects: demos-lang-kotlin
- Invocation:
kotlinc $< -include-runtime -d $@ - Processor type: Generator (single-file)
ghc
- What it does: Glasgow Haskell Compiler.
- Projects: demos-lang-haskell
- Invocation:
ghc -v0 -o $@ $< - Processor type: Generator (single-file)
ldc2
- What it does: D language compiler (LLVM-based).
- Projects: demos-lang-d
- Invocation:
ldc2 $(FLAGS) $< -of=$@ - Processor type: Generator (single-file)
nasm
- What it does: Netwide Assembler (x86/x64).
- Projects: demos-lang-nasm
- Invocation:
nasm -f $(ARCH) -o $@ $< - Processor type: Generator (single-file)
rustc
- What it does: Rust compiler for single-file programs (as opposed to cargo for projects).
- Projects: demos-lang-rust
- Invocation:
rustc $(FLAGS_DBG) $< -o $@ - Processor type: Generator (single-file)
dotnet
- What it does: .NET SDK CLI — builds C#/F# projects.
- Projects: demos-lang-cs
- Invocation:
dotnet build --nologo --verbosity quiet - Processor type: MassGenerator
dtc (Device Tree Compiler)
- What it does: Compiles device tree source (.dts) to device tree blob (.dtb) for embedded Linux.
- Projects: clients-heqa (8 subdirectories)
- Invocation:
dtc -I dts -O dtb -o $@ $< - Processor type: Generator (single-file)
Medium Priority — Build Systems
cmake
- What it does: Cross-platform build system generator.
- Projects: demos-build-cmake
- Invocation:
cmake -B $@ && cmake --build $@ - Processor type: MassGenerator
mvn (Apache Maven)
- What it does: Java project build and dependency management.
- Projects: demos-lang-java/maven
- Invocation:
mvn compile - Processor type: MassGenerator
ant (Apache Ant)
- What it does: Java build tool (XML-based).
- Projects: demos-lang-java, java-keynote
- Invocation:
ant checkstyle - Processor type: MassGenerator
Medium Priority — Converters and Generators
pygmentize
- What it does: Syntax highlighter — converts source code to HTML, SVG, PNG.
- Projects: demos-misc-highlight
- Invocation:
pygmentize -f html -O full -o $@ $< - Processor type: Generator (single-file)
slidev
- What it does: Markdown-based presentation tool — exports to PDF.
- Projects: demos-lang-slidev
- Invocation:
node_modules/.bin/slidev export $< --with-clicks --output $@ - Processor type: Generator (single-file)
lilypond
- What it does: Music engraving program — compiles .ly files to PDF sheet music.
- Projects: demos-lang-lilypond, book-openbook
- Invocation:
scripts/wrapper_lilypond.py ... $< - Processor type: Generator (single-file)
wkhtmltoimage
- What it does: Renders HTML to image using WebKit engine.
- Projects: demos-misc-highlight
- Invocation:
wkhtmltoimage $(WK_OPTIONS) $< $@ - Processor type: Generator (single-file)
Medium Priority — Documentation
jsdoc
- What it does: API documentation generator for JavaScript.
- Projects: js-jschess, java-keynote
- Invocation:
node_modules/.bin/jsdoc -d $(JSDOC_FOLDER) -c support/jsdoc.json out/src - Processor type: MassGenerator
Low Priority — Minifiers
jsmin
- What it does: JavaScript minifier (removes whitespace and comments).
- Projects: js-jschess
- Invocation:
node_modules/.bin/jsmin < $< > $(JSMIN_JSMIN) - Processor type: Generator (single-file)
yuicompressor
- What it does: JavaScript/CSS minifier and compressor (Yahoo).
- Projects: js-jschess
- Invocation:
node_modules/.bin/yuicompressor $< -o $(JSMIN_YUI) - Processor type: Generator (single-file)
closure compiler
- What it does: JavaScript optimizer and minifier (Google Closure).
- Projects: java-keynote
- Invocation:
tools/closure.jar $< --js_output_file $@ - Processor type: Generator (single-file)
Low Priority — Preprocessors
gpp (Generic Preprocessor)
- What it does: General-purpose text preprocessor with macro expansion.
- Projects: demos/gpp
- Invocation:
gpp -o $@ $< - Processor type: Generator (single-file)
m4
- What it does: Traditional Unix macro processor.
- Projects: demos/m4
- Invocation:
m4 $< > $@ - Processor type: Generator (single-file)
Low Priority — Binary Analysis
objdump
- What it does: Disassembles object files (displays assembly code).
- Projects: demos-os-linux
- Invocation:
objdump --disassemble --source $< > $@ - Processor type: Generator (single-file, post-compile)
Low Priority — Packaging
dpkg-deb
- What it does: Builds Debian .deb packages.
- Projects: archive.myrepo
- Invocation:
dpkg-deb --build deb/mypackage ~/packages - Processor type: Generator
reprepro
- What it does: Manages Debian APT package repositories.
- Projects: archive.myrepo
- Invocation:
reprepro --basedir $(config.apt.service_dir) export $(config.apt.codename) - Processor type: Generator
Low Priority — Profiling
pyinstrument
- What it does: Python profiler with HTML output.
- Projects: archive.apiiro.TrainingDataLaboratory, archive.work-amdocs-py
- Invocation:
pyinstrument --renderer=html -m $(MAIN_MODULE) - Processor type: Generator
Low Priority — Code Metrics
sloccount
- What it does: Counts source lines of code and estimates development cost.
- Projects: demos-lang-java, demos-lang-r, demos-os-linux, js-jschess
- Invocation:
sloccount . - Processor type: Checker (whole-project)
Low Priority — Dependency Generation
makedepend
- What it does: Generates C/C++ header dependency rules for Makefiles.
- Projects: xmeltdown
- Invocation:
makedepend -I... -- $(CFLAGS) -- $(SRC) - Notes: rsconstruct’s built-in C/C++ dependency analyzer already handles this.
Low Priority — Embedded
fdtoverlay
- What it does: Applies device tree overlays to a base device tree blob.
- Projects: clients-heqa/come_overlay
- Invocation:
fdtoverlay -i $@ -o $@.tmp $$overlay && mv $@.tmp $@ - Processor type: Generator
fdtput
- What it does: Modifies properties in a device tree blob.
- Projects: clients-heqa/come_overlay
- Invocation:
fdtput -r $@ $$node - Processor type: Generator
Requirements Generator — Design
A processor that scans Python source files and produces a requirements.txt
listing the third-party distributions the project imports. Fills the gap
between the Python analyzer (which discovers local dep edges) and the pip
processor (which consumes requirements.txt).
Problem
Users have Python projects with import statements. They want the set of
PyPI distributions their code needs, written out to requirements.txt.
Today they maintain this file by hand, which drifts from the actual imports.
Shape
A whole-project Generator processor named requirements:
- Inputs: every
.pyfile in the project (same scan as the Python analyzer —file_index.scan(&self.config.standard, true)). - Output: a single
requirements.txt(path configurable). - Discovery: one
Productwith all.pyfiles as inputs, one output path. Structurally identical to thetagsprocessor.
The classification problem
Every import X lands in one of three buckets:
- Local — a module that resolves to a file in the project. Skip.
- Stdlib — a module shipped with Python (
os,sys,json, …). Skip. - Third-party — a PyPI distribution. Emit to
requirements.txt.
The Python analyzer already resolves bucket 1 via
PythonDepAnalyzer::resolve_module. The new processor needs buckets 2 and 3.
Stdlib detection
Python 3.10+ ships sys.stdlib_module_names — a frozenset of every stdlib
top-level module name. We bake this list into a static table inside the
processor (src/processor/generator/requirements.rs) rather than probing
python3 at build time. Reasons:
- The list is stable across 3.10+ with a handful of additions per minor release.
- No tool dependency at build time — keeps the processor offline and hermetic.
- The list is ~300 names, a few KB of source.
A refresh script regenerates the table from python3 -c 'import sys; print(sorted(sys.stdlib_module_names))' when we bump Python support. The
list lives alongside the processor, not in a user-facing config.
Import → distribution mapping
The import name is not always the PyPI distribution name:
| Import | Distribution |
|---|---|
cv2 | opencv-python |
yaml | PyYAML |
PIL | Pillow |
sklearn | scikit-learn |
bs4 | beautifulsoup4 |
We bake a curated table of the common ~40 mismatches into the processor and
default everything else to identity (import X → distribution X). Users
override via config:
[processor.generator.requirements.mapping]
cv2 = "opencv-python"
custom_internal = "our-private-dist"
User entries win over the built-in table. This is lossy by design — we accept that unusual packages need a config entry — in exchange for:
- No dependency on an installed Python environment.
requirements.txtgeneration works on a clean checkout (no chicken-and-egg withpip install).- Deterministic output regardless of the caller’s environment.
The alternative — probing importlib.metadata.packages_distributions() —
is more accurate but requires packages to already be installed. Rejected
for now; can be added later as an opt-in resolve = "probe" mode if users
hit the mapping ceiling.
Configuration
[processor.generator.requirements]
output = "requirements.txt" # Output file path
exclude = [] # Import names to never emit (e.g. internal vendored modules)
sorted = true # Sort output alphabetically (vs. discovery order)
header = true # Emit a "# Generated by rsconstruct" header line
[processor.generator.requirements.mapping]
cv2 = "opencv-python" # User-provided import → distribution overrides
| Key | Type | Default | Description |
|---|---|---|---|
output | string | "requirements.txt" | Output file path |
exclude | string[] | [] | Import names to never emit |
sorted | bool | true | Sort entries alphabetically |
header | bool | true | Include a comment header line |
mapping | map | {} | Per-project import→distribution overrides |
Pinning (pkg==1.2.3) is deferred. The first iteration emits bare names.
Adding pinning later means probing pip show or parsing a lockfile —
separate concern.
Code organization
Shared import scanner
Factor the regex scanning out of src/analyzers/python.rs into a module
function shared by the analyzer and the generator:
#![allow(unused)]
fn main() {
// src/analyzers/python.rs
pub(crate) fn scan_python_imports(path: &Path) -> Result<Vec<String>> { ... }
}
Returns the raw top-level module names found in import X and from X import ... lines. The analyzer then runs this through resolve_module to
keep local ones; the generator runs it through the stdlib table and
mapping to produce the final list.
This fixes architecture-observations #6 (analyzers can’t hand data to processors) at the scope of this one feature: instead of building a cross-processor channel, we share a pure function.
Files
src/processor/generator/requirements.rs— the processor, plus its two data tables: the stdlib names table (static&[&str]) with anis_stdlib(module: &str) -> boolhelper, and the curated import→distribution mapping with aresolve_distribution(import: &str) -> &strhelper that falls through to identity. Every file undersrc/processor/must be a real processor, so the tables live in the processor’s own file.src/config/processor_configs.rs— addRequirementsConfig.src/processor/mod.rs— addpub const REQUIREMENTS = "requirements"tonamesmodule.docs/src/processor/generator/requirements.md— user-facing processor doc.
Processor structure
Mirrors tags (whole-project generator with one output):
#![allow(unused)]
fn main() {
pub struct RequirementsProcessor {
base: ProcessorBase,
config: RequirementsConfig,
}
impl Processor for RequirementsProcessor {
fn discover(&self, graph, file_index, instance_name) -> Result<()> {
// Scan for .py files; if none, no product.
// Add one product: inputs=all .py files, outputs=[output_path].
}
fn supports_batch(&self) -> bool { false }
fn execute(&self, _ctx, product) -> Result<()> {
// 1. Scan each input .py for imports.
// 2. For each top-level module name:
// - Skip if local (resolves to a project file).
// - Skip if stdlib.
// - Skip if in user's `exclude`.
// - Map import → distribution name.
// 3. Dedupe, sort if configured, write to output.
}
}
}
Cache behavior
Falls naturally out of the descriptor-based cache:
- Inputs: every
.pyfile + config hash. - Output:
requirements.txt. - Adding/removing an import changes file contents, triggers rebuild.
- Changing config (new mapping entry, new exclude) changes config hash, triggers rebuild.
- Code changes inside a function that don’t affect imports still trigger a rebuild, since we can’t cheaply know which lines matter. Acceptable — the regeneration is fast.
Auto-detection
auto_detect returns true when the file index contains any .py files.
Same criterion as the Python analyzer.
Out of scope (first cut)
- Version pinning.
- Multiple output files (
requirements-dev.txt,requirements-test.txt). - Optional dependencies / extras (
pkg[extra]). - Reading existing
requirements.txtto preserve comments or pins. pyproject.tomlorsetup.pyoutput —requirements.txtonly.
Each is a clean follow-up if users ask.
Crates.io Publishing
Notes on publishing rsconstruct to crates.io.
Version Limits
There is no limit on how many versions can be published to crates.io. You can publish as many releases as needed without worrying about quota or cleanup.
Pruning Old Releases
Crates.io does not support deleting published versions. Once a version is uploaded, it exists permanently.
The only removal mechanism is yanking (cargo yank --version 0.1.0), which:
- Prevents new projects from adding a dependency on the yanked version
- Does not break existing projects that already depend on it (they continue to download it via their lockfile)
- Does not delete the crate data from the registry
Yanking should only be used for versions with security vulnerabilities or serious bugs, not for general housekeeping.
Publishing a New Version
- Update the version in
Cargo.toml - Run
cargo publish --dry-runto verify - Run
cargo publishto upload
Feature: Per-Processor max_jobs
Update (2026-10-06): the semaphores in the plan below were replaced
when the executor moved to a ready-queue scheduler. Workers take units from
one queue, and a worker takes the first unit whose processor is below its
max_jobs (effective_max_jobs: the config value, capped by the plugin’s
max_jobs_cap). A capped processor no longer parks a worker on a
semaphore while other work waits. The configuration is unchanged.
Problem
When running rsconstruct build -j 20, all processors run with the same parallelism.
Processors like marp spawn heavyweight subprocesses (headless Chromium via Puppeteer),
and 20 concurrent Chromium instances cause non-deterministic TargetCloseError crashes
due to resource exhaustion.
Desired Behavior
Allow each processor to declare a max_jobs limit in rsconstruct.toml:
[processor.generator.marp]
formats = ["pdf"]
max_jobs = 4
With -j 20, marp would run at most 4 concurrent jobs while other processors use the full 20.
max_jobs unset or 0 means “use the global -j value” (current behavior).
Implementation Plan
1. Add max_jobs field to processor configs
File: src/config/processor_configs.rs
Add to the generator_config! macro (all variants) and checker config structs:
#![allow(unused)]
fn main() {
#[serde(default)]
pub max_jobs: Option<usize>,
}
Add to Default impl (max_jobs: None) and KnownFields list.
2. Expose max_jobs() on the ProductDiscovery trait
File: src/processor/mod.rs
#![allow(unused)]
fn main() {
fn max_jobs(&self) -> Option<usize> { None }
}
Each processor implementation returns self.config.max_jobs.
3. Build a per-processor semaphore map in the executor
File: src/executor/mod.rs
Add to ExecutorOptions or build during executor construction:
#![allow(unused)]
fn main() {
pub processor_max_jobs: HashMap<String, usize>,
}
Constructed from the processor map by calling max_jobs() on each processor.
4. Use semaphores in the dispatch loop
File: src/executor/execution.rs (lines 177-203)
Create an Arc<Semaphore> per processor that has a max_jobs limit.
In the execution loop:
- Batch groups: If the processor has
max_jobs, the batch thread acquires a permit before executing each chunk, limiting concurrent Chromium (or similar) processes. - Non-batch items: Instead of dividing all non-batch items into
parallelchunks regardless of processor, group by processor first. Items from limited processors get their own chunking (min ofmax_jobsandparallel), others use globalparallel.
5. Config display
Ensure rsconstruct processor config processor.generator.marp and rsconstruct config show display
the max_jobs field.
Files to Modify
src/config/processor_configs.rs- addmax_jobsfield to macros and manual configssrc/processor/mod.rs- addmax_jobs()toProductDiscoverytraitsrc/processor/*.rs- implementmax_jobs()for each processorsrc/executor/mod.rs- add semaphore map toExecutorOptionssrc/executor/execution.rs- semaphore-based dispatch in the level loopsrc/builder/build.rs- build the processor limits map and pass to executor
Alternatives Considered
batch_sizeworkaround: Settingbatch_sizelimits items per batch invocation, but batch mode runs sequentially within one process, making it slow.- Global lower
-j: Works but penalizes lightweight processors unnecessarily.
Plugin Registry: Ecosystem Survey
rsconstruct uses a hand-built plugin registry where processors self-register
at link time via inventory::submit!, declare their config schema, and are
instantiated from TOML config at runtime. This page documents the search for
existing Rust crates that could replace this machinery.
What rsconstruct needs
The plugin system combines four responsibilities:
- Link-time self-registration — each processor file submits a plugin entry. No central list to maintain. Adding a processor = adding one file.
- Per-plugin TOML config — each plugin declares known fields, required
fields, defaults, and a
create(toml::Value) -> Box<dyn Processor>factory. The framework deserializes the matching[processor.TYPE.NAME]section and passes it to the factory. - Defaults and validation — processor defaults, scan defaults, and output-dir defaults are applied in layers before deserialization. Unknown fields are rejected. Required fields are enforced.
- Name-to-factory mapping — the registry maps processor names to their
plugin entries for creation, introspection (
processor list), and config display.
Crates evaluated
inventory / linkme
The foundation rsconstruct already uses. inventory provides link-time
collection of typed values into a global iterator. linkme does the same
via distributed slices. Neither has any config awareness — they solve (1)
only.
- Verdict: already in use; does its job well.
spring-rs
The closest match conceptually. A Spring Boot-style Rust framework that
combines inventory-based plugin registration with TOML config via
#[derive(Configurable)] and #[config_prefix = "..."] attributes. Each
plugin declares its config struct with the derive macro, and the framework
auto-deserializes the matching TOML section.
However, spring-rs is a full application framework for web services (integrates axum, sqlx, OpenTelemetry, etc.). Pulling it in for a build tool would add a massive, opinionated dependency tree for ~50 lines of glue code savings.
- Verdict: right pattern, wrong scope. Not suitable.
config (crate)
Handles layered config loading from multiple sources (TOML, YAML, JSON, env vars) with type-safe deserialization. No plugin registration awareness at all — it’s a config library, not a plugin framework.
- Verdict: solves config layering, not plugin registration.
extism
A WebAssembly plugin runtime. Plugins are compiled to WASM and loaded at runtime with sandboxing. Completely different problem — runtime-loaded external plugins vs. compile-time self-registering internal plugins.
- Verdict: wrong problem domain.
plugin-interfaces
Designed for chat-client applications with FFI and inter-plugin messaging. Not relevant to build tools.
- Verdict: not applicable.
toml-cfg
Provides compile-time config macros (#[toml_cfg::toml_config]) that
embed config values from a TOML file at build time. No runtime registry,
no plugin awareness.
- Verdict: compile-time only; not what we need.
Conclusion
No existing crate provides the combination of link-time registration + per-plugin TOML config deserialization + defaults/validation + name-to-factory mapping. This is a genuine gap in the Rust ecosystem.
rsconstruct’s manual approach (~50 lines of glue in src/registries/processor.rs
using inventory::submit! + serde + the ProcessorPlugin struct) is the
standard Rust pattern for this. It is well-understood, has no external
framework dependency, and is unlikely to be improved upon by a third-party
crate without bringing in unrelated complexity.
Decision: keep the current hand-built registry. Revisit if a focused plugin-config crate emerges in the ecosystem.
Survey conducted: April 2026.
Rejected Audit Findings
Issues flagged during code audits (rounds 7-12) that were assessed and deliberately rejected. Documented here to prevent re-flagging in future audits.
Duration u128-to-u64 overflow in JSON output
File: src/json_output.rs (lines 130, 151)
Flagged in: rounds 9, 10, 11, 12
Duration::as_millis() returns u128, cast to u64 without bounds checking. Overflows after ~584 million years. No real build will ever hit this. Not fixing.
Pre-1970 mtime cache collision
File: src/object_store/checksums.rs (lines 25-27)
Flagged in: rounds 9, 10, 11, 12
Files with mtime before Unix epoch (1970) get unwrap_or_default() mapping to (0, 0). Two such files could share a cached mtime entry. Pre-1970 timestamps don’t occur on real build inputs. The mtime cache is only an optimization — the actual input checksum comparison catches real changes. Not fixing.
Dependency unchanged logic — no-dep products
File: src/executor/execution.rs (line 587)
Flagged in: round 9
Agent claimed !deps.is_empty() && deps.iter().all(...) should be deps.is_empty() || deps.iter().all(...). Wrong — products with no dependencies should NOT reuse cached checksums. The optimization is specifically for products whose upstream deps produced identical output, meaning transitive inputs are unchanged. No-dep products have no such guarantee.
Batch handle_success return value ignored
File: src/executor/execution.rs (line 339)
Flagged in: round 10
handle_success() return value is not checked in batch processing. This is correct — handle_success already calls record_failure internally when caching fails, properly marking the product as failed. In non-batch, the return value triggers a break from the retry loop, but batch has no retry loop. Stats are correct either way.
record_failure ignores mark_processor_failed in keep-going mode
File: src/executor/handlers.rs (lines 20-39)
Flagged in: rounds 11, 12
In keep-going mode, mark_processor_failed parameter is ignored. This is by design — failed_processors is only checked in non-keep-going mode to skip subsequent products from the same processor. In keep-going mode, all products run regardless, so tracking failed processors is unnecessary.
Arc reference leak — failed_processors not unwrapped
File: src/executor/execution.rs (collect_build_stats)
Flagged in: round 11
Agent claimed not unwrapping failed_processors Arc prevents other Arc::try_unwrap calls from succeeding. Wrong — each Arc has its own independent reference count. Not unwrapping one has zero effect on others.
Tera output paths lose directory structure
File: src/processor/generator/tera.rs (lines 100-106)
Flagged in: round 10
Templates in subdirectories produce output at project root (e.g., tera.templates/sub/README.md.tera → README.md). This is intentional — the comment on line 105 explicitly says “Output is at project root with the .tera extension stripped.” By design.
file_index src_exclude_dirs substring matching
File: src/file_index.rs (lines 76-80)
Flagged in: rounds 9, 10
src_exclude_dirs uses path_str.contains(dir) for filtering. The documented convention uses slash-delimited patterns like "/kernel/", which prevents false positives on path substrings. This is the configured behavior.
Object store trim path reconstruction
File: src/object_store/management.rs (lines 86-103)
Flagged in: round 9
Reconstructing checksums from filesystem paths (prefix + rest) during cache trim. The path structure is fixed (objects/[2-char]/[rest]), set by store_object(). Unexpected files in the objects directory are silently ignored during trim, which is the correct behavior.
Partial output caching (before the fix)
File: src/object_store/operations.rs (lines 144-147)
Flagged in: round 9
Originally flagged as a design choice. User overruled — missing outputs are now an error (anyhow::ensure!). This was accepted and fixed in a later commit, not rejected.
Zspell read-modify-write race
File: src/processor/checker/zspell.rs (lines 192-229)
Flagged in: round 11
Agent claimed file read-modify-write isn’t protected. Wrong — self.words_to_add.lock() on line 193 acquires the mutex, which is held for the entire function (not dropped until return). The lock prevents concurrent threads from interleaving. Cross-process races are not a concern for RSConstruct.
Duplicate dependency edges in resolve_dependencies
File: src/graph.rs (lines 227-230)
Flagged in: round 12
Agent claimed duplicate edges cause incorrect topological sort. The scenario requires a product to list the same input file twice, which doesn’t happen — FileIndex.scan() returns unique paths. Even if it did, duplicate edges would increment and decrement in_degree the same number of times, netting out correctly.
Python string injection in load_python_config
File: src/processor/generator/tera.rs (lines 205-208)
Flagged in: round 12
Agent claimed newlines in file paths could inject Python code. File paths come from FileIndex (filesystem scan) or Tera templates written by the project author — both are trusted input. Linux file paths from filesystem scans don’t contain newlines.
Batch assert_eq should be error return
File: src/executor/execution.rs (lines 323-325)
Flagged in: round 12
Agent suggested replacing assert_eq! with anyhow::bail! for batch result count validation. The assert is deliberate — a processor returning the wrong number of results is a contract violation (programming error), not a recoverable runtime condition. Assertions are appropriate for invariant violations.
Platform portability (Windows, macOS)
Flagged in: rounds 9, 10, 11, 12
Multiple agents flagged std::os::unix usage without #[cfg(unix)] guards, and missing #[cfg(windows)]/#[cfg(target_os = "macos")] blocks. RSConstruct is Linux-only. No platform compatibility code will be added.
DB recovery — file might not exist
File: src/db.rs
Flagged in: round 12
Agent re-flagged db.rs recovery, claiming fs::remove_file could fail if the file doesn’t exist. This was already fixed in round 8 — let _ = fs::remove_file() was changed to fs::remove_file()? which properly propagates errors.
Suggestions
Ideas for future improvements, organized by category. Completed items have been moved to suggestions-done.md.
Grades:
- Urgency:
high(users need this),medium(nice to have),low(speculative/future) - Complexity:
low(hours),medium(days),high(weeks+)
Build Execution
Distributed builds
- Run builds across multiple machines, similar to distcc or icecream for C/C++.
- A coordinator node distributes work to worker nodes, each running rsconstruct in worker mode.
- Workers execute products and return outputs to the coordinator, which caches them locally.
- Challenges: network overhead for small products, identical tool versions across workers, local filesystem access.
- Urgency: low | Complexity: high
Sandboxed execution
- Run each processor in an isolated environment where it can only access its declared inputs.
- Prevents accidental undeclared dependencies.
- On Linux, namespaces can provide lightweight sandboxing.
- A cheaper first step — detect, don’t isolate. rsconstruct discovers
dependencies (scan settings, analyzers) instead of having them declared,
so a dependency an analyzer misses silently becomes a stale result. Bazel
catches the equivalent mistake because its sandbox makes an undeclared
read fail. An opt-in check mode could get most of that benefit without
full isolation: trace each tool’s file reads (fanotify, or a ptrace/
seccomp-unotify
openatfilter) and report every file read inside the project that is not among the product’s inputs. Run in CI, it turns “editing this header rebuilt nothing” into an error naming the product and the file. - Researched 2026-10-06, deferred: how Bazel, Buck2, Pants, Nix, tup and BuildXL handle it, why observation fits rsconstruct better than a sandbox, the candidate mechanisms (strace leading) and the open questions are in Undeclared Reads.
- Urgency: medium (check mode) / low (full isolation) | Complexity: medium (check mode) / high (full isolation)
Persistent daemon mode
- Keep rsconstruct running as a background daemon to avoid startup overhead.
- Benefits: instant file index via inotify, warm Lua VMs, connection pooling, faster incremental builds.
- Daemon listens on Unix socket (
.rsconstruct/daemon.sock). rsconstruct watchbecomes a client that triggers rebuilds on file events.- Urgency: low | Complexity: high
Persistent workers
- Keep long-running tool processes alive to avoid startup overhead.
- Instead of spawning
rufforpylintper invocation, keep one process alive and feed it files. - Bazel gets 2-4x speedup for Java this way. Could benefit pylint/mypy which have heavy startup.
- Multiplex variant: multiple requests to a single worker process via threads.
- Urgency: medium | Complexity: high
Dynamic execution (race local vs remote)
- Start both local and remote execution of the same product; use whichever finishes first and cancel the other.
- Useful when remote cache is slow or flaky.
- Configurable per-processor via execution strategy.
- Urgency: low | Complexity: high
Execution strategies per processor
- Map each processor to an execution strategy: local, remote, sandboxed, or dynamic.
- Different processors may benefit from different execution models.
- Config:
[processor.checker.ruff] execution = "remote",[processor.generator.cc_single_file] execution = "sandboxed". - Urgency: low | Complexity: medium
Build profiles
- Named configuration sets for different build scenarios (ci, dev, release).
- Profiles inherit from base configuration and override specific values.
- Usage:
rsconstruct build --profile=ci - Urgency: medium | Complexity: medium
Conditional processors
- Enable or disable processors based on conditions (environment variables, file existence, git branch, custom commands).
- Multiple conditions can be combined with
all/anylogic. - Urgency: low | Complexity: medium
Target aliases
- Define named groups of processors for easy invocation.
- Usage:
rsconstruct build @lint,rsconstruct build @test - Special aliases:
@all,@changed,@failed - File-based targeting:
rsconstruct build src/main.c - Urgency: medium | Complexity: medium
Graph & Query
Build graph query language
- Support queries like
rsconstruct query deps out/foo,rsconstruct query rdeps src/main.c,rsconstruct query processor:ruff. - Useful for debugging builds and CI systems that want to build only affected targets.
- Urgency: low | Complexity: medium
Affected analysis
- Given changed files (from
git diff), determine which products are affected and only build those. - Useful for large projects where a full build is expensive.
- Urgency: medium | Complexity: medium
Demand-driven discovery: discover only what was asked for
-p Bnow runs B together with the producers of its inputs (select_processors, done 2026-10-06), but it gets there by discovering every processor and selecting from the full graph afterwards. Bazel and Buck2 never look at what the requested target does not need.- Trimming discovery itself to the upstream closure needs to know, before discovering, which processors can feed B — the processor-level routing graph (Routing Generated Files, step 4), kept between runs since it is only known after discovery (observation #9, the supply-driven model).
- Side effect today: under
-p, every processor’s config is validated during discovery, not just the selected ones’. - Urgency: low | Complexity: high
Critical path analysis
- Identify the longest sequential chain of actions in a build.
- Helps users optimize their slowest builds by showing what’s actually on the critical path.
- Display with
rsconstruct build --critical-pathor include in--timingsoutput. - Urgency: medium | Complexity: medium
Extensibility
Config registry
- A central repository of community-contributed processor configs.
- Install with
rsconstruct config install eslint. - Registry could be a GitHub repository with a JSON index.
- Version pinning in
rsconstruct.toml. - Urgency: low | Complexity: high
Project templates
- Initialize new projects with pre-configured processors and directory structure.
rsconstruct init --template=python,rsconstruct init --template=cpp, etc.- Custom templates from local directories or URLs.
- Urgency: low | Complexity: medium
Rule composition / aspects
- Attach cross-cutting behavior to all targets of a certain type (e.g., “add coverage analysis to every C++ compile”).
- Urgency: low | Complexity: high
Output groups / subtargets
- Named subsets of a target’s outputs that can be requested selectively.
- E.g.,
rsconstruct build --output-group=debugor per-product subtarget selection. - Useful for targets that produce multiple output types (headers, binaries, docs).
- Urgency: low | Complexity: medium
Visibility / access control
- Restrict which processors can consume which files or directories.
- Prevents accidental cross-boundary dependencies in large repos.
- Config: per-processor
visibilityrules or directory-level.rsconstruct-visibilityfiles. - Urgency: low | Complexity: medium
Developer Experience
Build Event Protocol / structured event stream
- rsconstruct already has
--jsonon stdout with JSON Lines events (BuildEvent, ProductStart, ProductComplete, BuildSummary) and--tracefor Chrome trace format. - A proper Build Event Protocol (file or gRPC stream) would enable external dashboards, CI integrations, and build analytics services beyond what JSON Lines provides.
- Write events to a file (
--build-event-log=events.pb) or stream to a remote service. - Richer event types: action graph, configuration, progress, test results.
- Urgency: medium | Complexity: medium
Build notifications
- Desktop notifications when builds complete, especially for long builds.
- Platform-specific:
notify-send(Linux),osascript(macOS). - Config:
notify = true,notify_on_success = false. - Urgency: low | Complexity: low
Parallel dependency analysis
- The cpp analyzer scans files sequentially, which can be slow for large codebases.
- Parallelize header scanning using rayon or tokio.
- Urgency: low | Complexity: medium
IDE / LSP integration
- Language Server Protocol server for IDE integration.
- Features: diagnostics, code actions, hover info, file decorations.
- Plugins for VS Code, Neovim, Emacs.
- Urgency: low | Complexity: high
Build log capture
- Save stdout/stderr from each product execution to a log file.
- Config:
log_dir = ".rsconstruct/logs",log_retention = 10. rsconstruct log ruff:main.pyto view logs.- Urgency: low | Complexity: medium
Build timing history
- Store timing data to
.rsconstruct/timings.jsonafter each build. rsconstruct timingsshows slowest products, trends, time per processor.- Urgency: low | Complexity: medium
Remote cache authentication
- S3 and HTTP/HTTPS remote caches are already supported.
- Still needed: explicit bearer token support, GCS backend, and environment variable substitution for secrets in config.
- Urgency: medium | Complexity: medium
rsconstruct lint — Run only checkers
- Convenience command to run only checker processors.
- Equivalent to
rsconstruct build -p processor.checker.ruff,processor.checker.pylint,...but shorter. - Urgency: low | Complexity: low
Watch mode keyboard commands
- During
rsconstruct watch, supportr(rebuild),c(clean),q(quit),Enter(rebuild now),s(status). - Only activate when stdin is a TTY.
- Urgency: low | Complexity: medium
Layered config files
- Support config file layering: system (
/etc/rsconstruct/config.toml), user (~/.config/rsconstruct/config.toml), project (rsconstruct.toml). - Lower layers provide defaults, higher layers override.
- Per-command overrides via
[build],[watch]sections. - Similar to Bazel’s
.bazelrclayering. - Urgency: low | Complexity: low
Test sharding
- Split large test targets across multiple parallel shards.
- Set
TEST_TOTAL_SHARDSandTEST_SHARD_INDEXenvironment variables for test runners. - Config:
shard_count = 4per processor or product. - Useful for pytest/doctest processors when added.
- Urgency: low | Complexity: medium
Runfiles / runtime dependency trees
- Track runtime dependencies (shared libs, config files, data files) separately from build dependencies.
- Generate a runfiles directory per executable with symlinks to all transitive runtime deps.
- Useful for deployment, packaging, and containerization.
- Urgency: low | Complexity: high
On-demand processors (build_by_default = false)
- Today every declared processor runs on every
rsconstruct build. The only per-invocation escape hatches are-x name(remember every time) orenabled = falsein the config (remember to flip back). Neither fits the “this processor exists, don’t run it unless I ask” use case — common for slow lifecycle processors likepython_package,docker_build,publish,release_tarball. - Add a per-processor boolean field defaulting to true:
build_by_default = falseon a processor means it’s discovered and classified like any other, but its products are filtered out of the default run. - Prior art: meson’s
build_by_default: false, Bazel’stags = ["manual"], buck2’stags = ["manual"]. All use the same shape — declarative opt-out on the rule, per-invocation opt-in via target naming. - CLI semantics map cleanly onto existing
-p/-xmachinery:rsconstruct build→ excludesbuild_by_default = falseprocessors (new behaviour).rsconstruct build -p python_package→ includes onlypython_package; the-pexplicit inclusion overrides the default-off flag.rsconstruct build -p processor.checker.ruff,python_package→ includes both, including the opt-in one.rsconstruct build --all(new flag) → includes everything including on-demand processors. Useful for CI that wants to verify the opt-in path doesn’t bitrot.
- Example config:
[processor.python_package] build_by_default = false src_dirs = ["."] - Design considerations:
@allmeta-shortcut: the existing@checker/@generatoraliases should continue to mean “all of that type, subject to the default-off filter.” Users who want “all checkers including on-demand ones” would sayrsconstruct build --all -p @checker— rare enough that the composition is fine.- Error on contradiction:
-p X -x Xalready errors;-p Xwhere X hasbuild_by_default = falseshould just work (explicit opt-in wins over declarative opt-out). - Watch mode:
rsconstruct watchshould honour the same default — don’t rebuild the package processor on every file save. Users who want watch-mode packaging can add-p python_packageto the watch invocation. - Discovery cost: on-demand processors still run discovery every build, because we need to know what their products would be (for output-conflict detection, graph completeness, and
--allsupport). This is negligible — discovery is O(files matched), not O(cost of running).
- Follow-up idea: named goals (meson-style aggregated targets or npm-style scripts) for the “I want a lint goal / deploy goal / ci goal” pattern. That’s Pattern B, layered above per-processor config — not needed to solve the basic on-demand case.
- Urgency: medium | Complexity: low
Decomposed cache key for richer --explain
- Today every product has a single descriptor key that mixes input checksum + config hash + tool-version hash + variant. A miss tells us “the key changed” but not which component.
--explaincan only sayBUILD (no cache entry)/BUILD (output missing)— not “your cflags changed” or “an input file changed”. - Store the three sub-hashes (input, config, tool) in a new redb table keyed by stable product identity —
(processor_iname, primary_path)whereprimary_pathis the first output for generators or the first input for checkers. - Schema:
product_components: (processor, primary_path) -> { input_hash, config_hash, tool_hash, timestamp }. ~100 bytes per product, so ~500KB extra disk for a 5000-product project. - Reads only on
--explain.classify_productsalready routes throughexplain_descriptor; extend that to look up the prior components row, recompute current components, diff the three, and return a richer reason likeBUILD (config changed: cflags, include_paths). - Writes only when explicitly tracking. Two reasonable gates:
- Option A (single flag):
--explainenables both write and read. CI runs without--explain→ zero overhead. Trade-off: the first explain run after enabling has no prior row → reports “no prior state” generically. Subsequent runs work fully. - Option B (separate
--track-changes/[build] track_changes = true): decouples capture from query. CI omits the flag → zero overhead. Devs opt in permanently via config. - Lean Option A: fewer flags, the existing
--explaincarries both ends of the lifecycle, and CI/CD pays nothing by default since neither flag is set.
- Option A (single flag):
- Tier 1 only. Says “input bucket changed” but not which file. For a
.ccfile with 100 headers, the user still doesn’t know which header. A future Tier 2 (per-input-file checksums) would resolve that at ~5-10x storage cost; defer until users ask. - Caveats: adds a third source of truth (alongside
descriptorsand the in-memory graph) to keep in sync. Stale entries (products dropped from config) accumulate harmlessly untilcache clear. - Urgency: medium | Complexity: medium
Caching & Performance
Deferred materialization
- Don’t write cached outputs to disk until they’re actually needed by a downstream product.
- Urgency: low | Complexity: high
Garbage collection policy
- Time-based or size-based cache policies: “keep cache under 1GB” or “evict entries older than 30 days.”
- Config:
max_size = "1GB",max_age = "30d",gc_policy = "lru". rsconstruct cache gcfor manual garbage collection.- Urgency: low | Complexity: medium
Shared cache across branches
- Surface in
rsconstruct statuswhen products are restorable from another branch. - Already works implicitly via input hash matching.
- Urgency: low | Complexity: low
Merkle tree input hashing
- Hash inputs as a Merkle tree rather than flat concatenation.
- More efficient for large input sets — changing one file only rehashes its branch, not all inputs.
- Also enables efficient transfer of input trees to remote execution workers.
- Urgency: low | Complexity: medium
Batched stat via io_uring (Linux)
combined_input_checksumcallsfs::metadataserially per input. Currently ~0.19% of CPU on a 10k-product project, so not a priority.- Becomes interesting at 100k+ products, on cold-cache CI runs, or against network filesystems where each
statxis a round-trip. - Try
rayon::par_iter+fs::metadatafirst; reach forio-uringonly if that is insufficient. Linux-only — must live behindsrc/platform.rs. - See Fast
staton Linux for the design notes. - Urgency: low | Complexity: medium
Reproducibility
Hermetic builds
- Control all inputs beyond tool versions: isolate env vars, control timestamps, sandbox network, pin system libraries.
- Config:
hermetic = true,allowed_env = ["HOME", "PATH"]. - Verification:
rsconstruct build --verifybuilds twice and compares outputs. - Urgency: low | Complexity: high
Determinism verification
rsconstruct build --verifymode that builds each product twice and compares outputs.- Urgency: low | Complexity: medium
CI & Reporting
CI config generator
rsconstruct ci generateoutputs a GitHub Actions or GitLab CI config that runs the build.- Detects enabled processors and required tools, generates install steps and build commands.
- Supports
--format=github|gitlab|circleci. - Urgency: medium | Complexity: medium
HTML build report
- Generate a visual HTML dashboard of build times, cache hit rates, and processor statistics.
rsconstruct build --report=build.htmlorrsconstruct report.- Include charts for timing trends, per-processor breakdown, cache efficiency.
- Urgency: low | Complexity: medium
PR comment bot
- Post build results (pass/fail, timing, warnings) as a GitHub PR comment.
rsconstruct ci commentreads build output and posts via GitHub API.- Urgency: low | Complexity: medium
Content & Documentation
rsconstruct init --detect
rsconstruct smart autoalready scans and enables processors, but a dedicatedinit --detectcould go further.- Generate a complete
rsconstruct.tomlwith processor-specific config (src_dirs, extensions, tool paths). - Urgency: medium | Complexity: medium
rsconstruct fmt — Auto-format rsconstruct.toml
- Sort
[processor.*]sections alphabetically, align values, remove redundant defaults. - Urgency: low | Complexity: low
Cross-project term sync
- Automatically keep terms directories in sync across multiple repos.
- Could run as a daemon or a periodic CI job.
rsconstruct term sync --repos=repo1,repo2or config-driven.- Urgency: low | Complexity: medium
Glossary generator
rsconstruct term glossarygenerates a markdown glossary from the terms directory.- Optionally pulls definitions from context in the markdown files where terms are used.
- Urgency: low | Complexity: medium
Link checker processor
- Validate that URLs in markdown files are not broken (HTTP HEAD requests).
- Configurable timeout, retry, and allow/blocklist patterns.
- Cache results to avoid re-checking unchanged URLs.
- Urgency: medium | Complexity: medium
Image optimizer processor
- Compress and resize images referenced in markdown files.
- Uses tools like
optipng,jpegoptim,svgo. - Config: quality levels, max dimensions, output format.
- Urgency: low | Complexity: medium
HTML+JS compression and packaging
- Minify and bundle HTML, CSS, and JavaScript files for deployment.
- Could use tools like
terser(JS),csso(CSS),html-minifier(HTML). - Bundle multiple JS/CSS files into single outputs, generate source maps.
- Integrate with existing eslint/stylelint processors for a full web frontend pipeline.
- Urgency: medium | Complexity: medium
Processor Ecosystem
WASM processor plugins
- Beyond Lua, allow processors written in any language compiled to WebAssembly.
- Provides sandboxing, portability, and language flexibility.
- WASI for filesystem access within the sandbox.
- Urgency: low | Complexity: high
Processor marketplace / registry
- A central repository of community-contributed processor configs.
- Install with
rsconstruct config install prettier. - Registry as a GitHub repository with a JSON index. Version pinning in
rsconstruct.toml. - Urgency: low | Complexity: high
Cleaning & Cache
Time-based cache purge
rsconstruct cache purge --older-than=7dto remove cache entries older than a given duration.- Currently only
cache clearexists which removes everything. - Walk the object store, check file mtimes, remove old entries.
- Urgency: medium | Complexity: low
Enhanced cache statistics
rsconstruct cache statscurrently shows minimal info.- Add: hit rate percentage, bytes saved vs rebuild time, per-processor breakdown, slowest processors.
- Helps users identify optimization opportunities.
- Urgency: medium | Complexity: medium
CLI & UX
Configuration
Environment variable expansion in config
- Allow
${env:HOME}or${env:CI}inrsconstruct.tomlto reference environment variables. - The variable substitution system already exists for
[vars]; extending it to env vars is natural. - Useful for CI/CD systems that pass secrets or paths via environment.
- Urgency: medium | Complexity: low
Per-processor batch size
- Each processor config has a
batchboolean, but batch size is global ([build] batch_size). - Different tools have different startup costs — fast tools benefit from large batches, slow tools from small ones.
- Add
batch_sizefield to individual processor configs, overriding the global default. - Urgency: medium | Complexity: low
Namespace processor sections by type
- Today a section is
[processor.TYPE.NAME]or[processor.TYPE.NAME.INSTANCE], and nothing in the section header says whetherNAMEis a checker, a generator, a creator, or an explicit processor. Reading someone else’srsconstruct.tomlyou have to already know thatmakechecks,cargocreates, andtagsgenerates. - Proposal: put the processor type into the section path, so every section is
[processor.TYPE.NAME]for a single instance and[processor.TYPE.NAME.INSTANCE]for named instances:[processor.checker.ruff] [processor.checker.pylint.core] [processor.generator.generic.marp.slides] [processor.creator.generic.cargo] - Benefits:
- Self-documenting config: the role of every section is visible in its header.
- Fixed-depth section paths. Today the second segment is a type or an instance depending on how many segments there are; with the type prefix the depth alone says single vs. named instance.
- Reads like the existing
@checker/@generatorgroup shortcuts for-p. - A wrong type is a config error the loader can report, which doubles as a check that the user knows what they declared.
- Costs and open questions:
- Redundant information: the type of
ruffis fixed by its plugin entry, so the user is repeating what the tool already knows. The only thing the loader can do with it is reject a mismatch. - Instance names leak everywhere:
-p processor.checker.pylint.core,out/processor.checker.pylint.core, cache keys, build stats. Either the iname becomeschecker.pylint.core(longer-p, longer output dirs, every cache key changes) or the section path and the iname stop matching (new thing to learn). Decide this first; it drives everything else. explicitis both a processor type and a processor name, so the naive form is[processor.explicit.generic.explicit.foo]. Either rename the explicit processor or treatexplicitas a type with a single implicit processor.- Four-level TOML headers for named instances.
- Migration touches every
[processor.*]section in every fleet repo (about 155 distinct sections as of 2026-10-03). No back-compat shim; one pass over the fleet, thenrsmultigit check sameclean.
- Redundant information: the type of
- Related, smaller fix to do first: rename the
scriptchecker tocheckerso the four generic user-command processors (checker,generator,creator,explicit) all carry their role in their name. About six in ten fleet sections are instances of these four, so this alone removes most of the ambiguity. If the itch survives that rename, revisit this namespace idea. - Urgency: low | Complexity: medium
Processor Ecosystem
Flake8 (Python linter)
- Many projects still use flake8 over ruff. Widely adopted.
- Checker processor using
flake8. Batch-capable. - Urgency: medium | Complexity: low
Security
Shell command execution from source file comments
EXTRA_*_SHELLdirectives execute arbitrary shell commands parsed from source file comments.- Document the security implications clearly.
- Urgency: medium | Complexity: low
Internal Cleanups
These are code-quality items surfaced by an architecture audit. Each is
localized; none block features. See architecture-observations.md for
larger structural items.
Consolidate processor discovery helpers
src/processor/mod.rsexposesdiscover_checker_products,discover_directory_products,checker_discover,checker_auto_detect,checker_auto_detect_with_scan_root,scan_or_skip— all similar, with subtle differences (some auto-applydep_auto, some don’t; some validate scan roots, some don’t).- Choosing the wrong helper is a silent correctness issue: a processor that picks
discover_checker_productswhen it neededchecker_discoverlosesdep_automerging and never finds out. - Collapse to one or two helpers with explicit flags for the variations. Document the contract each helper commits to.
- Urgency: medium | Complexity: low
Remove / complete remote_pull scaffold in ObjectStore
src/object_store/mod.rshas aremote_pullfield andtry_fetch_*helpers inoperations.rsthat nothing calls.- Either finish the feature (wire the fetch helpers into the classify path) or delete the scaffold. Unused public-ish surface rots.
- Urgency: low | Complexity: medium (complete) / low (delete)
Drop or use processor_type on ProcessorPlugin
src/registries/processor.rshasprocessor_typemarked#[allow(dead_code)]with a comment about a futureprocessor list --type=checkerfilter.- Either ship the filter or drop the field until it’s needed. Dead fields with comments accumulate.
- Urgency: low | Complexity: low
TOOLS registry is monolithic and unsorted
src/processor/mod.rshas ~170 entries in a static array mixing Python, Node, Ruby, Rust, Perl, System categories with no alphabetic ordering within groups.- Hard to find a tool when adding one; hard to audit for gaps (a tool with no install command makes
doctorsilently unhelpful). - Split per-runtime into separate files or sort alphabetically within a section. Add a unit test that every processor’s
required_tools()entries have a matchingTOOLSrow (this test exists — keep it; make the table easier to satisfy). - Urgency: low | Complexity: low
Centralize alias expansion
expand_aliasesinsrc/builder/build.rshandles@checker/@generator/@toolname/ bare-name syntaxes. It’s called once for-pand once for-x. Any new alias shortcut has to be added there.- No duplication today, but the function is in
build.rsdespite being useful elsewhere (completion,processor list,analyzer used). Move to a dedicated module and make it the canonical expander. - Urgency: low | Complexity: low
Inconsistent error-handling idioms in processors
- Some processors use
anyhow::bail!, someanyhow::Context::with_context(), some construct custom messages. The coding-standards doc already calls forwith_contexton every I/O operation, but processor-level error shape varies. - Pick one idiom per category (tool-failure vs. config-error vs. internal-error) and retrofit. Makes
--jsonerror events more uniform too. - Urgency: low | Complexity: low
Config validation timing
- Unknown-field and must-field validation runs inside
Config::load, which is correct. However, some cross-field validations (e.g. “cc_single_file needs include_paths if compiling C++”) happen later during processor creation or build. - Either pull all semantic validation into
Config::load(sotoml checkcatches everything) or accept that semantic errors surface later and document which is which. - Urgency: low | Complexity: medium
product list CLI
- Users can run
rsconstruct graph show(full graph) orrsconstruct status(per-processor summary), but there’s no flat list of “here are every product that would execute, with its primary input and output.” - Add
rsconstruct product list(beside the existingproduct show, parallel toprocessor listandanalyzer used). Respects-p/-x/--targetfilters. - Urgency: low | Complexity: low
ProductTiming.start_offset not populated for batch execution
src/processor/mod.rsdefinesstart_offsetonProductTiming; it’s populated for non-batch execution but may be None for batch paths.- Trace visualizations (
--trace) look jagged or incomplete when batches are involved. - Urgency: low | Complexity: low
Completed Suggestions
Items from suggestions.md that have been implemented.
Completed Features
- Remote caching — See Remote Caching. Share build artifacts across machines via S3, HTTP, or filesystem.
- Lua plugin system — built, then removed on 2026-10-17: no repository ever declared a
[processor.lua.*]stanza, and the host could not take part in cache versioning or field validation. Custom commands go through the config-driven processors (script,generic) instead. - Tool version locking —
rsconstruct tool locklocks and verifies external tool versions. Tool versions are included in cache keys. - JSON output mode —
--jsonflag for machine-readable JSON Lines output (build_start, product_start, product_complete, build_summary events). - Native C/C++ include scanner — Default
include_scanner = "native"uses regex-based scanning. Falls back toinclude_scanner = "compiler"(gcc -MM). --processorflag —rsconstruct build -p processor.generator.tera,processor.checker.ruffandrsconstruct watch -p processor.generator.terafilter which processors run.- Colored diff on config changes — When processor config changes trigger rebuilds, rsconstruct shows what changed with colored diff output.
- Batch processing — ruff, pylint, shellcheck, zspell, mypy, and rumdl all support batch execution via
execute_batch(). - Progress bar — Uses
indicatifcrate. Progress bar sized to actual work (excludes instant skips), hidden in verbose/JSON mode. - Emit
ProductStartJSON events — Emitted before each product starts executing, pairs withProductCompletefor per-product timing. - mypy processor — Python type checking with mypy. Batch-capable. Auto-detects
mypy.inias extra input. - Explain commands —
--explainflag shows skip/restore/rebuild reasons for each product during build. -ppulls in the producers of its inputs —-p Bruns B and the processors that generate B’s inputs, transitively, as--targetdoes for products; every processor is discovered and the selection is made on the full graph (select_processors).-xexcludes a processor and everything downstream of it, and an excluded processor is never pulled back in.- Unchanged-output pruning — When a product rebuilds to identical bytes, the products reading it are skipped with their outputs untouched: no rerun, no restore. Each product is decided at dispatch from the inputs it will read (R1), and its stale outputs are removed only right before it actually restores or rebuilds — no longer for every predicted change before the run. Products predicted to change that never run (a failed dependency, an error, Ctrl+C) have their stale outputs removed at the end of the run, so a failed build still leaves none behind. Processors whose one tool run writes many products’ outputs (mass generators) replace them themselves (
Processor::replaces_own_outputs). - Ready-queue scheduler — Products run the moment their own dependencies finish, from a pool of exactly
-jworkers, instead of level by level. Batch chunks run in parallel and count against-j;max_jobsis enforced by the queue instead of semaphores. See R6/R7 in Architecture Observations.
Completed Code Consolidation
- Collapsed
checker_config!macro variants — Merged@basic,@with_auto_inputs, and@with_linterinto two internal variants (@no_linterand@with_linter). - Added
batchfield to all manually-defined processor configs — All processor configs now supportbatch = falseto disable batching per-project. - Replaced trivial checker files with
simple_checker!macro — 25 trivial checkers reduced from ~35 lines each to 3-5 lines (~800 lines eliminated). - Unified
lint_files/check_filesnaming — All checkers now usecheck_filesconsistently. - Moved
should_processguard into macro — Addedguard: scan_rootbuilt-in toimpl_checker!, removed boilerplateshould_process()from 7 processors. - Simplified
KnownFields— Scan config fields auto-appended by validation layer viaSCAN_CONFIG_FIELDSconstant;KnownFieldsimpls only list their own fields. - Extracted
WordManagerfor spellcheck/aspell — Shared word-file management (loading, collecting, flushing, execute/batch patterns) inword_manager.rs.
Completed New Processors
- mypy — Python type checking using
mypy. Batch-capable. Config:checker,args,dep_inputs,scan. - yamllint — Lint YAML files using
yamllint.src/processor/checker/yamllint.rs. - jsonlint — Validate JSON files for syntax errors.
src/processor/checker/jsonlint.rs. - taplo (toml-lint) — Validate TOML files using
taplo.src/processor/checker/taplo.rs. - markdownlint — Lint Markdown files for structural issues. Uses
mdlormarkdownlint-cli. - pandoc — Convert Markdown to other formats (PDF, HTML, EPUB). Generator processor.
- jinja2 — Render Jinja2 templates (
.j2) via Python jinja2 library.src/processor/generator/jinja2.rs. - black — Python formatting verification using
black --check.src/processor/checker/black.rs. - rust_single_file — Compile single-file Rust programs to executables.
src/processor/generator/rust_single_file.rs. - sass — Compile SCSS/SASS files to CSS.
src/processor/generator/sass.rs. - protobuf — Compile
.protofiles to generated code usingprotoc.src/processor/generator/protobuf.rs. - pytest — Run Python test files with pytest.
src/processor/checker/pytest.rs. - doctest — Run Python doctests via
python3 -m doctest.src/processor/checker/doctest.rs.
Completed Test Coverage
- Ruff/pylint processor tests —
tests/processor/ruff.rsandtests/processor/pylint.rswith integration tests. - Make processor tests —
tests/processor/make.rswith Makefile discovery and execution tests. - All generator processor tests — Integration tests for all 14 previously untested generators: a2x, drawio, gem, libreoffice, markdown, marp, mermaid, npm, pandoc, pdflatex, pdfunite, pip, sphinx.
- All checker processor tests — Integration tests for all 5 previously untested checkers: ascii, aspell, markdownlint, mdbook, mdl.
Completed Caching & Performance
- Lazy file hashing (mtime-based) —
mtime_checkconfig (defaulttrue),fast_checksum()with MTIME_TABLE. Stores(path, mtime, checksum)tuples. Disable with--no-mtime. - Compressed cache objects — Optional zstd compression for
.rsconstruct/objects/. Config:compression = truein[cache]. Incompatible with hardlink restore (must userestore_method = "copy"). Checksums computed on original content for stable cache keys.
Completed Developer Experience
--quietflag —-q/--quietsuppresses all output except errors. Useful for CI scripts that only care about exit code.- Flaky product detection / retry —
--retry=Nretries failed products up to N times. Reports FLAKY (passed on retry) vs FAILED status in build summary. - Actionable error messages —
rsconstruct tool checkshows install hints for missing tools (e.g., “install with: pip install ruff”). - Build profiling / tracing —
--trace=file.jsongenerates Chrome trace format output viewable inchrome://tracingor Perfetto UI. rsconstruct build <target>— Build specific targets by name or pattern via--targetglob patterns and-d/--dirflags.rsconstruct why <file>/ Explain rebuilds —--explainflag shows why each product is skipped, restored, or rebuilt.rsconstruct doctor— Diagnose build environment: checks config, tools, and versions. Full implementation insrc/builder/doctor.rs.rsconstruct sloc— Source lines of code statistics with COCOMO effort/cost estimation.src/builder/sloc.rs.
Completed Quick Wins
- Batch processing for more processors — All checker processors that support multiple file arguments now use batching.
- Progress bar for long builds — Implemented with
indicatif, shows[elapsed] [bar] pos/len message. --processorflag for build and watch — Filter processors with-pflag.- Emit
ProductStartJSON events — Wired up and emitted before execution. - Colored diff on config changes — Shows colored JSON diff when processor config changes.
Completed Features (v0.3.7)
RSCONSTRUCT_THREADSenv var — Set parallelism via environment variable instead of-j. Priority: CLI-j>RSCONSTRUCT_THREADS> configparallel.- Global
output_dirin[build]— Global output directory prefix (default:"out"). Processor defaults likeout/processor.generator.marpare remapped when the global is changed (e.g.,output_dir = "build"makes marp output tobuild/marp). Individual processors can still override theiroutput_direxplicitly. - Named processor instance output directories — When multiple instances of the same processor are declared (e.g.,
[processor.generator.marp.slides]and[processor.generator.marp.docs]), each instance defaults toout/{instance_name}(e.g.,out/marp.slides,out/marp.docs) instead of sharing the same output directory. - Named processor instance names in error reporting — When multiple instances of the same processor exist, error messages, build progress, and statistics use the full instance name (e.g.,
[pylint.core],[pylint.tests]). Single instances continue to use just the processor type name. processor configwithout config file —rsconstruct processor config <name>now works without anrsconstruct.toml, showing the default configuration (same asdefconfig).tag collectcommand —rsconstruct tag collectscans the tags database for tags that are not in the tag collection (tags_dir) and adds them to the appropriate.txtfiles. Key:value tags go to{key}.txt, bare tags go totags.txt.rsconstruct statusshows 0-file processors — Processors declared in the config that match no files are now shown instatusoutput and the--breakdownsummary, making it easy to spot misconfigured or unnecessary processors.smart remove-no-file-processors— New commandrsconstruct smart remove-no-file-processorsremoves[processor.*]sections fromrsconstruct.tomlfor processors that don’t match any files. Handles both single and named instances.cc_single_fileoutput_dir from config — Thecc_single_fileprocessor now reads its output directory from the configoutput_dirfield instead of hardcodingout/processor.generator.cc_single_file. This fixes named instances (e.g.,cc_single_file.gccandcc_single_file.clang) which previously collided on the same output directory.clean unknownrespects .gitignore —rsconstruct clean unknownnow skips gitignored files. Previously it disabled .gitignore handling, causing intentionally ignored files (IDE configs, virtualenvs,*.pyc, etc.) to be flagged as unknown. RSConstruct outputs are still correctly identified via the build graph, so nothing is missed. Use--no-gitignoreto include gitignored files.- Cross-processor dependencies (fixed-point discovery) — Generator outputs are now visible to downstream processors on the first build. Discovery runs in a fixed-point loop: after each pass, declared outputs are injected as virtual files into the FileIndex, and discovery re-runs until no new products are found. This means a generator that creates
.mdfiles can feed pandoc/tags/spell-checkers in a single build, without needing a second build.
Completed Architecture Refactors
- Config provenance tracking — Every config field now carries
FieldProvenance(UserToml with line number, ProcessorDefault, ScanDefault, OutputDirDefault, SerdeDefault).rsconstruct config showannotates every field with its source. Usestoml_edit::Documentfor span capture. BuildContextreplacing process globals — All mutable process globals moved intoBuildContext: the three processor globals (INTERRUPTED,RUNTIME,INTERRUPT_SENDER) and the three checksum globals (CACHE,MTIME_DB,MTIME_ENABLED). Threaded through theProcessortrait, executor, analyzers, remote cache, checksum functions, and deps cache. Signal handler usesArc<BuildContext>.BuildPolicytrait — Extracted from the executor.classify_productsdelegates per-product skip/restore/rebuild decisions to a&dyn BuildPolicy.IncrementalPolicyimplements the current logic. Future policies (dry-run, always-rebuild, time-windowed) are a single trait impl.ObjectStoredecomposition —mod.rssplit from 664 → 223 lines into focused submodules:blobs.rs(content-addressed storage),descriptors.rs(cache descriptor CRUD),restore.rs(restore/needs_rebuild/can_restore/explain).
Completed Features (latest)
rsconstruct status --json— JSON output with per-processor counts (up_to_date,restorable,stale,new,total,native) and totals. Activated by--jsonflag.- Selective processor cleaning —
rsconstruct clean outputs -p processor.checker.ruff,processor.checker.pylintcleans only those processors’ outputs. Without-p, cleans everything. - Prettier processor — Checker using
prettier --check. Batch-capable. Scans.js/.jsx/.ts/.tsx/.mjs/.cjs/.css/.scss/.less/.html/.json/.md/.yaml/.yml.src/processor/checker/prettier.rs. - Bare
cleanrequires subcommand —rsconstruct cleannow errors with usage hint instead of silently defaulting toclean outputs. - Nondeterministic test race fix — Fixed TOCTOU race in
store_descriptorwhere parallel writers could getPermission denied. Now retries after forcing writable on first failure. - Suppress status line for non-build commands — The
Exited with SUCCESS/ERRORfooter only shows forbuild,watch, andclean. - Configurable graph validation — Four checks run after
resolve_dependencies(): (1) reject empty inputs (default on), (2) validate dep references (default on), (3) detect duplicate inputs within same processor (default off), (4) early cycle detection (default off). Config:[graph]section fieldsvalidate_empty_inputs,validate_dep_references,validate_duplicate_inputs,validate_early_cycles. - Checksum globals moved to BuildContext —
CACHE,MTIME_DB,MTIME_ENABLEDmoved fromsrc/checksum.rsstatics intoBuildContext.combined_input_checksum,checksum_fast,file_checksumall take&BuildContext. Completes the isolated-build-context story. rsconstruct fixcommand — Runs fixers (auto-format, auto-fix) on source files. Checkers declare fix capability viafix_subcommand/fix_prepend_argsonSimpleCheckerParams.processor listshows aFixcolumn. Supports-pfiltering, batch execution, and--json. Fix-capable processors: ruff, black, prettier, eslint, stylelint, standard, taplo, rumdl, markdownlint. (Partly reverted 2026-08-04: fix capability was unused, so all ninecan_fixflags were set tofalse; today onlyscriptinstances with afix_commandare fix-capable, and processors are named as positional arguments torsconstruct fix runrather than with-p. Removed entirely on 2026-10-17: the one fleet use was ascriptinstance, and nothing else ever declared a fixer.)processor search—rsconstruct processor search <query>searches by name, description, and keywords. All 91 processors have keywords covering language, tool category, file extensions, and ecosystem terms. Supports--jsonoutput.
TODO
Nothing open.