rubric-tool
v0.2.1
Published
Rubric — a governance checker for JSON and YAML documents that tells you what it found, and never passes silently
Maintainers
Readme
Rubric
A governance checker for JSON and YAML that tells you what it found — and never passes silently.
openapi.yaml
error 16:7 operation-summary
$.paths['/pets'].post — `summary` is empty
Every operation needs a summary. It is what shows in the endpoint list.
error 14:11 error-responses-described
$.paths['/pets'].get.responses['404'] — `description` is missing
An error response needs a description saying when it happens.
1 rule matched nothing
webhooks-documented
the selector found no nodes. Either this document has none, or the rule
is wrong. If the rule only applies to some documents, add a `when`
saying which.
2 errors, 1 broken ruleStatus: early. The engine, the ruleset language and both CLIs work. It has not been used in anger by anyone, and 1.0 is not close.
Install
cargo install rubric-tool # native binary
npx rubric-tool --help # WASM, no installBoth are the same code. render() lives in the Rust core, so the two front ends
produce byte-identical output and the same exit codes.
Use
rubric --rules rubric.yaml openapi.yaml
rubric --rules rubric.yaml specs/*.yaml --json
rubric --rules rubric.yaml --project rubric/project.yaml specs/**/*.yaml| exit | |
|---|---|
| 0 | nothing failing — which can include rules that did not apply, and waived findings, all of them listed |
| 1 | an error, a broken rule, a document that does not parse, or a run that failed as a whole: a ruleset that applied to none of the files, or rules limited to profiles with no --project |
| 2 | the ruleset or the project file does not load |
--json prints one document for the whole run:
{
"files": [
{
"file": "openapi.yaml",
"notApplicable": null,
"findings": [],
"brokenRules": [],
"notApplicableRules": [],
"waived": [],
"expiredWaivers": [],
"counts": { "errors": 0, "warnings": 0, "broken": 0, "notApplicable": 0, "waived": 0 }
}
],
"notices": [],
"failed": false
}notApplicable is the reason the ruleset's applies-to excluded the document,
or null. A document that does not parse appears as { "file", "error", "line" }.
notices are what is true of the run rather than one file, each with failing
and message. What changed in this shape between versions is in
CHANGELOG.md.
A ruleset
rules:
- id: operation-summary
select: $.paths.*[[email protected]]
require: summary
message: Every operation needs a summary. It is what shows in the endpoint list.
- id: path-segments-kebab
select: $.paths.*.@property
casing:
style: kebab
separator: { char: "/", allowLeading: true }
message: Path segments are kebab-case. /petStore should be /pet-store.
- id: operation-ids-unique
select: $.paths.*[[email protected]]
unique: operationId
message: Two operations share an operationId. Generated clients will collide.Fourteen assertions — require present absent match not-match casing
one-of length exactly-one-of at-least-one-of unique sorted
referenced none — documented in
the ruleset spec. Selectors are RFC 9535 JSONPath plus
keys and ancestors, in the selector spec.
Nothing in it is OpenAPI-specific. The engine reads JSON and YAML; that last example above would work unchanged on a Kubernetes manifest or a CI config.
Rules can also depend on what a document cannot say about itself. A project
file assigns profiles by path — public: [apis/public/**] — and a rule with
profiles: [public] applies only to those documents. A misspelt profile is a
broken rule, and a run that forgot --project fails rather than quietly skipping
every profiled rule. See
the spec.
The project file can also hold waivers: a rule's findings accepted in some
documents, with a required reason and an optional until date. A waived finding
is still reported, marked with its reason; it just does not fail the run. An
expired waiver's findings come back, and one that no longer waives anything is
noted so it can be removed. until ends the waiver, never the rule: it is for a
grace period, such as documents written before a rule existed, which must comply
by a date. See
the spec.
Three decisions worth knowing before you use it
A rule that matches nothing is a broken rule. Every comparable tool treats
an unmatched selector as silence, which makes a typo indistinguishable from a
clean document — and that is how a style guide quietly stops guarding anything.
Here it is reported, counted separately, and fails the build. When a rule only applies to some
documents, a when says which: a document without webhooks gets the webhook
rule reported as not applicable, while one with webhooks and a mistyped
selector still gets it reported as broken. See
the spec.
message is required, and it says what to do. Not "expected value to be
truthy" — that describes a failed assertion, not a problem, and it is why
people scroll past linter output. Write the sentence you would say to whoever
has to fix it.
require accepts false and 0. They are answers. deprecated: false is
a documented fact and retries: 0 is a configured value. Other linters use
JavaScript truthiness here and reject both — emptiness is a property of JSON
values, truthiness is a property of JavaScript, and it has no business in a
vocabulary about documents.
Rules to start from
rulesets/openapi.yaml is a starter ruleset for OpenAPI 3.x — seventeen rules
covering the gaps a reader would actually notice: undescribed responses and
parameters, missing operationIds, untagged operations, naming that generated
clients will mangle.
rubric --rules rulesets/openapi.yaml openapi.yamlCopy it and edit it. There is no extends, so the copy is yours — and a
ruleset that cannot be silently changed underneath you is the point, not an
omission. CI holds it to both halves of being useful: a clean document produces
nothing and no broken rules, and a bad one produces findings. The second is
what stops the first being satisfied by a ruleset that asserts nothing.
It declares applies-to OpenAPI 3, so run against another format or a Swagger
2.0 document it reports that document as not applicable, rather than handing it
findings from rules it was never meant to satisfy.
Coming from Spectral
Every Spectral core function maps onto one assertion, so importing a ruleset is
mechanical: given → select, then.function → the assertion key,
then.functionOptions → its value, then.field appended to the selector. The
ruleset spec has the table and the
two translations that are not one-to-one.
The differences you will notice first: one level instead of three, a message that has to say something, and rules that shout when they match nothing.
As a library
use rubric::{check::check, document::Document, render::{render, Style}, rule::Ruleset};
let doc = Document::parse(&yaml).expect("document does not parse");
let rules = Ruleset::parse(&ruleset).expect("ruleset does not load");
let report = check(&doc, &rules);
print!("{}", render(&report, "openapi.yaml", Style::Human { color: false }));examples/readme.rs is that snippet as a compiled example — it produces the
output at the top of this file, so cargo run --example readme fails if either
drifts.
Embedding it in another Rust program — Docy links it from src-tauri this way:
rubric-tool = { version = "0.2", default-features = false, features = ["serde"] }default-features = false drops clap, which only the CLI needs; serde puts
Serialize on Report and everything in it, so findings cross a Tauri command
without being re-modelled on the way.
const { check_document } = require('rubric-tool')
process.stdout.write(check_document(document, ruleset, 'openapi.yaml', 'human'))check knows only the document. check::check_with also takes a Context —
the project file, the document's path, and today's date for waivers — and
run::check_run checks several documents together and adds the run-level
notices. From JavaScript, check_files is the whole run, and is what the
rubric command itself calls.
How it is built
One Rust crate is the only place any logic lives. Two implementations of a governance tool is precisely the failure such a tool exists to prevent — a ruleset that passes in CI and fails in an editor is worse than no ruleset.
| | |
|---|---|
| selector | RFC 9535 JSONPath, plus .@property, @parent, @grandparent and ~= |
| document | one parse producing both the value tree and a path → span index |
| rule | the ruleset language |
| check | the fourteen assertions, and the guards that decide whether a rule applies |
| project | the project file: profiles, and the path patterns that assign them |
| waiver | waivers, and the dates they are checked against |
| run | several documents as one run, and what is true of the run as a whole |
| render | what a person reads — in the core, so no front end can drift |
The npm package is the same crate compiled to WASM (106 KB gzipped), not a reimplementation. The core does no I/O — it takes strings and returns a report — so JavaScript reads the files and no WASI or filesystem shim is needed.
Selectors are parsed, never evaluated. The obvious implementation is
jsonpath-plus, which runs filters through a JavaScript evaluator; that is what
CVE-2024-21534 was, and a ruleset may be imported from elsewhere. A selector
here parses into a structure that can only describe a selection, and there is
deliberately no custom-function mechanism at all.
Regular expressions use regex-lite: linear time, so a hostile pattern cannot
hang the process, and small enough to ship as WASM. The cost is ASCII-only
character classes.
Development
cargo test # and --features serde, for the embedder's types
cargo clippy --all-targets
cargo run --example readme # the snippet above, diffed against this README
npm run build # wasm-pack into npm/pkg, then check the tarball
tools/check-parity.sh # both front ends, byte for byte
tools/check-rulesets.sh # the starter ruleset: clean, loud, and silent on other formats
node tools/check-versions.mjs # Cargo.toml, npm/package.json and the tag agreeChanges are recorded in CHANGELOG.md, with anything breaking listed as such.
CI runs all of that on every branch, and a v* tag publishes to crates.io and
npm from one commit — npm gets the artifact the parity check ran against rather
than a second build made the same way.
Tests are checked by mutation, not just by passing: break the code on purpose and confirm a test notices. Several in here exist because a first version did not.
The build script ends by refusing a package tarball with no .wasm in it.
wasm-pack writes a .gitignore containing * into its output directory, and
npm reads a nested .gitignore as an exclusion list — so files: ["pkg"]
publishes three files, a valid package, and an npx rubric-tool that cannot
find its own binary. It is the failure this tool is named for, in its own
release process.
Licence
Apache-2.0.
