VibeGuard is built to run as a pre-merge gate in CI. A gate is only worth enforcing if its behaviour is predictable across releases. This page is the contract: it states which surfaces are stable, how compatibility is handled, and what counts as a breaking change.
Anything documented as stable here will not change in a backwards- incompatible way without a major version bump and a migration note in the release notes. Anything marked experimental may change in a minor release.
Scope: this contract covers the user-facing product surface (CLI, outputs, finding IDs, config). The plugin API has its own contract in
plugin-api.md, governed byPLUGIN_API_VERSION.
| Surface | Stability | Notes |
|---|---|---|
scan, gate, init, explain, rules, publish-check, baseline, version commands |
Stable | Names, exit-code meanings, and core flags |
Exit codes (0 / 1 / 2) |
Stable | See Exit codes |
Finding IDs (SEC-ENV, MAP-DIST, …) |
Stable | Renames are breaking; deprecations are documented |
| Rule IDs / config section keys | Stable | The rule id ↔ config_key mapping is published in rules.md |
| JSON / SARIF / diagnostics output schemas | Stable | Documented in output-schemas.md |
| Console (Rich) table layout | Experimental | Human-facing; may be re-laid-out without a major bump |
| Markdown / PR-comment wording | Experimental | Structure is stable; exact prose is not |
vibeguard.yaml schema |
Stable, additive | New keys are additive; removing/repurposing a key is breaking |
dev-namespaced / undocumented commands |
Experimental | Not part of v1 |
These commands and their documented behaviour are stable for v1:
vibeguard init— create avibeguard.yaml. Never overwrites an existing config; exits0with a notice if one is present.vibeguard scan— informational. It prints findings and always exits0on findings, even with--fail-on—--fail-ononscanonly sets the severity used to mark findings as "blocking" in the output; it does not turn findings into a non-zero exit. Operational errors (a bad--path, malformed config) are the exception: like every command,scanstill fails closed with exit2when it cannot run — see Exit codes.vibeguard gate— runs the same analysis asscanbut exits1when findings meet or exceed the--fail-onthreshold.vibeguard explain <ID>— remediation for a finding ID.vibeguard rules list/vibeguard rules explain <rule-id>— rule discovery.vibeguard publish-check— gate on the published file set for npm / python sdist.vibeguard baseline create|update— manage accepted-findings baselines.vibeguard version— version / Python / platform / install path.
The scan vs gate distinction is the most important contract in this
file: scan never fails your build on findings; gate is the thing you
put in CI. (Both still exit 2 on an operational error such as a bad path
— "informational" means "findings don't fail the build", not "scan can never
exit non-zero".) This split is guaranteed by tests (tests/test_cli.py,
tests/test_cli_e2e.py::TestScanFailOnHelp, ::TestPathValidation).
| Code | Meaning | Applies to |
|---|---|---|
0 |
Success — analysis completed with no blocking findings (scan returns 0 for any completed analysis, regardless of findings) |
all commands |
1 |
Blocking findings at or above --fail-on |
gate, publish-check |
2 |
Operational/usage error (bad path, malformed config, unknown ID) — every command fails closed here, including scan |
all commands |
Exit 2 is reserved for "the tool could not run as asked". It is distinct
from 1 ("the tool ran and the gate failed") so CI can tell a real failure
from a misconfiguration.
- The
vibeguard.yamlschema is stable and additive: new optional keys may appear in minor releases with safe defaults. - Removing a key, renaming a key, or changing a default in a way that makes a previously-clean repo fail is a breaking change and requires a major bump.
- The
rule id↔config_keymapping is not always 1:1 (for example, therisky_diffrule is configured underrisky_patterns:). The authoritative mapping is published per-rule inrules.mdand viavibeguard rules explain <rule-id>. - A missing config file is not an error — VibeGuard loads built-in
defaults (
tests/test_config.py::test_load_nonexistent_returns_defaults). - A malformed or invalid config (bad
fail_on, unknownpolicy, etc.) fails closed with exit2rather than silently falling back (tests/test_config.py).
- Finding IDs are stable identifiers. Once a finding ID ships in a release it will not be renamed or repurposed without a major bump. If a finding is retired, its ID is documented as deprecated rather than reused.
- This is what makes
ignore.findings,severity_overrides, and SARIFruleIdcorrelation safe to pin in your own config. - Default severities are stable, but are user-overridable via
severity_overridesand policy packs. Raising a default severity (which can newly-block a previously-passing gate) is treated as a breaking change for the affected finding and called out in release notes. - Rule metadata (
rule_id,config_key,default_severity,confidence,finding_ids) is published inrules.md, regenerated from the registry bymake docs.
The machine-readable reporters are integration surfaces and are stable:
- JSON (
--json), SARIF (--sarif), and IDE diagnostics field shapes are documented inoutput-schemas.mdand guarded by golden snapshot tests (tests/test_reporters_golden.py,tests/test_sarif.py). New fields may be added; existing fields are not removed or retyped without a major bump. - The console (Rich) table and Markdown / PR-comment renderers are
human-facing. Their structure (a severity-sorted table; a PASS/FAIL
headline that reflects blocking findings) is stable, but exact column
widths and prose are experimental and may change in a minor release.
Do not parse the console output — use
--json/--sarif.
The plugin API is versioned independently via PLUGIN_API_VERSION
(currently 1.0) and documented in plugin-api.md.
Plugins import only from vibeguard.api; everything else under
vibeguard.* is internal and may change without notice.
VibeGuard follows semantic versioning for the surfaces above:
- MAJOR — a backwards-incompatible change to any stable surface (removed/renamed command, removed finding ID, removed/retyped output field, removed/renamed config key, raised default severity that can newly-block).
- MINOR — additive, backwards-compatible changes (new rule, new finding ID, new optional config key, new output field, new command).
- PATCH — bug fixes and detection-accuracy improvements that do not change the stable contract.
Detection improvements are a deliberate grey area: fixing a false negative can newly-block a repo even in a patch release. We bias toward shipping these in minor releases and calling them out in the release notes when they can realistically change a gate result. The release checklist codifies this.
A safety gate must never report success when it could not actually do its job. The following operational cases are guaranteed and test-backed:
| Case | Behaviour | Guaranteed by |
|---|---|---|
--path does not exist |
exit 2, error message, never "Gate passed" |
tests/test_cli_e2e.py::TestPathValidation (#81) |
--path is a file, not a directory |
exit 2, "must be a directory" |
tests/test_cli_e2e.py::TestPathValidation (#83) |
| Malformed / invalid config | exit 2, fail closed |
tests/test_config.py |
| Zero files scanned due to user error | surfaced, not silently "clean" | tests/test_cli_e2e.py (#83) |
scan --pr-comment with blocking findings |
headline is FAIL, not PASS | tests/test_pr_comment.py (#82) |
explain / rules explain unknown ID |
exit 2, consistent message |
tests/test_cli.py, tests/test_cli_e2e.py (#90) |
| Plugin fails to load | scan continues; failure is reported, not swallowed | tests/test_plugin_discovery.py |
git unavailable in --diff mode |
reported; does not crash the gate | diff-scope handling |
Base branch undetectable in --diff mode |
scan degrades to git diff HEAD and emits a diagnostic (shallow clones get a fetch-depth: 0 hint) — never a silent narrowing |
tests/test_diff_scope.py::TestDegradedGitDiagnostic (#182) |
Unverifiable explicit --base / git.base_branch |
warning recorded, falls back to detection — not silently ignored | tests/test_git.py (#208) |
--diff answers "what did this change introduce or touch", so its scope is
the change set, not the whole repository:
- A finding is reported only if its file is part of the diff. Findings attributable to files outside the diff (pre-existing repository state) are not reported, so a PR gate is never blocked by unrelated history.
- Line-level findings are kept only when they fall on added/changed lines; file-level findings on a changed file are kept.
- If a changed file produces no parseable line ranges (rename, binary, mode-only, parse gap), its findings are kept conservatively — scoping never loses signal.
- Diff-aggregate findings (
DIFF-SIZE,DIFF-BREADTH,DIFF-RISK-FILES) are always reported in diff mode.
The base ref is resolved as --base → git.base_branch config → automatic
detection (origin/main → origin/master → main → master). The diff text
contract is pinned (color.diff=never, core.quotePath=false, --no-ext-diff,
explicit a//b/ prefixes) so local git configuration cannot change scoping.
- Exact console colours, icons, and table column widths.
- Exact wording of human-facing messages and Markdown prose.
- Internal module layout under
vibeguard.*(usevibeguard.apifor plugins). - Benchmark numbers (informational; see
benchmark.md). - Anything explicitly marked experimental above.
If a release changes any stable surface above without a major bump and a migration note, that is a bug — please open an issue and reference this contract.