Collectors
A collector gathers, a guardrail decides. Rather than every check shelling out to git or reading the same build files, the CLI runs a collector once per run, records what it found as JSON, and hands that document to every guardrail that asked for it. The check is then a function of the facts: no commands, no file reading, and the same data available to a guardrail you write yourself.
A guardrail declares what it needs in its definition, or its bundle declares it once for the whole category:
{
"collect": ["git"],
"check": { "run": "conventional-commits.py" }
}The check reads it back by name:
git = guardrail.facts("git")
if not git["resolved"]:
guardrail.skip("base ref %s is not resolvable" % git["baseRef"])
for commit in git["commits"]:
...| Collector | Collects | Inputs | Collected for |
|---|---|---|---|
clojure | The Clojure build in the project directory: which tool it uses, the Clojure version it depends on, the source paths and aliases it declares, the repositories it resolves from, and every dependency with its alias, origin and whether it is pinned. | projectDir | 4 guardrails |
codeowners | The CODEOWNERS file the repository carries, read as rules rather than as text: every pattern in file order with the owners it names, how many rules each owner is named by, and the owners that apply to the paths a guardrail asks about, resolved the way GitHub resolves them, where the last rule to match a path is the one that owns it. | paths | 6 guardrails |
coverage | The coverage the build already measured, read from whichever report format it left behind and normalized into one shape: the line and branch totals across every report, and a summary per file. The covered and missed line numbers themselves are left out unless lineDetail asks for them, because a large repository's line map is bigger than the facts document is allowed to be. Reports that name the same file are merged line by line, so a per module report and a merged one agree, but a report carrying only totals adds to them. | reports, maxFiles, lineDetail | 1 guardrail |
cpp | The C or C++ build in the project directory: which build system and package manager it uses, the C++ standard and CMake version it requires, the packages it declares and which of them are pinned, and the packages it expects the machine to carry. CMakeLists.txt is a script, so what it declares conditionally is not seen and scanned says so. | projectDir | 4 guardrails |
docker | Every Dockerfile in the tree, parsed without a tool: the stages it builds, the image each one starts from and how that image is pinned, the user the image ends as, the ports it exposes, the paths it copies in and the names of the build arguments and environment variables it declares, enriched with the findings of a hadolint report when the build already left one behind. | dockerfiles, reports, maxFindings | 5 guardrails |
dotnet | The .NET build in the project directory: the SDK it pins, every project file and the frameworks each targets, the packages they reference and which of them are pinned or supplied centrally, and the lock files beside them. | projectDir, maxProjects | 4 guardrails |
elixir | The Mix build in the project directory: the application it declares, the Elixir version it asks for, every application of an umbrella, the dependencies each manifest names with their environments and origins, and the lock file beside them. mix.exs is Elixir rather than a declaration, so what it declares conditionally is not seen and scanned says so. | projectDir | 3 guardrails |
env | Where the build is running: which CI provider, whether the runner is hosted or self hosted, what triggered it, and whether federated credentials are available to it. | envFile | 1 guardrail |
files | Presence, size and line counts of the well known files a repository is expected to carry, plus any extra path the guardrail asks for. | paths, path | 10 guardrails |
git | The repository, HEAD, the remotes, and every commit between the base ref and HEAD with its message, author, parents and the files the range changed. | baseRef | 6 guardrails |
golang | The Go build in the project directory: the module it declares, the Go version it asks for, every module of a workspace, the modules it requires directly and indirectly, and the replacements and lock file beside them. | projectDir | 4 guardrails |
gradle | The Gradle build in the project directory: its settings and manifest, every included project, the wrapper and the distribution it pins, the version catalog, and every dependency the build files declare or the lock files resolve, with the versions its platforms supply. | projectDir | 5 guardrails |
java | The Java version the Gradle or Maven build declares, how it declares it, and whether that mechanism makes the build reproducible, for the project and for every Gradle project it includes. | projectDir | 3 guardrails |
kotlin | The Kotlin version the Gradle or Maven build declares and the file that declares it, for the project and for every Gradle project it includes. | projectDir | 1 guardrail |
maven | The Maven build in the project directory: the root pom.xml coordinates, its modules, its properties and every dependency it and its modules declare, with the versions its imported BOMs supply. | projectDir | 5 guardrails |
nodejs | The Node.js project in the project directory: its manifest, the Node version and package manager it asks for, every workspace it declares, the dependencies each manifest names and which of them are pinned, and the lock files committed beside them. | projectDir, maxWorkspaces | 4 guardrails |
php | The Composer project in the project directory: the package it declares, the PHP version and extensions it asks for, the packages it requires and which of them are pinned, and whether the lock file is committed. | projectDir | 3 guardrails |
provenance | What the build attested about the artifacts it produced, read from the in-toto statements it already wrote, whether bare or inside a DSSE envelope, and recorded as the document states it. Nothing here verifies a signature. | reports | 0 guardrails |
python | The Python project in the project directory: its manifest and build backend, the Python version it asks for, the requirements it declares and which of them are pinned, and the lock files committed beside them. | projectDir, requirements | 4 guardrails |
ruby | The Bundler project in the project directory: the Ruby version it asks for, the gem sources it resolves from, the gems each manifest declares with their groups and origins, and the lock file beside them. The Gemfile is Ruby rather than a declaration, so what it declares conditionally is not seen and scanned says so. | projectDir | 4 guardrails |
rust | The Cargo build in the project directory: the crate it declares, the Rust version and edition it asks for, every workspace member, the dependencies each manifest names with where each comes from and whether it is pinned, and the lock file beside them. | projectDir, maxMembers | 4 guardrails |
sbom | The components a build says it ships, read from the bill of materials it already wrote, whether that is CycloneDX or SPDX, JSON, XML or tag-value, normalized into one list carrying the licence each component names. | reports, maxComponents | 5 guardrails |
scala | The sbt build in the project directory: the Scala and sbt versions it pins, every subproject the reader saw, the library dependencies each build file names with their configurations, and the plugins the build itself runs. build.sbt is Scala rather than a declaration, so what it declares indirectly is not seen and scanned says so. | projectDir | 4 guardrails |
scan | Findings from whatever scanner the build already ran, read from the report it left behind and normalized into one shape, whether the tool was a SAST, an SCA, a secret detector or an infrastructure scanner. | reports, maxFindings | 5 guardrails |
secrets | Secrets found in the checkout, read from the report a secret scanner already left behind, or collected by running gitleaks or trufflehog when the runner carries one and no report is there. Only the rule, the file, the line and a fingerprint are collected: the matched value never is, because these facts are attached to the run event. | reports, scanHistory, maxFindings | 2 guardrails |
terraform | The Terraform configuration in the checkout: every root directory holding .tf files, the backend it stores state in, the providers and modules it takes on and how tightly they are pinned, the resources it declares, the variables it takes, the lock file that resolves it, and any state file left in the tree. Read out of the files themselves, so it works on a runner with no Terraform installed, and enriched with the versions an initialised root has already resolved when the terraform CLI is there. | directory | 6 guardrails |
tests | The test results the build already wrote, read from whichever report format it left behind and normalized into one shape: the totals across every report, and the cases that failed, errored or were skipped. The passing cases are left out, because a guardrail gates on the others and Buildnote already carries every case as a test event. Nothing identifies a case across reports, so a build writing both a report per class and a merged one counts every case in both. | reports, maxFailures | 3 guardrails |
workflow | The pipeline definitions in the checkout, read as they are written: which triggers start them, what permissions they hand a job, which runner each job asks for, and every action a step reaches for and how tightly it is pinned. Names and shapes only, never a secret, an input value or an environment value. | pipelines | 5 guardrails |
Facts are collected once per run for each distinct set of the inputs the collector declares, so two guardrails asking for the same range collect once between them. A collector only ever sees the inputs it declares, which is what makes that sharing safe.
A collector can collect from other collectors rather than gathering the same thing twice. The CLI runs those first and hands it their facts, which it reads back with collector.collected("<name>"). They are still collected once per run, so a guardrail asking for both a collector and one it collects from pays for each once.
A collector that fails, times out or prints anything other than a JSON object collects nothing rather than failing the build. Its key is then absent from the facts, and guardrail.facts("<name>") skips the guardrail with a reason instead of raising, so a broken collector costs you a verdict rather than a red build.
Where a collector lives
Collectors ship in the same library as the guardrails. collectors/ and guardrails/ are two sibling namespaces under the library root, which is why a collector name can never collide with a guardrail id. Each collector is a directory of its own holding a collector.json, the script it runs, the icon that stands for it and the test that covers it:
collectors/<name>/collector.json
collectors/<name>/<name>.py
collectors/<name>/icon.svg
collectors/<name>/test_<name>.pyThe directory name is the collector name, which is also the key its facts arrive under. The definition beside it carries the rest:
| Field | Required | Meaning |
|---|---|---|
id | yes | The collector name, which must match the directory holding it. |
version | yes | The library version this collector is published under. It must match the version of the guardrail asking for it. |
name | yes | Title shown in these pages. |
description | yes | What the collector gathers, in one sentence. |
run | yes | Script path relative to the collector directory. Its extension picks the interpreter. |
icon | yes | An SVG beside the definition, shown against the collector in these pages. Draw it in currentColor so it follows the reader's theme, and use the technology's own mark where it has one. |
timeoutSeconds | no | How long the collector may take. Past it, it collects nothing. |
inputs | no | The inputs the collector reads, each with a default and a description. |
collect | no | Collectors this one is handed the facts of, gathered first and read back with collected(name). |
facts | yes | Every fact the collector prints, as a path into its document, each with what it means. |
A guardrail resolved from another repository still gets them: the CLI looks for collectors/<name>/collector.json in that guardrail's own source first and falls back to the built-in library, so a custom guardrail can declare "collect": ["git"] and read the same document as the ones Buildnote ships. A sha256 pin covers a guardrail's check script only, not the collectors it asks for.
Because a collector resolves at the version of the guardrail asking for it, the layout and the library version move together: a collector declaring a version other than the one it was asked for collects nothing.