Discipline Roadmap & Milestones

This document establishes the delivery phases, dependency graph, go/no-go gates, shipped gates, planned extensions, and outstanding checks for discipline.

Superseded when: A milestone completes, a new phase is initiated, or planned gate scope evolves. Update in place; do not fork.


Dependency Graph

flowchart TD
    P0["Phase 0: Fail-closed core"] --> P1["Phase 1: AST sentinel (Rust pack) + hygiene + configurability"]
    P1 --> P2["Phase 2: Packaging, CI, release"]
    P2 --> P3["Phase 3: Language packs I (Python, JS/TS, PHPT)"]
    P2 --> P4["Phase 4: Gate integrity & test floors"]
    P2 --> P5["Phase 5: Command gates & verification presets"]
    P3 --> P7["Phase 7: Language packs II (Java/Kotlin, C/C++, Go)"]
    P4 --> P6["Phase 6: Production dogfooding & legacy script retirement"]
    P5 --> P6
    P4 --> P8["Phase 8: Agent-evasion hardening"]
    P8 --> P9["Phase 9: Review follow-ups"]
    P9 --> P10["Phase 10: Remainder remediation"]
    P7 --> P10
    P10 --> P11["Phase 11: Agent reach (hooks, MCP, PR comments, replay) and coverage parity (Swift, Scala, Objective-C)"]
    P7 --> P8
    P11 --> P12["Phase 12: Pull-request policy from forge facts (ratification, review threads, verified references)"]
    P11 --> P13["Phase 13: Multi-agent guardrails before the edit (ref guard, leases, pre-tool hooks)"]
    P12 --> P13
    P12 --> P14["Phase 14: doctor forge-settings parity (GitLab variables and job token, Gitea and Forgejo)"]
    P11 --> P15["Phase 15: discipline audit (escape hatches across history)"]
    P12 --> P15
    P13 --> P17["Phase 17: a turn that ends on an announced action (stop-hook contracts, opt-in end-of-turn check)"]

Phases 3, 4, and 5 depend upon Phase 2 and proceed in parallel. Phase 8 Tier 0 and Tier 1 depend only on Phase 4; Tier 2 adds facts to every language pack, so it follows Phase 7.


Shipped Gates

| Gate | Suite | Languages | Rule Description | |—|—|—|—| | agents-md | agent-guard | any | AGENTS.md exists; CLAUDE.md / GEMINI.md do not fork it | | assertion-reduction | agent-guard | Rust, PHPT, Python, JS/TS, Java, Go, PHP, C/C++, C#, Ruby, Kotlin, Swift, Scala, Objective-C | assertion count / strength must not drop in an existing test | | vacuous-tests | agent-guard | Rust, PHPT, Python, JS/TS, Java, Go, PHP, C/C++, C#, Ruby, Kotlin, Swift, Scala, Objective-C | new tests must carry a non-tautological assertion | | ignored-tests | agent-guard | Rust, PHPT, Python, JS/TS, Java, Go, PHP, C/C++, C#, Ruby, Kotlin, Swift, Scala, Objective-C | tests must not be newly #[ignore]d or skipped without directive | | unsafe-safety-comment | agent-guard | Rust | unsafe blocks / impls carry a // SAFETY: comment | | deletion-rationale | agent-guard | any | deleted files and removed tests need a scoped removes: rationale | | time-estimates | hygiene | any | no calendar / duration estimates in markdown or the PR body | | pii | hygiene | any | no home paths, LAN IPs, denylisted hostnames, or fixed-format token literals (private-key headers; AWS, GitHub, Slack, OpenAI and Anthropic token formats; literal bearer headers) in tracked text: literal format matching, not credential scanning | | agent-scratch | hygiene | any | agent scratch state is never tracked | | shell-secrets | hygiene | shell, docker, workflows | no secrets on command lines, fixed-format token literals, or unverified piped scripts in shell, docker, or CI files: literal pattern matching, not secret scanning | | issue-link | hygiene | any | PR title or description links a tracking issue (#123, Fixes #123) | | review-threads | hygiene | any | the pull request has no unresolved review thread | | ratified-paths | agent-guard | any | edits to protected paths carry an owner’s ratification on an issue the pull request closes | | citation-metadata | hygiene | any | CITATION.cff and .zenodo.json are valid, agree with each other, and cite the concept DOI | | commit-provenance | hygiene | any | commits carry the required trailers; an agent-produced commit carries a review by someone else | | config-integrity | integrity | any | a change cannot weaken its own discipline.toml without a token | | stub-bodies | agent-guard | Rust, Python, JS/TS, Java, Go, PHP, C/C++, C#, Ruby, Kotlin, Swift, Scala, Objective-C | added functions are not stubs; existing bodies are not replaced by todo!() / NotImplementedError / return null | | harness-tampering | agent-guard | Go, Python, JS/TS, Rust | a file the test runner loads does not make a failing run pass | | error-swallowing | agent-guard | Rust, Python, JS/TS, Java, Go, PHP, C/C++, C#, Ruby, Kotlin, Swift, Scala, Objective-C | no new empty error handler or discarded Result outside tests | | instruction-smuggling | agent-guard | any (invisible characters, instruction files); Rust, Python, JS/TS, Java, Go, PHP, C/C++, C#, Ruby, Kotlin, Swift, Scala, Objective-C and prose files (phrases) | no invisible Unicode, unreviewed agent-instruction edits, or instruction-like text in comments and prose | | build-hooks | integrity | package.json, build.rs, setup.py, .npmrc, .pypirc, pip.conf, .cargo/config.toml, .env* | install and build hooks that gain network or shell access, and package-manager configuration edits, need a token | | toolchain-config | integrity | tsconfig, ruff, mypy, pytest (addopts flags and -k / -m selection), coverage, flake8, Cargo lints, rustflags, nextest, eslintrc, golangci, jest and vitest (JSON or a literal object), package.json test scripts, codecov, phpstan, phpunit, and compiler warning flags in Makefile, CMake, setup.py and build.rs | compiler, linter, type-checker, test-runner and coverage configuration cannot be loosened without a token | | sandbox-config | integrity | Claude Code, Codex, Gemini CLI, Qwen Code, OpenCode, Cursor and Copilot CLI settings, MCP server lists, devcontainer.json, Docker Compose, CI job and service containers | a change cannot widen an agent’s permissions or sandbox, or a container’s isolation, without a token | | scope-confinement | agent-guard | any | changes stay inside authorized paths | | suppression-delta | agent-guard | per pack | newly added linter / compiler suppression annotations | | provenance-tags | hygiene | any | published numerics carry (measured|target|projected) | | ci-integrity | integrity | any | workflow weakening: continue-on-error, || true, unpinned actions | | ci-skip-set | integrity | any | rollup skip set matches each job’s if: under the observed filter outputs | | test-floor | integrity | any | test-count ratchet read from the base ref | | golden-output | integrity | any | prevents stealth edits to committed golden/test output files without explicit override | | dependency-delta | integrity | any | manifest diff inspection: zero wildcards, source/license allowlists, and deny.toml verification | | test-budget | integrity | Rust, Python, JS/TS, Go, any | property-test and fuzz effort ratchet (cases, shrink iters, fuzztime, seed corpus) | | pr-checklist | hygiene | any | ticked PR checkboxes and declared touched surfaces are reconciled against the diff | | command | verification | any | fail-closed wrapper for any tool: zero-tests guard, canary, count ratchet | | sanitizers | verification | Rust, C/C++ | ASan / TSan preset with audited suppressions and a race canary | | msrv | quality | Rust | cargo check under the pinned MSRV | | miri | verification | Rust | Miri tiers with zero-tests guard | | unsafe-budget | verification | Rust | unsafe count ratchet | | bench-regression | bench | Rust, Go, Python, C/C++ | benchmark drift via harness adapters (deterministic counts or BCa intervals) | | archive-contents | integrity | any | distribution archive must contain required paths and zero forbidden developer artifacts | | manifest-sync | integrity | any | reconcile git-tracked files against packaging manifest declarations | | version-lockstep | integrity | any | version declarations across headers, manifests, and files must remain in lockstep | | gate-command-lint | agent-guard | markdown, shell | reject vacuous, masked, or inverted verification commands in plan files | | mechanism-sections | hygiene | markdown | require (inferred) or (verified: ) evidence tags in root cause sections | | [`citation-anchors`](/discipline/gates/#citation-anchors) | hygiene | markdown, any | verify path:line@sha citations against quoted text and git history, and relative Markdown links against the tree | | [`claim-labels`](/discipline/gates/#claim-labels) | hygiene | markdown, any | require RUN or READ labels on verification claims in PR bodies, commits, and Markdown |


Milestone Phases & Go / No-Go Gates

Phase 0: Fail-Closed Core

Phase 1: Sentinel, Hygiene, Configurability

Phase 2: Packaging, Multi-Platform CI, Release

Phase 3: Language Packs I (Fact Model Split)

Phase 4: Gate Integrity & Test Floors

Phase 5: Verification Presets & Benchmark Adapters

Phase 6: Production Dogfooding

Phase 7: Language Packs II

Phase 8: Agent-Evasion Hardening

Candidate work from a review of discipline as a safety net against autonomous coding agents. The review’s central finding: the detectors are strong, and the trust boundary around them is the cheaper target. A change that can switch its own run to advisory, or grant itself every override, does not need to beat an AST gate. Tiers are ordered; within a tier, items are independent.

Tier 0: the gates’ own trust boundary. Blocks the value of every later tier.

Item Gap Status
Advisory self-switch mode = "advisory" in the change’s own discipline.toml made the run exit 0 Shipped: reported by config-integrity under [meta], and not honoured until it is on the base side or allow-gate-weakening: meta lifts it
Guard self-disable [gates.config-integrity] enabled = false (or severity = "note") silenced the report of the change’s own weakenings Shipped: enablement is read from the base configuration; findings use the stricter of the base and head severity
Unclassified options Most gate options had no loosening direction, among them min_tests, max_increase, workflows, forbidden_paths, deny_dependencies, allowed_override_actors, and every command Shipped: one direction table covers every option; a unit test fails on any option the table does not name
Action advisory input advisory: true on the action step, or --advisory on a discipline check run line, was invisible to ci-integrity Shipped
Other forges’ workflows ci-integrity read .github/workflows/ only Shipped: .gitea/ and .forgejo/ workflows share the Actions rules; GitLab pipelines have their own base-versus-head diff (src/guards/ci_gitlab.rs). Open: what a pipeline pulls in through include:, and narrowed rules:
Self-granted overrides The PR body and commit bodies are written by the change author, fail_on_overrides defaults to false, and nothing capped the count Shipped, opt-in: directives.max_overrides is a per-change budget, held to the base file’s value whichever side judges the change, and deleting the file is refused (#932); directives.require_approval makes directive overrides fail until the forge shows an approving review of the head commit by an allowed_override_actors member who is not the author (read-only, through src/forge.rs; exit 2 when it cannot be read). GitLab approvals are open
Policy from the base ref The change is judged by its own discipline.toml Shipped, opt-in: --policy-from base / action input policy_from: base; ci-integrity reports the input being moved off base. Chosen over signing the file: a verifying key kept in the repository is writable by the same change, and verification needs a cryptography dependency
doctor enforcement discipline doctor (required check, CODEOWNERS, non-blocking jobs) ran in no workflow here Shipped for the local checks: the dogfood job runs doctor --local-only --strict, under which an uncovered CODEOWNERS target fails. doctor also reports the branch’s review rules (review, code-owner-review, last-push-approval). Running the forge-side checks in CI needs a token that can read branch protection and is open

Tier 1: coverage from existing machinery (base-versus-head structural diffs; no new AST facts).

Tier 2: new AST facts. Packs are hand-written visitors, so each fact is written once per language. Prerequisite, shipped: LanguagePack::supplies(Fact); a gate that needs a fact a pack does not supply names the file as not analysed.

Tier 3: governance.

Phase 9: Review Follow-Ups

Items from an external written review of Phase 8 (2026-09-21), each checked against the code before acting. The review’s “strengths” section was right on the gates and wrong on their reach (six languages for the new facts, not “9+”; agent_diff is a module, not a gate).

Review point Verdict Status
Assertion-free tests, tautologies, mocking the system under test Already covered by vacuous-tests (Vacuous Test Added, constant-expression tautologies) and the Phase 8 mock rules. The example assert val is not None was a real miss: a near-tautology that counts as an assertion. Shipped: Test Asserts Only Trivial Properties (src/ast/calls.rs, warning)
Assertion density, mutation analysis min_assertions_per_test exists. In-binary mutation stays declined: the command gate’s mutation presets are the control. Declined
Package hallucination / slopsquatting Correct description. Decided offline-only in Phase 8 (§3.3; a lookup discloses internal names). Declined; see the note below
Build hooks and dotfiles Correct and the largest gap. Shipped: build-hooks gate
Delay-based concurrency patching Correct. Shipped: Test Sleeps through ignored-tests (warning)
Reviewer-bot injection in PR descriptions instruction-smuggling already carried reviewer-steering for code and prose files; it did not read the PR body or commit messages. Shipped: change-description scan, directive lines excluded

Phase 10: Remainder Remediation

Every item Phases 7 to 9 left named as open, in one ordered plan. Each row is the whole remaining work for that item; a row ships when its gate criterion holds and the AGENTS.md §3.4 contract is met (unit controls, e2e through the binary, self-test case, a named test that kills a mutated detector). Rows in one step are independent of each other; a step depends on the step before it only where stated.

Step 0: false positives from the first consumer replay (before anything else: each is a v0.8.0 finding a real repository’s CI had already judged correct, checked against the code on 2026-09-21; a gate that blocks correct changes is held at warning by that consumer until these ship)

Item Verified against the code Work Ships when
A consumer cannot declare its own test entry points Shipped ([tests] functions / paths). Yes: the pytest-collection rule makes a script’s self_test() production code, and error-swallowing, pii and the assertion gates all read that boundary; today’s workaround is assert_helper_fns One declaration, [languages.<lang>] test_functions = [...] and a gate-independent test_paths, honoured by every gate that separates test code from production code (TestFn collection, handlers::extract_with_constants’s is_test_line, pii’s scope) One setting removes the three families of findings from the consumer’s replay; three gate-local options are not added
error-swallowing: expect-this-to-raise Shipped. Yes: try / except: pass / else: raise and except: continue followed by a recorded failure are empty handlers to handlers.rs A try whose else: raises, or whose fall-through records a failure, is an assertion, not a swallow: read the sibling else clause and the statement after the try The quoted snippets are silent; a bare except: pass in a library module still fails
error-swallowing: a tuple binding is not a call Shipped. Yes: rust_discards accepts any let _ = whose right side contains (, so let _ = (word, alloc); is reported as a discarded result Report let _ = only when the right side is a call expression (AST node, not text) The tuple is silent; let _ = file.sync_all(); still fails
error-swallowing: Cargo tests/, benches/, examples/ are test scope Shipped. Yes: test scope is the TestFn spans only, so a helper in crates/x/tests/*.rs is production code is_test_line also true for every line of a file under those directories (and the other packs’ equivalents via functions::test_path) let _ = catch_unwind(...) in an integration test is silent
unsafe-safety-comment: unsafe trait documented by rustdoc Shipped (heading required; a run-walk pairing bug in the comment collector, exposed by the fix, is fixed with it). Yes: an unsafe trait is an unsafe site requiring // SAFETY:; a doc comment with a # Safety section (the convention clippy’s missing_safety_doc checks) is not accepted Accept a preceding doc comment containing a # Safety heading for unsafe trait and unsafe fn; decide and document whether a contract stated without the heading counts (recommendation: no) The quoted OlcEngine trait is silent
stub-bodies: abstract base-class methods Shipped. Yes: the skip covers @abstractmethod and Protocol / TypedDict / NamedTuple bases, not abc.ABC nor a method overridden by a subclass in the same file Skip a stub in a class deriving from ABC / ABCMeta, and one whose name a same-file subclass overrides bump_version.py’s base-class pair is silent
time-estimates: past-interval phrasing Shipped. Yes: “N units later” is exempt, “the N-unit gap between” two past events is not Extend the terms-of-art list with the past-interval forms (N-unit gap between, N units between, past-tense equivalents) The quoted README line is silent; a forward-looking “ships in N units” still fails
--whole-tree records delta-only rules Shipped (GateInfo::delta_only in src/config.rs; symlinks skipped in changed_files). Yes: a whole-tree baseline diffs against an empty tree, so New Direct Dependency Added, Agent Instructions Changed and Test Arrives Ignored fire for every file, and symlinked CLAUDE.md / GEMINI.md are recorded separately In whole-tree mode, gates whose rule describes a delta report nothing (each GateOutcome names itself delta-only); a symlink resolves to its target once The consumer’s baseline records no delta-only rule
provenance-tags: what satisfies a ratio, and diff_only Shipped (ratio_satisfied_by, deterministic_units, diff_only). Policy disagreement, not a defect: the consumer’s paragraph-scoped rule accepts an interval, a results artifact reference, or a superseded / unsourced / provisional marker, and exempts deterministic units ratio_satisfied_by = ["interval", "artifact:<glob>", "marker:<word>"] and deterministic_units, default unchanged; diff_only as on the other hygiene gates With a config expressing the consumer’s rule, the gate agrees with its script on every tracked Markdown line
Wiring recipes Shipped (docs/guides/ci-platforms.md, two sections: rollup skip-set wiring, benchmark job wiring). Documentation A complete ci-skip-set rollup job (DISCIPLINE_CI_CONTEXT from toJson(needs) plus path-filter outputs, and the if: forms modelled) and a bench-regression job (merge base and head measured with iai-callgrind, require_sourced_override, which citation-freshness rules are enforced) in docs/guides/ci-platforms.md The consumer retires its two remaining scripts against the recipes

Acceptance for Step 0 as a whole: the consumer’s 100-PR replay with its config, the test-entry declaration and error-swallowing at error blocks only on instruction-smuggling AGENTS.md edits, its three true positives and its two deliberate || true lines.

Step 1: parity of the new facts across packs (the Tier 2 remainder; every later step benefits)

Item From Work Ships when
Function, handler and prose facts for PHP, Ruby and C/C++ Shipped (PHP_FUNCTIONS / RUBY_FUNCTIONS / C_FUNCTIONS and the matching handler, mock, retry and prose specs; PHP @ and Ruby rescue nil are Error Silenced, C/C++ (void)call() is Result Discarded). Tier 2, Tier 3 Fill FunctionSpec, HandlerSpec, mock/call kinds and prose kinds in src/ast/php.rs, ruby.rs, c_cpp.rs; classifiers exist in functions.rs (classify_php, classify_ruby, classify_c); set supplies() for Functions, Handlers, Prose stub-bodies, error-swallowing and instruction-smuggling report no “supplies no facts” note on a .php, .rb, .c / .cpp change, and the pack tests mirror the six existing ones
Kotlin pack Shipped (src/ast/kotlin.rs behind the default lang-kotlin feature, grammar tree-sitter-kotlin-ng). Phase 7 New src/ast/kotlin.rs behind lang-kotlin: @Test / @ParameterizedTest, JUnit / AssertJ / Kotest matchers, @Disabled / @Ignore, @Suppress, plus the four new facts The four-point contract per language holds, docs/GATES.md language table no longer lists Kotlin as planned, UNSUPPORTED_SOURCE_EXTS drops kt / kts

Step 2: precision of existing detectors (Tier 1 and Tier 2 corrections; independent of Step 1)

Item From Work Ships when
test-budget on AST facts Shipped (src/ast/budgets.rs, Fact::Budgets; Rust, Python, JS/TS packs; scripts and workflows stay line-based). Tier 1 Replace the line patterns in src/guards/test_budget.rs with pack facts: a Fact::Budgets (proptest cases, quickcheck tests, hypothesis max_examples, fast-check numRuns) read from call and attribute nodes The min_tests = 40 fixture class cannot recur (a budget inside a string or comment is not a budget); existing e2e cases pass unchanged
Unreachable assertions Shipped (src/ast/reach.rs, every pack). Tier 1 In each pack’s assertion walk, do not count an assertion under a constant-false condition or after an unconditional return / panic! / pytest.skip() at the same block level; reuse the Rust constant-expression check assert_eq! inside if false {} and after return counts 0; a self-test case pins each
Padded stubs Shipped (functions::classify_body, shared inert_statement). Tier 2 BodyShape::Stub also when a body is a stub marker plus statements that cannot affect the result (logging, a bare assignment, println!), judged per language fn f() { log::warn!("todo"); todo!() } is a stub; fn f() { init(); todo!() } is not
Logging swallow Shipped (handlers::body_swallows returns the kind; LOGGING_VOCAB shared with the stub classifier). Tier 2 In handlers.rs, a handler whose statements are all logging calls (log., logger., console.error, eprintln!, warn!) and no re-raise, return of the error, or state change is Empty Error Handler Added with a “logs and swallows” message The e2e negative control keeps a handler that logs and re-raises silent

Step 3: coverage of files and forges (Tier 0 and Tier 1 remainders)

Item From Work Ships when
Other lockfile formats Shipped (src/guards/lockfile.rs: pnpm, Yarn 2+, poetry, uv, composer, Gemfile). Tier 1 parse_lock for pnpm-lock.yaml, poetry.lock, uv.lock, composer.lock, Gemfile.lock, Yarn 2+ (__metadata:); go.sum stays exempt Each format has a source-swap and a dropped-hash test; the “not analysed” note is gone for them
Frozen-install flags in CI Shipped (Frozen Install Flag Dropped, Install Command Softened). Tier 1 Extend the ci-integrity --locked rule to npm ci → npm install, --frozen-lockfile, --immutable, --require-hashes, uv sync --locked / --frozen, poetry install --no-update A dropped flag or a softened install command is Cargo Flag Dropped-class finding under its own title
Added snapshot files Shipped (Snapshot Added For Existing Test; Jest, insta, syrupy / pytest-snapshot). Tier 1 In golden-output, an added snapshot whose name maps to a test that already existed on the base side (Jest __snapshots__/<file>.snap keyed by test title, insta <module>__<test>.snap, pytest-snapshot <test>.ambr) is reported; a snapshot for a new test is not Two e2e cases per framework: new test + new snapshot silent, existing test + new snapshot reported
GitLab include: and rules: Shipped (local includes followed; Verification Job Narrowed). Tier 0 ci_gitlab.rs follows include: local: files in the same tree (never project: / remote:, which are named as not read) and reports a rules: / only: / except: change on a verification job as Verification Job Narrowed A local include that adds allow_failure is reported; a remote include is a note
clippy.toml Shipped. Tier 1 Per-key direction table for the threshold keys (too-many-arguments-threshold, cognitive-complexity-threshold, type-complexity-threshold, … all Cap); allowed-* lists Grown; disallowed-* lists Shrunk Raising a threshold or growing an allow list is Toolchain Configuration Weakened
toolchain-config inheritance Shipped (inherited_changes). Tier 1 Report a changed extends / plugins / presets entry as Toolchain Configuration Changed (not analysed) at warning, the same way a configuration written as code is An extends swap is never silent

Step 4: forge-side enforcement (Tier 0 remainders; needs a token that can read branch protection and pull-request reviews)

Item From Work Ships when
Forge-side doctor in CI Partial (#776): the dogfood job runs discipline doctor against the forge with the workflow’s read token (rulesets are readable without a secret) and its findings are in the log; --strict waits on a second reviewer account: the branch rules now block deletion and require pull requests (deletion, pull-request, last-push-approval Pass), but review and code-owner-review need a required approving review, which a solo-maintained repository cannot require without blocking its own merges; the bypass list and the merge methods are shown to push or admin tokens only, so bypass is Warn under the workflow token and push-trigger is Info because merged-pr-body is on. Tier 0 A doctor job in ci.yml with a fine-grained token (read: administration, pull requests) stored as a repository secret, --strict, on main pushes only The job is in ci-gate’s needs and the asserted job count; required-check, review, code-owner-review, last-push-approval are Pass on this repository
GitLab approvals in require_approval Shipped (forge::gitlab_approvers; the pull context comes from CI_MERGE_REQUEST_IID / CI_MERGE_REQUEST_SOURCE_BRANCH_SHA). Tier 0 pull_approvers for ForgeKind::GitLab: projects/{id}/merge_requests/{iid}/approval_state, matched on the head SHA of the approval rule The FakeForge e2e has a GitLab variant; an approval of an earlier SHA is refused

Step 5: PR-body waivers on a push to the default branch (from a consumer’s replay at v0.9.0: a pull request whose body carried allow-agent-instructions: AGENTS.md <reason> passed on pull_request, was squash-merged, and the push run on main — base from github.event.before — failed on the same edit because a squash or rebase merge builds the commit message from the branch’s commits, not from the body, and a merge commit’s default message does not carry it either; every PR-body directive reproduces this)

Item From Work Ships when
Say why a waiver is missing on a push Shipped (check appends the push note to every directive-liftable finding and the gate notes). Consumer replay On a push (or --commit / --commit-range) run, a violation the matching directive would have lifted names the event and says PR-body directives are not in scope; the remediation names commit-message directives and the merged-pr-body source instead of a PR body the run never reads The replay of the consumer’s merge as a push says why it failed
Event / source matrix in the docs Shipped (CONFIGURATION.md Override Directives and Action Reference, ci-platforms.md §8, README quickstart note). Consumer replay docs/CONFIGURATION.md (action reference and directives) and docs/guides/ci-platforms.md state which sources each event sees (pull_request: body and branch commits; push: pushed commits, plus merged-pr-body once shipped), that squash and rebase merges drop the body, and the recommended trigger set; the README quickstart comment covers the push case next to edited The matrix is in both documents
ci-integrity: a narrowed verification step Shipped (Verification Step Narrowed, warning). Consumer workaround A newly added or narrowed step-level if: on a verification step (including the discipline step) is Verification Step Narrowed at warning, liftable with allow-gate-weakening: ci-integrity <reason> A diff adding if: github.event_name == 'pull_request' to a verification step is reported
merged-pr-body directive source Shipped (forge::merged_pull_on_forge, tokens::extract_directives_with_merged, directives.degrade_offline; on by default). Consumer replay On a push event, each commit in the range resolves its merged pull request through the forge client (GitHub commits/{sha}/pulls, Gitea / Forgejo commits/{sha}/pull, GitLab repository/commits/:sha/merge_requests) and its body is read under the same trust rules as on the pull request (line-anchored, allow_hidden, scoped subjects, allowed_override_actors against the PR author, max_overrides, require_approval); on by default for push events, reported in the notes with the PR number; a direct push keeps commits only; an unreachable forge or a token without permission is a named note with the finding standing (directives.degrade_offline, default true, fail-closed), or could not check (exit 2) when set to false A squash-merge commit whose PR body carries the waiver passes on push; the same commit with a body lacking it fails; a direct push fails; the GitLab / Gitea / Forgejo lookups have unit coverage
doctor flags the configuration Shipped (push-trigger finding: Warn when squash or rebase is allowed and merged-pr-body is not in directives.sources, Info when the source is on (the push run then needs a token that reads pull requests), Pass when the source is on and the workflow or job grants pull-requests: read or write, Pass when only merge commits are, Unknown when the API does not say; --local-only names the trigger). Consumer replay When a workflow runs the discipline action (or discipline check) on push to the default branch and the branch’s merge method allows squash or rebase, a Warn finding names the fix: enable merged-pr-body, restrict the step to pull_request, or put directives in commit messages The consumer’s ci.yml as it stood is reported

Declined, and stays declined unless the stated condition changes: registry verification of new dependencies (Phase 9 note: needs a private_prefixes contract, accepts registry outages as CI failures, and an amendment to AGENTS.md §3.3); in-binary mutation analysis (the command gate’s presets are the control); in-binary commit-signature verification (cryptography dependency and a trusted keyring; doctor reads the branch rule).

Phase 11: Agent Reach and Coverage Parity

What most widens adoption after v0.10.2: findings reaching an agent inside its edit loop and a reviewer on every forge, a one-command preview for a new repository, then the pack-parity and language gaps and the two live checks. The detection catalogue is not extended here. Each row ships when its gate criterion holds and the AGENTS.md §3.4 contract is met (unit controls, e2e through the binary, self-test case, a named test that kills a mutated detector).

Step 0: live checks (no code; evidence recorded in Outstanding Checks)

Item Status Work Ships when
Consumer replay of the merged-pr-body push event Done (RUN 2026-09-22) Replay the consumer’s squash merge of its #1071 as a push (base 020ec7a4, head a8eb5e30, GITHUB_EVENT_NAME=push, a token that reads pull requests) with a build of main after #153; control with DISCIPLINE_NO_NETWORK=1 Forge reachable: errors: 0, one override, the Agent Instructions Changed finding on AGENTS.md lifted by the directive read from merged pull request #1071, every gate noting the pull request. Offline: errors: 1 (the same finding), each gate noting merged-pr-body: not read. Both held.
Gitea Actions on a containerised forge Done for Gitea (RUN 2026-10-16, #774: Gitea 28.1.0, act_runner v0.6.1, discipline v0.22.0; records in Outstanding Checks); Forgejo not run Decision 2026-10-16: a disposable Gitea instance and act_runner in Docker with a scripted test repository stand in for a production server; the script is committed so the evidence can be re-run (tests/forge-lab/: up.sh, record.sh, which asserts each record on its diagnostic, and down.sh). The repository runs the job-container recipe (container: image: pinned by tag and digest, refs/pull/<n>/head checkout) on pull_request and push. Record: the Gitea and act_runner versions; a clean pull-request run; an inverted canary (a pull request seeding a known violation) whose report names the expected gate ids; a push run to the default branch whose notes read the merged pull request through commits/{sha}/pull; discipline doctor against that instance’s API The setup script and the five records are committed and summarised in Outstanding Checks; Forgejo with its runner where the same setup allows

Step 1: the agent loop (uses output the binary already produces; diff and check --staged are the fast path)

Item Status Work Ships when
Agent hook recipes Shipped (discipline hook run / hook install, src/hook.rs; Claude Code, Codex, Cursor, Aider, Copilot CLI, Antigravity, Qwen Code, OpenCode) discipline hook install --agent <claude-code\|cursor\|codex\|aider> writes the agent’s hook configuration, and docs/guides/ documents each by hand: Claude Code PostToolUse (after Edit / Write) and Stop hooks running discipline diff --format agent-prompt, exit 2 feeding the repair text back to the agent; the equivalents the other agents expose. The installer refuses to overwrite an existing hook and prints what it would add An e2e test drives the installed hook command on a working tree whose edit weakens an assertion and gets the blocking exit and the repair text; a clean edit passes
discipline mcp Shipped (src/mcp.rs; check_diff, list_gates, explain_finding, read-only) An MCP server over stdio in the binary (no listening socket, no network): check_diff (working tree or staged, the agent-prompt findings as structured content), list_gates, explain_finding. Read-only: no tool writes a file or a directive A stdio e2e test lists the tools and runs each; no tool’s output contains a directive token (the agent-prompt self-test extended to the MCP output)
Framework-neutral hook contract (AGENT-HOOKS-0.1) Tracked, not built The draft contract at github.com/responsibleai/agent-hooks (eight interception points, an AgentContext payload, allow / deny / transform verdicts, a conformance kit) agrees with discipline’s design where it overlaps: a control that errors counts as deny, denials do not repeat the checked content, and the contract is cooperative, not a security boundary. An adapter would read its payload at post_tool_call and output and answer deny for findings and for could-not-check (with the reason code), never transform; experimental, outside the 1.0 freeze. Its transform verdict and in-host approvals are not adopted: discipline names the repair and keeps waivers out of the agent’s reach A coding agent discipline supports hosts the contract, or the spec leaves draft; then the adapter ships with the spec’s conformance vectors for the points it answers
Hook observe mode and named could-not-check reasons Shipped (hook run --observe, hook install --observe, src/hook.rs) hook run --observe checks but never blocks: what would have blocked is said on stderr and appended to <git dir>/discipline/hook-observe.log (the contract’s evaluate_only, never presented as enforcement). The text an agent reads when a check cannot run names the report’s could_not_check.reason and gate, in the hook and in discipline mcp Each agent adapter answers in observe mode without blocking, and the agent-facing could-not-check text carries the reason code

The agent-facing surfaces never print waiver syntax: agent-prompt already omits directive tokens, so an agent learns to repair, not to excuse. Human output (explain, the terminal report) keeps it.

Step 2: pull-request comments (the reviewer-visible surface on Gitea and Forgejo, which have no code-scanning UI)

Item Status Work Ships when
Decide the write path Decided: in the binary, opt-in (AGENTS.md §3.3, docs/ARCHITECTURE.md §7.3) Posting is a forge write, which AGENTS.md §3.3 does not allow (three opt-in reads). Either amend §3.3 in the same PR (opt-in, a token that can write comments, exit 2 when the forge is unreachable, DISCIPLINE_NO_NETWORK honoured) or post from action.yml through the forge API without the binary The decision and its reason are in docs/ARCHITECTURE.md
One updating comment Shipped (check --comment, src/comment.rs; GitHub, Gitea, Forgejo, GitLab) GitHub, Gitea, Forgejo and GitLab: one comment per pull request, found by a hidden marker and edited on each run; findings, lifted overrides and notes, no waiver syntax for findings an agent authored A second run edits the comment and adds none; a pull request from a fork, whose token cannot write, gets a named note, not a failure

Step 3: adoption in one command

Item Status Work Ships when
discipline replay --last N Shipped (src/replay.rs; RUN 2026-09-23 on the consumer at its harness’s tip: 100 changes, 86 passed, 14 blocked, the same gates blocking the same changes as the harness) The consumer’s replay harness built in: rebuild each merged pull request on a base carrying the configuration under test, run the gates with that pull request’s body (through the forge read path when a token is present, commit messages only otherwise, said in the output), and print blocked / passed and per-gate counts. Reading pull-request bodies is a forge read: listed in AGENTS.md §3.3 with the other three Run against the consumer with its configuration, it reproduces the consumer harness’s 100-PR table (blocked count and per-gate errors)
discipline explain <gate-id> Shipped (src/explain.rs; a finding line resolves too) The rule, what it catches and misses, the default severity, and the directive and inline marker that lift it, from the same source as docs/GATES.md; a finding’s id or title resolves to its gate Every gate id resolves; the text matches the generated docs (docs --check)
check --help lists merged-pr-body Shipped The --directive-sources help still names only pr-body, commits The help text lists all three sources

A --fix suggestion (restore a deleted assertion from the base side, a // SAFETY: stub) waits until explain ships: it edits test files and needs its own design.

Step 4: pack parity (independent rows; the missing capability in a pack that already exists)

Item Status Work Ships when
Same-file helper resolution in JS / TS and PHP Shipped (javascript.rs / php.rs resolve_same_file_helpers, helper_checks counted) resolve_same_file_helpers in javascript.rs and php.rs, one level, cycle-safe, as the other eight packs: a module function or method whose body asserts or throws counts at each call from a test; helper_checks counted A JS test calling a local expectValid() and a PHPUnit test calling $this->checkRow() are not vacuous and a helper refactor is silent; removing the call is still a drop
Discards sorted by callee in Go and C / C++ Shipped (go_discard_class, c_discard_class) The classify_discard hook handlers.rs added for Rust, for Go x, _ := f() / _ = f() and C / C++ (void)call(): a known-fallible callee list (Go Write, Close, Sync, Flush, Scan, Encode, os.*, io.Copy, …; C write, fclose, fsync, close, pthread_*, …), known-infallible accessors not reported, anything else Value Discarded at warning Each pack has a fixture of a fallible discard (still error), an accessor (silent) and an unknown callee (warning)
Dispatch-table resolution beyond Python Shipped (ast::dispatch_calls with a DispatchSpec per pack: Rust, JS / TS, Go, Java, Kotlin, C#, Ruby) A same-file function named as an element of an array or list literal in a test body resolves as a call: JS / TS ([checkA, checkB].forEach(f => f())), Rust (for f in [check_a, check_b] { f() }), Go ([]func(){checkA, checkB}), Java / Kotlin / C# method references (this::checkA, ::checkA, delegates), Ruby (%i[check_a] / method(:check_a)) where the grammar exposes the name A table refactor is silent and a removed entry is a drop, per pack

Step 5: new language packs (Swift first: the most agent-heavy of the three; each needs a tree-sitter grammar crate whose licence is inside deny.toml and whose ABI matches the bundled tree-sitter; a pack whose grammar fails either is not started)

Item Status Work Ships when
Swift pack Shipped (src/ast/swift.rs behind the default lang-swift feature, grammar tree-sitter-swift; tests, handlers, functions, prose; no unsafe facts) src/ast/swift.rs behind lang-swift: XCTest func test*() in XCTestCase subclasses and Swift Testing @Test; XCTAssert* / #expect / #require (strong: XCTAssertEqual, #expect(a == b)); XCTSkip / .disabled traits; handlers (catch {}, try? as a silenced error); functions; prose; same-file helpers The four-point contract holds; docs/GATES.md language table lists Swift; UNSUPPORTED_SOURCE_EXTS drops swift
Scala pack Shipped (src/ast/scala.rs behind the default lang-scala feature, grammar tree-sitter-scala; tests, handlers (per catch arm), functions, prose; no unsafe facts) src/ast/scala.rs behind lang-scala: ScalaTest (test("..."), "x" should "y" in), MUnit, specs2; assert / assertEquals / shouldBe matchers; ignore / .ignore; handlers (catch { case _ => }, Try(...).getOrElse); functions; prose; same-file helpers As Swift, for scala
Objective-C pack Shipped (src/ast/objc.rs behind the default lang-objc feature, grammar tree-sitter-objc; tests, handlers, functions, prose; .mm C++ constructs are parse errors) src/ast/objc.rs behind lang-objc for .m and .mm (the C / C++ pack’s facts for the C part): XCTest - (void)test* in XCTestCase subclasses; XCTAssert*; @catch {}; (void) discards; functions; prose. .mm parses as Objective-C, and the Objective-C++ constructs the grammar cannot read are named in the notes As Swift, for m and mm

Step 6: distribution

Item Status Work Ships when
GitHub Marketplace listing Shipped (Discipline CI Gate) Publish the action (action.yml branding, the release as the listing’s version) The listing resolves and installs the tagged release
crates.io publication Shipped (discipline on crates.io) Publish the discipline crate to crates.io and add a publish step to .github/workflows/release.yml with trusted publishing or registry token cargo install discipline installs the tagged release from crates.io
pre-commit.ci Declined The published hook runs discipline check --staged; pre-commit.ci runs hooks on a checkout of the pull request where nothing is staged, so the hook would check an empty change and pass every pull request. A useful hook needs the pull request’s base, which pre-commit.ci’s clone is not known to provide, and a prebuilt-binary install (a PyPI wheel) because building the Rust crate there is heavy. The action and the container already gate every pull request, and the local pre-commit hook works Reopen when a sandbox repository shows pre-commit.ci exposing the base commit, and a PyPI project with trusted publishing exists
Editor and bot integrations Shipped: VS Code (a documented tasks.json problem matcher) and Renovate (renovate/discipline.json, extended as github>orieg/discipline//renovate/discipline); both are checked by tests/test_editor_integrations.rs, the preset also validated with renovate-config-validator --strict (RUN 2026-09-23, Renovate 44.111.2) A VS Code problem matcher for the terminal report; a Renovate preset that keeps the action, image tag and digest in lockstep Each has a sample and a test reading its output

In parallel: a public benchmark (independent of every step)

Item Status Work Ships when
Precision on agent-produced pull requests Shipped (agent-pr-precision-v2 at 82d02046, closing #777): every published number resolves to committed JSON (docs/benchmarks/results/{manifest.json,findings.json,adjudication.json,rater-packet.json,summary.json,post-hoc-audit.json}), whose counts and kappa tests/test_benchmark_power_bounds.rs recomputes from the item labels and whose intervals it checks against src/guards/perf/bounds.rs. The complete deterministic pipeline (--harvest-and-replay, --build-packet, generate_benchmark_artifacts, --post-hoc-audit) is committed in scripts/benchmark_precision.py, the source-tool-blinded packet is committed in docs/benchmarks/results/rater-packet.json, and adjudication.json records rater_kind = "llm-subagent" with the Rule 3 synthetic-rater disclosure A pre-registered study on public repositories only: how a pull request is labelled agent-produced (co-author trailers, bot accounts) and how a finding is adjudicated a false positive (two independent raters blind to the source tool, per docs/benchmarks/precision-preregistration.toml) are fixed before any data is read; per-gate precision with Wilson intervals; bounds and the detectable effect computed in committed, unit-tested code before any data is collected Every published number resolves to a committed artifact; a gate’s precision is claimed only where the interval’s lower bound clears the stated floor
Per-edit hook latency Measured (closing #982): scripts/measure_hook_latency.py, docs/benchmarks/hook-latency.md and docs/benchmarks/results-hook-latency/; the edit hook costs 47.31 s on this repository and 9.34 s on BurntSushi/ripgrep (median, measured, c7g.2xlarge), above the pre-registered 2 s, so a fast path or cache is written. Per gate (#994 step 1, scripts/measure_integrity_gates.py): test-floor carries 40.05 s of the integrity suite here and 4.72 s on ripgrep (measured). After the engine fast path (#994: each unchanged file counted once, module scans shared, a tests-only extraction; byte-identical reports), hook-post-edit is 19.60 s here and 6.35 s on ripgrep (median, measured), still above 2 s. With #995’s event-scoped gate selection on top (main at 1de13bc9), hook-post-edit is 8.44 s here and 4.35 s on ripgrep (median, measured; section 4.3), still above 2 s: the edit’s remaining cost is the changed file extracted once per gate and the runner rules rebuilt per run. In observe mode the edit hook no longer waits for it (#995 step 3) Release binary on an idle host: check on a one-file change and a large branch, the PostToolUse hook path with the hooks installed, on this repository and a second one; median and spread per cell, time split by git diff and per suite The figures are committed from an idle host and the pre-registered fast-path decision is recorded on #982

Phase 12: Pull-Request Policy From Forge Facts

A consumer runs a separate pull-request policy job of bespoke scripts beside discipline: owner ratification of edits to protected paths, a linked issue, resolved review threads, and the rule that the base, not the head, judges the change. Its ratification parser treated a prose mention of another project’s issue (which fixes #1162) as a closing reference, and Gitea’s 500 for the comments of a missing issue crashed the job. This phase moves the checks that belong in discipline onto the forge client, on all four forges, with every lookup ending in a named verdict. Each row meets the AGENTS.md §3.4 contract.

Item Status Work Ships when
Forge read layer Shipped (v0.15.0; src/forge.rs) Classified read failures (forge-not-found, -denied, -rate-limited, -unavailable, -malformed, -partial-list); bounded retries on transport failures, 5xx and short rate limits; a per-run request budget; lists read to their stated total or refused (read_all), with Gitea’s and Forgejo’s limit paging; a read-only GitHub GraphQL call; a canned API that answers statuses, headers and sequences Unit tests pin the classification, the retry delays and each paging shape; an e2e test sees three attempts and exit 2 on a persistent 502, and a normal verdict after one 503
Reference resolver and issue-link verify_references Shipped (v0.15.0; src/references.rs) References parsed as each forge reads them (keywords per forge, GitLab lists and nested paths, code and comments skipped); each resolved through issues/{n} before anything else is read, so a missing number is ref-not-found; cross-repository references not looked up unless listed; a repository the token cannot see is forge-denied. issue-link gains verify_references, require_open_issue (default true), reference_repos, accept_pull_references, waiver e2e against a loopback Gitea answering 500 for a missing issue’s comments: the body which fixes #1162 with Closes #12 passes with a ref-not-found note, alone it is a finding, and no comments are requested
ratified-paths gate Shipped (v0.15.0; src/ratification.rs, src/guards/ratified_paths.rs) Owner ratification of protected-path edits read from comments on the issues the pull request closes (server-side closing references on GitHub and GitLab, the body’s closing keywords on Gitea and Forgejo); exact paths only; never-ratifiable paths; edited, imported, email-created, bot and system comments refused; a validity window (path-last-changed by default); policy_from: base required; no directive lifts it Unit tests on all four forges’ answer shapes and e2e through the binary against a loopback Gitea: a protected edit with no ratification fails, an agent’s block does not count, a glob voids its block, a 500 on a missing issue’s comments is never requested. The edit rule’s assumptions (an unedited comment’s updated_at equals its created_at; a reaction leaves it unchanged) held on Gitea 1.24.7 and Forgejo 12.0.4 (live observations below)
ci-integrity discipline version change, doctor checks Shipped (v0.15.0; src/guards/ci_integrity/discipline_pin.rs, src/doctor.rs) Discipline Version Chosen By The Change when an Actions uses: ref, version / binary / download_url input, job container, GitLab image or include: moves discipline to an older release or a movable ref, or adds a binary source (an upgrade or a digest move is a note). doctor: workflow-protection (Gitea / Forgejo protected file patterns cover each workflow that runs discipline), thread-resolution (GitHub rulesets and classic protection, GitLab project setting), agent-permission (with ratified-paths agent logins: repository role and site administrator) Unit controls per forge; e2e through the binary for the ci-integrity finding (Gitea container, GitLab remote include, a lifted and an upgraded case) and for doctor against a loopback Gitea
review-threads gate Shipped (v0.15.0; src/review_threads.rs, src/guards/review_threads.rs) The pull request has no unresolved review thread, on all four forges (GitHub GraphQL reviewThreads, GitLab resolvable discussions, Gitea / Forgejo conversations grouped by path and line with the first comment’s resolver deciding). A gate rather than the run-level [pull_policy] table first proposed: it gets severity, frozen finding codes and config-integrity without new machinery Unit tests per forge; e2e against a loopback Gitea answering with the shapes observed live; a resolved conversation with an unresolved reply passes

Phase 13: Multi-Agent Guardrails Before The Edit

Several coding agents (Claude Code, Codex, Copilot CLI, agy, Cursor, Qwen Code, OpenCode, Aider) often work in one repository at once, each in its own git worktree. Every hook discipline installs today runs after the fact: after an edit (PostToolUse, postToolUse, tool.execute.after), at the end of a turn (Stop, agentStop, stop), or as a lint command (Aider). hook run knows two events, Edit and Stop, and reads only cwd from the payload. It never reads the tool name, the target path or the session, and it checks the branch diff of its own cwd. So an agent that edits another agent’s worktree, or rebases and force-pushes another session’s branches, goes unseen, and the stray edit is not even in the diff it checks. Nothing records which session owns a worktree or a branch. scope-confinement reads only the committed diff. One observed failure: two sessions rebased the same stacked branches, which raced their force pushes and nearly lost the stack. This phase adds checks that run before the tool call or the ref update. They are cooperative, not a security boundary (see Agent hook recipes): they stop mistakes, and the forge’s branch rules stay the control against an adversary. Each row meets the AGENTS.md §3.4 contract where it adds a check (unit controls, e2e through the binary, a self-test case, a named test that kills a mutated detector).

Item Status Work Ships when
Step 0: pre-tool contracts, observed live Partly done (RUN 2026-09-28): Claude Code 2.1.282, Copilot CLI 1.0.88, agy 1.2.12, OpenCode 1.18.25; Qwen Code 0.24.7 (RUN 2026-09-29: Claude Code’s payload and deny, tools write_file, edit, run_shell_command). Codex 0.159.0 (RUN 2026-09-29: Claude Code’s payload and deny, tools apply_patch and Bash; its project hooks run only once approved in an interactive session). Cursor is set aside. Aider has no pre-tool hook For each agent, record the pre-tool event, its payload fields (tool name, the edit’s target path, the shell command, the session id, cwd) and its deny shape, by running it live as the existing hooks were. Each probe ran a throwaway repository with a hook that saved its payload and answered deny, and asked the agent to write a file. Recorded: Claude Code PreToolUse (exit 2, or permissionDecision: "deny"); Copilot CLI preToolUse (permissionDecision: "deny", probed with an isolated COPILOT_HOME trusting only the probe); agy PreToolUse ({"decision": "deny", "reason"}; exit 2 also blocks, as a failed hook); OpenCode tool.execute.before (the plugin throws; it sees reads too). Every write target arrived as an absolute path, and every payload names its session Each supported agent has a recorded payload sample and deny output under tests/fixtures/pretool/ (pinned by tests/test_pretool_fixtures.rs), and the table in docs/CONFIGURATION.md states per agent what can be blocked before it runs
Step 1: git-level ref guard Shipped (v0.15.0; discipline lease install-guard writes the reference-transaction hook, discipline lease guard is its body) A reference-transaction git hook, installed once for every worktree (in core.hooksPath, else the common hooks/ directory) by discipline lease install-guard, that refuses a ref update (rebase, branch -f, reset, update-ref) of a branch leased by another live session (Step 2). The refusal names the owner. Git runs it whatever the agent is, including Aider and a plain shell. Remote force-push races stay with --force-with-lease and the ruleset’s non-fast-forward rule An e2e test with two worktrees: a rebase from the second of a branch leased by the first is refused and names the lease holder; the holder’s own rebase and an expired lease are not refused
Step 2: worktree and branch leases Shipped (v0.15.0; src/lease.rs, discipline lease take / release / list / check). Each pre-tool call refreshes the worktree’s heartbeat (Step 3a); the generated hook files take the lease when a session starts (hook run --event session-start; RUN 2026-09-28 with Claude Code, Copilot CLI, agy and OpenCode) A lease file per worktree in the repository’s common git directory (<common git dir>/discipline/leases/), holding agent, session id, worktree path, branches and a heartbeat. It is written by the session-start hook where the agent has one, and otherwise by discipline lease take. discipline lease list shows the holders. A lease whose heartbeat is stale can be taken over explicitly (lease take --steal), never silently. The lease file is shared state, not scratch: it stays in the git directory and is never committed Unit tests pin the stale-heartbeat rule and a concurrent take (one wins, the other is refused); e2e covers take, list, steal and release
Step 3a: hook run --event pre-tool for edits Shipped (v0.15.0; src/pretool.rs) Reads each agent’s pre-tool payload (the Step 0 fixtures): Claude Code, Copilot CLI, agy, OpenCode, and Codex / Qwen Code in Claude Code’s shape. For an edit tool, the target is resolved (relative to cwd, symlinks followed) and refused when it lies in another worktree of the repository (the deepest worktree root wins, since agent worktrees often sit inside the main checkout), when another live session leases this worktree, or when it matches scope-confinement’s forbidden_paths. An edit tool whose target cannot be read, a payload that is not JSON, and a check that cannot be made (a lease that cannot be read) are refused. An edit outside the repository is not refused: agents use scratch directories. Each call refreshes this worktree’s lease heartbeat. --observe logs and lets the call through e2e per agent with the recorded payloads: into another worktree refused in the agent’s recorded deny shape, its own allowed, a leased worktree refused until released, observe mode logged, fail-closed cases refused
Step 3b: hook install writes the pre-tool entries Shipped (v0.15.0; RUN 2026-09-28: Claude Code 2.1.282, Copilot CLI 1.0.88, agy 1.2.12 and OpenCode 1.18.25 each ran their generated file in a throwaway repository with a nested second worktree; the write into the other worktree was refused before it happened and the write in its own worktree went through). The session-start entry that takes the lease shipped after it. The user-level Copilot hook carries it with --if-configured. Qwen Code’s and Codex’s entries followed (RUN 2026-09-29: each generated file refused an edit into the other worktree and let one in its own through, and the session took the lease). Cursor is set aside Add the pre-tool entry to each agent’s generated hook file (Claude Code PreToolUse, Copilot CLI preToolUse, agy PreToolUse, OpenCode tool.execute.before), in observe mode first; a session-start entry that takes the worktree’s lease where the agent has one. Codex and Qwen Code wait until their contracts are observed live Each generated file re-run live with its agent: an edit into another worktree is refused and one in its own worktree is not
Step 3c: shell commands Shipped (v0.15.0; pretool::judge_shell, tree-sitter-bash 0.25). Shell payloads recorded live (RUN 2026-09-28): Copilot bash / toolArgs.command, agy run_command / toolCall.args.CommandLine with its Cwd, OpenCode bash / output.args.command. Refused: cd into another worktree; git -C / --git-dir / --work-tree into one, other than a read-only subcommand; an output redirect into one; a force push of a branch another worktree leases; a path in those positions that expands at run time; a command that does not parse. Live: Claude Code’s Bash redirect into another worktree was refused through the generated hook Parse a shell tool’s command with the tree-sitter bash grammar (AGENTS.md §2.3: no substring matching) and refuse git -C <other worktree>, cd into another worktree, and a force push of a branch another worktree leases Unit controls over command forms (quoting, && chains, subshells); e2e per agent with its shell payload
Step 4: doctor multi-agent checks Shipped (v0.15.0; doctor::multi_agent_findings): ref-guard (a warning with several worktrees and no lease guard, information with one), leases (a lease that does not parse fails, since it makes the guard refuse every branch update; stale leases are listed), pretool-hook (a generated hook file without its pre-tool entry; information, since the entry needs a release with --event pre-tool). Agent accounts that can bypass branch rules are the agent-permission finding (#268): GitHub names ruleset bypass actors by type and id, not login, so an admin role is what is checked doctor reports worktrees without the ref guard, leases whose holder is gone, agents whose hook file lacks the pre-tool entry, and agent accounts that can bypass branch rules Unit controls per finding; e2e against a repository with two worktrees

Phase 14: doctor Forge-Settings Parity

discipline doctor reads the repository settings that decide whether a mutable action ref, a moved release tag or an exposed secret can run. On GitHub it checks all of them: the Actions policy (actions-sha-pinning, allowed-actions, default-token, actions-approve-prs), immutable-releases, tag-protection, secret-scoping, and the pins themselves (imposter-commit, nested-action-pins). Run with an admin token on this repository after v0.15.0 (RUN 2026-09-29), each of them passes. At v0.15.0, on Gitea, Forgejo and GitLab only tag-protection was checked (protected-tag patterns), and every other check reported that the forge does not have the setting (src/doctor_settings.rs, src/doctor_pins.rs), although GitLab has settings that play the same role and what Gitea and Forgejo expose had not been checked against a running instance. This phase recorded what each forge exposes (Step 0), then checks it: GitLab’s job token and CI/CD variables with an audit of the token doctor runs with (Step 1), Gitea’s and Forgejo’s secrets (Step 2). The ci-integrity gate, which reads a change’s workflows, already covers Gitea and Forgejo workflows as it does GitHub’s; this phase is about the forge’s settings only.

Item Status Work Ships when  
Step 0: what each forge exposes, observed live Done (#314; RUN 2026-09-29): Gitea 1.24.7, Forgejo 12.0.4 and gitlab.com recorded (tests/fixtures/forge_settings/, pinned by tests/test_forge_settings_fixtures.rs; the per-forge table in docs/guides/ci-platforms.md). Gitea and Forgejo list Actions secrets and variables by repository and organisation, with no environment or other scope on a secret, and have no API for the token’s default permissions, an allowed-actions policy or release immutability; tag protections are there (name_pattern). gitlab.com returns each CI/CD variable with protected, masked, hidden and environment_scope, the job token’s inbound scope and allowlist, protected environments and tags, and the project’s fork and pipeline-variable settings; each setting needs its own read permission on a fine-grained token, which the API names when it is missing, and a token describes its own scopes and expiry (/personal_access_tokens/self) Against the Gitea 1.24 and Forgejo 12 containers used for Phase 12, and a GitLab project: record which Actions / CI settings each forge’s API returns (default token permissions, secret and variable scoping, action source restrictions, release immutability), with the token scope each needs. Settings a forge does not have stay “not available”, now with the evidence A fixture per forge under tests/fixtures/ with the recorded responses, and the table in docs/CONFIGURATION.md states per forge what doctor can read  
Step 1: GitLab variables, job token and the token’s own access Shipped (v0.16.0; #317; doctor_settings::gitlab, refusal_message in src/forge.rs; unit controls against the recorded responses, and e2e through the binary against a fake forge serving them, a fine-grained refusal included). Open: a live run against a GitLab project, which waits on a read-only token secret-scoping from GET /projects/:id/variables (a variable neither protected nor scoped to an environment is readable by any pipeline; ci_allow_fork_pipelines_to_run_in_parent_project widens that to forks); default-token from GET /projects/:id/job_token_scope (inbound_enabled) and ci_push_repository_for_job_token_allowed; protected environments where deploy variables are scoped; restrict_user_defined_variables / ci_pipeline_variables_minimum_override_role. Values are never read or reported: names only, as on GitHub. Token audit: a denial names the read permission it lacks (insufficient_granular_scope: Variable: Read, Job Token Scope: Read, …), and doctor reports that name instead of a generic “set GITLAB_TOKEN”; /personal_access_tokens/self gives the token’s scopes, whether it is fine-grained and its expiry, and doctor warns on a token that can write (api, write_*, runner or cluster scopes: it needs read_api and the read permissions only) and on one expiring soon Unit controls per finding against the recorded responses (tests/fixtures/forge_settings/gitlab.json), including a granular denial and an over-broad token; e2e against a GitLab project; the same pass / warn / could-not-check contract as the GitHub checks Unit controls per finding against canned responses; e2e against a GitLab project; the same pass / warn / could-not-check contract as the GitHub checks
Step 2: Gitea and Forgejo Shipped (v0.16.0; #321; doctor_settings::gitea_secret_scoping): secret-scoping lists the repository and organisation secrets by name, states that a workflow on any branch reads them, names the pull-request workflows that do, and warns on a secret no workflow reads; unit controls on the recorded lists and e2e through the binary against a fake forge. Open: the pin checks against an instance whose actions source is GitHub, since DEFAULT_ACTIONS_URL is server configuration a repository token cannot read From Step 0: a secret has no environment to scope it to, so every workflow run of the repository can read every repository and organisation secret. secret-scoping there reports, by name, the secrets that jobs run on pull_request read (from the workflows) and the ones no workflow reads. The token’s permissions, the allowed-actions policy and release immutability have no API on either forge: they stay information, now with that evidence. Where the instance’s actions source is GitHub (DEFAULT_ACTIONS_URL), the pin checks (imposter-commit, nested-action-pins) can resolve uses: against it Unit controls and an e2e run against both containers; a setting a version lacks stays information, not a failure  

Phase 15: discipline audit, Escape Hatches Across History

Every escape hatch is judged one change at a time: a directive lifts a finding in the run that reads it, config-integrity reports a loosening in the change that makes it, a baseline entry grandfathers a finding once. Nothing reads them across history, so patterns that only show over many changes go unseen: exemptions that only grow, a gate waived repeatedly and then loosened in configuration, waivers that lifted nothing, a pull-request body edited after merge. Issue #319 carries the investigation. Findings that shape the design (READ unless marked): an OverrideRecord exists only when a directive lifts a finding (src/tokens.rs), so a directive that matched nothing leaves no trace; replay drops the applied overrides the child report carries and keeps only refusals (src/replay.rs), and it runs one configuration under test, so it is not the engine for history; config-integrity’s Weakening is prose (src/guards/integrity/config_diff.rs); on this repository no first-parent commit carries a Co-Authored-By trailer (RUN 2026-09-29), so which agent made a change cannot be read from history.

The audit is a read-only subcommand, not a gate (the patterns span changes) and not a mode of replay. It emits data first; signals are queries over it. Each record names its input’s trust tier: git objects on protected main (A), records the forge keeps (B), or text the author wrote (C); a tier-C signal is a hint only. Out of scope: exact-text boilerplate matching, per-file hot spots and per-agent trends (rewording and splitting defeat them), --fail-on until a signal has a measured false-positive rate, and any agent-facing output (a list of accepted waivers teaches an agent what passes).

Item Status Work Ships when
Step 1: applied and unused overrides Shipped (v0.16.0; #324, #331): replay --json lists each case’s applied overrides and overrides_by_gate, and check reports unused_directives (#324, which carried the work of the closed #323); each override names the finding it lifted (code, gate/code), set at every lift site, inline markers included, and carried into replay --json replay --json: each case lists the overrides its change applied (gate, directive, source, hidden; no reason text), additively. check: a directive that lifted no finding is reported in the JSON report, and each override names the finding code it lifted Unit and e2e controls: a directive that lifts a finding, one that lifts nothing, one scoped to the wrong subject; schemas regenerated; surface recorded
Step 2: structured configuration loosening Shipped (v0.16.0; #325): Weakening carries key, change (changed, removed, increased, decreased, lowered, gained, lost, emptied), before, after and count (a list entry is counted, never named), and what() renders the unchanged finding text Weakening carries key, direction, before and after; config-integrity formats its message from them Existing config-integrity tests pass unchanged; a unit test pins the structured fields
Step 3: discipline audit extraction Shipped (v0.16.0; #327; src/audit.rs, tests/test_audit.rs). Directives from pull-request bodies are read with --forge (Step 6, #334); without it, commit messages only. An inline marker counts when a comment opens with it. RUN 2026-09-29 on this repository, 300 changes: exit 0 Git only, no child process: per first-parent change, directives from the commit body (and the merged pull-request body when the forge is readable), discipline.toml loosenings between the change’s parent and itself, exempt_paths and baseline growth, inline discipline:allow(...) markers. One JSON record per exception with class (process, detector, config, baseline, inline), evidence (claimed or applied) and trust tier. A historical configuration that does not parse is a record, not exit 2. Reason text is a hash and length unless asked for E2e against a fixture repository with each record kind; schema in src/output_schema.rs; DISCIPLINE_NO_NETWORK=1 keeps it offline
Step 4: signals Shipped (v0.16.0; #329): eight git-only signals with a rank and a next action, checks with found / clean / not-checked, and recorded tightenings so a loosening shows as restored or open. RUN 2026-09-30 on this repository (323 changes): guard-gate-loosened #270, loosened-without-pull-request 2 changes, loosening-without-waiver 3, waived-then-loosened 3, loosened-not-restored 10. Broad subjects wait for a subject field (the reason is hashed); “directives that lifted nothing” is not-checked until the audit reads replay output Queries over Step 3: exemptions and baselines that only grow, a gate waived then loosened, directives that lifted nothing, broad subjects, hidden directives. “No second reviewer” is one repository-level line Each signal has a positive and a negative fixture and a named next action
Step 5: visual report Shipped (v0.16.0; #330; src/audit_html.rs; golden page tests/fixtures/audit/report.html). Views as agreed in the mockup review; every row links to its evidence on the origin remote’s forge (GitHub, GitLab, Gitea, Forgejo), never a hard-coded host. Ratification and review columns read “not checked” until Step 6 discipline audit --format html writes one self-contained HTML file: exceptions over time by class and gate, the growth of exemptions and baselines, and a table per signal linking each row to its commit or pull request. Charts are inline SVG rendered by the binary; the page loads nothing from the network and runs no script it does not carry. The JSON output (Step 3) is the export; the HTML is a rendering of the same data, with no field the JSON lacks. A mockup against this repository’s history is agreed before the renderer is written The mockup is agreed; a golden-file test pins the HTML for a fixture history; the page renders with no network access
Step 6: forge facts (opt-in) Shipped (v0.16.0; #334, #335, #336 (landed through #338), #340, and the body-edit part): audit --forge reads each change’s merged pull request (GitHub, GitLab, Gitea, Forgejo, through src/forge.rs): directives in its body beyond the commit message, each linked to the pull request, and whether a login other than its author approved its head; a change the forge cannot answer for leaves those checks not-checked. Each protected edit is judged as ratified-paths judges it (ratification::judge, against the parent configuration and the path’s last change in history): ratified, ratified by the pull request’s own author login, unratified, not required, never ratifiable or not checked, linked to the ratifying issue comment. The audit reads after the merge, so a closing issue the merge closed still counts, and one the forge says was closed before the merge does not. RUN 2026-09-30 on this repository: the 11 protected edits since #270 were each ratified by the pull request’s own author login. Cited issues: each waiver’s reason references (cites) are read once (references::issue_facts: state, GitHub close reason, close time) and judged against the waiver (waiver-cites-missing-issue, waiver-cites-issue-closed-before, waiver-cites-issue-not-planned). Body edits: on GitHub, each pull request’s lastEditedAt is compared with its merge time (pulls[].body_edited_after_merge, the pull-request-body-edited check); on GitLab, Gitea and Forgejo that check is not-checked. Differences from the plan: who closed a cited issue and its closing commit are not read, and check does not record a body hash (the audit cannot read CI artifacts), since the forge’s own edit time answers the same question on GitHub. RUN 2026-09-30 on five other repositories (GitHub and Gitea): all forge reads answered with a token; without one, the Gitea reads were refused and every forge question read not-checked Directives read from pull-request bodies, and ratification comments and reviews per protected edit, each linked to the exact pull-request body, pull-request comment, issue body or issue comment it came from; issue timeline for issues a reason cites (state reason, who closed it, closing commit); check records a hash of the pull-request body it read so the audit can detect a post-merge edit. Through src/forge.rs; “could not check” on a forge without the data Unit controls against recorded responses; off under DISCIPLINE_NO_NETWORK=1
Step 7: which agent made a change Deferred (decision 2026-09-30). Every record an agent could write about itself (a commit trailer, a git note, a pull-request label or comment, a per-agent author email) is a claim that agent can forge or leave out, so it could only ever be a note, never a gate. Trailers and co-author lines also add the text to commit messages that this repository keeps out. Done instead: discipline audit never reads a missing record as a person. Each change with an exception is claimed (a commit carries a commit-provenance agent marker, which the commit asserts and nothing verifies), agent-login (with --forge, the pull request was opened by an agent_logins or [bot] login), or no-record. The threat model (docs/ARCHITECTURE.md §1.4) says what attribution can and cannot show. Revisit when a decision depends on which tool made a change (for example, comparing waiver rates across tools). Then one forge login per tool is the only record bound to a credential that adds no text (it builds on Step 8); git notes re-attached to the squash commit by CI are the offline fallback The audit tells a change with an agent record from one with none, and never reports a missing record as a person Unit controls for each state; the agent-identity check is found or not-checked, never clean
Step 8: a separate login for the agents on this repository Tool side shipped (v0.16.0; repository side #776): gates.ratified-paths.refuse_author_ratification (default false) refuses a ratification written by the login that opened the pull request (ratification-by-pull-author), and every ratification when the run does not know that login. It stays off in this repository for now: every agent here opens pull requests as the owner’s login, the only ratifier. Why it matters: every coding agent here posts as that login, so a ratification or approval by it does not show that a second party agreed. RUN 2026-09-30 (gh): the four protected-path edits ratified since #270 (#280, #298, #300, #309) were each ratified by their own author login, shortly before merge; none of 303 merged pull requests was reviewed by another login. Left, repository side (the owner; discipline.toml and the workflows are protected paths, so no agent can ratify these): 1. create the agents’ login (a machine user, or a GitHub App whose installation token the agents use); 2. give every agent that login’s credentials for git and gh, so its commits, pull requests and comments carry it; 3. in discipline.toml, add it to agent_logins (a login in both lists is already a configuration error), keep ratifiers to the owner, set refuse_author_ratification = true, and restore never_ratifiable for the workflow directories, which #270 emptied; 4. optionally, require one approving review in branch protection, which audit’s independent-review then shows. With the option on, a pull request the owner opens cannot be ratified by the owner: an owner’s own protected edit goes through an agent’s pull request or an administrator merge. Left, tool side: a doctor warning when every ratification in a window came from the pull requests’ authors Agent commits and pull requests carry the new login; e2e: with the option on, an author’s own ratification is refused and another ratifier’s passes; discipline audit shows no self-ratified protected edit after the switch  
Step 9: waivers that lifted nothing (--replay) Shipped (v0.16.0): audit --replay <file> joins a discipline replay --json report (Step 1’s per-case applied overrides) to the audit’s waivers by commit: each finding waiver of a replayed change is lifted or not (evidence applied when it was), a waiver that lifted nothing is the waiver-lifted-nothing signal, and directive-lifted-a-finding gets found / clean / not-checked with what the replay did not cover. Inline markers are judged by file and line (#360): the gates read markers by line text, so markers quoted in tests and documentation count as markers, and the replay shows they lift nothing; they stay out of the signal and out of the HTML page’s marker table. The audit re-runs nothing; the replay’s cost stays opt-in. RUN 2026-09-30 on this repository (replay --last 60, then audit --last 60 --forge --replay): 12 directive waivers judged, each lifted a finding; #309 and #347 were not judged (their replays could not check); all 9 inline markers added in those changes lifted nothing, each quoted in a test or a doc comment The last git-only question the audit could not answer, answered from a replay the user already ran Unit controls: an alias, an unapplied directive, no-issue, inline markers by file, a case that could not be checked, a change not replayed, a file that is not a replay report

Phase 16: Agent and Container Containment Settings

An agent’s containment is set in files the repository tracks: its permission lists and default mode, its sandbox and network allow-lists, its MCP servers and hooks, and the Dev Container or Compose service it runs in. A change that widened any of them passed every gate: instruction-smuggling reported any edit to a hook file without reading what it changed, and nothing read the container definitions. Running containment itself (system calls, outbound traffic, credential reads, stopping a session) stays with the sandbox, and is stated as a non-goal in docs/ARCHITECTURE.md §1.3.

Item Status Work Ships when
Step 0: which keys each agent reads Done (READ and RUN 2026-09-29): each key is taken from the vendor’s documentation or published settings schema, or the installed binary (Claude Code 2.1.282, Gemini CLI 0.44.1, OpenCode 1.18.25, Cursor CLI, Copilot CLI 1.0.89, agy 1.2.13 installed; Codex and Qwen Code from their published schema and documentation only). agy documents no project settings file; Codex reads a project .codex/config.toml once the project is trusted; Qwen Code’s folder trust is off by default Record, per agent, the project and user settings paths, the keys that widen containment, and each mode’s values in order The rule table cites a source per agent
Step 1: agent settings (#341) Shipped (v0.16.0): sandbox-config, src/guards/sandbox_config.rs on toolchain-config’s engine with two new judgements (a ranked mode with the value an absent key takes; a list gaining an entry that matches a fragment). An added or deleted file is compared with an absent one Claude Code, Codex, Gemini CLI, Qwen Code, OpenCode, Cursor and Copilot CLI settings and the MCP server lists Unit controls per agent, e2e through the binary, a self-test case, mutation evidence
Step 2: container isolation (#342) Shipped (v0.16.0): in the same gate, devcontainer.json and Compose, and a firewall script reported as changed, not analysed Privilege, namespaces, capabilities, confinement options, devices, engine-socket mounts, runArgs, host-engine features, initializeCommand Same as Step 1
Step 3: doctor posture (#343) Shipped (v0.16.0): agent-sandbox Each agent’s settings in the repository (warning) and in the user’s home (information) read for modes and switches looser than the defaults Unit test with a repository file, a user file, a clean file and an unparseable one
Step 4: the non-goal stated (#344) Done (v0.16.0): docs/ARCHITECTURE.md §1.3 Say what discipline does not contain at run time, and why n/a

Phase 17: A Turn That Ends On An Announced Action

A coding agent on a weaker model often ends a turn by announcing its next action and not taking it: the final message is one sentence of intent (“Now let me run the fail-then-pass test and check the Miri issue.”), the model stops normally, and the person has to type “continue”. Tracked in #706. The check is opt-in ([hooks.premature-stop], off by default) and lives in hook run only. This section records what each agent’s end-of-turn hook can do (Step 0, evidence), the detector with its measured results, what is built, and what is open. Every claim is labelled RUN (executed, 2026-10-08) or READ (from documentation, a schema or a binary).

What was observed first (RUN, the owner’s local session records). 6,144 turn endings (a final assistant message with no tool call, a normal stop) from Claude Code transcripts and an OpenCode database: 4,950 distinct texts, 263 of them from 19 models other than Claude’s, 4,687 from Claude models. For the local open-weights model the report came from: under OpenCode, 0 of 47 endings below 100k prompt tokens and 2 of 39 above announced an action and stopped; under Claude Code, 4 of 6 did, two of them at 53k and 54k prompt tokens, so a long session makes it more likely and a short one does not rule it out. The same model also ended three turns under OpenCode with a tool call written out as text (</parameter> </function> </tool_call> after a sentence of intent): a second way to announce and not act, all three below 100k. Of the 100 endings the person answered with “continue”, “proceed” or “go ahead”, nearly all were questions and requests for approval, so that reply is not a usable label for this defect.

Item Status Work Ships when
Step 0: end-of-turn contracts, observed live Done for all six headless CLI agents (RUN 2026-10-08: Claude Code 2.1.282, Qwen Code 0.25.0, Copilot CLI 1.0.91, agy 1.3.0, OpenCode 2.0.22; RUN 2026-10-15: Codex 0.162.1, Qwen Code 0.25.2). Cursor CLI is READ; Aider has no end-of-turn hook. Fixtures: tests/fixtures/stop/, pinned by tests/test_stop_fixtures.rs Each probe ran a throwaway repository whose stop hook saved its payload, copied the transcript as it stood, refused the first stop with a marked instruction and let the second through. The agent was asked to run one shell command and reply with the sentence above. The table below has the result per agent Cursor is set aside from the headless CLI gate (needs interactive IDE sign-in)
Step 1: the detector, measured Shipped (v0.19.0; src/turn.rs, corpus tests/fixtures/premature_stop_corpus.json, pinned with zero disagreement by corpus_is_judged_as_labelled_and_other_languages_are_recorded_misses). Measured verdict below: INTERMEDIATE, which is why the default mode is observe. The Rust matcher gives the same answer as the prototype it replaced on all 6,144 recorded endings (RUN) The last sentence of the final message, outside fenced code, is matched against a closed set of first-person immediate-intent openings; a hand-over or a condition in the same sentence clears it. Tool-call markup at the end of the message is a second, separate match Refusing is recommended only once an owner-set precision floor is cleared by the Wilson lower bound on endings the detector’s author did not label
Step 2a: hook run at the end of a turn, agents whose payload carries the message Shipped (v0.19.0): Claude Code, Qwen Code and Codex. [hooks.premature-stop] with enabled, mode, max_per_session, tool_call_as_text, read from the base ref; config-integrity judges the table. Run live in refuse mode (RUN 2026-10-08, Claude Code 2.1.282 and Qwen Code 0.25.0; RUN 2026-10-15, Codex 0.162.1 and Qwen Code 0.25.2): the refusal reached the model, which answered with a reason (Claude Code: no tests exist) or finished the work it had started (Qwen Code and Codex). On Qwen Code 0.25.0 stop_hook_active was false on the stop that followed a refused one once the model had called a tool in between (fixed in Qwen Code 0.25.2, where stop_hook_active stays true), and the session cap bounds the loop either way After the change check passes, the final message is judged; observe logs the code and lets the stop through, refuse exits 2 once with the refusal text. Never refused: a stop marked stop_hook_active, a session at its cap, a payload listing background work, a payload without a message e2e on the Step 0 payloads through the binary (tests/test_premature_stop_hook.rs), unit controls, mutation evidence
Step 2b: Copilot CLI and agy Done (src/transcript.rs, src/hook.rs, fixtures pinned in tests/fixtures/stop/, e2e in tests/test_premature_stop_hook.rs). Readers parse JSON / JSONL transcripts for Copilot CLI and agy, extracting the final message and whether tool calls followed. Refusals feed back via each agent’s native protocol (decision: block for Copilot, decision: continue for agy). Loop guards are respected (stop_hook_active for Copilot, executionNum > 0 for agy). A missing or unreadable transcript lets the stop through with a note on stderr in observe mode or once the loop guard or session cap applies; otherwise, in refuse mode, the stop is refused as could-not-verify (v0.20.0, #730, #732; src/hook.rs), bounded by the same guard and cap A reader of each agent’s transcript (the shapes pinned in tests/fixtures/stop/) that yields the final message and whether a tool call followed, built as its own module so the later ideas below can use it e2e on recorded transcripts; a transcript that cannot be read is refused in refuse mode within the loop guard and cap, and let through with a note otherwise
Step 3: OpenCode stop handler Done (#778; src/hook.rs opencode_stop and the generated plugin, fixtures tests/fixtures/stop/opencode/turn_events_v2.json, synthetic.json, plugin_argv.json, pinned by tests/test_stop_fixtures.rs; e2e in tests/test_premature_stop_hook.rs, the plugin driven under node in tests/test_agent_hooks.rs). Run live with OpenCode 2.0.28 in refuse mode (RUN 2026-10-16, isolated home, a free hosted model): under the background service the turn ending “Now let me run the tests.” was refused once with session.synthetic, the model looked for tests and answered with a reason (none exist), and the stop after it was let through at the marker; under opencode run --standalone the check was skipped and the skip logged (verdict: "skipped", reason: "standalone") The plugin reads the turn from event.subscribe() (the final session.text.ended; a session.tool.called or a step that ended tool-calls after it) and on session.execution.succeeded sends it to hook run --agent opencode --event stop, in a promise nothing awaits. A refusal comes back as {"decision": "block", "reason"} on stdout, so a release without the event (a usage error) is never sent to the model; enforcing, it is re-sent with session.synthetic, else session.prompt. hook run keeps the loop guard (<git dir>/discipline/opencode-stop-<session>, consumed by the next stop) and the cap (turn-<session>), and runs nothing when the base ref enables neither end-of-turn check A live session under the background service and one under run --standalone, each recorded

What each agent’s end-of-turn hook gives and accepts.

Agent Event Final message Transcript Refusal that fed text back to the model Loop guard the agent exposes
Claude Code 2.1.282 (RUN) Stop last_assistant_message in the payload transcript_path (JSONL; the final message was in it when the hook ran) exit 2, stderr; the model received it as “Stop hook feedback” and answered it stop_hook_active: true on the continued stop. The payload also lists background_tasks and session_crons
Qwen Code 0.25.0 / 0.25.2 (RUN) Stop last_assistant_message transcript_path (JSONL, message.parts, functionResponse for tool results) exit 2, stderr; answered stop_hook_active (true on the continued stop in 0.25.2 even after tool calls); background_tasks, crons; the payload also carries input_tokens and context_usage
Copilot CLI 1.0.91 (RUN) agentStop not in the payload transcriptPath (events.jsonl): the last assistant.message event has content and toolRequests (empty on the final message), and was written before the hook started {"decision": "block", "reason"}; the reason arrived as a user message and was answered stop_hook_active
agy 1.3.0 (RUN) Stop not in the payload transcriptPath (transcript_full.jsonl): a model step is PLANNER_RESPONSE with tool_calls or with content; the final one was present when the hook ran {"decision": "continue", "reason"}; delivered as a system message (“Stop hook blocked termination: …”) and answered none documented; the payload’s executionNum went 0 then 1 across the continuation, and terminationReason was NO_TOOL_CALL both times. Whether executionNum resets on the next user message was not checked
OpenCode 2.0.22 / 2.0.28 (RUN) none; a plugin reads event.subscribe() (an async iterable in 2.x) session.text.ended carries text the event stream itself: session.tool.called, session.step.ended with finish (tool-calls or stop), then session.execution.succeeded, which names no directory (2.0.28) session.synthetic({sessionID, text}) from the plugin (2.0.28): admitted as type: "synthetic" (delivery: "steer"), not as the person’s message; a new execution started, and under the background service the model answered it. session.prompt({sessionID, text}) (2.0.22) is admitted as a user message. Under opencode run --standalone (the plugin’s process is serve --stdio; the service’s is serve --service) the process exited before the new execution produced text, and the queued message stayed in the session’s inbox, where the background service later ran it none: session.idle of 1.x did not appear in 2.x’s stream, and nothing marks a plugin-sent message, so the guard and the cap are ours
Codex 0.162.1 (RUN) Stop last_assistant_message (required field) transcript_path (JSONL rollout file) exit 2, stderr; the model received it, ran the announced command (cargo test) and answered stop_hook_active: true on the continued stop even after tool calls; also turn_id, model, permission_mode
Cursor CLI (READ: the installed bundle; not signed in) stop not established; the bundle also names an afterAgentResponse event a transcript path is passed to hooks (CURSOR_TRANSCRIPT_PATH) {"followup_message"} loop_count in the payload against loop_limit in the hook file; a stop hook is skipped once the count reaches the limit
Aider (READ: src/hook.rs module header) none (lint-cmd runs after an edit) none none none not applicable

The detector and AGENTS.md §2.3. §2.3 forbids substring matching to verify a code invariant, because a comment can satisfy it. This check splits in two. Whether the turn made a tool call after the final message is never read from text: it is the agent’s own structured record (the stop event itself, toolRequests, tool_calls, finish, terminationReason), as the pre-tool check reads a payload and not a log line. What the final sentence says is prose, not code, and no grammar parses it; the repository already judges prose in time-estimates, under the rule that its matcher is pinned by a labelled corpus (tests/fixtures/time_estimates_corpus.json). This check follows that precedent and narrows it: the match is anchored at the start of the last sentence, fenced code is removed first (a quoted “Let me check” in a block does not count), the openings are a closed list, and the corpus is the contract. A substring match on the same words was run as a control and is far worse (last row of the results).

Matched as an announcement: a last sentence that opens, after fillers such as “Now,” or “OK.”, with “let me”, “let’s”, “I’m going to”, “time to”, “now to”, “now I’ll”, “next, I’ll”, “I’ll now”, “I’ll start by”, “proceeding to”, “moving on to”, or “I need to” followed by an action verb. Cleared when the same sentence ends in a question mark, hands over (“let me know”, “want me to”, “say the word”, “your call”, “please”), states a condition or a wait (“once”, “when”, “after you”, “until”, “if you”), or opens with a non-action (“let me explain”, “let me be direct”, “I’ll wait”). A plain “I’ll …” is deliberately not matched: in the records it is 56 distinct endings, nearly all promises to report on background work that was really running.

Results (RUN). The labels are the detector author’s alone, read from the last sentence without the session around it; no one else has reviewed them.

Set Precision Recall What it does and does not show
Recorded endings, all 4,950 distinct 7 of 8 announcements flagged were real = 0.88, Wilson 95% [0.53, 0.98]. Tool-call-as-text: 3 of 3 on the 263 endings from non-Claude models, every one read: 9 of 9 (6 announcements, 3 tool calls as text), Wilson [0.70, 1.00] The one false flag is “I’ll first check …, then start …” written while a tool was unavailable. Recall among the 4,687 Claude-model endings was not measured: only the flagged ones, the plain promises and the “continue”-answered ones were read. The detector was written on one half of the sessions and frozen before the other half was scored, but the four motivating examples were known beforehand and resumed sessions repeat text across halves, so this is not a clean held-out result
The same, Claude-model endings only 1 of 2 not measured 2 flags in 4,687 endings, Wilson [0.0001, 0.0016]: on a strong model the check would almost never speak
Constructed corpus, 102 final messages (tests/fixtures/premature_stop_corpus.json) 27 of 27, Wilson [0.88, 1.00]; 0 of 66 other endings flagged all languages 27 of 36 = 0.75 [0.59, 0.86]; English 27 of 27; French, Spanish, German, Portuguese, Chinese, Japanese 0 of 9 Written by the detector’s author in one sitting, so it is sympathetic by construction. It pins the classes asked about (“Let me know if …”, questions, a summary that names future work, conditions and waits, promises with background work, a quoted announcement) and records the non-English misses as misses
Controls on the constructed corpus no hand-over clearing: 3 false flags. Plain “I’ll” counted: 5 false flags, and 67 flags on the recorded endings instead of 11. Substring match on “let me”, “I’ll”, “I will”: 8 false flags substring: 20 of 36 The corpus tells the variants apart; it is least sensitive to the hand-over list (3 of 66)

Verdict: INTERMEDIATE. The precision point estimate on real endings is 0.88 and its lower bound 0.53, on 8 flags; no floor was set before measuring, and none could be claimed as cleared on this count. It is enough to justify observe mode, where a wrong match costs a log line, and not enough to justify refusing a stop by default.

Where it belongs. It is not a gate: a gate judges a diff and CI never sees a transcript. It is a check of hook run at the end of a turn, and exists nowhere else (check, mcp, replay, audit and every report format are unchanged).

The same view of a turn, for later. The transcripts read in Step 0 hold more than the final message: every tool call with its input, and for most agents its result (Claude Code tool_use and tool_result; Copilot CLI tool.execution_start and tool.execution_complete with the shell exit code; agy tool_calls and the result step; Qwen Code functionResponse; OpenCode session.tool.called, session.tool.success and session.tool.failed). One reader per agent producing an ordered list of (tool, input, outcome) for the turn would serve this check and the two ideas discussed with it: an edit after the last passing verify command, and the same tool call repeated. Both shipped in v0.21.0 as [hooks.verified-stop] and [hooks.tool-loop] (#700, #701, #760), hardened in v0.22.0 (#768); they read the hook payloads (the post-tool witness and the pre-tool call), not the transcript. The constraint that follows for Step 2 is to build the reader as its own module with the transcript shapes pinned by fixtures, not as string searches inside the detector. It does not extend to Aider (no transcript), and for Codex the transcript path is nullable.

Open questions.

  1. The precision floor and who labels: decided 2026-10-16 (#779) and evaluated 2026-10-21 (tests/fixtures/premature_stop_recorded_endings.json, scripts/evaluate_premature_stop_labels.py, src/turn.rs::tests::recorded_endings_packet_matches_detector_and_wilson_bounds, closing #779). mode = "observe" stays the default. refuse is recommended only once the Wilson 95% lower bound of its precision is at least 0.90 (target) on endings labelled by the human owner. Across all 19 distinct recorded turn endings flagged by src/turn.rs::judge in the local OpenCode and Claude Code session logs (15 announced_action, 4 tool_call_as_text, plus 6 unflagged control endings), human-owner review yielded $k = 18$ true positives and $1$ false positive (rec-19, a legitimate stop waiting for interactive gh auth login): point precision 0.947368 (18 / 19), Wilson 95% CI [0.753613, 0.990648] vs 0.90 floor (target), verdict INTERMEDIATE_floor_within_ci (announced_action alone: $k = 14 / 15$, point precision 0.933333, Wilson 95% CI [0.701835, 0.988133]; tool_call_as_text alone: $k = 4 / 4$, point precision 1.000000, Wilson 95% CI [0.510109, 1.000000]) (measured: Apple M-series macOS 15 arm64, 82d02046). Because even at $100\%$ precision a sample requires $n \ge 35$ (35 / 35 $\implies$ Wilson 95% lower bound 0.900894 $\ge 0.90$ (target)) to clear the 0.90 lower-bound floor, refuse stays opt-in and not recommended until additional recorded endings from observe mode bring the Wilson 95% lower bound above 0.90.
  2. Fail-open on an unreadable transcript: decided in v0.20.0 (#730, #732): refuse mode refuses as could-not-verify, bounded by the loop guard and the session cap.
  3. Other languages (dropped; see Known Limitations). The detector reads English only and misses every non-English announcement in the corpus. Models that answer in the person’s language will not be caught. Per-language opening lists are cheap to write and need their own labelled endings; none exist yet.
  4. A plain “I’ll …” with no background work running is sometimes this defect and is not matched. Claude Code and Qwen Code list background tasks, which would let an empty list count as evidence; the other agents do not.
  5. OpenCode: how the refusal reaches the model, and run --standalone: decided 2026-10-16 (#778) and built in Step 3. The refusal is sent with session.synthetic first, so it is not recorded as the person’s message, and with session.prompt (recorded as a user message) when session.synthetic is missing or throws; under opencode run --standalone the check is skipped and the skip logged. If a hook that fires before the loop stops ships (session.stopping, with output.stop = false; anomalyco/opencode#16626, open, three implementations closed unmerged), the handler moves to it. The third-party OpenCode-Hooks plugin’s session.idle hooks do not block (RUN 2026-10-16: GitHub API, #778).
  6. The refusal arrives as a user message on Copilot CLI and OpenCode, as hook feedback on Claude Code and as a system message on agy. A weak model may answer the refusal with another announcement; the cap then ends the turn. How often that happens is unmeasured: every probe here used an instruction the model could satisfy in one word.

Recommendation for the remaining steps. Every headless CLI agent is judged now; Cursor and Aider are out of scope. mode = "refuse" stays an opt-in nobody is told to choose until the floor decided in question 1 is cleared: the observe log from real sessions, OpenCode’s included, is the cheapest way to get the endings a second labeller needs.

1.0 Readiness

Before 1.0 a minor release may change gate behaviour; from 1.0, docs/ARCHITECTURE.md §3.1 binds (a default only becomes stricter within a major version, and a looser one needs a ledger entry with the configuration that restores it). 1.0 ships when all of these hold:

Criterion Status Ships when
Consumer replay of the v0.11 features Met: replay --last 100 (200 on the multi-language one) on four consumer repositories (a PHP C extension, a Python CLI, a Go MCP server, a Rust core with bindings in seven languages) and on public Swift, Scala and Objective-C projects; every false positive they found is fixed (the last Objective-C and Swift ones for the release after v0.12.3), and what still blocks is a true positive or defensible discipline replay --last 100 on at least one consumer repository shows no false-positive block from the v0.11 language packs, agent-facing checks or archive-contents formats
Live agent sessions Met (RUN 2026-10-16, RUN 2026-10-21, #780): Claude Code (2.1.295), Copilot CLI (1.0.91), OpenCode (2.1.2), agy (1.4.2), Codex CLI (0.162.1) and Qwen Code (0.25.2) ran the installed hook in a live session and acted on a finding; the four-step session captures (weaken_call, finding, repair_call, after_repair) are committed in tests/fixtures/stop/enforcing_sessions.json and pinned in tests/test_stop_fixtures.rs alongside the stop and pre-tool fixtures under tests/fixtures/stop/ and tests/fixtures/pretool/ (including Qwen Code 0.25.2 stop_continued.json after a tool call, OpenCode 2.1.2 shell_post_pass_v2.json / shell_post_fail_v2.json, and agy 1.4.2 run_command_post_pass.json / run_command_post_fail.json); Cursor is set aside from the headless CLI gate (as in Phases 13 and 17) One real session each with the headless CLI agents (Claude Code, Codex CLI, Copilot CLI, agy, Qwen Code and OpenCode) runs the installed hook and the agent receives and acts on a finding; the contracts cited in src/hook.rs are confirmed or corrected
Forge evidence Met (RUN 2026-10-16, #774): the five records Phase 11 Step 0 lists, from a containerised Gitea 28.1.0 with act_runner v0.6.1 and the discipline v0.22.0 image (tests/forge-lab/; summary in Outstanding Checks) The containerised Gitea records Phase 11 Step 0 lists exist
External review Met (decision 2026-10-16, #775): the owner, a human reviewer, read the gate contract and the threat model; 1.0 ships without a separate independent review, and this is a review by the project’s author, not an independent one A non-LLM reviewer has read the gate contract (docs/ARCHITECTURE.md §3) and the agent-facing threat model (docs/ARCHITECTURE.md §1.4) and their findings are resolved
Interface freeze Met: the stability policy is docs/ARCHITECTURE.md §3.2, held by tests/test_stability_contract.rs against tests/fixtures/v1_surface.json and by the schema snapshots in tests/test_output_schemas.rs. No release since v0.17.2 removed or renamed a name in v1_surface.json (additions only), and the three output schemas did not change across the v0.20 (v0.20.0 to v0.20.3) or v0.21 (v0.21.0 to v0.21.2) lines (RUN 2026-10-16: git diff of tests/fixtures/v1_surface.json and discipline.{report,replay,audit}.schema.json between consecutive tags) One full minor release passes with no removal or rename in tests/fixtures/v1_surface.json and no change to a pinned schema field, and a stability policy states what 1.0 freezes

Default Changes (Compatibility Ledger)

Default enablement and severity are part of the compatibility contract (docs/ARCHITECTURE.md §3.1). Every change to a built-in default is recorded here, newest first; a loosening within a major version is not allowed without an entry. Each entry names the one-line configuration that restores the previous behaviour.

Release Gate Old default New default Direction Reason Restore previous behaviour
v0.26.0 harness-tampering off, error on, error for the three exact forms; harness-exits-zero at warning at most stricter A harness file that makes a failing run pass is seen by no test-facing gate (#833). The agent-PR precision study v3 ran with the gate enabled and it blocked none of 232 agent pull requests: false blocks are bounded at about 0.016 per pull request (Wilson 95% upper bound), a bound, not a precision estimate. Only forms a change adds are reported, so an existing tree does not fail on upgrade. Two idioms are reported by design: session.exitstatus = 0 on exit status 5 in pytest_sessionfinish, and an environment-gated item filter in pytest_collection_modifyitems (rewrites in docs/GATES.md). A repository run with --fail-on-warnings now also blocks on harness-exits-zero. [gates.harness-tampering] enabled = false (config-integrity reports that edit once as a weakening: it needs allow-gate-weakening: or a ratified change); for harness-exits-zero under --fail-on-warnings alone, exempt_paths or allow-harness-tampering: <path> <reason>
v0.22.0 stub-bodies equality-made-constant on a changed production method at warning at most (exit 0) at the gate’s severity (error by default, exit 1) stricter Restores v0.19.0 blocking for a substantive equality body made constant allow-stub: <Type> <reason>, or [gates.stub-bodies] severity = "warning" (whole gate)
v0.22.0 citation-anchors no minimum quote length min_quote_chars = 8 stricter A one-character quote is contained in almost any line and verified anything min_quote_chars = 0
v0.21.1 claim-labels (new gate) off, error none (off) Requires RUN or READ verification labels on behavioural claims in pull-request bodies, commit messages, and configured prose paths (#703, #761). Off because prose claim-labelling conventions are repository-specific. n/a
v0.20.1 suppression-delta Findings quoted the full source line containing the suppression annotation Findings report location and suppression kind only (quote_line = false); opt-in quote_line = true restores quoted line stricter Enforces locations-only report contract to prevent source and secret leakage [gates.suppression-delta] quote_line = true
v0.20.0 citation-anchors (new gate) off, error none (off) Verifies path:line@sha citations against quoted text and git history. Off because git-anchored citation conventions are repository-specific. n/a
v0.20.0 gate-command-lint (new gate) off, error none (off) Rejects vacuous, masked, or inverted verification commands in plan files. Off because plan file conventions are repository-specific. n/a
v0.20.0 mechanism-sections (new gate) off, error none (off) Requires (inferred) or (verified: <target>) evidence tags in root cause sections. Off because root-cause documentation is repository-specific. n/a
v0.19.0 harness-tampering (new gate) off, error none (off) Reports a change that adds, to a file the test runner loads, a Go TestMain that drops the test result, a pytest hook that rewrites outcomes or removes items, a replaced unittest result method, or an exit with status zero (warning at most). Off because it was new, and turning it on was left to a default change of its own (#481). n/a
v0.17.0 ignored-tests A test skip conditioned on a CI variable (CI, GITHUB_ACTIONS, etc.) reported at warning Reported at error (gate severity) stricter A test that skips itself when running in CI is disabled where the merge gate runs. Generic environment conditions stay at note. To waive specific CI skips entirely, add their variables to approved_predicates. [gates.ignored-tests] ci_skip_severity = "warning"
v0.16.0 sandbox-config (new gate) on, error stricter A change that widens an agent’s permissions or sandbox (Claude Code, Codex, Gemini CLI, Qwen Code, OpenCode, Cursor and Copilot CLI settings, MCP server lists) or a container’s isolation (devcontainer.json, Compose) is reported; a changed firewall script at warning as not analysed. A repository with none of these files examines nothing. [gates.sandbox-config] enabled = false
v0.15.0 ci-integrity first_party_action_prefixes = ["actions/", "github/"]: tag refs to those owners were not reported first_party_action_prefixes = []: every remote action and reusable workflow needs a 40-character commit SHA stricter GitHub’s “require actions to be pinned to a full-length commit SHA” policy exempts no owner, so a repository that turns it on failed at run time on refs discipline passed. By default only a ref new relative to the base is judged, so existing actions/*@v4 refs are reported only under diff_only = false. Re-adding a prefix is reported by config-integrity as a loosening. [gates.ci-integrity] first_party_action_prefixes = ["actions/", "github/"]
v0.15.0 ci-integrity workflows = the Actions workflow directories and GitLab pipelines workflows also matches .github/actions/**/action.yml, .github/actions/**/action.yaml, action.yml, action.yaml stricter A composite action’s nested uses: runs in every workflow that calls it; its metadata file is read for pinning only (no rollup or job checks). [gates.ci-integrity] workflows = [".github/workflows/*.yml", ".github/workflows/*.yaml", ".gitea/workflows/*.yml", ".gitea/workflows/*.yaml", ".forgejo/workflows/*.yml", ".forgejo/workflows/*.yaml", ".gitlab-ci.yml", ".gitlab/ci/*.yml", ".gitlab/ci/*.yaml"]
v0.15.0 review-threads (new gate) off, error none (off) The pull request has no unresolved review thread. Off because it reads the forge, and GitHub and GitLab can enforce the same at merge. n/a
v0.15.0 ratified-paths (new gate) off, error none (off) Edits to protected paths need an owner’s ratification on an issue the pull request closes. Off because what is protected and who ratifies are the repository’s policy. n/a
v0.15.0 citation-metadata (new gate) on, error stricter A change that adds or edits CITATION.cff or .zenodo.json is checked: each file parses and carries its format’s required keys with well-formed values (ORCID check digits, dates, URLs, Zenodo’s vocabularies), DOIs are syntactically DOIs, doi is the concept DOI when identifiers says which is which, and the two files agree on title, authors, ORCIDs, keywords and licence. A repository with neither file examines nothing; problems in files a change leaves alone are notes. [gates.citation-metadata] enabled = false
v0.14.0 agent-scratch exempt_paths without the bootstrap exempt_paths gains .claude/hooks/discipline-bootstrap.sh looser The script hook install --agent claude-code writes is shared project configuration, like .claude/settings.json; instruction-smuggling still reports a change to it. [gates.agent-scratch] exempt_paths = [".claude/settings.json", ".cursor/hooks.json", ".cursor/mcp.json", ".aider.conf.yml"]
v0.14.0 pii every ~/.<agent> path reported a tool’s own configuration location allowed (agent_config_standard_paths = true) looser Documenting where a tool reads its settings or hooks is not a leak of a maintainer’s setup; the personal content under the same directory is still reported. [gates.pii] agent_config_standard_paths = false
v0.9.0 build-hooks (new gate) on, error stricter A new or changed package.json lifecycle script, a build script gaining process / network / shell access, or any edit to package-manager configuration (.npmrc, .pypirc, pip.conf, .cargo/config.toml, .env*) is reported. [gates.build-hooks] enabled = false
v0.8.0 commit-provenance (new gate) off, error none (off) Required commit trailers and a review by someone else on agent-produced commits. Off because which trailers a repository requires is its own policy. n/a
v0.8.0 instruction-smuggling (new gate) on, error stricter Zero-width, bidirectional and tag characters in added lines and any edit to an agent-instruction file (AGENTS.md, .cursorrules, copilot-instructions.md, skill files, …) are reported; instruction-like phrases and encoded blobs in comments, strings and prose at warning. Findings carry location and class only. [gates.instruction-smuggling] enabled = false
v0.8.0 error-swallowing (new gate) on, error stricter A new empty error handler or discarded fallible result outside tests is reported, base against head per file. Rust, Python, JS/TS, Go, Java, C#; other packs name their files as not analysed. [gates.error-swallowing] enabled = false
v0.8.0 stub-bodies (new gate) on, error stricter An added function whose whole body is todo!() / raise NotImplementedError / a not-implemented throw, or an existing body replaced by a stub, an empty body or a bare constant return, is reported. Rust, Python, JS/TS, Go, Java, C#; other packs name their files as not analysed. [gates.stub-bodies] enabled = false
v0.8.0 toolchain-config (new gate) on, error stricter A change cannot loosen the toolchain configuration it is judged by: same design as config-integrity, one rule table over tsconfig, ruff, mypy, pytest, coverage, flake8, Cargo lints, rustflags, nextest, eslintrc, golangci, jest, codecov, phpstan, phpunit. A configuration written as code is reported at warning as not analysed. [gates.toolchain-config] enabled = false
v0.7.0 ci-skip-set (new gate) on, error stricter Checks a rollup job’s skip set against the filter outputs it observed. Inert (reported as not evaluated) until a workflow passes DISCIPLINE_CI_CONTEXT. [gates.ci-skip-set] enabled = false
v0.7.0 suppression-delta on, error on, warning looser #[allow(...)] is the reviewed escape hatch from clippy -D warnings and # noqa is routine; 78 findings (measured) across one consumer’s last 100 merged pull requests. First entry recorded under this contract. [gates.suppression-delta] severity = "error"
v0.5.0 (retrospective) time-estimates on, error on, warning looser Brownfield documentation produced mostly pre-existing findings. Shipped without a migration note; a consumer relying on the default stopped blocking silently. [gates.time-estimates] severity = "error"
v0.5.0 (retrospective) agents-md on, error on, warning looser Missing or forked agent guidance is hygiene, not a code defect. Shipped without a migration note. [gates.agents-md] severity = "error"
v0.5.0 (retrospective) bench-regression on, error on, warning looser Wall-clock benchmarks are sensitive to runner jitter. Shipped without a migration note. [gates.bench-regression] severity = "error"
v0.2.1 (retrospective) issue-link on, error off looser Needs a repository-specific tracker convention. Shipped without a migration note. [gates.issue-link] enabled = true
v0.2.1 (retrospective) provenance-tags on, error off looser Encodes a research-publication policy most repositories do not hold. Shipped without a migration note. [gates.provenance-tags] enabled = true

From v0.7.0 a new gate that ships enabled is listed too, since for a consumer it changes what blocks. Gates introduced between v0.2.0 and v0.6.0 are not listed.

Behaviour Changes

A change to what a gate reports, an exit code, or an output, with an unchanged default. Newest first; a release’s notes carry, under “Upgrading”, the rows of its own version and of every earlier patch of the same minor (scripts/release_notes_upgrade.py), so a patch release that is the first of its minor to reach a registry still lists what the minor changed. A row is labelled with the first tag that contains the change.

Release Area Change Direction Migration
v0.30.0 hook run --observe (after an edit), hook log (phase, run_id, tree_digest, check_pid), hook log In observe mode, the edit hook answers the agent at once and runs its check as a detached child: a started line when the hook returns, a finished line from the check. An OS lock on <git dir>/discipline/hook-check.lock keeps checks from overlapping. A check still waiting when a newer edit starts, or whose tree changed while it ran, logs verdict: "superseded" and no verdict. hook log marks a started line with no finished line as killed, timed out or still running. Before, the edit hook waited for the whole check, and said on stderr what would have blocked. The end of the turn and enforcing mode are unchanged and synchronous (#995) changed A reader of the observe log pairs started and finished lines by run_id, and reads an edit’s verdict from the finished line. The would-be findings of an edit are no longer printed on the hook’s stderr; the end of the turn still prints them.
v0.30.0 hook run (after an edit), check under an edit hook, hook log (not_evaluated) Each gate declares a scope (file, change set, repository, provenance) and the paths that trigger it. The check an agent’s edit hook runs evaluates file and change set gates, plus a repository or provenance gate whose trigger paths the change touches (a workflow file for ci-integrity and ci-skip-set, AGENTS.md / CLAUDE.md / GEMINI.md for agents-md). Every other gate is reported as not evaluated: in its notes (not evaluated on an edit), in a line of the agent prompt, and in the hook log’s new not_evaluated list. It counts neither as passed nor as failed. Before, an edit ran every gate. The end of the turn, pre-commit, CI and any run in a CI job still run every gate (#995) looser (an edit only) None: the end-of-turn hook and CI still evaluate every gate. A reader of the observe log that counted per-edit findings of test-floor, dependency-delta or the provenance gates reads them from the stop lines.
v0.30.0 check (new --timings, DISCIPLINE_TIMINGS, report timings), hook run (log duration_ms) check --timings or DISCIPLINE_TIMINGS=1 adds a timings object to the JSON report: wall time per gate and per shared phase (the diff extraction five gates share has its own entry), and deterministic counters of full extractions, bytes extracted and changed- and tracked-file listings. Absent unless asked for; unstable until 1.0; never part of a verdict, a baseline, a fingerprint or a replay cache key (replay removes the variable from each check it runs). Every change check a hook runs logs duration_ms, the wall time the hook waited for it, and hook log prints it (#995) additive None
v0.30.0 action (new require_provenance and trusted_root inputs, install step), install.sh On a mirror, another forge, or github.com without a token, the install step checks the release’s Sigstore bundle (discipline.intoto.jsonl) with gh attestation verify --bundle for the same signer workflow and tag when gh is on the runner and the store serves the bundle: provenance=verified on success, a failed install when it does not verify, and a failed install when no release tag is resolved. The trusted root is Sigstore’s, online, or the trusted_root file, never the mirror’s. New input require_provenance (default false) fails the install before the binary runs when provenance stays unverified; binary_path is accepted. The --version probe of the binary runs without GH_TOKEN. An unverified install on github.com is a ::warning instead of a ::notice. install.sh: --gpg-key / DISCIPLINE_GPG_KEY, which could never succeed (releases publish no SHA256SUMS.asc), now fail with a message naming --verify-provenance, a new option that runs the same bundle check before installing, with --trusted-root (#986, #987) stricter A mirror that serves discipline.intoto.jsonl to a runner with gh installs only a release whose bundle verifies, and needs a version input when the action ref does not resolve one. A mirror whose bundle cannot verify (no network and no trusted_root) stops serving the bundle or sets trusted_root. install.sh --gpg-key users switch to --verify-provenance.
v0.30.0 check --format github-summary (job summary), init (starter file, test-runner detection) The job summary puts the verdict, the counts and the findings first and the gate table last, folded in <details> with its on and off counts; the table and its planned-gates line were above the findings. init detects pytest from a tests/test_*.py file, and its starter file carries a commented # mode = "advisory" line under [meta]. The terminal and JSON reports are unchanged (#985) none A workflow that parses the job summary reads the gate table inside <details>
v0.30.0 config-integrity (a first discipline.toml) A discipline.toml added where the base side has none is compared with the built-in defaults the base ran at: mode = "advisory", a disabled or demoted gate, a grown exempt_paths and every other loosening are reported as config-integrity/gate-weakened, advisory mode is not honoured for that run, and the gate runs although the new file switches it off. Before, nothing was compared and the advisory mode applied at once. A file that restates or tightens the defaults (discipline init) reports nothing. The policy floor still needs a base configuration file (#984) stricter A change adding a first discipline.toml that loosens a default carries one allow-gate-weakening: <gate or meta> <reason> line for each loosened gate. A file that only tightens needs none.
v0.30.0 action (download_url input, new provenance output) The install step refuses a download_url that is cleartext http:// to anything but a loopback address (127.0.0.1, localhost, [::1]), and any scheme other than https://, file:// and that, as install.sh does; DISCIPLINE_INSTALL_ALLOW_INSECURE_HTTP=1 in the environment accepts one. Before, any URL was fetched. New output provenance: verified (the release workflow’s build provenance attestation), unverified (SHA256SUMS of the release store only) or local (binary_path) (#978) stricter A workflow installing from a cleartext mirror sets DISCIPLINE_INSTALL_ALLOW_INSECURE_HTTP: '1' in its env:, or moves the mirror to https.
v0.30.0 doctor (workflows, codeowners fixes) With no job running discipline, the fix names this release pinned to its commit (uses: orieg/discipline@<sha> # vX.Y.Z), which ci-integrity accepts; it named @v0, a tag ref ci-integrity reports. A missing CODEOWNERS is advised at the repository root, which every forge reads, instead of .github/CODEOWNERS (#972) none None
v0.30.0 Policy refusal policy/protective-gate-disarmed (exempt_paths of a protective gate) Which paths an exempt_paths entry must not reach is derived from the tables the protective gates read, not a fixed list of nine: every agent-instruction file instruction-smuggling knows (GEMINI.md, .github/copilot-instructions.md, .opencode/, .github/instructions/, …) and the base’s instruction_files, every agent settings file and container definition sandbox-config reads, the base’s ci-integrity workflows, the configuration and baseline files config-integrity compares, and the base’s protected_paths. An entry naming one such file directly is refused too. Exempting .github/copilot-instructions.md from instruction-smuggling was accepted on one allow-gate-weakening: directive and is now refused (#967) stricter A change that exempts one of these files from its protective gate on purpose: make it to the base branch’s configuration in a change of its own, merged under the repository’s review rules without the discipline check. A narrow entry such as tests/fixtures/** is unchanged.
v0.30.0 check (report: directive_notes, terminal directives: line, job summary) When fail_on_overrides is set and the run’s actor is in allowed_override_actors, the report notes that fail_on_overrides was not applied and how many overrides the change writes stand. Before, the bypass left nothing in the report. The login is not echoed (#974) additive None
v0.30.0 pii (remediation of a fixed-format credential finding) A credential match (the fixed-format token classes pii shares with shell-secrets) is told to revoke or rotate the credential, then remove it. Before, it got the placeholder advice of a home path or a host name (#968) none None
v0.30.0 hook install --upgrade (the discipline-hook-file: digest), Renovate preset The digest a generated hook file carries leaves out the release on its pin lines: the action pin of the Copilot setup workflow (uses: orieg/discipline@<ref> # vX.Y.Z) and the Claude Code bootstrap’s version="vX.Y.Z". A pin Dependabot or Renovate moved is no longer reported as changed after hook install wrote it, and --upgrade rewrites the file without --force; any other change to those lines or to the file still is. A digest written by 0.29.0 and earlier, over the whole text, is still read. The Renovate preset moves the version of a bootstrap written without pinned digests (sums=release) and leaves a pinned one alone (#964) looser None. A file written by 0.29.0 or earlier counts a moved pin as an edit until hook install --upgrade rewrites it once.
v0.30.0 check (gate notes: refused hidden directives) A note about one directive takes its gate from the directive table where the directive is read, not from the words in the note. A refused hidden directive written in its namespaced form (the gate id in parentheses after allow) is noted on its own gate; before, it resolved to no gate and was noted on every gate a directive can lift. Other notes are placed as before (#983) none None
v0.29.0 hook run, hook log, report (hook), doctor (hook-binary) Every change check a hook runs appends a line to <git dir>/discipline/hook-observe.log in enforcing mode too, and a clean change in observe mode is logged as allowed (was: only what observe mode would have blocked). Lines carry record, mode, version, decision, exit_code, check_exit, errors and warnings beside the earlier fields; an enforcing pre-tool refusal is logged. New discipline hook log prints the lines (--json, --last). The check a hook runs carries a hook object in its JSON report (agent, event, mode, binary_version, decision). discipline doctor reports hook-binary: a warning when the repository ships an agent hook file and discipline is not on PATH, where every hook exits 0 without checking; --strict fails on it (#937) additive (doctor --strict: stricter) A reader of the log that took every line for a would-be block filters on decision. Where doctor --strict runs with hook files and no discipline on PATH, put the binary’s directory on PATH (the action does).
v0.29.0 audit (report, exit code), configuration ([audit]) The audit reports waiver_density: each audited change’s waivers (directive and inline-marker records) per changed file, oldest first, and how many times in a row it rose; the text report prints the recent values, and checks gain waiver-density-rise. New [audit] waiver_density_rise = N (default 0, off), read from the audited branch’s tip: a rise across N or more changes in a row is a policy failure (policy_failures), and audit exits 1. config-integrity reports switching it off or raising N as a loosening (#944). additive (exit 1 only with the tripwire set) None.
v0.29.0 check (report: terminal, job summary, review_routing step output, JSON, --comment, agent-prompt), hook run, hook log A review-routing section after the verdict: the directives the change carries, the inline markers it adds and removes, the authority files it changes (deterministic); the capability sites it adds (process spawn, dynamic evaluation, network; Rust, Python, JS/TS, Go; non-exhaustive) and the tests it adds and removes (heuristic); a coverage footer. Zero rows are left out; pointers are capped at five per row. The JSON key review_routing is declared unstable until 1.0 and is not in the frozen surface. Never changes the exit code; no finding code (#944). additive None. A consumer that matched the exact last lines of the terminal report, or the line after the job summary’s heading, allows for the new lines.
v0.29.0 discipline explain, MCP explain_finding, the gate tables of the docs A gate that reads a language pack’s facts publishes the packs that supply them, in registry order, built from the registry instead of hand-written text (#938). stub-bodies, error-swallowing and instruction-smuggling list the same languages in another order; the test-pack gates list PHPT after Rust. none None.
v0.29.0 test-floor (test-density-regression), discipline baseline The baseline gains version 3: [assertions] records the assertions of each tracked test file whose tests run, written by baseline --write and seeded by baseline --migrate (which now also rewrites a version-2 file, keeping its findings). test-floor reports a file holding fewer assertions than the larger of the base side’s and the change’s recorded count as test-density-regression, a warning, whatever assertion-reduction judged of the diff (#936). stricter (new warning; exit unchanged without --fail-on-warnings) Run discipline baseline --migrate in a change of its own to seed the counts (config-integrity accepts it alone, as for version 1); without counts nothing is compared and a note says so. An intended drop takes allow-test-shrink: <path> <reason>, then baseline --write. A tool that reads discipline-baseline.toml and rejects unknown keys accepts [assertions].
v0.29.0 replay Each verdict reached is recorded under <git dir>/discipline/replay-cache/, keyed by the change’s replayed marker (the binary’s bytes, the configuration under test, the commit id, the merged pull request’s body and author, DISCIPLINE_NO_NETWORK); a replay of the same change under the same marker reads it back instead of rebuilding it, and says so ((cached) on the row, N of M verdicts read from the replay cache; --json gains cached on the summary and on each cached case). A change that could not be checked is never recorded. The changes left are checked by --jobs N workers at once (default one per core, at most 4), listed in history order. New --no-cache checks every change again (#933). additive (output lines and JSON fields) None. --no-cache restores a full rebuild; --jobs 1 the sequential loop.
v0.29.0 replay, review-threads, ratified-paths A replayed change has no live pull request (its merged body is read instead), so in a CI job the gates that read the pull request record their “not checked” note for it, as on a laptop, instead of failing the change as a configuration error. A plain check in CI still needs the pull request: DISCIPLINE_REPLAY_CASE is honoured only when the base is a commit replay built (#933). fix None.
v0.29.0 check The report counts every baseline entry no finding of the run matched, a disabled gate’s included (stale_baseline_entries in --format json, omitted at zero; a baseline: N stale entries ... line in the text report). Each enabled gate’s own note is unchanged (#933). additive None.
v0.29.0 audit The audit reads the tip’s baseline [assertions] and reports the tests and assertions of the recorded files at the tip (density in --json, a Tests at the tip: line in the text) and an assertion-density check (found when a file holds fewer than recorded, not-checked when the baseline records no counts) (#936). additive None.
v0.29.0 Policy refusals (policy/protective-gate-disarmed, policy/policy-file-deleted) and the base side’s override budget A change that switches off, demotes, or exempts a protected path from config-integrity, ci-integrity, ratified-paths, sandbox-config or instruction-smuggling, where the base configuration runs it, is refused (exit 1) whatever directive it carries; so is a change that deletes the configuration file the base holds. max_overrides, max_inline_overrides and require_approval are held to the base file’s values under --policy-from head too (the lower budget wins; the base’s approval rule and reviewers stay). Both refusals are new entries in the SARIF, JUnit and GitLab reports (#932). stricter A change that disarms a protective gate on purpose cannot carry allow-gate-weakening: for it any more: make the change to the base branch’s configuration in a change of its own, merged under the repository’s review rules without the discipline check. A repository that raises its budget in the same change that uses it passes only after the raise has merged.
v0.29.0 assertion-reduction (weakened-bound: a comparison operator moved the loose way) A comparison in an existing test whose operator now accepts more, the assertion otherwise unchanged, is reported as assertion-reduction/weakened-bound at the gate’s severity: Python assert comparisons == to !=, <, <=, > or >= (whatever the literal), > to >= and < to <= (or to a literal past the old one, integers read as integers, so > 5 to >= 6 is not reported), > / < / >= / <= to != against a value the bound excluded, and in / not in flipped; a Go if whose block fails the test, read as its failure condition (!= 3 to < 3, >= 10 to > 10). Assertions are paired by their text with the operator and the literal masked; one that appears twice in a test is not compared, and a rewrite that also lowers the count or the equality class stays assertions-reduced. Before, nothing was reported for these edits (#934) stricter Restore the comparison, or allow-assertion-drop: <test> <reason>
v0.28.0 deletion-rationale (test-removed-without-rationale: tests merged into their container) A test nested under a container (P > a, also :: and # names) that the change merges into a new head test named P is not reported, when that test is not ignored and holds at least as many assertions that are not tautologies, as many strong ones, every literal expected value and every equality operand of the tests it took over; the gate’s notes name each merge. Before, describe('P', ..) turned into test('P', ..) reported every nested test but one as removed (agent-pr-precision-v5 ITEM-0126, ITEM-0127 on vitejs/vite#23604). A merge that changes an operand (toBe(one) to toBe(two)) is still a removal (#911, #915) looser None
v0.28.0 stub-bodies (how base and head functions pair) Functions of one name pair first by place (the statement, declaration or key-value pair around them, up to 256 bytes, when no other function of the name shares it), then likewise by signature, then by identical body in source order, then by order within the stretch between two pairs. Before, they paired by name and order alone, so adding a function before others of its name shifted every later pair: an unchanged get() { return true; } below a new get() was stub-bodies/body-replaced-by-stub (agent-pr-precision-v5 ITEM-0044 on denoland/deno#33403), and an unchanged stub below a new function of its name was stub-bodies/stub-body-added. A body replaced in place is still reported, with or without additions beside it, and so is a body moved to a new function of its name while its place gets a bare constant (#913); functions with neither a place nor a signature of their own pair by body and order only (#910) looser None
v0.28.0 instruction-smuggling (invisible-characters-added, invisible-characters-in-description: a variation selector) A U+FE0E or U+FE0F that completes a standardized emoji variation sequence is no longer reported: it directly follows a base Unicode’s emoji-variation-sequences.txt (Emoji 16.0) lists, or, for a keycap base (#, *, 0-9), is followed by U+20E3. Before, every variation selector was reported, so a Renovate release-notes heading ⬆️ Dependencies in a pull-request body or commit message was instruction-smuggling/invisible-characters-in-description (agent-pr-precision-v5 items ITEM-0015, ITEM-0016 on astral-sh/ruff#29355). A selector after any other character, a second one in a row, U+FE00-U+FE0D and U+E0100-U+E01EF are still reported (#909) looser None
v0.28.0 assertion-reduction, vacuous-tests (an iteration over a name bound to an empty literal) An assertion that runs once per element of a name bound to an empty literal counts 0, as one over the literal itself does since v0.27.0: JS/TS const xs = [] / let xs = [], Python xs = [] / xs = (), Rust let xs = [] / vec![] / Vec::new(), Go xs := []int{} / xs := map[K]V{}, iterated by the forms v0.27.0 reads. The binding must be the occurrence of the name nearest before the receiver, inside a function, in a block that holds the receiver, and the name’s scope (that block; for Python, the function) must hold no other occurrence of it outside the loop body or callback; a name touched anywhere else in its scope still counts. Rust vec![] and Vec::new() written in place, and & / &mut before the collection, are read too. An existing test whose assertion moves there (expect(f(2)).toBe(4) -> const cases = []; cases.forEach(() => expect(f(2)).toBe(4))) is now assertion-reduction/assertions-reduced, and a new test whose only assertions are there is vacuous-tests/vacuous-test-added. Before, the assertion counted and nothing was reported (#906) stricter Move the assertion out of the callback or loop, or allow-assertion-drop: <test> <reason> / allow-vacuous-test: <test> <reason>
v0.27.0 vacuous-tests, assertion-reduction (a Python assertion on a target the test patches) An assert or self.assert* call that makes at least one call, every one to a target a mock.patch / patch / unittest.mock.patch (plain string literal path) or patch.object (dotted-name object, string literal attribute) replaces at that point, as a with item around it or a decorator of the test, is a tautology and counts 0; the callee is matched by its dotted name against the last segments of the patched path, read from the syntax tree. A new test with mock.patch('src.calc.f', return_value=9): then assert calc.f(1) == 9 and nothing else is now vacuous-tests/vacuous-test-added, and an existing test whose check becomes one is assertion-reduction/assertions-reduced. Before, the assertion counted and nothing was reported. A test that patches a dependency and calls the real target, an assertion that also calls something unpatched, and a patch target that is not a literal keep the assertion counted; other packs are not read (#899) stricter Assert on the real target with its dependencies patched, or allow-vacuous-test: <test> <reason> / allow-assertion-drop: <test> <reason>
v0.27.0 assertion-reduction, vacuous-tests (a Python expected value computed by the code under test) An assert a == b or self.assertEqual(a, b) whose one side is a call and whose other side is a name the test function binds only by plain assignments from that same call (same callee, same arguments, read from the syntax tree) is a tautology and counts 0. An existing test turned from assert f(2) == 4 into expected = f(2) then assert f(2) == expected is now assertion-reduction/assertions-reduced, and a new test of nothing else is vacuous-tests/vacuous-test-added. Before, the assertion counted and nothing was reported. A name bound any other way in the function (a parameter, an augmented or other assignment, a loop, with or except target) keeps the assertion counted; other packs are not read (#899) stricter Compare against an independently written expected value, or allow-assertion-drop: <test> <reason> / allow-vacuous-test: <test> <reason>
v0.27.0 assertion-reduction, vacuous-tests (an iteration over an empty literal) An assertion that runs once per element of an empty literal counts 0, as one under a constant-false if does: JS/TS, in a function passed to forEach, map, filter, some, every, reduce or find called on [], or in the body of for (x of []) / for (k in []); Python, in the body of for _ in []: / for _ in ():; Rust, in the body of for _ in [] { } or in a closure passed to for_each, map, filter, any, all, fold or find on [], [].iter(), [].into_iter() or [].iter_mut(); Go, in the body of a for ... range over an empty slice or map literal. An existing test whose assertion moves there (expect(f(2)).toBe(4) -> [].forEach(() => expect(f(2)).toBe(4))) is now assertion-reduction/assertions-reduced, and a new test whose only assertions are there is vacuous-tests/vacuous-test-added. In JS/TS a same-file helper called in unreachable code (this form, a constant-false if, after a terminator) no longer lends the test its checks. Before, the assertion counted and nothing was reported. A name bound to [], and a collection built by a call, are not read (#899) stricter Move the assertion out of the callback or loop, or allow-assertion-drop: <test> <reason> / allow-vacuous-test: <test> <reason>
v0.26.0 templates/argo-workflow-template.yaml, check --max-report-lines The Argo gate step deepens its depth-50 fetch until a merge base resolves (exit 2 when none exists or a fetch fails), sets DISCIPLINE_POLICY_FROM=base, runs one check that writes the JUnit, SARIF and JSON reports (the separate diff run is gone), and maps exit 0/1/2 to success / failure / could-not-check in a new discipline-status output parameter. New check --max-report-lines N (DISCIPLINE_MAX_REPORT_LINES) prints at most N lines of the terminal report on stdout and one line counting the rest; other formats and report files are not cut (#891) additive A copy of the template keeps working; take the new gate step to judge by the base policy and report could-not-check on its own. A step that read sarif.sarif gets it from check --report-sarif at the same path
v0.26.0 command (loom and miri presets’ zero-tests guard), miri The loom and miri presets’ zero-tests guard, and the miri gate’s, reads every libtest running N tests line (running 1 test too) and reports zero tests only when the output has at least one such line and every one has a count of zero. Before, it was the pattern running 0 tests matched anywhere in the output, and cargo miri test prints that line under an empty Doc-tests section after unit tests ran, so such a crate was command/zero-items-executed (miri/zero-tests-executed for the gate) on every change. A run whose every section ran none is still reported. Output with no such line (a failed build, cargo nextest, --format json) is not read by the guard, as the pattern never matched it either. The miri gate checks for a missing toolchain before the guard, so that run exits 2 whatever it printed. config-integrity shows the new guard as {libtest: a "running N tests" line is printed and every one has N = 0}, which no configured value equals, so running 0 tests written down beside one of these presets is now a pattern other than the preset’s (#883) looser Nothing for a command that prints libtest output. A configuration that wrote down zero_items_pattern = "running 0 tests" keeps the old matching; removing it is reported once by config-integrity as a removed pattern, lifted with allow-gate-weakening: command <reason>
v0.26.0 command (cargo-mutants default command, {diff_file}) The preset’s default command is cargo mutants --in-diff {diff_file}. Before, it was cargo mutants --in-diff, which names no diff file; the command runs without a shell or stdin, so cargo-mutants refused it and a configuration that kept the default command was command/command-failed on every change. {diff_file} in an argument of a command or canary_command of the command gate is now replaced by the path of a file holding the change’s diff (the diff the gates read, base against the working tree or, under --staged, the index, in git diff’s form with a/ and b/ prefixes), written to the system temporary directory only for a command that names it and removed after it exits; a diff that cannot be written or removed is exit 2. A change now passes when no mutant in the changed lines survives; one that touches no Rust source passes with no mutant tested (cargo-mutants prints Diff changes no Rust source files). A command that held the text {diff_file} before now receives a path in its place (#844) looser To keep the previous command, set command = "cargo mutants --in-diff" beside preset = "cargo-mutants"; a configuration that wrote that command down and now removes it changes an executed key, which needs DISCIPLINE_ALLOW_COMMAND_CHANGE on the runner for that change
v0.26.0 pr-checklist (test claims written as prose) A plain bullet or sentence of the PR body that says tests were added or changed (Added regression tests for X, I added tests, Add tests for X, Tests were added, Tests for X, with regression tests), when the diff adds or extends no test, is reported as pr-checklist/prose-claims-tests, at warning at most whatever the gate’s severity is. Before, only a ticked checkbox was reconciled. Negated, deferred and quoted claims (> and HTML blockquotes), a Tests added: none field, a with tests aside next to a parenthesis, code, HTML comments, directive lines and checkbox items are not read; the vocabulary is listed in docs/GATES.md. A repository that runs pr-checklist with --fail-on-warnings now also blocks on it (#835, #896) stricter Add the tests or reword the claim; allow-pr-checklist: test <reason> lifts it, as it lifts the checkbox claim
v0.26.0 citation-anchors (relative Markdown links) With the gate enabled, a relative link, image or reference definition ([text](/discipline/configuration/), ![alt](/discipline/configuration/), [label]: CONFIGURATION.md) on an added line of a .md / .markdown file whose target is not a tracked file or directory on the head side, compared case-sensitively after the query and fragment are dropped and percent-encoding is decoded, or whose .. segments climb above the repository root, is reported as citation-anchors/link-target-missing. URLs (any scheme), absolute and protocol-relative paths, same-page anchors, template expressions, and links in code or HTML comments are not checked. The gate stays off by default (#834) stricter Correct the link or add its target; a document whose links resolve elsewhere (a site generator) goes in gates.citation-anchors.exempt_paths
v0.26.0 command (mutation presets’ zero-items guard) The cargo-mutants, mutmut, stryker and pit presets’ zero_items_pattern is \b0 mutants tested (was 0 mutants tested), so it matches a count of exactly zero. Before, it matched anywhere in the output, and a run that tested 10, 20 or 100 mutants was reported as command/zero-items-executed. A run that printed 0 mutants tested is still reported. config-integrity compares a configured zero_items_pattern with the new default, so 0 mutants tested written down beside one of these presets is now a pattern other than the preset’s (#847) looser Nothing for a run that tested mutants. A configuration that wrote down zero_items_pattern = "0 mutants tested" keeps the old matching; removing it or writing '\b0 mutants tested' is reported once by config-integrity as a removed or changed pattern, lifted with allow-gate-weakening: command <reason>
v0.26.0 command (base-tests default command) The preset’s default command is env RUSTC_BOOTSTRAP=1 cargo test -- -Z unstable-options --format=junit, which runs on a stable or a nightly Rust toolchain. Before, it was cargo test -- --format=junit, which libtest refuses on every toolchain (--format=junit needs -Z unstable-options, and that needs a nightly toolchain or RUSTC_BOOTSTRAP), so a configuration that kept the default command was command/base-test-failed on every change and no base test ran. A change now passes when every base test passes against head code, and a base test that fails is named. The JUnit report is read across every document the output holds (cargo test prints one per test binary), as it was before. The command now needs env, which every Linux and macOS runner has (#842) looser To keep the previous command, set command = "cargo test -- --format=junit" beside preset = "base-tests"
v0.26.0 directives.fail_on_overrides (--fail-on-overrides) The run fails on the overrides a change writes: every directive it applies (PR body, commit messages, merged pull request body), and each inline marker on a line it adds or modifies or in the pull request body, counted as max_inline_overrides counts them (--staged: the index against HEAD; a base that is the empty tree counts every marker). Before, it failed on every applied override, and pii, time-estimates and shell-secrets scan the whole tree, so in a repository with one marker every change failed, whatever it touched. Markers already in the tree stay applied and listed under outcomes[].overrides[], now with "already_in_tree": true; the top-level overrides count is unchanged. The text report’s failure: line ends with : N written by this change, and ; M marker(s) already in the tree are not counted when there are any. replay lists a gate in refused_overrides only for an override the change writes. A change that adds a marker or applies a directive still fails (#880) looser None: a repository with markers in the tree now passes changes that add none; to refuse the tree’s markers as well, remove them or move their files to exempt_paths
v0.26.0 directives.max_inline_overrides The budget counts the inline markers a change writes: those on the lines it adds or modifies, and in the pull request body (--staged: the index against HEAD; a base that is the empty tree counts every marker). Before, it counted every inline override the run applied, and pii, time-estimates and shell-secrets scan the whole tree, so a change that wrote no marker failed any cap below the repository’s total. Markers already in the tree stay applied and listed under outcomes[].overrides[]; only the count changes. What a change may add is unchanged: N markers of its own pass a cap of N, N + 1 fail it. A shell-secrets inline override now records the line of its marker, not of the secret it lifts, when the two differ in a command continued across lines; the subject still names the secret’s line. The refusal reads N inline override(s) on lines this change adds (was applied). fail_on_overrides counts the same markers since #880 (its own row) (#849) looser None: a repository whose existing markers exceed its cap now passes changes that add none; to bound the tree’s markers as well, remove them or move them to exempt_paths
v0.26.0 harness-tampering, test-floor (pytest plugins registered from a hook, Cargo [[test]] path remaps) A module of the repository that a conftest.py registers as a pytest plugin from code, <x>.pluginmanager.import_plugin("<name>") with a literal name or <x>.pluginmanager.register(<module>) where an import of the same file binds <module>, is a harness file, as a pytest_plugins module is; so is a module such a plugin names or registers in turn, and a dotted name now also resolves under src/. A plugin module that a changed conftest.py starts loading without itself changing is read as if every form in it were added. A third-party name, a computed name and a registered object (register(mod.Plugin())) are not read. A tests/<name>.rs or tests/<name>/main.rs whose <name> a [[test]] entry takes for another path is not a Cargo test target, as Cargo builds it: the static count no longer counts its tests, and harness-tampering no longer reads it as a target root. When the change makes that remap, or moves the path of a declared [[test]] target, over a root file the base side ran and that still holds tests, test-floor/tests-moved-out-of-default-run is reported on the Cargo.toml, naming the target and the file, unless another target still reaches the file. Before, both were not reported (#854). A test-runner probe in a registered plugin module is now read as harness code, so stub-bodies/test-runner-probe-added no longer reports it, as for a pytest_plugins module stricter Leave report outcomes and the exit status alone in plugin modules; point a [[test]] target back at its file, or allow-test-shrink: Cargo.toml <reason>
v0.26.0 command (base-tests with no base test file) A configured base-tests command whose base side holds no file the preset overlays (no file under its test directories or test file names, and none matching a [tests] paths glob) is reported as command/base-tests-no-cases and the command is not run. Before, it passed with the note no base test files found and compared nothing, as in a Rust crate whose tests are all inline #[cfg(test)] modules in src/. When base test files exist, a changed source file whose base version holds tests is named in a note: those tests run at their head version (#856) stricter Move the tests the command should compare under a test path, or set allow_zero = true on the command when no base test is meant to run; allow-command: <name> <reason> lifts it for one change
v0.26.0 assertion-reduction, vacuous-tests (JS/TS assert(a === b)) The JS/TS pack counts assert(..) and assert.ok(..) as a strong assertion when the first argument, parentheses around it removed, is one ===, ==, !== or != comparison, read from the syntax tree, as assert.strictEqual(a, b) is. Weakening it to a truthiness check in an existing test (assert(f(3) === 6) -> assert(f(3)), assert.ok(f(3)), assert(f(3) > 5)) is now assertion-reduction/assertions-reduced; before, both forms counted as one weak assertion and nothing was reported. assert(x === x) / assert.ok(x == x) is now a self-comparison, a tautology to the pack, reported as assertion-reduction/self-comparison-assertion-introduced or vacuous-tests/self-comparison-assertion-added like assert.strictEqual(x, x), and a new test with nothing else is vacuous-tests/vacuous-test-added. An equality inside a larger argument (assert(a === b && c)), a negated one, and Deno’s assertFalse(a === b) stay truthiness checks (#862) stricter Restore the equality, or state why with allow-assertion-drop: <test> <reason>
v0.26.0 ignored-tests, harness-tampering (Python script-style tests) A module-level test function that a Python file’s if __name__ == "__main__": block runs on the base side (directly, or through a module-level function the block names) and that the change stops running there, by removing the call or the block while the function stays, is reported as ignored-tests/existing-test-skipped. A sys.exit(0), sys.exit(), os._exit(0) or raise SystemExit(0) / raise SystemExit() / raise SystemExit that the change adds as a statement of a module itself, outside any compound statement, in a Python file that is not a test file, a harness file or an entry point (__main__.py, a pyproject.toml command’s module, a module with a __main__ block on both sides), is reported as harness-tampering/harness-exits-zero, held at warning (#843). Before, both passed: the test’s assertions were untouched, and harness-exits-zero read harness files only. The gate’s notes move to pattern version 3 and count the modules read for an exit at import. stricter Call the test from the entry point again, or allow-ignore: <test> <reason>; remove the exit or move it under if __name__ == "__main__":, or allow-harness-tampering: <path> <reason>
v0.26.0 vacuous-tests (a new test of mock and trivial checks only) A new test whose checks are interaction checks on a double and assertions that hold for nearly any value, with at least one interaction check and nothing else, is vacuous-tests/asserts-only-on-mocks, one finding whose message names both kinds: Python m.assert_called_once_with(1) with assert r is not None (or isinstance), JS/TS expect(m).toHaveBeenCalledWith(1) with expect(r).toBeDefined(). Before, the trivial check counted as a check on the result and nothing was reported; for Python this undoes the loosening the #828 row records (such a test was asserts-only-on-mocks before #828 and nothing after it). A Python or JS/TS interaction check that the pack counts as an assertion is no longer read as a check on the result, so a new JS/TS test whose only check is expect(m).toHaveBeenCalledWith(1) (a matcher counted as strong) is now asserts-only-on-mocks too; before, nothing was reported. In Java, Kotlin and Go, where the interaction check (verify(, AssertExpectations) is not an assertion, a new test of interaction checks and trivial assertions of which one is counted as strong (Java and Kotlin assertNotNull, Go NoError) moves from vacuous-tests/asserts-only-trivial-properties to vacuous-tests/asserts-only-on-mocks, at the same severity. A check on the result beside the interaction check (an equality, a comparison) keeps the test quiet as before (#865) stricter Assert on the result or the observable effect, or allow-vacuous-test: <test> <reason>
v0.26.0 assertion-reduction (Rust, Go and JS/TS parenthesised literals) A parenthesised literal in a Rust, Go or JS/TS assertion is read as the literal inside it, however many pairs wrap it: an expected value (assert_eq!(f(2), (4)), if got != (4) { t.Errorf(..) }, expect(f(2)).toBe((4))) or a bound (assert!(f(3) < (100)), if d > (1500)*time.Millisecond, toBeLessThan((100))) changed in an otherwise unchanged assertion is now assertion-reduction/expected-value-changed or assertion-reduction/assertion-bound-loosened, as the unparenthesised form already was. A parenthesised comparison that is the whole condition is read too: a Rust assert!((n == 3)) or one side of && / \|\|, a Go if (got != 4). Before, nothing was reported for these forms (#861) stricter Restore the value, or allow-assertion-drop: <test> <reason>
v0.26.0 stub-bodies (test-runner probes in production code) A read of whether a test runner is loaded that the change adds to production code is reported as stub-bodies/test-runner-probe-added, held at warning whatever the gate’s severity: Python "pytest" in sys.modules / "unittest" in sys.modules (also sys.modules.get(..)) and PYTEST_CURRENT_TEST / PYTEST_XDIST_WORKER read from os.environ or os.getenv; JS/TS process.env.JEST_WORKER_ID, process.env.VITEST, process.env.VITEST_WORKER_ID and import.meta.vitest; Rust cfg!(test) outside an item compiled in test builds only; Go testing.Testing(), flag.Lookup("test.v") and strings.HasSuffix(os.Args[0], ".test"). The read counts where it decides something (a condition, an and / or operand, a value assigned or returned). Before, such a branch made a function return what its tests expect only under them, and nothing was reported. Test code, harness files the runner loads, a probe the base side held in the same function, and NODE_ENV === "test" are not reported (#824) stricter Remove the probe, or justify it: allow-stub: <path>:<line> <reason>
v0.26.0 toolchain-config (pytest -k / -m, package.json test scripts, Jest / Vitest written as code) pytest addopts (pytest.ini, tox.ini, setup.cfg, pyproject.toml) that gains a -k or -m expression, or whose expression now rejects a test the base one accepted (a not term or an and term gained, an or alternative lost; decided over every truth assignment of its names), is toolchain-config/toolchain-config-weakened; one that cannot be read is reported as changed. A package.json script named test or test:* (root or workspace) that gains an exit-code mask, read from its shell syntax tree as npm runs it (\|\| true, \|\| :, \|\| exit 0, ; exit 0), or a lax runner flag (--passWithNoTests, --onlyChanged, --changedSince, --lastCommit, --changed, --testPathIgnorePatterns, --testNamePattern) is the same finding. jest.config.* and vitest.config.* whose exported object is a literal on both sides are read like jest.config.json: a grown testPathIgnorePatterns / modulePathIgnorePatterns / coveragePathIgnorePatterns / Vitest test.exclude, a narrowed testMatch / testRegex / Vitest test.include, or a lowered coverageThreshold is weakened (was toolchain-config-change-not-analysed at warning for any change), and a gained Jest preset is not analysed; a change anywhere else in such a file is still not analysed, as is a file whose object is not a literal. modulePathIgnorePatterns, testMatch and testRegex are read in jest.config.json and the package.json jest key too. Before, none of these forms was reported (#831) stricter Revert it, or justify it: allow-toolchain-weakening: <key> <reason> (addopts, test, testPathIgnorePatterns, or the file path)
v0.26.0 assertion-reduction, vacuous-tests (JS/TS promise matchers not waited on) A JS/TS .resolves / .rejects matcher, chai-as-promised expect(p).to.eventually.<matcher>(..), and assert.rejects / assert.doesNotReject (Deno assertRejects) count as an assertion, and as an expected exception, only when the test waits on the promise they return: the operand of await, the value of a return, an arrow function’s expression body, an element of a Promise.all([..]) the test waits on, the receiver of a .then(onFulfilled) / .catch(..) / .finally(..) it waits on, or a variable the declaring function waits on that way. Otherwise the test finishes before the matcher settles: an existing test whose check becomes such a chain is assertion-reduction/assertions-reduced, and a new test whose only checks are such chains is vacuous-tests/vacuous-test-added. Before, the chain counted either way. A chain inside Promise.allSettled, Promise.race, Promise.any or .then(f, onRejected) is not counted (#829) stricter await or return the matcher, or allow-assertion-drop: <test> <reason> / allow-vacuous-test: <test> <reason>
v0.26.0 assertion-reduction (an expectation of the assertion’s own failure) An expectation of a throw added around a test’s own assertion that catches the assertion’s failure type is read as catching it: Python with pytest.raises(E): / self.assertRaises(E) with E AssertionError, Exception or BaseException (or a tuple holding one), JS/TS expect(() => { .. }).toThrow() / .toThrowError() / .rejects.toThrow() and assert.throws(() => { .. }) with no class or Error / AssertionError, Java assertThrows(E.class, () -> ..) and Kotlin assertThrows<E> { } / assertFailsWith<E> { } / assertFails { } with E AssertionError, Error, Throwable or AssertionFailedError. The assertion is assertion-reduction/assertion-failure-caught at its line, citing the expectation’s line, and leaves the effective count, in an existing test and in one the change adds. A Rust #[should_panic] (bare, or expected holding assertion, left or right) or a JUnit 4 @Test(expected = AssertionError.class) added to an existing test whose assertions are effective is the same finding, once, at the attribute. Before, an added expectation was never reported, and the inverted test passed only when its assertion failed. An expectation of any other class, and one the base test already had, are unchanged (#825) stricter Remove the expectation, or make it expect the failure of the code under test; or justify it: allow-assertion-drop: <test> <reason>
v0.26.0 harness-tampering (ledger correction) Ledger correction: the v0.20.0 row listed forms the gate did not report. report.failed = False in a report hook is not a form: failed and passed are read-only properties of a pytest report derived from outcome, so the assignment raises AttributeError and pytest ends with exit 3. A plugin registered from a hook (config.pluginmanager.register(...), import_plugin(...)) and a Cargo [[test]] path remapping or harness = false were not reported. The row now states what v0.20.0 reported, and docs/GATES.md lists these under what the gate does not catch (#845). The gate’s behaviour is unchanged none none
v0.26.0 assertion-reduction, vacuous-tests (an assertion swallowed in a same-file helper) Assertion Failure Caught Inside Test reads the handlers of the same-file helpers a test calls, up to three calls from the test, in Python, JS/TS, Java, Kotlin, C#, Go and Rust. An assertion in a helper that a handler of the helper catches and swallows (def check(a, b): try: assert a == b except AssertionError: pass) is reported as assertion-reduction/assertion-failure-caught on the assertion’s line, naming the helper and each calling test, and is taken out of the test’s effective count where the pack counts the helper into it. Before, moving an assertion into such a helper kept the count and nothing was reported. A test the change adds that calls such a helper is reported too, and a new test whose only check is such an assertion is also reported by vacuous-tests. A swallow the helper already had is not new. A helper that returns a value with return is not read (#832) stricter Let the helper’s handler re-raise or fail, return the outcome for the test to check, or allow-assertion-drop: <test> <reason>
v0.26.0 ignored-tests (Python module-level import skips) A module-scope pytest.importorskip("m"), and a try that imports whose except ImportError / ModuleNotFoundError (or bare except:) handler calls pytest.skip(.., allow_module_level=True), are read as a skip of every test of the file. When the module is a file or package of the repository (relative, or found at the root, under src/ or beside the test file), it is unconditional: ignored-tests/existing-test-skipped (or ignored-tests/ignored-test-added) at the gate’s severity. Another project’s module (pytest.importorskip("numpy")) is ignored-tests/test-conditionally-skipped at note. Before, neither form was read and nothing was reported. A module already guarded on the base side is unchanged (#830) stricter Import the module plainly so a broken import fails the tests, or justify the skip: allow-ignore: <test> <reason>
v0.26.0 assertion-reduction, vacuous-tests (Python mock assertion calls) The Python pack counts a call of a mock assertion method (m.assert_called_once_with(1), also assert_called, assert_called_once, assert_called_with, assert_awaited, assert_awaited_once, assert_awaited_with, assert_awaited_once_with, assert_not_called, assert_any_call, assert_has_calls), on any object, as one assertion, never a strong one, as the JS/TS pack counts expect(m).toHaveBeenCalled(). Deleting such a call from an existing test, or leaving its name read without a call (m.assert_called_once), is now assertion-reduction/assertions-reduced, and one inside a handler that swallows its failure is assertion-reduction/assertion-failure-caught. Before, neither was counted and nothing was reported. A new test whose only checks are such calls is still vacuous-tests/asserts-only-on-mocks; a new test with one such call beside a not-null check and nothing else is no longer reported (it was vacuous-tests/asserts-only-on-mocks), and the call counts toward min_assertions_per_test, as in JS/TS (#828; the looser asserts-only-on-mocks case is tracked in #865) stricter Restore the call, or state why with allow-assertion-drop: <test> <reason>
v0.26.0 assertion-reduction (a value rewritten in a case-table row) A row of a @pytest.mark.parametrize list, a JS/TS test.each array or a Go struct table that keeps its place and its count, and whose one cell changed from one literal to another while every other cell is unchanged, is reported as assertion-reduction/expected-value-changed at the line of that value. Before, rewriting the expected value of a row ((2, 4) -> (2, 5)) kept the row count, the assertion count and the assertion text, and nothing was reported. Rows reordered, added, removed, or edited in more than one cell, and an ambiguous pairing, are not reported by this rule (#826) stricter Restore the value, or state why the new one is right with allow-assertion-drop: <test> <reason>
v0.26.0 assertion-reduction, vacuous-tests (Python parenthesised assertions) The Python pack reads a parenthesised condition, operand or argument as the expression inside it, however many pairs wrap it: assert (f(3) == 6), "msg" is an equality, so reformatting an existing test’s assertion with parentheses is no longer assertion-reduction/assertions-reduced, and a new test whose parenthesised equality sits beside a mock assertion is no longer vacuous-tests/asserts-only-on-mocks. The same reading applies to tautologies (assert (True)) and to is not (None); a parenthesised tuple is still a tuple and still a tautology (#859) looser none
v0.26.0 assertion-reduction (Python parenthesised literals) A parenthesised literal in a Python assertion is read as the literal: an expected value (assert f(3) == (6), self.assertEqual(f(3), (6))) or a bound (assert t < (1.5), rel=(1e-6), self.assertLess(ms, (200))) changed in an otherwise unchanged assertion is now assertion-reduction/expected-value-changed or assertion-reduction/assertion-bound-loosened, as the unparenthesised form already was (#859) stricter Restore the value, or allow-assertion-drop: <test> <reason>
v0.26.0 command (base-tests run that read no test case) A base-tests command that succeeds while neither its output nor a top-level *.xml file holds a <testcase> (empty output, no JUnit printed, a report with no case) is reported as command/base-tests-no-cases. Before, it passed with the note 0 base tests passed against head code, so a command that prints no JUnit passed every change. A failing command that read no case is still command/base-test-failed (#846) stricter Make the command print a JUnit report, or set allow_zero = true on the command when no base test is meant to run; allow-command: <name> <reason> lifts it for one change
v0.26.0 vacuous-tests, assertion-reduction (Python always-true asserts) The Python pack counts two more assert forms as tautologies: an assert of a non-empty tuple (assert (x == 1, "msg")), and assert m.called_once_with(..) (also called_with, has_calls, any_call, called_once, not_called) where every binding of m in the test function is Mock(), MagicMock(), AsyncMock() or a with patch(..) as m / patch.object(..) as m target, without a spec. A new test whose only checks are these is vacuous-tests/vacuous-test-added; an existing test whose check becomes one has one effective assertion fewer (assertion-reduction/assertions-reduced) (#836) stricter Write the assertion as the condition (assert x == 1, "msg") and call the mock’s assertion method (m.assert_called_once_with(..)); or allow-vacuous-test: <test> <reason> / allow-assertion-drop: <test> <reason>
v0.26.0 deletion-rationale, test-floor, assertion-reduction (Python tests redefined under one name) In a Python module or class body, only the last def of a name (plain, async or decorated) is read as a test: an earlier one is not counted by test-floor and not paired by assertion-reduction. A base-side test that the change hides behind a later def of its name (a test renamed to the name of one above it, or a new def added below an existing one) is reported as deletion-rationale/test-shadowed-by-redefinition with the line of the hidden definition and of the one that hides it; a redefinition already present on the base side is not reported. A definition inside an if, try or other compound statement, and a rebinding by assignment, are not judged (#827) stricter Give one of the tests another name, or state why with removes: <test> <reason>
v0.25.2 hook run (check and stop events, --verbose) When a hook run passes with only non-blocking warnings whose sorted (code, location, message) digests match the previous run in the same session, stderr emits a compact one-line summary (discipline hook: PASS (<N> unchanged warning(s); run \discipline hook run –verbose` or read for details)`) instead of repeating the full report on every tool call; blocking failures, new or changed warnings, and `discipline hook run --verbose` (`-v`) emit the full report and refresh `/discipline/sessions/.report.txt` (#818) looser none; pass --verbose (-v) to force the full report
v0.25.1 assertion-reduction (Go named-struct test-case maps) Go map literals whose value type resolves to a non-empty struct type declared in the test body, its file, or another file of its package (including pointer types *T and type aliases type T = struct{...}), or whose entries are keyed struct literals ("n1": {DisplayName: ..., Phase: ...}), are recognized as case tables wherever they stand, matching inline map[string]struct{...}; 3-clause for loops bounded by len(rows) also record rows as iterated (#815) looser none
v0.25.0 toolchain-config (SonarQube / SonarCloud scope and CI scanner arguments) Reads Java .properties files (sonar-project.properties, .sonarcloud.properties; =, :, whitespace separators, #/! comments, \ continuations, escaped separators; malformed lines report toolchain-config-unreadable) and -Dsonar.<key>=... / --define[=]sonar.<key>=... scanner arguments in .github/workflows/, .gitea/workflows/ and .forgejo/workflows/ (sonar-scanner, mvn ... sonar:sonar, gradle ... sonar/sonarqube, and SonarSource/sonarqube-scan-action / SonarSource/sonarcloud-github-action with.args). Reports grown sonar.exclusions, sonar.coverage.exclusions, sonar.cpd.exclusions, sonar.test.exclusions, sonar.issue.ignore.{multicriteria,block,allfile}, narrowed or newly added sonar.inclusions, sonar.test.inclusions, sonar.sources, and sonar.qualitygate.wait switched from true to false or removed (#809) stricter Revert the scope loosening or justify it with allow-toolchain-weakening: <key-or-path> <reason>
v0.25.0 assertion-reduction (Python strong assertion forms) Python assert operator strength is read from the comparison_operator syntax node’s operator children instead of substring matching on expression text, and existence or type checks (is not None, None is not x, isinstance, issubclass, self.assertIsNotNone, self.assertIsInstance, self.assertNotIsInstance) are classified as weak rather than strong assertions; ==, !=, in, not in, is, is not against a non-None operand, self.assertIsNone, and the self.assertEqual family remain strong (#808) stricter Keep a value comparison or justify a deliberate weakening with allow-assertion-drop: <test> <reason>
v0.24.0 pretool shell and worktree confinement Shell tool invocations enforce current-worktree lease and base-branch forbidden_paths before evaluating the shell script; wrapper commands (sh -c, eval, env, xargs), interpreter invocations (python, node, ruby, perl), backslash path separators, git -c/-C option ordering, and worktree/remote/config subcommands are checked; unparsed_read_only_tool anchors to the leading JSON tool key, rejects Unicode escapes, and fails closed (#790) stricter Acquire a worktree lease or restrict shell edits to the active worktree
v0.24.0 instruction-smuggling variation selectors, mixed separators, and width-agnostic base64 invisible_class reports Unicode Variation Selectors (U+FE00..=U+FE0F, U+E0100..=U+E01EF); fold_word splits on mixed separator runs (-, _, .); join_wrapped joins base64 blocks of any consistent line width (>= 16); files without a prose-capable pack have string literals scanned (#790) stricter Remove hidden variation selectors or smuggling phrases, or supply a scoped allow-instruction-smuggling directive
v0.24.0 pii and shell-secrets placeholder boundaries and eval detection is_placeholder_or_var requires a non-alphanumeric boundary after todo or fixme so credentials beginning with todoist_ or fixmeup_ are no longer exempted; shell-secrets reports secret expansion inside eval commands alongside sh -c (#790) stricter Replace live tokens or pass secrets via environment variables without eval interpolation
v0.24.0 baseline line-less finding fingerprints (v2-msg:) Line-less findings without an anchor hash with the distinct "v2-msg:" domain prefix so an anchor cannot collide with a message fallback, while still matching existing "v2:" baseline entries; fingerprint collisions fail closed in release builds (#790) stricter discipline baseline --write refreshes legacy line-less entries
v0.24.0 ratified-paths, audit --format html, report --format junit, mcp Gitea and Forgejo ratification checks classify [bot] accounts and sub-second comment edits via raw RFC 3339 strings; audit --format html percent-encodes {id} and {repo} URL segments; JUnit XML replaces C1 controls and bidirectional overrides with U+FFFD; discipline mcp enforces a 10 MiB per-line cap on stdio (#790) stricter none
v0.23.0 hook run --event stop, the OpenCode plugin New event: the generated OpenCode plugin sends the end of each 2.x turn (session.execution.succeeded) to hook run --agent opencode --event stop, which judges it with [hooks.premature-stop] and [hooks.verified-stop]; an enforcing plugin sends a refusal back with session.synthetic (else session.prompt). Nothing changes for a repository whose base ref enables neither check: the stop runs no check. Skipped and logged under opencode run --standalone (#778) stricter (opt-in) discipline hook install --agent opencode [--observe] --upgrade
v0.23.0 hook install (post-tool entry, all agents but Cursor, Aider and OpenCode) The generated post-tool entry first runs discipline hook run --help and passes with a one-line notice on stderr when the installed release does not list post-tool (before v0.22.0), instead of exiting 2 with a usage error that Claude Code reports as a blocking error after every shell call; the pre-tool, check and session-start entries are unchanged bug fix discipline hook install --agent <name> [--observe] --upgrade
v0.23.0 pii home paths and LAN addresses in test code In test code (a test path, or the lines of a test the language pack finds, such as a Rust #[test] function in a production file): a home path whose user name is a conventional test name (dev, test, alice, jdoe, …) is not reported, and a private LAN address is reported as a warning instead of at the gate’s severity. Other user names in test code, and everything outside test code, are reported as before (#777) looser none; a team that wants LAN addresses in tests to block adds an extra_patterns entry
v0.23.0 pii and shell-secrets placeholder credentials A private-key header that is the whole of one string literal bound by = (pemHeader = "-----BEGIN PRIVATE KEY-----") and an Authorization: Bearer value starting with a hexadecimal example word (deadbeef, cafebabe, feedface, baadf00d) are not hits; a header followed by a key body, on its own line, or written out alone (echo), is still reported (#777) looser none
v0.23.0 stub-bodies, error-swallowing test-fixture inputs A file the change adds below a fixtures directory inside a test, tests or __tests__ directory (a linter’s input under resources/test/fixtures/) is test scope whatever it holds: it is no longer judged as production code for holding no test. A fixture file that replaces a deleted production file is still judged as production code (#777) looser none
v0.23.0 JavaScript/TypeScript expect.poll and expect.soft receivers expect.poll(fn).toBe(..) (Vitest, Playwright) and expect.soft(x).toEqual(..) are assertions, read from the syntax tree, also split across lines: a test made of them is not vacuous-tests/vacuous-test-added, and assertion-reduction counts them like expect(x); a poll with no matcher still asserts nothing (#777) looser none
v0.23.0 dependency-delta manifest sections A dependency is compared with the base entry of the same name in the same section only (peerDependencies, devDependencies, dev-dependencies, require-dev, a Poetry group, …): a devDependencies workspace link is no longer read as loosening a peerDependencies range of the same name (#777) looser none
v0.23.0 dependency-delta/direct-dependency-added A name that appears in a section the base manifest did not list it in (moved from devDependencies to dependencies, or added to workspace.dependencies) is reported as added to that section; it was compared with its entry in another section stricter allow-dependency: <name> <reason> or allow_dependencies
v0.22.0 library discipline::guards::perf::bounds::sample_size_for_interval Returns an error when the required n is not finite or exceeds the new MAX_SAMPLE_SIZE (1e9) instead of saturating to usize::MAX; adds pub const MAX_SAMPLE_SIZE stricter Callers passing a vanishing half-width handle the Err; values at or below 1e9 are unchanged
v0.22.0 gate-command-lint/unfailable-command Read from the bash syntax tree: also reports \|\| echo …, \|\| /bin/true, \|\|true, \|\| exit 00, \|\| { true; }, (cmd \|\| true) and \|\| printf 'lit'; \|\| true inside a string or comment, an a \|\| b whose left side only tests a condition, and an \|\| echo default inside $(...), are not reported; a command that does not parse is named in the notes stricter Remove the mask, or end the fallback with a failing command (\|\| { echo failed; exit 1; })
v0.22.0 gate-command-lint/pipeline-exit-masked Any pipeline with a failable stage before the last is reported without pipefail, whatever the filter (\| xargs, \| python3); pipefail counts only from a real set command, not text such as echo "set -o pipefail" stricter Add set -o pipefail; before the pipeline, or set allow_pipeline_without_pipefail = true
v0.22.0 ci-integrity/exit-code-masked (GitHub run:, GitLab script) Read from the bash syntax tree on both forges: also reports \|\|true, \|\| exit 0, \|\| echo …, \|\| /bin/true, \|\| { true; }, set +o errexit; a mask only mentioned in a comment or string, an a \|\| b whose left side only tests a condition ([ -f x ] \|\| echo missing, a function made of conditions), or an \|\| echo default inside $(...), is not reported; the GitHub finding is reported at the masking line; a changed script that does not parse, or a non-bash/sh shell:, is named in the notes stricter Remove the mask or excuse it with allow-ci-weakening: <step name> <reason> (GitLab subject unchanged: \|\| true)
v0.22.0 stub-bodies/equality-made-constant An existing production equality method whose substantive body is replaced by constant true is reported at the gate’s severity (it was capped at warning since v0.20.0; v0.19.0 reported it as body-replaced-by-stub at gate severity). Added equality methods, ones already constant on the base side, and test code stay capped at warning stricter Implement a real comparison, or allow-stub: <Type> <reason>
v0.22.0 stub-bodies/equality-made-constant carve-outs Read from syntax nodes: a comment naming a derive, a mock import or @Generated no longer exempts; a hand-written equality method in a @dataclass, attrs or pydantic class, a Lombok or record class, or a Kotlin/Scala data/case class is reported; test doubles in fakes/mocks/stubs/doubles modules, and in tests/ files judged as production code, are exempt stricter (looser for test-double modules) allow-stub: <Type> <reason> for a deliberate constant equality
v0.22.0 stub-bodies constant bodies return Boolean.TRUE; and parenthesised constants (return (true);, return (null)) are trivial bodies, for both equality-made-constant and body-replaced-by-stub stricter none
v0.22.0 suppression-delta report text (quote_line = false) The message and remediation name a fixed tool and the rule identifiers only; descriptions, justifications, reason =, msg= and other free text are no longer echoed stricter none; quote_line = true still quotes the line
v0.22.0 suppression-delta JavaScript sites v8 ignore, node:coverage ignore and node:coverage disable comments are suppression sites when they open the comment stricter allow-suppression: coverage <reason> or a path
v0.22.0 unsafe-budget A changed file whose head side does not parse stops the run with exit 2 (could not check) instead of a note and a skip, restoring the behaviour before v0.20.1 stricter Fix the file, or exempt the path
v0.22.0 config-integrity Under --disable <gate>, a change whose own file leaves the gate off, including by deleting an enabled = true table so a default-off gate falls back to off, is reported as gate-weakened; a head config read or parse error stops the gate instead of skipping stricter allow-gate-weakening: <gate> <reason>
v0.22.0 mechanism-sections An untagged claim whose 57th byte falls inside a multi-byte character is reported instead of crashing the run (exit 101) bug fix none
v0.22.0 mechanism-sections ~~~ fenced blocks are code, not claims looser (false positive removed) none
v0.22.0 mechanism-sections A heading that starts with a designated heading (Root cause (summary), 2. Root cause) is in scope; #word with no blank is no longer a heading stricter Tag the claims, or rename the heading
v0.22.0 mechanism-sections A (verified: <path>) target must be tracked in the index, not only present on disk stricter git add the cited file
v0.22.0 citation-anchors A quote with fewer than min_quote_chars (default 8) non-whitespace characters is reported as the new citation-anchors/quote-too-short stricter Quote more of the line, or set min_quote_chars
v0.22.0 citation-anchors A cited commit must be the base or one of its ancestors; a commit only the change or another branch holds is commit-unresolvable stricter Cite a commit already on the base branch
v0.22.0 citation-anchors (tracker titles) DISCIPLINE_NO_NETWORK=0 or false no longer skips the check, matching the forge client; a tracker-title-mismatch at the start of a line reports the correct line (it was one short) bug fix Baseline entries keyed on the old line: discipline baseline --write
v0.22.0 scope-confinement A rename is judged at its source as well as its destination; symlinks added, retargeted or removed under a forbidden path (or outside allowed_paths) are reported stricter allow-scope: <path> <reason>
v0.22.0 claim-labels A label needs a separator and real content; (RUN), [READ], `RUN`, **RUN**, RUN:, -- RUN, (RUN: x) and markers inside code spans no longer satisfy a claim stricter Write RUN: <command> or (READ: path:line)
v0.22.0 hook run --event post-tool ([hooks.verified-stop] witnessed runs) A run counts as passing only when its exit code is stated: a numeric field, an Exit code: N statement (Codex tool_response string, Qwen llmContent, Copilot <exited with exit code N>; any non-zero statement wins), or a Claude Code PostToolUse that was not interrupted. A payload with a result but no stated code was read as exit 0 and is no longer witnessed; Copilot resultType: "success" alone no longer counts bug fix (stricter) none; a passing run in an unrecognised shape now needs run_at_stop = true
v0.22.0 hook run ([hooks.verified-stop], refuse mode) A stop whose refusal cannot be counted against max_per_session (no session id, an unwritable session store, or sessions/.lock held) is refused (exit 2) with the reason on stderr instead of being let through; stop_hook_active and agy executionNum still let it through bug fix (stricter) none
v0.22.0 hook run ([hooks.verified-stop] default commands) With no command configured, only the default runners whose ecosystem marker is a file at the base tree’s root are vouched (cargo test/Cargo.toml, pytest/pyproject.toml, setup.py or pytest.ini, npm test/package.json, go test ./.../go.mod); before, all four were vouched and run_at_stop required all four to pass. With none applicable, the refusal says so bug fix (looser for single-ecosystem repositories) Set [hooks.verified-stop].commands on the base branch when no marker applies
v0.22.0 hook install (all agents but Cursor and Aider) Generated hook files run hook run --agent <name> --event post-tool after the agent’s shell tool (Bash, run_shell_command, bash, run_command, OpenCode bash/shell), so [hooks.verified-stop] can witness verification runs from installed hooks; it never blocks additive discipline hook install --agent <name> [--observe] --upgrade
v0.22.0 session store (<common git dir>/discipline/sessions/) Session files and the lock file are created 0600 on Unix (were 0644 under the usual umask) bug fix none
v0.22.0 hook install --agent opencode (2.x execute.after) In enforcing mode, a failed check whose call.result has neither text content nor a string output is written to stderr instead of being dropped bug fix discipline hook install --agent opencode --upgrade
v0.22.0 release tarballs Archives record owner and group 0 with no names, directories and files at 0755/0644 and the binary at 0755, on both GNU tar and bsdtar runners bug fix none
v0.21.2 hook run --agent codex ([hooks.premature-stop] last_assistant_message) On a Codex CLI Stop event without a transcript_path, [hooks.premature-stop] evaluates the payload’s last_assistant_message for unexecuted action announcements and tool calls written as text (#482, #765) stricter when enabled none
v0.21.1 hook install --agent opencode (2.x execute.after content preservation) In OpenCode 2.x tool.hook("execute.after"), call.result.output is a structured metadata object while call.result.content holds the model-visible text blocks; the generated plugin now appends post-edit violation reports to call.result.content[0].text (falling back to call.result.output only when it is a string) instead of overwriting the metadata object (#570, #762) bug fix Refresh the generated plugin with discipline hook install --agent opencode --upgrade
v0.21.1 New gate claim-labels (off by default) Added claim-labels gate scanning PR bodies, commit messages, and configured targets globs for unlabelled behavioural claims, rejecting placeholder markers (RUN: TBD/TODO) and issuing claim-labels/unlabeled-claim findings unless a RUN or READ label appears in the paragraph or allow-unlabeled-claim: <subject> <reason> is supplied (#703, #761) additive none; opt in by setting [gates.claim-labels] enabled = true
v0.21.1 hook run ([hooks.verified-stop] and [hooks.tool-loop] session guards) Added opt-in [hooks.verified-stop] (requires a configured verify_commands invocation to succeed after the latest file-modifying tool call before the agent stops on a dirty worktree, with vouched SHA-256 tree/command state) and [hooks.tool-loop] (refuses identical tool calls repeated past max_identical_calls without intervening worktree progress, hashing inputs with SHA-256 so raw arguments are never written to disk) (#700, #701, #760) stricter when enabled Opt in via [hooks.verified-stop] and [hooks.tool-loop] in discipline.toml
v0.21.1 command presets (resolve-locked-* package-manager presets) Added four opt-in [[gates.command.commands]] presets (cargo-resolve-locked, npm-resolve-locked, uv-resolve-locked, go-resolve-locked; there is no yarn, pnpm or pip preset) that delegate lockfile-resolution verification to native package managers in locked/offline-capable mode and distinguish missing-version resolution failures (exit 1) from unreachable registries (exit 2) (#704, #758) additive none; opt in by adding preset = "<ecosystem>-resolve-locked" under [[gates.command.commands]]
v0.21.1 hook install --agent claude-code (--pin-sums bootstrap digest pinning and deterministic release archives) Added discipline hook install --agent claude-code --pin-sums <SHA256SUMS> to embed per-platform SHA-256 release digests directly into .claude/hooks/discipline-bootstrap.sh (eliminating same-origin SHA256SUMS fetches at bootstrap time), preserved pinned digests across --upgrade, and normalized release tarball headers (--mtime, --uid 0, --gid 0, --numeric-owner, gzip -n) for reproducible builds (#653, #757) stricter Pin the committed bootstrap script with discipline hook install --agent claude-code --pin-sums <path-to-SHA256SUMS>
v0.20.3 assertion-reduction, vacuous-tests, test-floor (module-scoped Rust helper resolution and test pairing) Qualifies Rust non-test helper function names with their enclosing mod path (src/ast/rust.rs), resolves same-file helper calls against the caller’s module hierarchy from innermost to outermost before falling back to top-level helpers (src/ast/mod.rs, src/ast/helper_loops.rs, src/guards/agent_diff/helper_pairing.rs, src/guards/agent_diff/assertion_reduction.rs, src/guards/agent_diff/crate_helpers.rs), and pairs renamed tests within the same module before cross-module leaf-name similarity (src/guards/agent_diff/test_matching.rs). Inserting a sibling mod with a same-named helper (such as fn fixture) no longer overwrites an unchanged module’s helper assertion count (#755) looser (false positive removed) allow-assertion-drop directives added for unchanged tests beside a newly inserted sibling module are no longer needed
v0.20.2 suppression-delta (JavaScript coverage comments) istanbul ignore and c8 ignore count only when they open a comment, matching istanbul-lib-instrument and v8-to-istanbul; prose comments mentioning istanbul ignore or c8 ignore mid-sentence (// do not write istanbul ignore here) are no longer reported (#685) looser (false positive removed) A baseline entry for such a prose comment no longer matches anything; discipline baseline --write drops it
v0.20.2 hook install --agent opencode (shell call timeouts) The generated OpenCode plugin wraps all six discipline shell invocations in Promise.race timeouts (HOOK_TIMEOUT_MS for pre-tool, post-tool, and premature-stop checks; CHECK_TIMEOUT_MS for discipline check), preventing an unresponsive child process from stalling the OpenCode session (#570, #752) stricter Refresh the generated plugin with discipline hook install --agent opencode --upgrade
v0.20.2 ci-integrity (GitLab CI set +e status check exemption) A GitLab CI verification job script that sets set +e and explicitly inspects $? or rc= before restoring set -e is recognized as a checked negative-control block rather than reported as Exit-Code Masking Added, matching GitHub Actions workflow scripts (#493, #751) looser (false positive removed) allow-gate-weakening: ci-integrity directives for checked set +e blocks in .gitlab-ci.yml are no longer needed
v0.20.1 assertion-reduction, vacuous-tests, stub-bodies, error-swallowing, test-floor (same-file helper and receiver-call resolution scaling) Fixed a super-linear scaling regression in v0.19.0 and v0.20.0 (#586, #609, #617, #693) where ParsedFileFacts::called_helper, least_helper, resolve_helper_reach (src/ast/mod.rs), method_checks::count (src/ast/method_checks.rs), helper_loops::mark (src/ast/helper_loops.rs), and weakened_crate_helpers / helper_with_reach (src/guards/agent_diff/crate_helpers.rs) rescanned all file functions per call site without memoization or name indexing. Pre-indexes the three-level helper graph by exact name and leaf segment and filters weakened_crate_helpers to functions present in base test_helpers, preserving identical gate findings additive Upgrade from v0.19.0 or v0.20.0 to v0.20.1
v0.20.1 unsafe-budget, stub-bodies, error-swallowing (parse-failure policy alignment) Aligned syntax-error handling with agent_diff and suppression-delta: files that fail to parse on the head side fail closed in error-swallowing and stub-bodies, and unsafe-budget refuses unparseable Rust files (#746) stricter Fix syntax errors in changed files or exempt generated fixtures via exempt_paths
v0.20.1 config-integrity (CLI switches decoupled from diff weakening) Ephemeral CLI overrides (--disable, --severity) no longer trigger config-integrity/gate-disabled-in-diff or severity-lowered-in-diff when discipline.toml itself was not weakened (#745) looser none
v0.20.1 test-floor (uncommitted report fallback and dual verification) When report_path is not tracked in the base ref, test-floor verifies the working-tree JUnit report against both head and base AST-discovered test identities (#744) stricter Ensure report_path is generated by the test suite before running discipline check
v0.20.1 suppression-delta (locations-only contract and quote_line option) Findings report location and suppression kind (allow + lint name) by default without quoting source lines; opt-in quote_line = true restores quoted annotation with directive scrubbing (#481, #743) stricter Configure quote_line = true under [gates.suppression-delta] if downstream parsers depend on quoted lines
v0.20.0 stub-bodies (equality made constant) Added stub-bodies/equality-made-constant detection for functions returning constant values matching equality comparison sites, with carve-outs for mock/stub/fake test doubles. Keyed function pairing by type and name to prevent cross-type method pairing within the same file (#736). A changed equality method made constant, reported as body-replaced-by-stub at gate severity in v0.19.0, was capped at warning by this change (looser for that case; restored in v0.22.0) stricter for added methods, looser for changed ones Ensure functions implement real logic, or annotate test doubles with mock/stub/fake naming
v0.20.0 harness-tampering (pytest force_result, exitstatus, plugin modules, Cargo test target modules) Reports a force_result call in a pytest_runtest_makereport or pytest_runtest_logreport hook, and an assignment to session.exitstatus in pytest_sessionfinish, as pytest-hook-masks-results. Reads a plugin module named by pytest_plugins in a conftest.py, and the modules of a Cargo test target (mod common; in tests/it.rs), as harness files, so the forms of the gate, and an unconditional exit with status zero (harness-exits-zero, a warning at most), are reported there too (#735). Corrected (#845): this row listed report.failed = False, plugin registration from a hook, and a [[test]] path remapping or harness = false as caught; the gate reported none of them stricter Remove harness tampering or add allow-harness-tampering: <path> <reason>
v0.20.0 bench-regression (sample size and minimum detectable difference) Added numerical bounds in src/guards/perf/bounds.rs: standard_normal_quantile, sample_size_for_interval, and minimum_detectable_difference bounds under statistical power constraints with p1 + delta <= 1.0 validation (#734) additive none
v0.20.0 assertion-reduction (Deno standard assertions, await expressions) Deno standard assertions (assertAlmostEquals, assertGreater, assertLessOrEqual, etc.) mapped to expected values, bounds, and caught handlers. Unwraps await expressions around assertion calls in JavaScript/TypeScript (#733) stricter Restore dropped checks, or carry allow-assertion-drop: <test> <reason>
v0.20.0 hook run (Copilot CLI and agy transcript parsing, --enforce flag) Premature-stop hook expanded to parse GitHub Copilot CLI and Google Antigravity (agy) session transcripts. In refuse mode, missing or unreadable transcripts fail closed (refuse turn once). Added --enforce flag to discipline hook install to switch observe hooks to enforcing mode (#730, #732) stricter when enabled Switch hook to enforcing mode with discipline hook install --enforce
v0.20.0 pii (term denylist for files, paths, commits, and refs) hygiene gate gained term_denylist support scanning file paths, contents, commit subjects/bodies, and git ref names, with delimiter boundaries ([^A-Za-z0-9]) and masking denylisted terms in path locators (#731) stricter Remove denylisted terms or configure term_denylist in discipline.toml
v0.20.0 New gates citation-anchors, gate-command-lint, mechanism-sections (off by default) Added three new gates: citation-anchors (verifies path:line@sha citations against quoted text, git history, and online forge issues), gate-command-lint (rejects vacuous, masked, or inverted verification commands in plan files), and mechanism-sections (enforces (inferred) or (verified: <target>) evidence tags in root cause sections) (#729) additive none; opt in by enabling the respective [gates.<id>] table
v0.19.0 bench-regression (ratio run provenance commit) In paired-ratio mode, evaluate_run verifies that run.provenance.commit is a 40-digit hex SHA-1 and resolves in git via the evidence context. A missing or non-commit object is reported as not comparable; shallow clones emit a note. Before, the commit field was not verified outside record_paths (#651) stricter Ensure benchmark run files record a full commit SHA that exists in the repository
v0.19.0 ignored-tests (JUnit DisabledIf off values, Kotest spec class skips) JUnit @DisabledIfEnvironmentVariable and @DisabledIfSystemProperty with matches reading as off (0, false, no, off) are evaluated symmetrically to EnabledIf and run in CI (not reported as CI skips). Kotest tests defined inside spec bodies (StringSpec, FunSpec, etc.) inherit class-level conditional skip annotations (@DisabledOnOs, @DisabledIfEnvironmentVariable) (#651) stricter Add allow-ignore: <test> <reason> if a test is intended to be skipped
v0.19.0 provenance-tags (thousands grouping and percent in figures) verify_cited_figures admits figures with thousands grouping (commas and underscores: 1,234,567, 1_234_567) and percentages (99.5%). Before, grouped figures were unrecognized or truncated, and percentages were not stripped before numeric comparison (#651) looser none
v0.19.0 ignored-tests (conditional skips in loops, switch/match, and preprocessors) In C#, Ruby, PHP, Swift, Scala, C/C++ and Objective-C, skip statements inside loops (while, for, do, foreach, until), switch/case/match, and preprocessor conditionals (#if, #ifdef, #ifndef) are recognized as conditional skips (reporting as notes or CI skips under CI conditions). Before, these structures were not recognized as enclosing condition guards, reporting tests as unconditionally skipped errors (#651) looser none
v0.19.0 Terminal, job summary, pull request comment, agent prompt (invisible characters) Control characters other than tab, line feed and carriage return, bidirectional controls, zero-width characters (including joiners and marks), soft hyphens and tag characters are replaced with U+FFFD across the four human-facing report formats. Before, zero-width characters and bidi marks passed through (#681). Commit provenance squash entry subjects preserve control characters for the report layer instead of replacing them with spaces stricter none
v0.19.0 assertion-reduction (net helper call gain across helper categories) Net gain from helper calls (helper_call_gain) computes signed net changes across own reach, helper pairs, and resolved crate helpers before clamping to zero. Before, saturating subtraction was applied to each category separately, which could allow gains in test-support helpers to mask assertion drops in crate helpers (#688) stricter Restore dropped checks, or add allow-assertion-drop: <test> <reason>
v0.19.0 assertion-reduction/test-helper-weakened (helper leaving test-support path) A helper leaving a test-support path (moved to another file or the file renamed to a non-test-support path) can only pair into another test-support path. Moving or renaming a helper out of test support is reported as deleted in its original test-support path (#688). Before, it could pair into the non-test-support file stricter Keep test helpers in test-support paths, or add allow-assertion-drop: <helper> <reason>
v0.19.0 hook install --agent qwen, a hook’s own check, doctor (hook-mode) .qwen/settings.json is written with "$version": 4, the settings version Qwen Code 0.25.0 otherwise adds when it starts. A hook’s check, hook install --upgrade and doctor read a generated file as generated when it differs only in that key (added after the last one, or with another whole number). Before, the first Qwen Code session changed the generated file, the hook reported instruction-smuggling/agent-instructions-changed for it, and doctor could not read its mode. Any other difference in the file is reported as before, and CI reports the key like any change to the file looser None is needed. hook install --agent qwen --upgrade adds the key to a file an earlier release wrote and Qwen Code has not yet started on
v0.19.0 hook run at the end of a turn (Claude Code, Qwen Code, Codex) New opt-in check [hooks.premature-stop], off by default: with it enabled on the base ref, a final message that announces an action the agent did not take, or ends in a tool call written as text, is logged (mode = "observe", the default) or refuses the stop once (mode = "refuse"), with the codes turn/announced-action-not-taken and turn/tool-call-written-as-text. check and every report are unchanged. config-integrity reports the table being switched off or quietened (#706) stricter when enabled None: nothing changes until the table is enabled
v0.19.0 doctor (hook-mode, truthful pretool-hook) discipline doctor reports hook-mode for each agent hook file: Pass when enforcing, Warn when observe (with remediation showing how to switch to enforcing), Unknown when unreadable or edited, and Warn when a hook file has no check or stop entry. pretool-hook for an observe-mode file reports that it logs rather than refuses (#699) stricter When running discipline doctor --strict, a hook file in observe mode now fails: switch it to enforcing by deleting the file and running discipline hook install --agent <name>, or accept the warning without --strict
v0.19.0 assertion-reduction (pairing of expected exceptions), handlers nested in one another, baseline lookup Pairing the expected exceptions of a test, reading handlers nested in one another (JavaScript, Java, Kotlin, C#), and looking up the findings a baseline does not hold now cost work in proportion to their number. Before, 2,000 rewritten expectations cost about 2e11 instructions in an unoptimised build (#672). Findings, their order and fingerprints are unchanged none none
v0.19.0 ci-integrity (line of a finding, inline exemption) A finding is reported on the line of the job, step, key, trigger or pin it names. Before, it was the first line containing the name: a job test below any runs-on: ubuntu-latest was reported on that line. An inline discipline:allow(ci-integrity) is read from that line, so it now excuses only the job or step it is written on; before, one on another job’s or step’s line could excuse a finding (#678) stricter A baseline entry for a finding that moves to another line no longer matches: record it again with discipline baseline --write. Move an inline allow that sat on the old line to the job’s or step’s own line
v0.19.0 dependency-delta (manifest that does not parse) A changed Cargo.toml, pyproject.toml, package.json or composer.json that does not parse on the head side stops the run (exit 2), naming the file and line. Before, it read as declaring no dependency, and such a change to package.json or composer.json passed. On the base side it is a note. With toolchain-config also enabled, the stop comes before its toolchain-config-unreadable finding (#678) stricter Fix the manifest; no directive lifts it
v0.19.0 archive-contents (directory that cannot be listed) A directory that cannot be listed while archive_path is looked for stops the run (exit 2) when the pattern can match below it. Before, it was skipped (#678) stricter Make the directory readable, or give archive_path a literal prefix that does not reach it
v0.19.0 command (base-tests report file) With no JUnit XML in the command’s output, the report read is the first *.xml in name order at the top of the working directory that holds cases. Before, it was whichever the directory listing gave first (#678) none A runner that writes several reports prints one, or names the one to read so it sorts first
v0.19.0 test-floor (pytest python_files entry that is not a glob) An entry that does not compile as a glob is no longer matched as text inside a file name. A file no other entry matches is counted with collection not determined, and a note says why (#678) looser Write the entry as a glob
v0.19.0 suppression-delta (qualified and file-level annotations) Now reported: an annotation is read by the last segment of its name, so Java @java.lang.SuppressWarnings, Scala @scala.annotation.nowarn and @annotation.nowarn, and Kotlin @kotlin.Suppress are sites, and so is the Kotlin file annotation @file:Suppress(...). Imports are not resolved: an annotation of another package with the same simple name is reported too (#685) stricter Remove the suppression, or add allow-suppression: <rule-or-path> <reason>
v0.19.0 suppression-delta (comment spellings the tools accept) Now reported: a RuboCop directive with any blanks or none after # and around the colon; Python noqa in any letter-case, # type:ignore with any spacing, # pylint: disable-next=, and a noqa after other comment text; a Java comment holding NOSONAR and a // comment opening with noinspection <id>; a Kotlin comment opening with NOSONAR. Java and Kotlin comments were not read before. A Python comment carrying a second tool’s noqa (# type: ignore # noqa: E501) is one more site; the first keeps its rule, snippet and fingerprint (#685) stricter Remove the suppression, or add allow-suppression: <rule-or-path> <reason>
v0.19.0 suppression-delta (Kotlin annotation named by its first segment) No longer reported: a Kotlin annotation whose first identifier is Suppress or SuppressWarnings but whose type’s last segment is another name, such as @Suppress.Inner("x") (#685) looser A baseline entry for such a site no longer matches anything
v0.19.0 Every language pack, baseline fingerprints (sources of many like items) A change holding hundreds of AssertJ catchThrowable calls in one Java method, Kotlin runCatching results bound to names, a JavaScript promise chain of hundreds of links, a long chain of calls in a JavaScript or TypeScript file, Rust catch_unwind closures hundreds deep, a Python class of hundreds of methods, a Go file of hundreds of tests, or hundreds of findings in one file is checked in work that grows with its size. Before, each cost the square: a Python class of 1,000 methods cost about 1e11 instructions in an unoptimised build and now 3.6e9 (#672). Findings and fingerprints are unchanged none none; a Rust caller of the library passes a GoPackageLevel to extract_go_cases_in_package
v0.19.0 assertion-reduction/test-helper-weakened (Rust helper outside a test-support path) Now reported: a Rust helper in a #[cfg(test)] module outside a test-support path (a src/**/test_support.rs declared by #[cfg(test)] mod, an inline #[cfg(test)] mod, a #[cfg(test)] function) that loses checks or is deleted, when a test’s call resolves to it through the crate’s modules. The finding names the calling tests. Before, such a helper could be weakened with its tests untouched and nothing was reported, including after a move that is no longer a finding (#688) stricter Restore the checks, or add allow-assertion-drop: <helper> <reason>
v0.19.0 Every language pack (assertions of many literals, caught assertions) A change holding an assertion with hundreds of expected values or bounds, a Rust test that rebinds one name to catch_unwind results, a Kotlin test with many expected exceptions, or a Python try with many assertions is checked and reported. Before, the run could fail to return: 18 lines of let r = std::panic::catch_unwind(..) cost about a trillion instructions, and a 24 kB file of suites inside one expect did not finish (#672). Findings are unchanged none none; a Rust caller of the library reads Expectation.calls as a slice instead of a Vec<String>
v0.19.0 audit --format html (hidden characters) In text the page quotes from the history, the configuration or the forge (a commit subject, a file name, a directive name, a configuration key or value, a parse error, a cited reference), a control character other than tab, line feed and carriage return, a bidirectional control, a line or paragraph separator, a zero-width character, the soft hyphen and a tag character are each written as U+FFFD. Before, they were written as they were, so a subject or a file name could reorder or hide text in a row (#681). A zero-width joiner inside an emoji sequence is replaced too. Verdicts, exit codes and the JSON output are unchanged stricter none; a page compared byte for byte with an earlier one differs only where the history holds such a character
v0.19.0 assertion-reduction (Rust helper moved to another module) No longer reported: a Rust test whose helper moved unchanged to another module of its crate, when the call resolves to the helper through the mod declarations and the use items or path, the helper is #[cfg(test)] code and it still holds the checks. A note names the helper and its file. A call that cannot be resolved to one helper is reported as before (#683) looser allow-assertion-drop lines written for such moves are no longer needed
v0.19.0 assertion-reduction (Rust crate helper no longer called) Now reported: a Rust test that stops calling a resolved crate helper, starts calling a different new helper and drops assertions of its own is not excused by the new helper alone (#683) stricter Restore the checks, or add allow-assertion-drop: <test> <reason>
v0.19.0 test-budget/fuzz-target-removed (manifest parsed) A fuzz manifest’s targets are read from its parsed TOML. Now reported: a removed target whose name is not the first key of its [[bin]] entry, sits under a header with a trailing comment, is written as a literal string, or is in an inline bin = [..] array; and a target whose entry is gone while a comment or a string still spells it (#678) stricter Restore the target, or add allow-test-shrink: <target> <reason>
v0.19.0 test-budget/fuzz-target-removed (false positives) No longer reported for a target that is still listed after its keys were reordered, a comment was added above name or its quoting changed, nor for text in a comment or a string that was never a target (#678) looser A baseline entry for such a finding no longer matches anything; discipline baseline --write drops it
v0.19.0 test-budget (fuzz manifest that does not parse) A head fuzz/Cargo.toml that does not parse as TOML stops the run (exit 2, gate test-budget), naming the file and line. Before, it read as a manifest with no targets. A base manifest that does not parse adds a note and no finding (#678) stricter Fix the manifest
v0.19.0 Could-not-check text, lease errors (order) The read errors kept for a tree with several unreadable Rust files, and the lease named when several lease files are invalid, are the same on every run, in path and name order (#678) none none
v0.19.0 ci-integrity (output order) rollup-needs-incomplete lists the missing jobs in name order, several rollup-needs-removed findings come in job then dependency name order, and the note for a workflow treated as a move lists its jobs in name order. Before, the same input could give different text on each run (#675). Fingerprints are unchanged none none
v0.19.0 test-budget (output order) Several fuzz-target-removed or seed-corpus-decreased findings come in name and path order on every run (#675) none none
v0.19.0 test-floor/tests-moved-out-of-default-run (several targets) A module that several test targets stopped declaring is reported at the same target on every run, the last in path order. Before, the target named, and so the finding’s fingerprint, could change between runs (#675) none A baseline entry recorded while another target was named no longer matches and the finding shows once; discipline baseline --write records it again
v0.19.0 harness-tampering (a file that is a test target more than once) A file that is a test target of several packages, or of several [[test]] entries, is described as a harness = false target when any of them sets it. Before, the wording depended on the order the manifests were read (#675). Message wording only none none
v0.19.0 hook install --upgrade (local edits), new --force A generated script, workflow or plugin now carries a line with a digest of its own content. --upgrade rewrites such a file only when the digest matches, or when a file with no digest equals this release’s output apart from that line. A file changed after it was written, or written by v0.18.0 or earlier and different from this release’s output, is left as it is, with a unified diff and exit 1; before, it was overwritten without a message. A generated JSON hook file whose check timeout is below the default is no longer reset silently. In a JSON hook file that holds other hooks or settings, entries of ours that differ are refused with a diff. New --force (with --upgrade) overwrites and prints what was discarded; it never replaces a --pin-sums bootstrap with an unpinned one, and in such a JSON file it replaces only our entries. The digest detects an edit; it is not a signature. stricter Read the diff, then --upgrade --force. --upgrade --timeout <n> keeps a timeout.
v0.19.0 Every language pack (deeply nested sources) A source nested hundreds of levels deep is read in work that grows with its size. Before, a few kilobytes of nested functions or chained calls could keep a run from finishing (#669). Findings are unchanged. Nested function declarations and classes in JavaScript and TypeScript, nested #[test] functions in Rust and nested t.Run in Go still cost the nesting times the size of what is nested, bounded by the depth limit none none
v0.19.0 Every language pack, pre-tool hook, ci-skip-set (deeply nested text) The binary does its work on a thread with a 256 MiB stack, and a source whose syntax tree nests deeper than 4,096 levels is refused as could not parse `<path>`: the source nests <n> levels deep, past the 4096 this tool reads: exit 2 under the gates that need every file’s facts, a note from the rest. Before, a deeply nested source ended the process with a stack overflow and no report (about 1,200 nested calls in Go, #667). A Rust assertion-macro argument or a Python string skip condition that a pack parses on its own is held to the same limit and to its parse budget, where it was read as not an expression. The pre-tool hook refuses a command nested past the limit. ci-skip-set reports Skip Decision Unverifiable for a workflow condition nested more than 64 levels or holding more than 256 && or \|\|, where it ended the process. The deepest of about 30,000 real sources measured nests 2,056 levels. stricter None expected.
v0.19.0 vacuous-tests, assertion-reduction (self-comparison assertions) Two new findings, both warnings: vacuous-tests/self-comparison-assertion-added for a new test that can fail and also holds an equality assertion whose two operands are the same tokens, and assertion-reduction/self-comparison-assertion-introduced for an existing test that gains one while its counts hold. When the count drops, the existing assertions-reduced finding names the self-comparison lines, and a vacuous new test’s message names them too, so a test carries one finding. Not reported: a pair holding a call or a macro invocation; an assertion inside a macro definition; an operand the same test also compares with something else. Exact form only, with no alias or value-flow analysis. additive None.
v0.19.0 vacuous-tests, assertion-reduction (what counts as a tautology or an equality assertion) Operand identity is by token, so white space, comments and wrapping parentheses no longer hide a tautology. Java assertThat(x).isEqualTo(x), isSameAs and isSameInstanceAs, JavaScript assert.deepEqual and deepStrictEqual, and a parenthesised Python, Catch2 or C condition are tautologies. C# Assert.AreEqual, AreNotEqual, AreSame, AreNotSame and NUnit Assert.That(a, Is.EqualTo(b)) / Is.SameAs are equality-strength assertions, so weakening one is an assertion drop and Assert.AreEqual(x, x) is a tautology; before, they counted as plain assertions. stricter Carry allow-vacuous-test: <test name> <reason> or allow-assertion-drop: <test name> <reason>.
v0.19.0 New gate harness-tampering (off by default) When enabled, it reports a change that adds one of these to a file the test runner loads: a Go TestMain that never calls m.Run() or drops its result before os.Exit (test-main-result-discarded); a pytest report hook that assigns outcome, or a collection hook that removes items while reading no marker, keyword or option (pytest-hook-masks-results); a unittest result method assigned, or overridden by an empty body (unittest-result-method-replaced), each at the gate’s severity; and an exit with status zero and no condition around it in a conftest, a start-up file, a Jest or Vitest configuration or set-up file, or the root of a Cargo test target (harness-exits-zero, a warning at most). A harness file is one by its name or by the runner’s configuration, never by a word in its path. Exact forms only, with no value-flow analysis; the reference lists what is not caught. Lifted by allow-harness-tampering: <path or path:line> <reason>. Not evaluated on a whole-tree run. additive None; opt in with [gates.harness-tampering] enabled = true.
v0.19.0 SARIF, JUnit and GitLab reports (policy refusals) A refusal that fails the run is an entry in the three reports: policy/max-overrides-exceeded, policy/max-inline-overrides-exceeded and policy/approval-required, at SARIF error, as a failed JUnit test case in a new policy test suite, and at GitLab major. A hidden directive that was not read, which does not fail the run, is policy/hidden-directive-refused at SARIF note, a passing JUnit test case, and GitLab info. Each entry carries the rule id, where the refusal is and fixed wording; counts, logins and directive text stay in the JSON and text reports. Exit codes, the JSON report and the text report are unchanged, and a run with no refusal writes the three reports as before. A run failed by fail_on_overrides is not carried, since the JSON report does not hold it either. additive None. A consumer that fails on any SARIF error or JUnit failure now sees a refused run there, where before only the exit code showed it; one that counts JUnit test cases sees the policy suite.
v0.19.0 ignored-tests (skip markers and CI early returns read from the tree) A display name, description, parameter value, fixture name or test name that contains Skip, skip:, .skip, enabled = false or [. no longer marks a test as skipped in C#, Python, Ruby, JavaScript, Java, Kotlin and Catch2; the marker itself is still read, and @Test(enabled = false) is now read at any spacing. An early return whose condition names a CI variable only inside a longer string or a comment is no longer a CI skip; a real environment read is unchanged. looser None.
v0.19.0 error-swallowing (Go), config-integrity (base_report) Go _ = f() is Result Discarded when f is on the list of fallible callees the two-value form already uses; before, only a left side with a comma was read. base_report added beside an existing test_report is reported as a change of evidence, since the base side of the comparison then comes from a runner file. stricter Handle the error, or carry allow-swallow: <path>:<line> <reason>. allow-gate-weakening: test-floor <reason> for the report.
v0.19.0 replay, audit, commit-provenance (reading commits) A pull request number is read only where a subject ends with (#N), by one function; before, replay and audit read the last one anywhere in the subject, and (#0) counted. A commit message that is not UTF-8 is read lossily, as check reads it: audit records its directives where it recorded nothing, and replay no longer rebuilds it with an empty message. An override’s source in replay names the replayed commit, so replay --json is the same on every run. A ref that is not a commit is named in the error. stricter None.
v0.19.0 Job summary, pull request comment, baseline and repository messages (text) In the job summary and the comment, a word that is an issue or pull request reference, a commit id, a mail address or a run of seven or more hex digits is written as a code span, in quoted text and in the tool’s own message text alike, so it cannot become a link; references the report writes itself stay links. The baseline subcommand names a file outside the repository by its file name only. A core.worktree in the user’s git configuration that names a missing or another directory stops the run with exit 2 and names the key; before, the second case read that other directory. stricter A consumer matching the raw Markdown of the summary or comment.
v0.19.0 test-floor (tests-moved-out-of-default-run, more rules) The finding is also reported when a change switches a Cargo target’s tests off in its own manifest (test = false or harness = false on a [[test]], [lib] or [[bin]], autotests = false), or removes a mod declaration, over tests that still exist while the count does not fall; and for a Deno exclude or vendor added over tests that Deno’s default names collected. A removed mod line is reported on the file that held the declaration. stricter Take the rule back, or carry allow-test-shrink: <file holding the rule> <reason>.
v0.19.0 test-floor (static count: Deno, bare Jest and Vitest dependencies, Jest patterns) Both sides are read the same way, so the direction depends on the repository. In a Deno-only repository Deno.test registrations and their steps are counted, and only files deno test would run count, by its default names and its exclude and test.include lists. With jest or vitest as a dependency and no configuration, the runner’s default names decide; before, every file counted. A Jest testMatch pattern that starts with a literal character collects nothing, as in Jest. Under Vitest 1 and 3 a test file named like a tool’s configuration file is left out. node:test files below a nested package.json whose script runs node --test are counted. A Jest configuration with testMatch and testRegex together, or more than one configuration source, is counted whole with a note, since Jest refuses to run it. Compared with Deno 2.6, Jest 27 and 29, Vitest 1, 3 and 4; Jest 30, Vitest 2 and Bun are from documentation. stricter A min_tests measured on the old basis may need measuring again. Name a suite the model leaves out under [tests] paths.
v0.19.0 test-floor (notes), assertion-reduction (Deno) A head-side note names a test file renamed into a place an existing rule leaves out. A per-side note counts Rust files under src/ or tests/ that hold tests and that no crate root or test target reaches; they were left out silently. A go.work with a directive the go tool does not know, or a second go line, is unreadable, as the tool treats it. Deno’s assertThrows and assertRejects from its standard assertion module are read as expected failures. additive None.
v0.19.0 Base fetch in CI (message text) The git fetch the binary runs to find a missing base in CI runs with LC_ALL=C and without LANGUAGE, so the diagnostic quoted in the exit-2 message is in English whatever the runner’s locale. No verdict changes. additive None, unless a script matched a translated git message.
v0.19.0 assertion-reduction, vacuous-tests (expected failures in Go, Swift, Scala, Objective-C) A widened, loosened or dropped expected failure is reported in four more languages. Go: errors.Is and errors.As in a failing condition, testify error and panic assertions on the package, a suite or a bound assertion object, and gocheck ErrorMatches, Panics and PanicMatches; compared by the sentinel or type named, presence and message, with no hierarchy. Swift: the type checked in an XCTAssertThrowsError closure, XCTAssertNoThrow, and #expect / #require(throws:). Scala: ScalaTest intercept, assertThrows and thrownBy, and specs2 throwA, on Java’s table. Objective-C: the XCTAssertThrows and XCTAssertNoThrow families, which are now also counted as assertions. Before, these counted as assertions only and their class or message was not compared. stricter Carry allow-assertion-drop: <test name> <reason> for a loosening made on purpose.
v0.19.0 assertion-reduction (more expected-failure forms; C# Assert.Throws by framework) Now read: Catch2 and doctest REQUIRE_THROWS / CHECK_THROWS macros and their variants, AssertJ typed entry points and catchThrowable with assertions on the caught value, Chai should style, and the Kotest message assertion on the value shouldThrow returns. In C# the framework is read from the file’s using directives: in an MSTest file Assert.Throws<T> accepts subclasses, so Assert.ThrowsException<T> or Assert.ThrowsExactly<T> replaced by it is a loss of exactness and a move to a parent class is a widening; with no framework named, Assert.ThrowsException<T> replaced by Assert.Throws<T> is reported too. stricter As above.
v0.19.0 assertion-reduction (expected failures no longer misreported) In a C# file whose directives name xUnit or NUnit, Assert.Throws<T> is exact, so replacing Assert.ThrowsExactly<T> or Assert.ThrowsException<T> by it is not reported. assertThatExceptionOfType(X.class) rewritten as its typed entry point, and assertThatThrownBy(..) rewritten as catchThrowable with the same assertions, were reported as an expectation no longer checked and are not. An Objective-C test whose only check is XCTAssertNoThrowSpecific is no longer a vacuous test. looser None.
v0.19.0 assertion-reduction (helpers in test-support files) A function in a test-support file that holds no test is reported as a weakened helper only when a test names it; before, any function there losing a check was reported. conftest.py and the setup functions a runner calls are judged as before. A helper in a test-support file the change does not touch is read from the head tree and stands for the checks it holds, and a failure exit under an equality comparison in a helper of another file covers a dropped equality assertion; before, a helper outside the change stood for nothing, so a lossless move behind it was reported. Not seen as named: a function used only as a value, or reached only through a function of another unchanged file. looser List a checking helper under assert_helper_fns, or keep a test that calls it.
v0.19.0 assertion-reduction, vacuous-tests, test-floor (gocheck suites, case sources, MSTest, pytest.warns) gocheck suite methods are tests, and assertions on a local bound to s.Require(), s.Assert(), require.New(t) or assert.New(t) are counted; assert.New(t) itself is no longer an assertion. A Java @ValueSource naming a constant is one case. A proptest! closure in a same-file helper counts into the tests that call it. MSTest Assert.ThrowsExactly<T> replaced by Assert.Throws<T> is reported as a loss of exactness, and ThrowsExactly and ThrowsException count as equality-strength assertions. pytest.warns is read as an expected warning, so a dropped match or a wider class is reported. .each under a runner name imported from a project module is a case source that is not counted, with the non-literal note. stricter Carry allow-assertion-drop: <test name> <reason> for a change made on purpose.
v0.19.0 assertion-reduction (case sources no longer misread) A Go case table or row type in another file of the same package is resolved when the test’s file is in the change, so a table moved to another file is not a lost case list. .each and parametrize are case sources only on the runner’s or pytest’s own names; a project function of that name declared in the file is not read. looser None.
v0.19.0 test-floor (tests-moved-out-of-default-run) New finding: a change that adds a rule over tests the base side’s default run collected, which still exist and are no longer collected, is reported once per rule on the file that holds it. Rules read: a Cargo workspace exclude, a Go build constraint, a nested go.mod, a go.work use list that leaves a module out, pytest testpaths, python_files, norecursedirs and a literal collect_ignore, Jest roots, testPathIgnorePatterns, testMatch and testRegex, Vitest root, include and exclude, Deno exclude and test.include, and a .gitattributes vendored or generated attribute. Before, the first three left the count as it was with a note only, and the others were seen only when the count fell below the floor. When a count finding is reported in the same run for the same files, the rule is a note beside it. stricter Take the rule back, or carry allow-test-shrink: <file holding the rule> <reason>; a min_tests subject does not lift it.
v0.19.0 test-floor (static count, collection rules checked against the tools) No longer counted: a file importing node:test where no root package.json script runs node --test and [tests] paths does not name it; a file whose vendored or generated attribute is set through a top-level [attr] macro; a file below node_modules under any Jest configuration; a file a Vitest exclude entry names by directory; a file under dist, cypress or .cache with Vitest 1 to 3 pinned and no exclude; a file a root deno.json leaves out in a Deno-only repository. Now counted: a node:test file outside Jest’s patterns when a script runs node --test; a node_modules test under a Vitest exclude that replaces the default list. A Go module the go.work in force does not use is counted with a note. The Jest rules were run on 27 and 29 and the Vitest rules on 1 and 4; Jest 30, Vitest 2 and 3 and Bun are from documentation. stricter Name a suite the model leaves out under [tests] paths.
v0.19.0 Ruby and Python packs (a source past what the grammar’s scanner can store) A Ruby source with a here-document word longer than 255 bytes, and a Python source with more than 200 distinct indentation prefixes, is refused before it reaches the parser, as could not parse `<path>` with the limit named; the run exits 2 where a gate needs the file’s facts. Before, the Ruby case aborted a build with assertions and, in a release build, let the here-document take the rest of the file so that code after it was not read; the Python case aborted or wrote past the scanner’s buffer (#636). The Python limit also guards the reading of setup.py and conftest.py. The largest prefix count found in real Python sources is 59. stricter None.
v0.19.0 provenance-tags (what a measured tag cites) Three checks, each behind a new key that is off by default. verify_measured_commit: a (measured: <host>, <commit>) tag on an added line that names a placeholder or lacks the form is provenance-tags/placeholder-provenance-tag, and one whose commit is not 7 to 40 hex digits resolving to one commit object in the local object database is provenance-tags/unresolvable-measured-commit. record_paths with record_commit_key: an added or changed JSON or JSONL record whose commit key is missing, not a full id, or names no commit is provenance-tags/unresolvable-record-commit. verify_cited_figures with figure_tolerance_pct: a tagged figure that agrees with no value of the tracked artifact its paragraph cites is provenance-tags/figure-disagrees-with-artifact. An id absent from a shallow clone, an abbreviation two objects share, and a record file or artifact that does not parse stop the run with exit 2. Switching a check off, removing a record_paths glob, changing record_commit_key or raising the tolerance is a config-integrity weakening. A figure derived from stored values has no marker yet: the artifact has to store it. additive None; nothing changes with the keys off. To adopt, fetch full history in CI so cited commits resolve.
v0.19.0 config-integrity (msrv pinned_version; test-floor test_command, test_report, head_report) A change that lowers pinned_version, compared as a version (1.10 to 1.9 is lower), or removes it, is reported as gate-weakened; raising or adding it is not, and a value that is not dotted numbers cannot be ordered, so any change of it is reported. Adding test_command, test_report or head_report where the base had none is reported as a change of evidence, since the count is then read from a basis the base did not use; base_report added is not. doctor and the docs name the directive where they recommend adopting a test report. stricter allow-gate-weakening: msrv <reason> or allow-gate-weakening: test-floor <reason> on the change that makes it. A repository’s first discipline.toml needs none.
v0.19.0 Configured patterns of every gate; sanitizers (canary) Every regular expression of an enabled gate is compiled before any gate runs; one that does not compile is exit 2, reason configuration, naming the gate and the key, where before it was reason gate and only when the gate reached it, so a gate that returned early passed. canary = true with a sanitizer other than thread is the same configuration error: the one canary expects a thread sanitizer diagnostic. stricter Repair the pattern (under --policy-from base the repairing change is accepted, as for globs). Set sanitizer = "thread" or canary = false.
v0.19.0 ci-integrity (a workflow side that does not parse); baseline and read-error messages A workflow whose base side does not parse is no longer a place a removed job can be said to have moved to, and a note names a workflow side, or a deleted GitLab pipeline’s base side, that could not be parsed. A baseline file outside the repository is named by its file name only, and config-integrity does not compare it, with a note; before, an absolute path outside the repository stopped the gate with exit 2. A check that fails on several reads lists up to five and counts the rest. An executed key supplied through --config-override needs DISCIPLINE_ALLOW_COMMAND_CHANGE; that is unchanged and now documented. stricter None.
v0.19.0 merged-pr-body, audit --forge, replay (what a forge’s answer about a commit means) Only an answer that positively says no pull request merged is read as a direct push: on GitHub, HTTP 200 with a list in which every entry says whether it merged and none did. A 404 from GitHub’s lookup is a failed lookup; before, it read as a direct push. On every forge an answer that does not say whether a pull request merged, or a commit answer that does not name the commit, is a failed lookup too. A failed lookup is a cannot resolve note under directives.degrade_offline = true, exit 2 under false, and could_not_check in replay and audit --forge. GitHub’s 422 for a commit it does not have is also recognised by its fields when its message is worded another way. The request paths were exercised against the test forge only. stricter Give the run a token that can see the repository’s pull requests.
v0.19.0 Pre-tool hook, session-start note, agent-prompt report, MCP server, discipline lease (quoted text) Every text quoted from the repository, a payload or another session (a path, a branch, a command, a worktree, an agent and session id, an error) is one code span on one line of at most 512 characters, or one fenced block of at most 4000, with control characters replaced and a [cut: N more characters not shown] note outside the quotation. The Location: line of the agent-prompt report is a code span. A suggested lease take --branch command shows <branch> for a name that is not letters, digits and / . _ -. Verdicts, exit codes and JSON shapes are unchanged. stricter A consumer matching refusal text, lease messages or the Location: line: the quoted parts are now in backticks.
v0.19.0 Job summary, pull request comment (quoted text) In quoted text *, _, ~ and [ are escaped, and a word a renderer would link by itself is written as a code span, so quoted text cannot render as emphasis or as a live link; the report’s own documentation links are unchanged. The terminal report is unchanged. stricter A consumer matching the raw Markdown of these two outputs.
v0.19.0 error-swallowing, stub-bodies (a new file in test scope by its name only) A swallowed error or a stub in a file the change adds under a test-looking name or directory (TestDataBuilder.cs, app/test_runner.py, tests/support/helpers.py) is reported when the file holds no test that checks anything, with a note that names the file and says whether it holds no test or holds tests of which none checks anything; before, the whole file was test code. Unchanged: a file under [tests] paths; a file that is test code by its build tool’s rule (Go _test.go, Rust under tests/, benches/ or examples/, conftest.py, the JVM test source sets, Swift under Tests/); a file holding a test that checks something; and every file the base already had. A Go production file deleted and added again in the same directory as <stem>_test.go with no checking test is judged as production code; _test.go was exempt from that pairing. The note left when a directive lifts a reclassification says that path:line lifts one handler and not the move. stricter Declare real support directories under [tests] paths, or the helpers a test file asserts through under assert_helper_fns. A single handler takes allow-swallow: <path>:<line> <reason>.
v0.19.0 Every language pack, pre-tool hook (a parse that does not finish) Every parse has a budget of 256 steps plus one for every 4 bytes of source, a step being 100 parser operations, so the same text gives the same verdict on every machine. A source file whose parse reaches it is reported as could not parse `<path>`: the parser did not finish within its budget, and the run exits 2 where a gate needs the file’s facts; before, a 15-byte Rust input kept the run from returning (#640). test-budget, instruction-smuggling and pii note a file their pack could not parse, where they said nothing. The pre-tool hook refuses a shell command whose parse is cut. The error for an unparsable file now starts with the path, so notes that quote it changed text. stricter None; no directive lifts it, the file has to change.
v0.19.0 commit-provenance (required_trailers on a squash) In a forge’s message for a squash of several commits (subject ending (#N), one * subject paragraph per commit), required_trailers is judged for each entry: a trailer in another entry or in the gathered block no longer covers an entry without it. Each such entry is one commit-trailer-missing finding naming its position and subject, with its own fingerprint, so a squash with no required trailer anywhere gives one finding per entry and a baseline entry for it no longer matches. A message with several * paragraphs that are not placed as entries is judged whole, with a note. When one of the gate’s two rules is off, the outcome names it. stricter Sign off every commit before squashing, or carry allow-commit-provenance: <sha> <reason>, which lifts every entry of that commit.
v0.19.0 discipline audit (a configuration in history with a removed key) A discipline.toml in history carrying gates.commit-provenance.allow_author_review, a key that was accepted between two releases and never released, is compared without it; before, the change was recorded as config-unreadable and skipped, so a loosening beside the key was not reported. The record names the key in detail. A change that itself sets, drops or alters the key still gets one config-unreadable record. check still refuses the key. stricter None.
v0.19.0 ignored-tests (conditional skips in C#, Ruby, PHP, Swift, Scala, C, C++, Objective-C) A skip call under a condition is a conditional skip in these packs: a note, or ci_skip_severity when the condition holds in CI, and approved_predicates applies. Before, it was Existing Test Skipped or Ignored Test Added wherever it stood. PHPUnit #[Requires..] attributes are notes. In every pack a skip in the else branch of a condition is conditional under the negation (it was an error in Python, Go and Mocha), and @pytest.mark.xfail(<condition>) is judged by its condition. A Rust cfg is a CI condition only by its parsed predicate, so feature = "slow-ci" is no longer one by its text. looser None.
v0.19.0 ignored-tests (skip forms now read) Now read: NUnit Assume.That, xUnit Skip.If, Skip.IfNot, Assert.SkipWhen, Assert.SkipUnless and the SkipWhen / SkipUnless attribute members, test-unit omit_if and omit_unless, Swift Testing .enabled(if:) and .disabled(if:), ScalaTest and MUnit assume(..), Unity TEST_IGNORE and TEST_IGNORE_MESSAGE, JUnit assumeThat, assumeNotNull and an assumption under an if, and a pytest mark reached through a module-level name of the same file (also in pytestmark). A JUnit @DisabledIf("m") or @EnabledIf("m") is judged by what the same-class method reads, so one that reads a CI variable is a CI skip. #[cfg(not(feature = "ci"))] on a Rust test is an unconditional skip. The forms each pack reads are listed in docs/GATES.md. stricter Carry allow-ignore: <test name> <reason>, add the condition to approved_predicates, or remove the skip.
v0.19.0 assertion-reduction (assertion-failure-caught), vacuous-tests (Go recover) require.*, assert.*, t.Fatal and t.Error after a recovering defer are no longer reported as caught, since they do not panic, and a new test with such a defer and real assertions is not a vacuous test; a require.* inside the deferred function is not reported. A panic(..)-based check after a deferred recover() that does not fail the test is reported, and err.Error() or fmt.Errorf(..) in the deferred function no longer reads as failing it. looser Directive lines written for the old findings can go. For a panicking check, fail the test in the deferred function.
v0.19.0 assertion-reduction (assertion-failure-caught, more forms read) Now reported: a Python except <ProjectType>: or contextlib.suppress(<ProjectType>) around an assertion when the handler only passes, continues or logs and the type is not known to be unrelated to AssertionError; an assertion in a synchronous callback (the functions listed in docs/GATES.md) inside a swallowing try; a JavaScript or TypeScript .catch(handler) naming a same-file function that swallows; a finally that returns (Java, Kotlin, JavaScript, TypeScript, Python); a Rust catch_unwind result branched on only where it succeeded; an NUnit handler calling Assert.Pass, Assert.Ignore or Assert.Inconclusive; a configured assert_helper_fns helper inside a swallowing handler. stricter Re-raise or fail in the handler, derive a project exception from a standard class in the same file, or carry allow-assertion-drop: <test name> <reason>.
v0.19.0 vacuous-tests, test-floor (Go suite entry points, receiver calls) A testify suite.Run(t, ..) entry function is no longer read as a subtest with no assertion, so a new suite file is not reported as a vacuous test; the static test count is one lower per entry point, on both sides alike. Receiver calls are followed into C++ member functions defined in a class body, through Rust macro arguments, and for Scala calls without parentheses, so a test that asserts only through them is not a vacuous test. looser None.
v0.19.0 Baseline fingerprints (repeated lines, pull request body, manifest-sync) A finding on a line whose text repeats higher in its file carries its occurrence in its fingerprint, so a baseline entry for one handler no longer accepts an identical one added below it. The first or only occurrence keeps the fingerprint it had, a file holding N identical entries still accepts N occurrences, and the baseline version is unchanged. A finding on the pull request body is fingerprinted by the line it is on, and an instruction-smuggling finding on the title or body by a hash of the text; before, one entry accepted the kind on every later pull request. Two manifest-sync rules naming one manifest give two findings, the later one anchored on its patterns; before, a debug build stopped. Not closed: an identical line inserted above a baselined one takes its fingerprint. stricter A baseline entry for a pull request body finding written by an earlier release no longer matches: remove it, or record it again.
v0.19.0 instruction-smuggling, shell-secrets (directive subject with a line) A subject written as path:line lifts the finding on that line only; before, it lifted every finding of the gate in the file, and for shell-secrets the line was never consulted. A path:line subject no longer lifts the line-less agent-instructions-changed finding. A line with no finding is listed as unused. stricter Name the bare path to lift the file.
v0.19.0 bench-regression (allow-regression under require_sourced_override) Every allow-regression line is read for the arms it names; before, only the first was. An arm approved by a second line is no longer reported, a void line is reported with the arms it names, and the paired-ratio check reads a later line when an earlier one is void. looser None.
v0.19.0 Every language pack (a NUL byte in a source file), Swift pack (file ending in a compiler directive) A NUL byte ends any token in the grammars’ lexers, so a declaration after one that a file already held inside a comment or a string could be dropped from what the gates read: an empty handler added after such a byte in a Kotlin file passed with a warning. Every pack now parses a copy in which a NUL byte is a control byte of the same width, so declarations after it are read and findings on them are reported; a file whose only fault was a NUL inside a comment or a string no longer gives Source File Parsed With Errors, and one outside both still does. A newly added NUL byte is reported as before. A Swift file is parsed with a line break at its end when it has none, so the grammar’s scanner no longer reads past a directive string at end of file (#621); no finding moves for a well-formed file. stricter None.
v0.19.0 msrv, miri, sanitizers (untrusted-command-modification) A change cannot get text it supplies executed by these gates. Before anything runs, each executed key (msrv: command; miri: args; sanitizers: sanitizer, canary) is compared with the merge base copy. A key the change adds, alters or removes, or sets where the base has no configuration or one that does not load, is refused with <gate>/untrusted-command-modification and nothing runs; a CI run with no base ref is refused the same way. A command supplied through --config-override counts as the change’s. A miri args item and the sanitizers sanitizer are passed as exactly one argument each and held to one shape (args item: ASCII letters, digits and _ . / : = , @ + -; sanitizer: a lower-case letter, then lower-case letters, digits and -); another shape is exit 2, reason configuration. stricter Land the executed keys on the base branch first, or set DISCIPLINE_ALLOW_COMMAND_CHANGE on the runner for the change that introduces them (DISCIPLINE_COMMAND does not authorise it). Write a miri args item that held two arguments ("--package core") as two items. A sanitizer list with commas is no longer accepted.
v0.19.0 assertion-reduction (expected exception, tables and same-file bases) The standard exception tables are checked against the runtimes and hold the classes they lacked (63 Java classes, Python PythonFinalizationError and ExceptionGroup under Exception, three PHP classes, SuppressedError), with new Ruby and C++ tables; the .NET table is read, not generated. An expected exception moved from a project class to a base class declared in the same file is reported, in Python, Java, C#, JavaScript, TypeScript, PHP, Kotlin, Ruby and C++, through several declarations and on into the standard classes; before, that relation was unknown and the move passed. A whole-message matcher replaced by a contained one is reported. stricter A widening made on purpose carries allow-assertion-drop: <test name> <reason>.
v0.19.0 assertion-reduction, vacuous-tests (expected-failure forms now read) A widened, loosened or dropped expectation is reported in forms that were not read: pytest raises imported bare, assertWarns and assertWarnsRegex, AssertJ chains, Node assert.throws and rejects, Chai expect(..).to.throw and assert.throws, NUnit Throws constraints, WithMessage after an awaited ThrowAsync, PHPUnit expectExceptionMessageMatches and expectExceptionObject, Kotlin assertThrows<T>, assertFailsWith and Kotest shouldThrow, RSpec raise_error, Minitest assert_raises, and googletest EXPECT_THROW. A new test whose only check is one of these forms is no longer a vacuous test. stricter As above. Forms still not read are listed in docs/GATES.md.
v0.19.0 assertion-reduction (expected exception, no longer reported) A class written under a qualifier that is not the standard library’s (errors.TimeoutError, my.pkg.IOException) is a project class with unknown parents, so its move to a standard class is not reported; before, it was read as the standard class of that name. A class the file declares under a standard name is judged by its declaration. A dropped expected exception is not reported when the same test now asserts, by equality with a literal, the result of the one call the site guarded (Python, JavaScript, TypeScript). The dropped-expectation finding names the class or message and the directive that lifts it. looser None.
v0.19.0 Pre-tool hook (shell commands with non-ASCII characters) The hook parses a shell command from an ASCII copy and judges each word by the command’s own text, so the bash grammar is never given a character above 127 (on glibc builds it read out of bounds after { or {1.., #618). A here-document whose delimiter has a non-ASCII character is refused on every platform; where char is unsigned (aarch64 Linux) a delimiter in U+0080 to U+00FF was accepted before. On Linux builds, whose C library classifies non-ASCII characters differently from macOS, a non-ASCII character in a variable-name or assignment position is now read as part of a plain word, as on macOS; the effect on Linux verdicts was not measured. stricter Use an ASCII here-document delimiter.
v0.19.0 error-swallowing (constant_fallback_paths) New key, empty by default: in the files it names (a benchmark or evaluation harness), a new error handler that assigns or returns a non-zero numeric literal in place of the result is reported as error-swallowing/error-replaced-by-constant, at warning at most. A zero in any spelling is a default and is not reported; a sentinel such as -1 is. Go and Rust are not covered. Removing an entry from the key is a config-integrity weakening. additive None; nothing is reported until the key is set.
v0.19.0 error-swallowing (allow-swallow with a line) A subject written as path:line, or file-name:line, lifts the finding on that line only; before, it lifted every finding of the gate in the file. A bare path, a file name or a directory prefix lifts the file as before. A directive whose line holds no finding is listed as unused. stricter Name the path alone to cover the file, or add one line per handler.
v0.19.0 command, test-floor (runner switches, bounds, runs with no base) DISCIPLINE_COMMAND makes only the table command it supplies moot, and DISCIPLINE_COMMAND_<NAME> only that entry’s command; a changed preset, canary_command, entry or test_command is refused unless DISCIPLINE_ALLOW_COMMAND_CHANGE is set. Before, setting DISCIPLINE_COMMAND authorised every such change. A CI run with no base (--staged before the first commit) runs no configured command without that switch. Command output cut at the 25 MiB capture limit stops the run with exit 2 when a check reads the output (forbid_output, zero_items_pattern, count_pattern, a base-tests report); before, the check read the cut output as whole. The snapshot is read before any command runs, from the head side; a symbolic link or a file over 25 MiB stops the run. A table-level snapshot or snapshot_ignore with no table command or preset is a configuration error. test_command runs through the bounded runner: it is stopped after 600 seconds and a listing past 25 MiB is refused. A note says when the change enabled a command table the base had disabled. stricter A runner that relied on DISCIPLINE_COMMAND to accept other changes also sets DISCIPLINE_ALLOW_COMMAND_CHANGE for that run. Make a checked command print a summary. Commit the snapshot as a regular file and do not regenerate it inside the checked command. Move a table-level snapshot to the entry it is for.
v0.19.0 config-integrity (keys with a default), init, doctor A key added where it replaces a default is judged as changed from that default: pattern in [gates.issue-link] over the built-in pattern, and command or canary_command over the one a command preset supplies; before, an added key had no base value and was not judged. With DISCIPLINE_ALLOW_COMMAND_CHANGE set, a guard key written beside a preset the change first names is compared with that preset’s default. Removing a key whose value equals its built-in or preset default is no longer reported. init gives a Gradle project command = "gradle pitest" beside preset = "pit", calls only cargo-mutants diff-scoped, and says its lines are an example when no test runner is detected. stricter A pull request that adopts a custom issue pattern, or replaces a preset’s command or guard, carries allow-gate-weakening: issue-link <reason> or allow-gate-weakening: command <reason>.
v0.19.0 assertion-reduction (helpers, Go suites), vacuous-tests, ignored-tests, test-floor, now reported A Go testify suite method (func (s *Suite) TestX() on a type that embeds suite.Suite) is a test named Suite.TestX, and assertions made on the suite (s.Equal, s.Require().NoError, s.Assert().True) count; before, it was recorded as a helper holding nothing, so one that lost assertions, was added empty or gained s.T().Skip() was not reported. A lifecycle method (SetupTest) that loses assertions is a weakened helper. The static count rises by one per suite test method on both sides. Assertions replaced by a call to a same-file helper are excused only up to the checks the helper holds per call, unless the helper checks in a loop or is called in one; before, any new call to a same-file helper that fails excused the whole drop. Equality assertions replaced by a helper whose failure exit sits under a condition that is not an equality comparison are a strength drop. A drop is no longer excused by a same-named helper of another changed file when the calling file has its own helper of that name. stricter A deliberate change takes allow-assertion-drop: <test> <reason> (Suite.TestX for a suite test). A floor constant derived from the static count of a repository with suite tests is re-derived.
v0.19.0 assertion-reduction (helpers), no longer reported A lossless move of assertions behind a call through a type or an object (Checks.check(r), s.check(t, r)), into a method of the same file or of a changed test-support file, in every language pack; a lossless move through two or three levels of same-file helpers, in the packs that followed one level; equality assertions rewritten in a same-file helper as a failure exit guarded by an equality comparison (if a != b { fail }). looser None.
v0.19.0 assertion-reduction (test-cases-reduced, property tests), vacuous-tests, now reported Cases dropped from a parametrized test are excused as moved only when the same row arrives in another test of the same file, compared by content (layout, comments and a trailing comma aside); before, the drop was excused whenever the file’s total stayed the same, so a drop beside an unrelated new parametrized test passed. The note says how many dropped cases were found moved. A literal case list that gains, or is replaced by, a spread or splat element (JavaScript / TypeScript, Python) is “no longer a literal list”. In Rust: removing #[test] from a function in proptest! is a removed test; a quickcheck property whose result becomes always true (true, x == x, TestResult::passed(), an if or match of only such branches, a name bound to one) is an assertion loss, in quickcheck! and #[quickcheck] alike, and a new property with only such a result is vacuous; a loosened bound, a changed expected value and a caught assertion are read inside a proptest! body and its closure form. A one-column test.each template is counted. stricter None. A deliberate change takes allow-case-drop: or allow-assertion-drop: with the test first.
v0.19.0 assertion-reduction (test-cases-reduced, property tests), vacuous-tests, no longer reported A #[quickcheck] property, or a test whose checks sit in a proptest!(\|..\| { .. }) closure, with real checks was reported vacuous; a quickcheck result bound to a name read as assertions reduced; Go slices or maps of a type whose name only contains the letters of case or test (Showcase, Contestant), and Rust attributes that only begin like #[case] or #[values], were read as case tables; a line break inside a test.each template value counted as a row. A function in proptest! without #[test] is no longer counted as a test. A list with a spread on both sides is not judged. looser None.
v0.19.0 test-floor (static count), no longer counted Left out of the count because no default run executes them, each with a note: a test file marked linguist-vendored or linguist-generated in a tracked .gitattributes; a Go test file behind //go:build ignore or a constraint no build satisfies; a Rust file under src/ that no crate root declares with mod, and the library files of a package with [lib] test = false beside a binary; with a pytest configuration, files under a norecursedirs directory (the default list included) and files a literal collect_ignore / collect_ignore_glob in conftest.py names; files outside a literal Vitest include or inside its exclude (vitest.config.*, or a test block in vite.config.*); files outside a Jest configuration named by --config, -c or --rootDir in a package.json script; and .mjs / .cjs / .mts / .cts test files under Jest’s default patterns before Jest 30. Taking tests out of a run through any of these is now a count drop. The count of a repository with such files falls on both sides. changed A floor set as a constant above the new count must be lowered to it. A file that should count is named in [tests] paths.
v0.19.0 test-floor (static count) and test naming, now counted or noted pytest python_functions and python_classes name the tests for every gate, so a repository that sets them sees its tests as pytest collects them; .pytest.ini is read; a [pytest] section in setup.cfg is no longer read (pytest rejects it), so files it excluded count. A vitest key in package.json is no longer read as configuration. bunfig.toml is a runner configuration, so JavaScript tests beside it count where they were left out. Counted with a note saying the default invocation does not run them: tests in a package a Cargo workspace excludes, in a nested Go module, and in a Go file behind a custom build tag. A change that puts existing tests behind one of these three leaves the count unchanged and shows only in the notes; the test-identity ratchet (test_report) is the control that reports it. changed Move a [pytest] section in setup.cfg to [tool:pytest].
v0.19.0 directives (every kind that takes a subject) A directive names its subject at the start of its text only: the finding’s subject written in full (several words or a colon allowed, optionally quoted), or the first token under the existing allowances (a path’s file name or directory prefix, an action without its version, the last segment of a Suite::name or Class#name). Everything after it is the reason. Before, a subject was found anywhere in the text as a word, so a directive written for one subject also lifted any other finding of that kind whose subject was a word of its reason (a test named adds was lifted by a reason containing “adds”), a name quoted later in the reason lifted too, and a list (a, b, a and b, a b) named every item. One line now names one subject. allow-regression reads its arm from the start of its text the same way; before, an arm name anywhere in the reason approved that arm. A gate that still reports a finding a directive only mentions adds a note saying which subject the directive was read with. stricter Put the subject first, one directive line per subject. For allow-unsafe, allow-msrv, allow-miri and allow-sanitizers, start with the documented keyword (allow-msrv: crate <reason>). A subject with spaces or a colon is written in full first.
v0.19.0 ignored-tests (skips that take a condition), no longer an error @pytest.mark.skipif, @unittest.skipIf / skipUnless, a pytestmark list, test.skipIf, it.skipIf and describe.skipIf are conditional skips judged by their condition, as the documentation already said: a condition that reads no CI variable (a platform, a version, a missing dependency) or that holds only outside CI is a note; before, every such decorator was an unconditional “Existing Test Skipped” error. A constant-false condition (skipif(False)) is no skip and is not reported. A Rust cfg on a test is judged by its parsed predicate, so feature = "ci_skip_list" is no longer read as a CI skip. In test-floor, a test under such a form counts as running. looser None.
v0.19.0 ignored-tests (skips that take a condition), now reported A skip-if form whose condition holds in CI is a CI-conditional skip at ci_skip_severity, and approved_predicates applies to it. A constant-true condition is an unconditional skip. Now read: test.runIf / it.runIf / describe.runIf (a note unless the test is skipped in CI), Mocha this.skip() as a statement of a test (an error) or under a condition (a note unless it holds in CI), and in Java and Kotlin the JUnit conditional annotations (@DisabledIfEnvironmentVariable, @EnabledIfEnvironmentVariable, the system-property, OS and JRE forms, @DisabledIf) and assumeTrue / assumeFalse / assumingThat; before, none of these gave a finding. A CI variable added to a skip that was already CI-conditional is reported unless that variable is approved. A skip under a name written exactly as a CI variable and bound to a call the file does not define (CI = helpers.in_ci()) is CI-conditional, where it was a note. A Rust #[cfg(not(any(miri, ci)))] or #[cfg(not(github_actions))] on a test is an unconditional skip, where the text rule missed it. stricter A reviewed CI-conditional skip takes an approved_predicates entry for its variable.
v0.19.0 test scope (every gate that reads it: error-swallowing, stub-bodies, assertion-reduction, vacuous-tests, ignored-tests, test-floor, test-budget, command) A file name counts as a test name only at a word boundary: the start of the name, a separator, or the capital that starts a word. Latest.java, Contest.php, Contests.cs, repotest.java and Kotlin names ending in the letters IT (AUDIT.kt) are production code; before, the bare letters matched. The Test prefix (Java, C#) matches only when a word ends after Test, Tests or Testing: Testimonial.java, Tester.java, Testable.cs are production code, and un-annotated test*() methods in them stop counting as tests. RepoTest.java, TestRepo.java, RepoTests.cs, test_repo.py, repo_test.go, repo.test.ts are unchanged. A file added under a test name with no test in it, in a change that also deletes a production file of the same stem and language (loader.py deleted, test_loader.py added), is judged as production code by error-swallowing and stub-bodies, with a note naming both paths; a Go _test.go file is exempt, and a new test-named file with nothing deleted beside it is unchanged. stricter List a real test or support file whose name no longer matches under [tests] paths.
v0.19.0 test scope (file names and directories), error-swallowing (test-path-reclassification) .test. and .spec. are test names before .mjs, .cjs, .mts, .cts, .jsx and .tsx as well as .js and .ts; a root-level __tests__/, androidTest/ (Kotlin) and it/ (Scala) are test directories, where before only a nested one was. Such files are test code for every gate that reads test scope. A file that was test code on the base side through [tests] paths and is renamed into a conventional test path no longer gets test-path-reclassification. When allow-swallow: <path> lifts a reclassification, a gate note names it, both paths and the number of handler findings the same line covers. looser None.
v0.19.0 reports (text quoted from outside the binary) Text that comes from a forge, a pull request, a commit, a file name, a directive or a tool’s output is neutralised once per output format. Terminal text (check, audit, replay, doctor, error messages): every control character except tab is replaced with U+FFFD and a quoted value stays on one line; a finding’s message or remediation that spans several lines is indented under its finding. Job summary and pull request comment: outside code spans, &, <, >, @, link brackets, unmatched backticks and control characters are escaped, in running text as well as table cells, and a location or directive containing a backtick gets a longer fence; text inside a code span is no longer entity-escaped, so `Vec<u8>` renders as written. Before, a newline in a forge’s refusal message, an author login or a file name could add a line that read as a report line (Status: PASS, a gate row), and markup, a mention, a link or a terminal control sequence passed through. JSON, SARIF and the GitLab report carry the original text through their serialisers, as before; JUnit XML also drops U+FFFE and U+FFFF. A forge’s refusal reason is one line everywhere, including the JSON notes (control characters become spaces). changed None. A script that matched a multi-line finding message at the start of a line in the text report must allow for the indent; the JSON report is the stable form.
v0.19.0 merged-pr-body directive source, audit --forge, replay (Gitea, Forgejo, GitLab) A 404 for the pull request of a commit is settled by one more read-only request for the commit itself: found means no merged pull request (a direct push, as before), not found means the forge does not have the commit (a note on a push check; could_not_check in replay and audit --forge), and any other answer is a failed lookup under directives.degrade_offline. Before, a commit the forge did not have was read as a direct push. replay’s detail after a failed lookup carries a token hint only for a refusal or a rate limit, names the variables read for that forge and says whether a token was sent; before, it suggested a token after every failure. stricter None.
v0.19.0 vacuous-tests, assertion-reduction (counters over a test body), no longer reported Text inside a string literal or a comment is not code: is_err(), is_ok() or time.sleep(1) in a fixture string no longer makes a test “asserts only trivial properties” or adds a sleep finding (Python, Rust, JavaScript, Java, Kotlin, C#, Swift, Scala, Ruby, C++). For a Rust assertion macro only its condition arguments are judged, so a predicate that appears only in the failure message does not count. A test that asserts through a method of a struct or class defined in its file (v.done()) is no longer “contains no assertion” in vacuous-tests; assertion-reduction counts as before. A tautology inside a swallowing handler is subtracted from a test’s assertions once, not twice, so a test that also has a real assertion is no longer reported vacuous. looser None.
v0.19.0 vacuous-tests (swallowed assertions) Swallowed assertions are counted by assertion, not by line: a test whose only assertions are two swallowed ones on one line is reported as vacuous; before, the second was not subtracted and the test passed. stricter None.
v0.19.0 command (forbid_output, zero_items_pattern, canary_expected_diagnostic) Each is a regular expression only. A value that does not compile stops the run with exit 2 (could_not_check.reason configuration), naming the key, checked before any gate runs for the table and for every [[commands]] entry, and for the change’s copy under base policy. Before, such a value was matched as literal text, so a guard meant as a pattern never fired and nothing said so. Every preset default compiles and is unchanged. stricter Escape regular-expression metacharacters meant literally: forbid_output = ['(FAILED'] becomes ['\(FAILED'].
v0.19.0 ci-integrity (job count, action files), test-budget (fuzz_targets), baseline messages documented_job_count_path without documented_job_count_pattern, or the reverse, stops the run with exit 2; before, the check silently did not run. When no examined workflow has the rollup_job, a note says the count was not compared. The job-count document is read from the head side, so --staged reads the index. An existing action.yml or action.yaml whose head side no longer parses is ci-integrity/pipeline-file-unreadable and is not counted as examined (a new one stays a note). [gates.test-budget] fuzz_targets is read: its globs add fuzz crates outside the root fuzz/ directory, and one that does not compile stops the run; before, the key was never read. Results for the default configuration are unchanged. A baseline file inside the repository is named by its relative path in could-not-check messages. stricter Set both job-count keys or neither. Fix the YAML. Fix or remove a fuzz_targets glob that does not compile.
v0.19.0 --policy-from base (a base configuration that does not pass the up-front checks) A base configuration whose only fault is a glob or pattern that does not compile, or a half-set job-count pair, no longer stops the change that repairs it. For that run each repaired key is read from the change and everything else from the base copy; a note on the key’s gate, on config-integrity and on stderr names the key, and config-integrity judges the edit as it judges any edit of that key (replacing an entry of a restricting list is still a weakening that needs allow-gate-weakening:). A change that leaves the value as it is, replaces it with another that does not compile, or switches the gate off still stops with exit 2. looser None.
v0.19.0 baselines (version-2 fingerprints of findings with no line) A finding with no line is fingerprinted by its code and path, which for 24 finding kinds did not identify what the finding is about, so one baseline entry matched any occurrence of that kind on that path in any later change, and two occurrences in one run changed each other’s fingerprint (a debug build stopped, exit 101). Each now carries an anchor naming its subject. Affected: ci-integrity (a removed verification job), ci-skip-set (a job the workflow does not define), instruction-smuggling (invisible characters or instruction-like text in the title, the body or a commit message), dependency-delta (a lockfile entry from a new source, or with its integrity hash removed), bench-regression (a regressed, removed or new arm, a stale exempt_arms entry, every paired-ratio cell finding), command (every finding located at the configuration file, and a failed base test), provenance-tags (a superseded figure in a JSON file), test-floor (a test dropped from the suite), test-budget (a fuzz target removed from a harness list), build-hooks (an added or changed lifecycle script), version-lockstep (a version mismatch), toolchain-config (a configuration that inherits through a new key). Findings of every other kind keep their fingerprints, and version-1 baselines are unchanged. stricter Version-2 baseline entries written before this change for these findings no longer match: they are reported as stale, and the findings they covered are reported again. Review them and regenerate with discipline baseline --write.
v0.19.0 hook install (--upgrade, modes) --upgrade without --observe leaves an observe-mode OpenCode plugin in observe mode, as it does a JSON hook file; before, it rewrote the plugin as enforcing. The “upgraded” and “already runs” lines name the mode (“in observe mode”, “in enforcing mode”). A plain install on a file the same release wrote in the other mode says which mode the file is in, which was asked for and what to run; before, it called the file an earlier release’s. A plugin with the generated header whose mode cannot be read (its marker line and its command flags disagree) is refused with exit 1 and left unchanged unless --observe is given; this includes an observe-mode plugin written before the marker line existed (v0.17.2 and earlier). changed Upgrade an observe-mode plugin from v0.17.2 or earlier with hook install --agent opencode --upgrade --observe. To switch an observe installation to enforcing, delete the generated file and install again without --observe.
v0.19.0 assertion-reduction (expected-exception-widened), vacuous-tests, now reported An expected exception widened in a test whose with block, lambda or callback was also edited, whose as binding was renamed, or that has several expected-exception sites: each base site needs a head site of its own that accepts no more (before, sites were compared only when their text, block included, occurred once on each side). An expected exception that is no longer checked while the assertion count holds. A move to a parent class in the Python, Java, .NET or PHP standard hierarchy (KeyError to LookupError, FileNotFoundException to IOException), and a class added to a Python tuple; before, only five general names were ordered. A matcher replaced by one that accepts every message ("", .*) or shortened to a part of itself. assertThrowsExactly to assertThrows, ThrowExactly<T> to Throw<T>, and .not.toThrow() gaining a type or message. Now read: #[should_panic = ".."], proptest! cases, the pytest.raises call form and expected_exception=, @Test(expected = .., other = ..), toThrow with a member class, template or constant, Assert.Catch, qualified assertion classes, typeof(T), FluentAssertions Throw, and the PHPUnit class, message and code. One class replaced by another with no known relation is still not reported. stricter None. A deliberate change takes allow-assertion-drop: <test> <reason>.
v0.19.0 assertion-reduction (expected-exception-widened), vacuous-tests, no longer reported .not.toThrow(T) to .not.toThrow(); a matcher given up for a narrower class; pytest.raises(E) rewritten as pytest.raises(expected_exception=E); and, in vacuous-tests, a C# test whose only check is act.Should().Throw<T>(). looser None.
v0.19.0 config-integrity ([gates.command] keys over a preset) A zero_items_pattern, canary_expected_diagnostic or snapshot that a change adds to a command table whose base side names a preset supplying that key is reported as the key changed from the preset’s value; before, an added key had no base value to compare and was not judged, so a change could replace the preset’s guard with its own. A snapshot added to a [[gates.command.commands]] entry over its preset’s snapshot counts as one lost entry. Writing the preset’s own value down is not reported, and a key added where no preset supplies one adds a check, as before. stricter None. A deliberate replacement takes allow-gate-weakening: command <reason>.
v0.19.0 ignored-tests (CI-conditional skips), now reported A skip is CI-conditional, reported at ci_skip_severity, when the CI read reaches it through a local variable, a variable of an enclosing describe callback or test, a module-level constant, or a helper function of the same file (Rust, Python, JavaScript / TypeScript, Go); the finding names the variable read, and approved_predicates matches that variable. Also reported: an early return under such a condition, under an outer if, or in a second if (before, only the first conditional skip of a test was read); test.runIf(..) with a condition true only outside CI; Mocha this.skip() under a CI condition; a skip changed from holding outside CI to holding in CI. Environment reads of GITHUB_RUN_ID, BUILD_ID, DRONE, WOODPECKER and any CI_* name count as CI variables. A condition that involves a CI variable and cannot be decided is reported. A skip in the else branch of an outside-CI condition is “Test Conditionally Skipped” instead of “Existing Test Skipped”. A name imported from another file is not followed. stricter None. A reviewed skip takes an approved_predicates entry for the variable.
v0.19.0 ignored-tests (CI-conditional skips), no longer reported A skip that holds only outside CI, so the test still runs in CI, is a note: if not os.environ.get("CI"), os.Getenv("CI") == "", !process.env.CI, .is_err() on the read, #[cfg_attr(not(ci), ignore)], and the else branch of a CI if. A variable spelled like a CI variable that the file binds to something with no CI read is a note; a name the file does not bind is still read as CI. looser None.
v0.19.0 assertion-reduction (assertion-failure-caught), no longer reported A handler that cannot swallow the assertion failure, or does not: a Java or Kotlin catch of Exception, RuntimeException or another standard class an AssertionError is not an instance of; a C# catch of a .NET class an assertion failure is not an instance of, and a try whose only mention of Assert. is inside a string; a broad empty handler after one that always catches the failure and rethrows it; a Rust catch_unwind result that is unwrapped, returned, passed to resume_unwind, matched with a failing branch, or named in an asserting macro; a JavaScript catch that calls done(err) or reject(..); Python soft assertions collected in a handler and asserted after the try, and a retry loop that fails after its last attempt; Go assertions after a deferred recover() whose function calls t.Fail(), require.Fail or assert.Fail. looser None.
v0.19.0 assertion-reduction (assertion-failure-caught), now reported Kotlin: an assertion in a try whose catch swallows an assertion failure, and in a runCatching { } whose result is unused (documented before, not implemented). Java and C#: a swallowing catch of a type that is not a known standard class (before, only when the clause text contained Error, Exception or Throwable), a swallowed assertion in a try-with-resources, and a C# handler that only prints text mentioning Assert.Fail. Rust: a catch_unwind whose closure is wrapped in AssertUnwindSafe, and one whose only check was a comment, a check on another binding, or a bare is_err() statement. JavaScript / TypeScript: assertions in a promise chain ending in .catch(() => {}). Python: assertions under a multi-line except (..) tuple, except*, or contextlib.suppress(..) naming AssertionError, Exception or BaseException. Each test a change adds counts in the gate’s examined total. stricter None. A deliberate handler takes allow-assertion-drop: <test> <reason>.
v0.19.0 assertion-reduction (helpers), now reported A test helper that loses assertions is reported (test-helper-weakened) when its file also holds tests (a Go _test.go with other tests, a conftest.py whose fixture is named test_*), unless a test of that file already carries the drop; when it is a Go method with a receiver, a JavaScript / TypeScript class method or a PHP trait method; and when it stops calling a helper that holds the checks (a helper counts the same-file helpers it calls). A test’s own assertion drop beside a weakened helper in another file is reported: allow-assertion-drop: <helper> lifts the helper’s finding only, and a count drop no longer hides a strength drop. A helper deleted from one file is reported in that file. Test-utility file names need a separator or the start of the name before them, so latest-utils.ts and dbtestutil.go are production code for every gate that reads test scope (db_testutil.go and render-test-utils.ts still match). stricter None. A deliberate change takes allow-assertion-drop: <test or helper> <reason>; a utility file with a run-together name is listed in [tests] paths.
v0.19.0 assertion-reduction (helpers), no longer reported Checks extracted into a sub-helper with none lost; a helper renamed with nothing else changed; assertions moved into a helper the same change adds, or into a helper of a changed file that also holds tests, when the helper holds as many; a comment-only edit beside two helpers of one name (two impl blocks, overloads); Go fatal assertions moved into a helper that also aborts; and functions under examples/ and benches/, which are no longer read as assertion helpers. looser None.
v0.19.0 commit-provenance, audit (trailer reading) The trailers of every entry of a forge’s multi-commit squash message are read (the Key: value run directly before each * subject entry or the dashed rule, by the same rule as the run that ends the message), and the commit is judged once on all of them: an agent marker in any squashed commit makes the squash agent-produced, and a review or a required trailer in any entry counts. Before, only the run that ends the message was read. An indented line continues the trailer above it, as in git, so an indented quote of trailers is body text and no longer satisfies required_trailers. A trailer block followed by a git cherry-pick -x line, and a message with CRLF line endings, are read. With no rule on (required_trailers empty, and require_agent_review false or agent_markers empty) the gate reports zero examined and a “not evaluated” note instead of counting as passed. changed None. A squash that now reads as agent-produced needs the review trailer the rule asks for.
v0.19.0 merged-pr-body directive source, audit --forge, replay An HTTP 422 from GitHub is read as “the forge does not have this commit” only when its message begins No commit found for SHA. Any other 422 is a failed lookup: a note under directives.degrade_offline = true, exit 2 (could_not_check.reason forge) under false, and a failed read in audit and replay, reported as “the forge answered HTTP 422 for commit … without saying the commit is missing” without repeating the response. Before, every 422 was read as a commit not on the forge. A note about the directive sources is always reported once for the run (directive_notes, a directives: line); before, one whose text contained removes, deletes or an allow-… name, an author login for example, was moved to one gate’s notes, where --suite could drop it. stricter None.
v0.19.0 configured patterns (ci-integrity documented_job_count_pattern, command count_pattern, bench-regression exempt_arms) Each is compiled before any gate runs, for the configuration in force and for the change’s copy, and a value that does not compile stops the run with exit 2 (could_not_check.reason configuration), naming the key. The two capture patterns must also have a capture group. Before, a documented_job_count_pattern with no group aborted the binary, and the others were ignored, so the check they configure did not run. A disabled gate’s patterns are not checked. stricter Correct the pattern; a capture pattern takes one group around the number, for example (\d+).
v0.19.0 read and parse failures (dependency-delta, ci-integrity, test-floor, every gate that reads a tracked file) Now stops the run with exit 2: a deny_file that exists and does not parse as TOML (before, an empty policy), and a tracked file under a directory that cannot be searched (before, read as absent). Now reported: an existing workflow whose head side no longer parses as YAML is ci-integrity/pipeline-file-unreadable (before, a note only; a new workflow that does not parse stays a note), and a floor constant the head side no longer defines is test-floor/floor-constant-decreased (before, only a lowered constant was). Now noted: a job-count document that is not text, that the pattern does not match, or whose captured text is not a number (“not compared”). The GitLab pipeline-file-unreadable message gives the line and column of the parse error and no longer quotes the parser’s text. A base file whose name is not UTF-8 is included in the base file list. stricter Fix the file or its permissions. A deliberately removed floor constant takes allow-test-shrink: <constant name> <reason>.
v0.19.0 test-floor (static count), no longer counted Left out of the count because the runner does not run them: Go test files below a vendor directory, in testdata, or in a file or directory whose name starts with . or _; a Cargo [[test]] target with test = false or harness = false, a test file no target names under autotests = false, the src/ files of a library-only package with [lib] test = false, and a .rs file under tests/ that no running test target declares as a module (fixtures, a disabled subdirectory); Rust tests behind #[cfg(false)], behind a module cfg separated by a comment, or behind an inner #![cfg(..)]; files a Jest testPathIgnorePatterns entry (default /node_modules/) or a negated testMatch pattern excludes, and a.test.helper.js by default; and a JavaScript or TypeScript file in a repository that tracks no JavaScript package manifest or runner configuration (counted since v0.18.0), with a note. Switching tests off through any of these is now a count drop. The count of a repository with such files falls on both sides. changed A floor set as a constant above the new count must be lowered to it. A suite run with no manifest (node --test, Deno, Bun) is named in [tests] paths to count.
v0.19.0 test-floor (static count), now counted Counted because the runner runs them: a Cargo [lib], [[bin]] or [[test]] path outside src/ and tests/, also in a workspace member; a Rust test behind a cfg that only contains not(test) (any(not(test), unix)) or behind a target-specific optional dependency; Jest files matched by a testRegex on the path from the root, under a relative roots entry resolved against rootDir, and test.js / spec.ts by default; pytest testpaths written ./tests or as a glob, with the configuration file chosen as pytest chooses it (pytest.ini, then pyproject.toml, tox.ini, setup.cfg). Collection is “unknown”, so every test file counts with a note, with Jest projects, a preset when the configuration sets neither testMatch nor testRegex, a (a\|b) group or an unanchored testMatch pattern, a Vitest exclude, and a nested package configuration; beside Cypress or Playwright only the files the Jest configuration leaves out are unknown. Removing such a file can now block. stricter None.
v0.19.0 assertion-reduction (test-cases-reduced), now reported A parametrized or table row the change turns into a comment is a dropped case in Python, JavaScript / TypeScript, Go and Java, and so is a row put behind # in a @CsvSource text block (Java, Kotlin); before, a commented row was still counted. Also counted now, so that removing them is reported: every table of a Go test (before, only the largest), a package-level Go table the test names, a ranged-over Go slice of a struct type declared in the same file, Rust #[test_case(..)] attributes and #[values] beside #[case], rows of a describe.each table, a test.each template cut to its header, several JUnit sources on one test (they add up), @EnumSource(names = ..), @NullSource, @EmptySource and @NullAndEmptySource, C# [DataRow] rows and a row gaining Skip or Ignore, and a Python class-level parametrize or pytestmark. In a proptest! or quickcheck! body an assertion made unreachable is a dropped assertion, and a body that does not parse is reported as source-parsed-with-errors. stricter None. A deliberate reduction takes allow-case-drop: <path> <reason>.
v0.19.0 assertion-reduction (test-cases-reduced), no longer reported A Go table moved between the test function and a package-level variable of the same file with the same rows (reported since v0.18.0); a shrinking Go set of empty structs (map[string]struct{}); a Rust #[values] list rewritten with the same number of values; a Java text block replaced by the array of the same rows, and a shortened nullValues. Counts quoted in a finding change where the old count was wrong (Java text blocks counted their delimiter lines, Rust values were counted by token). A @ValueSource that names a constant now reads “no longer a literal list”. looser None.
v0.19.0 error-swallowing, stub-bodies (renames into test scope) A production file stays production code through a rename that also changes its extension or its language (Repo.cc to RepoTest.cpp, Repo.java to RepoTest.kt, notes.txt to test_notes.py): the swallowed errors and stub bodies the change adds to it are reported, and error-swallowing reports test-path-reclassification for it. Before, such a rename was classified by its new path, so a test-looking name made the whole file test code. A file that was test code on the base side is classified by its new path as before. A rename of production code into a path only [tests] paths reads as test code now leaves a gate note, and each renamed file counts as one examined item. stricter None. A file that really became a test file takes allow-swallow: <path> <reason>.
v0.19.0 command (untrusted-command-modification), test-floor (test_command) A change cannot get a command it supplies executed. command: a table-level canary_command the change adds or alters now counts as a modified command definition, so the gate reports command/untrusted-command-modification and runs nothing; before, only command, preset and the entries were compared, and a canary added on the table (also over a preset default, or inherited by an entry) was executed. test-floor: a test_command the change adds or alters, including one in a configuration the base does not have, is not run; the gate reports the new finding test-floor/untrusted-test-command, takes no count and skips the floor comparison. Before, the command was executed and its output taken as the test count. No directive lifts either finding. An unchanged command, and a run under --policy-from base, behave as before. stricter Land the command on the base branch first, or set DISCIPLINE_ALLOW_COMMAND_CHANGE (or DISCIPLINE_COMMAND) in the runner environment for the change that introduces it.
v0.19.0 audit (config-unreadable) The detail of a config-unreadable record ends with the key the parser stopped on (unknown key and the key in backticks), when the failure is an unknown key; before, it held the first line of the parse error, which gives a line and a column and no name. Only the key’s name is added, never a value. The record, its signal and the exit code are unchanged: a discipline.toml in history that carries a removed key is still not compared, so its loosenings are still unknown. changed None.
v0.19.0 baselines, commit-provenance Each commit-provenance finding is anchored by its commit id, and commit-trailer-missing by the lower-cased trailer key too, so its version-2 fingerprint is the commit’s own. Before, the findings had no anchor: a lone finding of a code hashed its code only, so one baseline entry matched the lone finding of that code on any commit of any later change, and two findings of one code in a run fell back to hashing their messages, so a finding’s fingerprint changed when another commit was added to the range (a debug build stopped on the repeat, exit 101). A baseline entry now matches the commit it was written for and nothing else. stricter Version-2 baseline entries for commit-provenance written before this change no longer match and are reported as stale: remove them, or regenerate with discipline baseline --write; a commit that must stay as it is takes allow-commit-provenance: <sha> <reason>. Version-1 baselines and --migrate are unchanged.
v0.19.0 hook install --agent opencode (the generated plugin, OpenCode 2.x) On OpenCode 2.x a session takes its worktree’s lease. The plugin’s 2.x block passed event.subscribe a callback, which 2.x does not call (RUN with 2.0.22: no lease after a session); it now reads the event stream event.subscribe() returns and takes the lease once per session, on the first of session.created and session.execution.started that names it. The lease never blocks a session, in either mode. The 1.x block is unchanged. stricter discipline hook install --agent opencode --upgrade rewrites a generated plugin.
v0.19.0 hook install --agent opencode (OpenCode 2.x plugin block), hook run --agent opencode --event pre-tool On OpenCode 2.x the shell tool is named shell, and the generated plugin and the pre-tool parser looked for bash only: a 2.x shell command was never checked. Both now read shell as well, so a command that acts on another worktree is refused there (run live with OpenCode 2.0.22). The 2.x block also gives the check after an edit an empty stdin; before, under opencode run --standalone it waited on the server’s stdin and the edit did not return. The 1.x block is unchanged stricter Regenerate the plugin: discipline hook install --agent opencode --upgrade (with --observe to keep an observe-mode file in observe mode), and commit it with its allow-agent-instructions: line
v0.19.0 test-floor (identity ratchet & reports), doctor (test-report) A runtime test report without base_report or an explicit floor (min_tests or constant) is refused with exit 2, preventing mixed-basis comparison against base static count. Empty JUnit XML reports or reports with zero testcases fail closed with exit 2. A configured test_command executes to generate the test report before report resolution and identity ratcheting. Only passed test cases in reports are counted toward floors (skipped and failed cases do not count). An allow-test-shrink directive naming only a classname does not lift dropped test cases in that class. discipline doctor warns when test_report points to a build output directory (target/, build/, out/, dist/) without base_report. stricter Configure base_report or set min_tests when supplying a runtime test report; ensure test reports contain valid passed test cases.
v0.19.0 toolchain-config, command (base-tests), dependency-delta, ci-skip-set, test-floor Leftover gaps from the output-order and unread-inputs audit (#678): package.json is parsed strictly as JSON without JSONC comments or trailing commas, reporting toolchain-config-unreadable when malformed; base-tests ignores committed root *.xml files when searching for the report generated by a test command; a base lockfile that fails to parse emits a gate note instead of silently defaulting to empty entries; ci-skip-set::job_line anchors to direct child keys under jobs: handling single/double quotes and indentation, returning None for undefined jobs; and an unparseable pyproject.toml stops the pytest configuration search with collection left undetermined and a note explaining why. stricter Fix unparseable configuration files or lockfiles.
v0.19.0 guards (lift_or_push), archive-contents (glob_matches) Override and finding handling across 23 guard modules is consolidated via GateOutcome::lift_or_push with conditional anchor_last, removing repetitive match and if let Some(ov) boilerplate while strictly preserving finding and override semantics. In archive-contents, archive entry glob pattern matching is consolidated using the single-shot matcher crate::doctor_settings::glob_matches instead of inline GlobBuilder compilations. none none
v0.18.0 base detection, push detection (check with no --base and no DISCIPLINE_BASE_REF) An event payload’s before makes the run a push, and is taken as the base, only for a push: the environment names the event push, or nothing names it and the payload holds no pull_request. Before, any payload with a before did. GitHub’s pull_request synchronize payload carries one (the previous head), so such a run examined only the latest push to the pull request, although GITHUB_BASE_REF was set; it now examines the pull request against its base branch. A run that is not a push no longer takes the commit subject and body as the pull request text. A push run, and a run with a *_EVENT_BEFORE variable set, are unchanged. The action passes its base explicitly and was not affected. stricter None. A pull request run that relied on examining only the last push must pass --base.
v0.18.0 hook install --agent opencode The OpenCode plugin written with --observe no longer refuses a tool call or throws from any hook, whatever the command does. Before, its pre-tool hook threw on a non-zero exit, so with discipline not on PATH, or an installed release that did not know the arguments, every edit and shell call was refused in observe mode. In that case the call now passes with nothing logged, since the binary owns the observe log. The enforcing plugin refuses exactly as before, and its OpenCode 2 setup writes one line to stderr when the host offers no tool.hook, in which case no call is checked. Whether OpenCode 2 fires that block has not been observed. changed Re-run discipline hook install --agent opencode --observe --upgrade (or without --observe) to regenerate the plugin.
v0.18.0 CI event payload (check, baseline, and the hook and MCP runs that start check) A payload variable (FORGEJO_EVENT_PATH, GITEA_EVENT_PATH, GITHUB_EVENT_PATH) that is set and non-empty but names a file that cannot be read, or that is not JSON, stops the run with exit 2 (could_not_check.reason configuration), naming the variable. Before, it read as “no payload”: the pull request title and body scans did not run, and a push run fell back to HEAD~1. No variable set, an empty variable, and a readable JSON payload of any content behave as before. stricter Unset the variable, or make the file it names available where the binary runs (for example mount the event file into the container).
v0.18.0 hook run, MCP check_diff With no --base and no base named by the environment, the base is the repository’s default branch again: the branch origin/HEAD names, else (when the repository has no origin/main or main) origin/master, master, origin/trunk or trunk, provided it resolves and shares history with HEAD. Since v0.17.0 these runs passed no base, so the fallback was the literal origin/main: a repository on master got “base ref origin/main does not resolve” (exit 2), and one whose default branch is develop beside a release branch named main was measured against main. The refusal on a clone that holds only the change’s own branch is kept. A bare discipline check and CI runs are unchanged. changed None. --base and DISCIPLINE_BASE_REF still take precedence. A default branch with another name whose local copy is the checked-out branch is still not used as the base; pass --base.
v0.18.0 time-estimates, provenance-tags Two exemptions match a typographic apostrophe (U+2019) again: a Q1 to Q4 label in a possessive (Q1’s) is not a time estimate, and can’t see negates a mechanism claim. Both matched in v0.16.0; a v0.17.0 refactor replaced the character with a second ASCII apostrophe, so text written with typographic quotes was reported. looser (false findings introduced in v0.17.0 removed) None.
v0.18.0 assertion-reduction (test-cases-reduced) Kotlin @ValueSource and @CsvSource cases are counted; before, the Kotlin extractor never produced a count, so removing rows was not reported. A parametrized test that had two or more literal cases on the base side and whose head side has no count is now reported: either its case source is no longer a literal list (a list replaced by a call or a constant), or no case list is read on it any more (the parametrization removed, in any language). Before, both passed, the first with a note. Lifted by allow-case-drop: <test> <reason>. A source that was never a literal still leaves a note only. stricter Justify an intended change with allow-case-drop: <test> <reason>. A Go table moved from the test body to package level and rstest #[case] replaced by #[test_case] are read as “no case list” and need the directive.
v0.18.0 assertion-reduction (assertions moved into a helper) A drop in a test’s own assertions is excused as “moved into helper” only for what the helper accounts for: its own checks when the test newly calls it, per call, and only the checks it gained when the test already called it. The remainder is reported, with the helper’s share counted (a test going from three assertions to one call of a one-check helper is “dropped from 3 to 1”). Before, any call to a paired helper excused the whole drop, so a test could lose every assertion as long as it called a helper whose file was in the change. The helper is matched by the exact name called, no longer by suffix. stricter Move the assertions the test lost into the helper, keep them in the test, or justify the drop with allow-assertion-drop: <test> <reason>.
v0.18.0 ignored-tests (CI-conditional skips) A CI condition added to a conditional skip a test already had is reported like a newly added CI-conditional skip (ci_skip_severity, approved_predicates and allow-ignore apply as they do there). Before, a test whose base side had any conditional skip was not looked at again, so if testing.Short() could become if testing.Short() \|\| os.Getenv("CI") != "" unreported. A condition that was already CI-conditional, or that changes without gaining a CI variable, is not reported. stricter Keep the test running in CI, list the variable in approved_predicates, or justify it with allow-ignore: <test> <reason>.
v0.18.0 test-floor (static count) A file is left out of the static count only when a parsed runner configuration excludes it. When collection is not determined, every test the language pack finds in the file counts, on both sides, as in v0.16.0: languages with no runner model (Java, Kotlin, C#, Scala, Swift, Objective-C, Ruby, PHP, C/C++), JavaScript / TypeScript with no parsed runner configuration, and Python with no pytest configuration at all (the runner may be unittest or Django). Since v0.17.0 those files counted only under a shared set of test paths, so tests in src/App.test.tsx, MyAppTests/LoginTests.swift or polls/tests.py were on neither side and removing them did not lower the count. .mts / .cts files follow the JavaScript rules. The note is now one per side and reason, with the number of files, and no longer quotes a configured value. stricter (the static count can rise) A min_tests measured on v0.17.x may now be below the count, never above it: raise it to the new examined value to keep the floor tight. A Python repository that runs pytest with no configuration and keeps non-collected def test_* helpers can add a pytest.ini so they stay out of the count. Vendored or generated files in a language the repository has no runner configuration for now count too when the language pack reads a test in them (a bundled JavaScript file in a Python project, for example), so removing such a file can report Test Count Below Floor: list those directories in [gates.test-floor] exempt_paths, or justify the removal with allow-test-shrink: <file> <reason>.
v0.18.0 assertion-reduction (expected-exception-widened), vacuous-tests Python with pytest.raises(..) as e: and with self.assertRaises(..) as cm: are read like the form without as; before, the pytest.raises form bound with as was not read, so widening it was not reported. C# Assert.Throws<T> now reads T, so widening the type is reported. A C# call is an expected exception only as a member of an assertion class (Assert, StringAssert, CollectionAssert, ClassicAssert); before, any call whose text contained Throws was one, so a new test holding only mock.Setup(..).Throws(..) was not reported as vacuous. Assert.DoesNotThrow is no longer recorded as an expected exception; it still counts as an assertion. stricter A test whose only check was a mock configured to throw is now Vacuous Test Added: add an assertion.
v0.18.0 assertion-reduction (assertion-failure-caught) On a test that exists on both sides, a swallowed assertion is new by its position inside the test, not by its line in the file. Before, any edit that shifted lines above an untouched test (an import added, the test moved) reported its existing swallowed assertion as new, at the gate’s severity. A handler added around an assertion, a second swallowed assertion, and one swallowed assertion traded for another are reported as before. looser (false findings on untouched tests removed) None.
v0.18.0 configuration globs ([tests] paths, and every glob list of an enabled gate) Every configured glob is compiled before any gate runs, for the configuration in force and for the change’s own copy of it. A [tests] paths glob that does not compile is exit 2 (could_not_check.reason configuration); before, it matched nothing, so files declared as test scope were not treated as tests. Under --policy-from base a change that adds a glob that does not compile to an enabled gate is exit 2 naming that gate; before, it passed and every later run stopped once it merged. A gate that returned early (nothing in its scope changed) now also stops on its invalid glob. A glob in a disabled gate is still not read. stricter Fix the glob the error names. If the base branch already carries one and the policy comes from the base side, the repair cannot pass its own run: merge it with a run under --policy-from head, or through the repository’s bypass.
v0.18.0 config-integrity An optional key that only one side sets is judged by what leaving it unset means. Now reported: ci_skip_severity removed where it was above the gate’s severity; noise_margin_pct or ratio_tolerance_pct added above zero; a gate’s allow_hidden = true added when [directives] allow_hidden is false; test-floor’s test_report, base_report or head_report removed or repointed. Before, none was reported, because an unset optional key is absent from the compared table and only ci_skip_severity added on head was looked at. The three report keys are now in the configuration schema. stricter A change that makes one of these edits on purpose carries allow-gate-weakening: <gate> <reason>.
v0.18.0 config-integrity, advisory mode, test-floor, command A base-side discipline.toml that exists but does not load with this binary no longer reads as “no base configuration”. config-integrity runs even when the change disables it and reports Base Configuration Unreadable (a warning, as before when it ran); a change that sets mode = "advisory" over such a base is not honoured without allow-gate-weakening: meta; test-floor and command add a note that the base min_tests / min_count ratchet was not read; a base baseline file that does not parse adds a note. Before, the change could switch config-integrity off or go advisory unreported, and the skipped ratchets left no trace. stricter Repair the configuration on the base branch. A change that adopts advisory mode in the same edit as the repair needs allow-gate-weakening: meta <reason>.
v0.18.0 every gate that reads a file from git (test-floor, ci-integrity, command, dependency-delta, golden-output, ci-skip-set, shell-secrets, the diff gates behind assertion-reduction, baselines and fingerprints) A failed git read of a file (an object that is missing or corrupt, or an I/O error) is no longer read as “the file does not exist”: the run stops with exit 2 naming the path and the side, and the gate where one gate owns the read (a failed fingerprint or baseline read is not attributed to a gate). Before, the read error was dropped at about twenty call sites, so a check could be skipped silently, for example a base Cargo.toml that could not be read left runner collection empty and a base workflow that could not be read skipped its exposure checks. A failure inside the diff gates is now reported with could_not_check.reason gate naming the first enabled one of them; before it was internal with no gate. ci-integrity also adds a note naming the file, side and line when a workflow side does not parse; before, a head side that did not parse skipped the exposure and rollup checks without a word (those checks are still skipped). Looser in one case: a directory or a submodule pointer at a path now reads as “not a file here” on every side. At the call sites that already stopped on a read error this was exit 2 (for example a base directory replaced by a file of the same name, or test-floor.constant_file naming a base directory); the path is now judged as absent on that side. An absent file and every successful read behave as before. stricter (looser for a directory or submodule pointer at a path) Restore the unreadable object (fetch the missing history: git fetch --unshallow, or a full clone when the clone is partial); there is no switch to read a failed read as absent.
v0.18.0 CI event payload (every gate that reads the pull request, its title or the push base) The payload file is located in one fixed order everywhere: FORGEJO_EVENT_PATH, then GITEA_EVENT_PATH, then GITHUB_EVENT_PATH; the first variable that is set decides, and a later variable is never tried. Before, the pull request body, author and title were read in that order, falling through to the next variable whenever a payload did not yield the field, while push-base detection used the opposite order, so a run that set the variables to different files could read different payloads. A first-choice file that cannot be read or parsed stops the run (the row on an unusable payload). Unchanged when only one variable is set. changed Unset the variable that should not apply.
v0.18.0 commit-provenance New require_agent_review (default true) switches the agent review rule. An empty review_trailer, which used to switch it off, is deprecated: it still loads, is read as require_agent_review = false and leaves a deprecation note in the report, until the next major version. Set together with require_agent_review = true it is a configuration error (exit 2) naming both keys. additive Replace review_trailer = "" with require_agent_review = false; nothing breaks until it is replaced.
v0.18.0 assertion-reduction, vacuous-tests, test-floor (counts) A call to a configured assert_helper_fns helper counts as one assertion and no strong assertion at the call site in every language pack; when its body is in the same file, the body’s own assertions replace that count, so a strong assertion appears only from a visible body. Before, Go, Java, Kotlin, Scala, Swift, ObjC, Ruby, PHP and C# counted the call as a strong assertion, while Rust, JS, Python and C/C++ did not, so replacing a strong assertion with a call to a helper defined elsewhere went unreported in the first group. One matcher (the full call text or its final :: / . segment) is shared by every pack: in Rust, obj.check(...) now matches a configured check; in JS and Python a dotted call to a same-file helper is no longer counted twice. A configured name now takes precedence over the built-in and extra_macros readings. Per-test assertion counts in reports change for repositories that configure helpers. stricter Define the helper in the same file so its body is read, or keep a direct strong assertion in the test.
v0.18.0 directives Notes about the directive sources as a whole (merged-pr-body: commit … arrived through merged pull request #N, N directive(s) read from merged pull request #N, a lookup that could not be made) are reported once per run, in the new report field directive_notes and a directives: line of the text report. Before, each was copied into the notes of about 30 gates. A note about one directive still goes to its gate. A commit GitHub does not have (it answers HTTP 422 “No commit found for SHA”, as for a test fixture made only locally) now reads as “not on the forge”, like a direct push: its message is the only directive source. Before, it was a failed lookup: a cannot resolve note under directives.degrade_offline = true, and exit 2 under false. Other refusals are unchanged. changed A consumer that read these notes from a gate’s notes reads directive_notes.
v0.18.0 error-swallowing, stub-bodies One test-file rule for every language pack: shared test conventions or the pack’s own naming convention. A file the change adds is classified by its path; Java (Test*.java, *Test.java), C# (Test*.cs, *Test.cs, .Tests/) and Python (test_*.py) files outside test directories are now test code for error-swallowing, and Go and C# functions in shared test paths (such as examples/, benches/) for stub-bodies. A file that was production code on the base side keeps that classification through a rename when its extension is unchanged (the extension condition was removed after this release; see the later row for renames into test scope), so renaming production code into a test convention no longer exempts it, and the rename is reported as error-swallowing/test-path-reclassification. A file that was test code on the base side is classified by its head path, so a rename out of test scope is judged as production code, as before. looser (added files in Java, C#, Python, Go); stricter (renames into test scope, every pack) For files that should stay checked, keep them outside the test conventions; a rename into test scope that is intended is lifted with allow-swallow: <path> <reason>.
v0.18.0 commit-provenance Trailers are every trailing paragraph whose lines are all Key: value, back to the first paragraph that holds prose. Before, only the last paragraph was read, so a squash merge that split the trailer block (GitHub puts each trailer it does not recognise in its own paragraph) hid a Reviewed-by: the author wrote and reported agent-commit-without-review on main. Trailers in those earlier paragraphs now also count toward required_trailers and agent_markers. fix None.
v0.18.0 scope-confinement, unsafe-budget, suppression-delta An invalid glob in these gates’ path settings (forbidden_paths, allowed_paths, exempt_paths) stops the run with exit 2 naming the glob, as other gates already did. Before, a malformed pattern was skipped, so a forbidden path could pass. stricter Fix the glob the error names.
v0.17.0 assertion-reduction allow-case-drop: <test> <reason> lifts only test-cases-reduced. Before, it was an alias of allow-assertion-drop and lifted every assertion-reduction finding on the named test, including assertions-reduced, assertion-bound-loosened, expected-exception-widened and assertion-failure-caught. stricter To lift every assertion-reduction finding on a test, use allow-assertion-drop: <test> <reason>.
v0.17.0 config-integrity Adding ci_skip_severity to [gates.ignored-tests] when the base leaves it unset is compared with the gate’s severity, which is what unset means. Setting it lower (for example note under the default error) is reported as a weakening. Before, a key present only on the head was never compared, so the change passed. stricter Keep ci_skip_severity at or above the gate’s severity, or justify lowering it: allow-gate-weakening: ignored-tests <reason>.
v0.17.0 ignored-tests (approved_predicates) approved_predicates matching uses whole-identifier boundaries instead of substring matching, and waiving a condition requires approving every CI variable in it (#460). Before, an approved predicate could match as a substring (such as CI matching GITLAB_CI or CIRRUS_CI, C matching CI), waiving unrelated CI variables. stricter Ensure approved_predicates lists the exact identifier or CI variable name for each condition to waive.
v0.17.0 ignored-tests (CI environment variables) JENKINS_URL and JENKINS_HOME are recognized as CI environment variables in conditional test skips (#460). Tests conditioned on these variables are treated as CI skips (error by default). Before, conditions on Jenkins variables were treated as generic environment conditions (note). stricter Remove the skip in CI or add JENKINS_URL / JENKINS_HOME to approved_predicates, or carry allow-ignore: <test> <reason>.
v0.17.2 test-floor Jest rootDir and roots are read: <rootDir> in testMatch stands for the configured rootDir, and only files under roots (default <rootDir>) are collected. A rootDir or roots entry that is not a repository-relative path, or a Vitest root / dir, makes collection unknown, so the file is counted with a note. Before, rootDir was ignored, so a suite under a configured rootDir dropped out of the count. stricter None; restore deleted tests or lift with allow-test-shrink: <test> <reason>.
v0.17.0 test-floor Test files are dropped from the static count only when the runner’s configuration is known and excludes them. Every .rs file under tests/ counts (modules such as tests/it/foo.rs included by tests/it/main.rs). For JS/TS, when no Jest or Vitest configuration is found, Mocha is in use, the configuration is a jest.config.* / vitest.config.* script, or a configured pattern does not compile, the count falls back to the standard test paths and a note names the reason. Before, these files were treated as uncollected, so deleting tests from them did not lower the count. stricter None; restore deleted tests or lift with allow-test-shrink: <test> <reason>.
v0.17.0 ignored-tests, test-floor Rust #[cfg(...)] on a test is evaluated from its syntax tree with three-valued logic, against the features of the crate that owns the file (the nearest Cargo.toml with a [package] table, read from the same side of the change). A predicate that is definitely false (an undeclared feature of a crate whose manifest was read, any(), not(test)) is an unconditional ignore; an undecided one that names a Cargo feature (a declared feature, or no owning manifest) is a conditional skip; cfg(test), platform, target_feature and custom cfgs have no effect, as before #470. A member crate’s manifest that does not parse adds a note. Before, not(...) was ignored, so #[cfg(not(feature = "x"))] with x undeclared was reported as an ignored test, and target_feature = "..." was read as a Cargo feature. looser None.
v0.17.0 assertion-reduction Shared assertion helpers in test paths (such as tests/helpers.py, conftest.py, tests/common/mod.rs, Go _test.go/testutil, test-utils.ts) that lose effective assertions, equality/pattern checks, or fatal exits report Test Helper Function Weakened (assertion-reduction/test-helper-weakened) directly on the helper (#453). Calling tests in the diff are cited in the finding message. A refactor moving assertions from tests into a helper with assertions is recognized as a refactor in notes, and tests whose assertion drop is accounted for by a weakened helper are attributed with a note rather than double-reported. stricter Restore the helper’s assertions, or justify the drop: allow-assertion-drop: <helper-name> <reason> or allow-assertion-drop: <path> <reason>.
v0.17.0 assertion-reduction Test cases removed from a parametrized or table-driven test are reported as Test Cases Reduced In Parametrized Test (#450). Statically counts test cases across Python (@pytest.mark.parametrize list, tuple, Cartesian product), JS/TS (test.each array and tagged template literal), Go ([]struct table-driven slices), Java and Kotlin (@ValueSource, @CsvSource), C# ([InlineData], [TestCase]), and Rust (rstest #[case], #[values]). If cases are split or moved across tests in the same file so the file-level total case count is preserved, the reduction is read as a refactor and recorded in gate notes without failing. Non-literal case sources emit an explanatory note. stricter Restore the test cases, or justify the drop in the PR body or commit message: allow-case-drop: <test> <reason> (or allow-assertion-drop:).
v0.17.0 assertion-reduction An assertion whose failure is caught by an enclosing handler in the same test is reported as Assertion Failure Caught Inside Test (#451): Python try ... except AssertionError: pass / except Exception, Rust discarded std::panic::catch_unwind(\|\| ...), JS/TS try { expect(...) } catch {}, Java/Kotlin try { assertEquals(...) } catch (AssertionError e) {}, C# try { Assert.Equal(...) } catch (Exception) {}, Go recover() in defer enclosing assertions. The swallowed assertion drops out of effective assertions. Negative controls (re-raising handlers, test failures, assertions in handlers, and asserted catch_unwind results) stay silent. stricter Restore the assertion to propagate failures, or justify the handler in the PR body or commit message: allow-assertion-drop: <test> <reason>.
v0.17.0 test-floor, ignored-tests Language-specific test runner collection rules (pytest python_files/testpaths, Jest/Vitest testMatch/testRegex, Go *_test.go, Rust tests/*.rs/tests/*/main.rs/src/**/*.rs and [[test]] path) filter uncollected files from static test-floor counts (#448). Tests guarded by #[cfg(feature = "x")] where x is undeclared in Cargo.toml [features], or #[cfg(any())] / #[cfg(all(any()))], are treated as unconditional ignores and excluded from test-floor counts. A test no longer discovered by runner rules drops out of the floor and is reported as a test count drop. stricter Name test files to match runner discovery patterns, declare Cargo features in [features], or lift with allow-test-shrink: <test> <reason>.
v0.17.0 command A snapshot on a base-tests command (the base-tests preset, or an entry named base-tests) is a configuration error (exit 2). Before, the base-tests path never reached the comparison, so the snapshot was configured but never checked and the run passed. stricter Move the snapshot to its own [[gates.command.commands]] entry, or remove it.
v0.17.0 command (cargo-public-api preset) The preset renders the surface (cargo public-api --simplified, nightly) and compares it with the committed public-api.txt; a difference is command/snapshot-mismatch (#462). Before, it ran cargo public-api diff, which diffs against the latest crates.io release, reaches the network and exits 0 on any API change, so the gate could not fail on one. Lines starting with # in the file are not compared. stricter Commit the output of cargo +nightly public-api --simplified as public-api.txt. To keep the previous command, replace preset = "cargo-public-api" with command = "cargo public-api diff" under [gates.command].
v0.17.0 command New snapshot and snapshot_ignore keys on [gates.command] and on [[gates.command.commands]] entries: stdout must match the committed file line by line, with a trailing \r and the final newline not significant, and lines matching an ignore pattern left out on both sides (#461). A difference is command/snapshot-mismatch, located at the snapshot’s first differing line; the output’s own text is not echoed. Output that cannot be compared is exit 2: cut off at the 25 MiB capture limit, not UTF-8, empty (unless allow_zero = true), or with no snapshot file. An invalid snapshot_ignore regex or a path outside the repository is a configuration error. The snapshot is the command’s protected policy file. config-integrity: adding snapshot tightens; changing or removing it, or adding or editing a snapshot_ignore pattern, loosens. Without a snapshot key, a command whose output passes the capture limit gets a note saying forbid_output saw only the captured part. additive None: no snapshot is compared unless one is configured.
v0.17.0 assertion-reduction, vacuous-tests, test-floor (Rust) Property tests inside proptest! and quickcheck! macro blocks are parsed and extracted into test facts: prop_assert!, prop_assert_eq! and prop_assert_ne! count as assertions (prop_assume! does not), a quickcheck boolean return and TestResult count, a tautological true return reads as vacuous, and prop_compose! generator blocks are excluded (#472). stricter Give the property test an assertion of its own, or lift with allow-assertion-drop: <test> <reason>.
v0.17.0 hook run, MCP check_diff On a shallow clone where no base resolves (every candidate holds HEAD), hook run and MCP check_diff refuse with exit 2 / could_not_check (reason repository) instead of passing on an empty diff (#479). stricter Fetch the base branch, or pass --base naming it.
v0.17.0 doctor, init, action, GitLab component, Argo template New doctor findings test-report and mutation-testing: information when a recognized runner is detected without test_report / a diff-scoped mutation preset configured in [gates.command], pass when configured (#478, #480). init writes a commented mutation-preset block matching the detected runner (cargo-mutants, mutmut, stryker, pit). The action and GitLab component take test_report / test_base_report / test_head_report inputs, the Argo template a test-report parameter. additive None.
v0.17.0 hook install hook install --agent opencode writes an OpenCode v2 export default plugin (id plus setup) alongside the legacy named export, which OpenCode 2.0+ requires of server plugins (#474). additive Delete .opencode/plugins/discipline.js and reinstall, or run with --upgrade.
v0.17.2 release crates.io publication via OIDC trusted publishing: the promote job runs cargo publish (id-token: write), and cargo install discipline is documented (#459). additive None.
v0.17.0 assertion-reduction Widening what a test accepts as the expected failure (Expected Exception Or Panic Widened) in an existing test is reported as a weakening: removing matchers (Rust #[should_panic(expected = "...")] -> #[should_panic], Python pytest.raises(..., match=...) -> without match=, JS toThrow(...) -> toThrow()), dropping exception types, widening exception types to general ancestors (Exception, BaseException, Throwable, Error), or switching to ThrowsAny (#452). stricter Restore the expected exception or matcher, or justify the change in the PR body or commit message: allow-assertion-drop: <test> <reason>.
v0.16.0 release Attach a CycloneDX SBOM (JSON) to each published release (discipline.cdx.json), attested via GitHub’s SBOM attestation (actions/attest) against the release archives and checksummed in SHA256SUMS (#434). additive None.
v0.16.0 doctor workflow-protection (Gitea / Forgejo) matches a protected file pattern as the forge does: case folded, * and ? stop at . as well as /, ** crosses both. Before, .gitea/workflows/* was reported as protecting .gitea/workflows/ci.yml, and the fix recommended it; the forge does not apply it there, and a pull request editing the workflow merged (RUN: red-team lab, Gitea 1.24.7 and Forgejo 12; #428). The fix now names .gitea/workflows/**, .forgejo/workflows/**, .github/workflows/**. stricter A branch rule protecting workflows with <dir>/workflows/* now reads warn (--strict fails): change the pattern to <dir>/workflows/**.
v0.16.0 directives, ratified-paths, issue-link (verify_references), audit, archive-contents Three panics on text with a multi-byte character, found by the new fuzz targets (#418): a directive reason such as tod\u{fffd}o (placeholder check), a reference preceded by a non-ASCII character such as café #1 (closing-keyword check, used to read a pull request’s references and closing issues), and a source-map URL with a multi-byte character in its first five bytes. Before, the run panicked (a release build aborts) instead of judging the text. fix None.
v0.16.0 assertion-reduction, vacuous-tests (Rust) A library function’s ? is no longer counted as a check of the unit test that calls it, as its .unwrap() / .expect() already were not (#392): it hands the error to the caller, where the test’s own ?, unwrap or assertion checks it. In a fallible test, and in a helper that is test code (a #[cfg(test)] module or function, or a file under a test path), a ? still counts. Moving a library function’s ?s into another module read as an assertion drop in every unchanged test that called it: #420 waived 12 such tests (#422). RUN: discipline check --base 3178b4f^ at 3178b4f reported 12 Assertion Count Decreased In Existing Test findings before and none after; the Expected Value Changed In Existing Test finding at tests/test_report_redaction.rs:146 stays. changed A test that relied on a library function’s ? as its only check now reads as having fewer assertions, and a new one as vacuous; give it an assertion of its own.
v0.16.0 error-swallowing A JS/TS promise .catch callback that only logs is reported as Error Logged And Dropped, like a logging-only catch clause: p.catch((e) => console.error(e)), p.catch((e) => { console.error(e); }), p.catch(function (e) { logger.warn(e) }). A callback that logs then rethrows or returns a computed value, calls another function, or a named handler is not reported. (#419) stricter A new callback of this form is reported (an existing one is not, the gate is a base-versus-head delta). Handle the error, or justify it with allow-swallow: <path> <reason> or discipline:allow(error-swallowing) on the line.
v0.16.0 pii (secrets), shell-secrets pii with secrets = true checks every text file for every fixed-format token class shell-secrets knows, from one shared table (#404): OpenAI and Anthropic API keys (sk-), GitHub fine-grained tokens (github_pat_) and GitHub tokens longer than 255 characters, Slack tokens of the broader xox[baprs]- shape, and a literal Authorization: Bearer <value> (not a variable or placeholder), besides the private-key headers, AWS ids and GitHub gh[pousr]_ tokens it already checked. shell-secrets now also takes the AWS prefixes ABIA and ACCA and any private-key label. Findings keep reporting the file, line and token class only. The declared-test-scope exemption is unchanged. Entropy detection is not part of this change, and the schema no longer claims it. stricter A documented example that has a real token’s shape: an inline discipline:allow(pii) on the line, or allow_patterns. secrets = false switches the token checks off.
v0.16.0 error-swallowing A handler whose whole body returns an empty or default literal is reported like return null: Python return False / 0 / "" / [] / {} / (), JS/TS return 0 / "" / [] / {}, Java and C# return 0 / "" and empty-collection returns, PHP return 0 / "" / [] / array(), C/C++ return 0. Trivial statements compare with whitespace folded. JS/TS p.catch(() => {}), .catch(() => null), .catch(() => []) and .catch(function () {}) are Error Silenced. (#402) stricter A new handler of these forms is reported (an existing one is not, the gate is a base-versus-head delta). Handle the error, or justify it with allow-swallow: <path> <reason> or discipline:allow(error-swallowing) on the line.
v0.16.0 toolchain-config Compiler warning flags in build files are judged (#403): in Makefile / GNUmakefile / *.mk (*FLAGS assignments and compiler recipe lines), CMakeLists.txt / *.cmake (CMAKE_<LANG>_FLAGS, add_compile_options, target_compile_options), setup.py (extra_compile_args) and build.rs (cc::Build .flag(..), .warnings(false), .extra_warnings(false), .warnings_into_errors(false)). A lax flag gained (-w, -Wno-error, -Wno-error=<diagnostic>, -Wno-<diagnostic>) or a strict flag lost (-Werror, -Wall, -Wextra, -Wpedantic, -D warnings) is Toolchain Configuration Weakened; an unchanged flag is not reported. The lax list also gains -Wno-error and the -Wno- prefix for rustflags and addopts. A build file whose syntax tree has errors is Toolchain Configuration Unreadable. stricter allow-toolchain-weakening: <flag, variable or command, or path> <reason>; or [gates.toolchain-config] exempt_paths.
v0.16.0 suppression-delta C and C++ diagnostic pragmas are suppression sites (#403): #pragma GCC diagnostic ignored, #pragma clang diagnostic ignored and MSVC #pragma warning(disable: ...) / warning(suppress: ...). Before, the C/C++ packs counted NOLINT* only. The directive is read from the parse, so a pragma in a comment or string, push / pop, and an unchanged pragma are not new. stricter A change that adds one of these pragmas is now reported (warning by default); name the option or code list in allow-suppression: <rule> <reason>, or list it in allowed_suppressions.
v0.16.0 ratified-paths (refuse_author_ratification), directives.require_approval On GitLab the merge request’s author is read from the forge (projects/:id/merge_requests/:iid, author.username). Before, the author was GITLAB_USER_LOGIN, which names the login that started the pipeline: when another login’s push or re-run started it, the author’s own ratification counted, and require_approval refused the starter’s approval (the author’s own approval was already refused, since the GitLab approvals read drops the merge request’s author). A merge request the forge cannot answer for is exit 2 (forge). require_approval with an empty author (a merged pull request the forge names no author for, on a push) is exit 2; before, the author’s own approval counted. fix A GitLab run with either option needs a token that can read the merge request (read_api), which the approvals and closing-issue reads already needed.
v0.16.0 issue-link New exempt_authors (#408): a pull request opened by a listed login (dependabot[bot], renovate[bot]) needs no tracking-issue reference. The author is read from the forge’s event payload, never the run’s actor; on GitLab, from the merge request (author.username), and a merge request that cannot be read is exit 2. Directive In Subject Line and every other gate still apply. An entry that is empty or contains whitespace is a configuration error. additive None: the list is empty by default.
v0.16.0 instruction-smuggling, pii, error-swallowing, stub-bodies A changed file the gate cannot read as text is named in a note (skipped ` (binary or unreadable file)`); `pii` scanning the whole tree still counts the binary files the change did not touch. Language-pack extensions missing from the text list (`.cs`, `.cts`, `.mts`, `.dart`, `.inc`, `.m`, `.mm`, `.phtml`, `.rake`, `.sc`, `.gemspec`) and `Gemfile`, `Rakefile`, `Dockerfile`, `Makefile` are read as text even with a NUL byte. stricter None: a file that was skipped is now read or named.
v0.16.0 directives New option directives.max_inline_overrides caps the number of inline source marker exemptions (discipline:allow(...)) a change may apply. Raising or removing it is a weakening flagged by config-integrity (probe esc-12). stricter Set directives.max_inline_overrides = N in discipline.toml to enforce a budget on inline markers.
v0.16.0 ci-integrity A configured rollup_job (e.g. ci-gate) that is removed or renamed in CI workflow files cannot be excused as a job rename or split by job_moved, preserving required context enforcement for branch protection (probes ci-01, ci-02). stricter Keep the required rollup job name intact, or lift with allow-gate-weakening: ci-integrity <reason>.
v0.16.0 tokens Directives and waivers require a rationale with at least two alphanumeric characters; single-word reasons with trailing sentence punctuation (todo., TBD!) are trimmed before lookup; ok, temp, dummy, null, placeholder, and asdf are added to the built-in placeholder list alongside todo, tbd, none, n/a, ..., and <reason>; and placeholder matching folds Unicode confusables, combining marks, invisible characters, and leetspeak (#385, probes esc-10, esc-11). stricter Provide a meaningful rationale containing at least two alphanumeric characters explaining the exception.
v0.16.0 all gates that read whole files A file with a known text extension is read as text whatever its first bytes, and a printable magic number (MZ, %PDF-, GIF89a, BZh, PAR1, ARROW1) makes a file binary only with a NUL byte in its first kilobyte. Before, a source file starting with MZ = 0 was read as binary, so pii, instruction-smuggling, error-swallowing and stub-bodies skipped it. stricter None: a file these gates now read reports what it carries.
v0.16.0 check When --base main or origin/main does not resolve, a guessed default branch (origin/HEAD, master, trunk) that already holds HEAD is refused (exit 2). Before, origin/HEAD after git clone --branch <change> named the change itself: an empty diff, and exit 0 on a change that deleted a test file. stricter Fetch the base branch, or pass --base naming it.
v0.16.0 instruction-smuggling The change description’s directive lines are scanned for instruction-like text, their reasons included (#363). Only an allow-agent-instructions line that parsed as a directive and names a location of this change (pr-body, pr-title, a commit:<sha7> in the range, or a changed path) is exempt; a fenced line, a commit’s subject line, a fake subject or any other directive’s reason is scanned. Findings stay at warning, so a repository with fail_on_warnings (DISCIPLINE_FAIL_ON_WARNINGS) fails a run whose directive reason quotes an injection phrase, for example an allow-ignore reason. RUN 2026-10-01: discipline replay --last 100 on this repository raised 0 new warnings over the 81 directive lines of the 86 changes it could check. stricter Move a quoted phrase to an allow-agent-instructions: <location> <reason> line, or out of the description.
v0.16.0 audit In --format html, a source link percent-encodes the file path segment by segment. Before, a directory named %2e%2e read as .. in a browser and the link opened another repository on the same forge, and # or ? in a file name cut the link short. fix None.
v0.16.0 assertion-reduction, vacuous-tests (Rust) A library function’s .unwrap() / .expect() is no longer counted as a check of the unit test that calls it; in a test, and in a helper that is test code (a #[cfg(test)] module or function, or a file under a test path), it still is. A refactor of library code that ended a same-file wrapper chain read as an assertion drop in unchanged tests (#392). Library assert!, panic! and unreachable! still count. changed A test that relied on a library function’s expect as its only check now reads as having fewer assertions; give it an assertion of its own.
v0.16.0 directives A directive on a line indented four or more columns (an indented code block), or inside a fence after a shorter fence of the same character, is code and no longer arms an override (F8). Before, an indented block was read, and a fence closed on any line starting with its first three characters. stricter Start a directive at the beginning of its line (up to three spaces), outside any code block.
v0.16.0 assertion-reduction New finding expected-value-changed (Expected Value Changed In Existing Test): an existing assertion that now expects a different value while its count and strength hold (assert_eq!(add(2, 2), 4) -> 5). Read for Rust, Python, JS/TS and Go equality assertions and inline snapshots; assertions are paired by their text with the literal masked. At the gate’s severity, a warning in staged mode (#386). Replay of this repository’s last 100 merged changes (RUN 2026-10-01): one finding, #285, an intended behaviour change that would have needed the directive; no other change edited an existing expected value. stricter A test whose expected value changes with an intended behaviour change records it: allow-assertion-drop: <test> <reason>.
v0.16.0 config-integrity Removing an optional key is a weakening when its absence reads looser than the value removed: bench-regression.max_noise_cv (absent means no noise check), and a gate’s allow_hidden = false while [directives] allow_hidden = true. Before, only evidence, floor and cap keys were reported when removed. Found by the lab runs tests/red_team/attacks/cfg-04 and cfg-06 (#359). stricter Keep the key, or lift with allow-gate-weakening: <gate> <reason>.
v0.16.0 doctor On GitLab, two-factor reads the project’s top-level group (require_two_factor_authentication: a warning when off, could not check without access to the group, information for a personal namespace) and environment-reviewers names the environments that masked or hidden variables are scoped to and that need no deployment approval. Both were not available on this forge before. stricter (--strict fails on the new warning) Require two-factor authentication in the top-level group; add a deployment approval to an environment whose variables deploy or publish.
v0.16.0 doctor New local finding security-policy (#366, OpenSSF Scorecard Security-Policy): a pass when SECURITY.md is at the root, in .github/ or in docs/, information otherwise. additive None.
v0.16.0 doctor New findings (#366): webhooks, by host only (GitHub: a warning for a webhook with no secret or with TLS verification off; GitLab: a warning when TLS verification is off; Gitea and Forgejo: information, since they do not say whether a webhook has a secret), and environment-reviewers (GitHub: information naming the deployment environments that hold secrets and need no required reviewer). stricter (--strict fails on the new warning) Give each webhook a secret and keep TLS verification on; add required reviewers to an environment whose secrets deploy or publish.
v0.16.0 doctor New access findings (#366), counted and never named: deploy-keys (a warning when a deploy key can push; GitHub, GitLab, Gitea, Forgejo), outside-collaborators (information); on GitHub, for an organisation-owned repository, org-base-permission (a warning on write or admin), two-factor (a warning when not required) and visibility-change (a warning when members may change visibility). A setting the token cannot see is could not check (a warning). stricter (--strict fails on the new warnings) Make deploy keys read-only, lower the organisation base permission, require two-factor authentication; run doctor with an organisation owner’s token to read the organisation settings.
v0.16.0 doctor New repository-settings findings from the OWASP CI/CD Security Cheat Sheet (#366): auto-merge (GitHub: a warning when allow_auto_merge is on and no approving review is required; information elsewhere), forking (a warning when a private or internal repository allows forks; GitHub, GitLab), secret-scanning (secret scanning and push protection, information when off; GitHub, GitLab) and dependency-alerts (GitHub, information when off). A setting the token cannot see is could not check (a warning). The forge client reads a 204 No Content answer as an empty one instead of a malformed one. docs/guides/ci-platforms.md maps every cheat-sheet item and Scorecard check to a finding, a gate, “not checked” or out of scope. stricter (--strict fails on the new warnings) Require an approving review or turn off auto-merge; turn off forking of a private repository; run doctor with an admin token to read the admin-only settings.
v0.16.0 check (report) The note for a refused hidden directive (directives.allow_hidden = false) names the directive and where it was (hidden directive \removes` in commit abc1234 ignored …), no longer its reason, and is attached once, to that directive's gate. Before, it quoted the reason word for word, so text hidden from reviewers reached the report, –format agent-prompt` and the step summary, and a directive whose name matched no gate was copied to every gate (#362). fix None.
v0.16.0 tokens Directives that require a scoped subject and a rationale (removes:, allow-ignore:, allow-assertion-drop:, etc.) reject subject-only entries and placeholder reasons (todo, tbd, n/a, <reason>). A subject-only waiver without a non-empty, non-placeholder reason no longer lifts a finding (#358, probe esc-03). stricter Add a non-empty, non-placeholder rationale explaining the waiver following the subject.
v0.16.0 sandbox-config Reads CI job and service containers in .github/workflows/, .gitea/workflows/ and .forgejo/workflows/: a job’s container or a services entry whose options gain --privileged, a host namespace, --cap-add, --device or a confinement switched off, or whose volumes mount the host’s Docker, Podman or containerd socket, is sandbox-config-widened, as a Dev Container or Compose service already is (#367). stricter Lift with allow-sandbox-widening: <workflow path or key path> <reason>.
v0.16.0 check (CI base fetch) The git fetch discipline runs in CI when the base does not resolve no longer runs a command the repository’s own configuration names. Hooks, the file-system monitor, ext:: / fd:: / git:// transports and the upload-pack program are overridden; a repository-set core.sshCommand, credential.helper or core.askPass is replaced by the runner’s global or system value. Before, a hostile .git/config ran commands there (#364). fix A checkout that relies on a repository-local core.sshCommand (actions/checkout with ssh-key) and does not fetch the base now gets exit 2 from the fallback fetch: check out with fetch-depth: 0, as the error says.
v0.16.0 instruction-smuggling The change description: a directive line inside an HTML comment (<!-- no-issue: ... -->) is no longer exempt from the phrase and blob scan (a bot reads it; a reviewer does not see it), and every line, directive lines included, is checked for invisible characters. Before, a directive line was skipped by both checks. A visible directive line’s reason is still not scanned (#363). stricter (an invisible character on a directive line blocks) Move a hidden directive out of the comment or remove the invisible character; lift a quoted phrase with allow-agent-instructions: pr-body <reason>.
v0.16.0 instruction-smuggling More prose files are scanned as whole lines: .mdx, .ini, .cfg, .conf, .ipynb, .svg and extensionless README / NOTES. docs/GATES.md lists the exact set, generated from the gate (#354). stricter (warning) Lift a legitimate line with allow-agent-instructions: <path:line> <reason>, or exempt the path.
v0.16.0 instruction-smuggling A phrase split across consecutive line comments (//, #, --) or consecutive non-blank added lines of a prose file is found, and reported at the first line of the shortest run that carries it (Lines 3-4 of ...). A directive naming any line of that run lifts it. A phrase on one line is reported as before (#354). stricter (warning) Lift with allow-agent-instructions: <path:line> <reason>.
v0.16.0 instruction-smuggling encoded-blob accepts base64url (-, _), a run with / inside it (a path is now rooted or has a dotted segment), base64 wrapped at 64 or 76 columns (joined into one run; a PEM block is not), and a hex run of 80 or more digits that decodes to text. SSH public keys are excluded by shape (#354). stricter (warning) Lift with allow-agent-instructions: <path:line> <reason>, or exempt the path.
v0.16.0 instruction-smuggling Phrases are matched on a folded copy of the text as well. Folding drops invisible characters and combining marks, applies NFKC, maps TR39 Latin look-alikes and leetspeak digits to letters, and splits words joined by two or more -, _ or .. They are also matched after decoding ROT13, %xx escapes, hex and base64/base64url runs; new classes rot13-encoded, percent-encoded, hex-encoded and base64-encoded name the encoding. New class mixed-script: a word that mixes Latin letters with a Cyrillic or Greek look-alike. Finding codes are unchanged, all at warning. New dependency unicode-normalization (#354). stricter (warning) Lift with allow-agent-instructions: <path:line> <reason>; a quoted injection in a threat document is the expected case.
v0.16.0 audit A loosening of sandbox-config raises guard-gate-loosened (look-first), as a loosening of ratified-paths, config-integrity, ci-integrity or instruction-smuggling does: with the gate off, an agent can widen its own sandbox and remove the hooks that run discipline with no finding. stricter None.
v0.16.0 doctor New local finding agent-sandbox: an agent settings file in the repository that runs an agent with less containment than its defaults (a looser mode, a protection switched off) is a warning, one in the user’s home is information. Lists (allowed commands, MCP servers) are left to sandbox-config. stricter A repository file it names fails doctor --strict: remove the setting or move it to a user-level file.
v0.16.0 audit New replay (the report --replay read: cases, checked), shown in the HTML page’s “Read” line. waiver-lifted-nothing ranks review, not look-soon, and says the replay re-checks with the current discipline: a waiver lifts nothing when the finding was never there, or when the gate has changed since (refined, or a directive format it no longer reads). The HTML page’s protected-path row points to the ratification the forge read. The demo report (docs/demo/audit.html) is regenerated with --forge and --replay. additive None.
v0.16.0 audit With --replay, inline markers are judged too: lifted is true when the replayed check applied an override of the marker’s gate read from its file and line, else false. A marker that lifted nothing (usually one quoted in a test or a documentation example) is counted in the directive-lifted-a-finding detail, not in waiver-lifted-nothing, and the HTML page leaves it out of its per-gate chart and its table of markers outside tests and docs (#360). additive None.
v0.16.0 audit New --replay <file>: reads a discipline replay --json report of the same history; each finding waiver of a replayed change gains lifted (evidence becomes applied when it lifted a finding), the new signal waiver-lifted-nothing (review) lists waivers that lifted nothing when re-checked with the replaying discipline version (never needed, or the gate has changed since), and directive-lifted-a-finding is answered (found, clean, or not-checked naming the changes the replay could not check or did not include). no-issue is not judged. Inline markers match an applied override by file and line; one that lifted nothing (a marker quoted in a test or a doc example) is counted in the check’s detail, not in the signal, and left out of the HTML page’s marker table and per-gate chart (#360). A file that is not a replay report is exit 2 (Phase 15 Step 9). additive None.
v0.16.0 audit New identities: for each change with an exception, claimed (a commit-provenance agent marker on its commit), agent-login (with --forge, its pull request’s author is in ratified-paths.agent_logins or a [bot] login) or no-record. The agent-identity check is found or not-checked, never clean, since a missing record does not show that a person made the change (Phase 15 Step 7, deferred). additive None.
v0.16.0 ratified-paths With closing_source = "server" on GitHub or GitLab, a closing reference the pull request’s body names but the forge does not list is named in the note and in the protected-path-unratified message, with how to link it (#350). The verdict is unchanged: the body still ratifies nothing. audit --forge shows the same reason for such a protected edit. additive None.
v0.16.0 test-floor Test identity ratcheting from JUnit XML test reports (test_report, head_report, base_report, or flags --test-report, --test-head-report, --test-base-report): verifies every test that passed on base passes on head, reporting test-floor/test-dropped-from-suite for missing, skipped, or failing tests; excused by allow-test-shrink: or removes:. stricter Excuse removed or renamed tests with allow-test-shrink: or removes:.
v0.16.0 ratified-paths New refuse_author_ratification (default false): a ratification written by the pull request’s own author is refused (new finding ratification-by-pull-author, at note, beside protected-path-unratified), and so is every ratification when the run does not know the pull request’s author. Turning it off again is a loosening config-integrity reports. ratification::Input gains pull_author (Phase 15 Step 8). additive None; off by default.
v0.16.0 audit With --forge on GitHub, pulls[].body_edited_after_merge says whether a pull request’s body was edited after its merge (lastEditedAt, one GraphQL query per 50 pull requests), and the new pull-request-body-edited check lists those pull requests; the HTML page marks a waiver read from one. On GitLab, Gitea and Forgejo the check is not-checked. Completes Phase 15 Step 6. additive None.
v0.16.0 audit A loosening whose own change also tightened the same option (an edited list entry, which config-integrity counts as lost unless it can prove the edit tighter) carries edited: true, is flagged “edited entry” in the HTML page, and no longer counts toward loosened-not-restored. Found by running the audit on other repositories: a change that made a version check stricter read as a loosening never restored. fix None.
v0.16.0 audit Directive records gain cites: the issue references in the reason. With --forge, issues lists each cited issue in the repository (state, GitHub close reason, close time; others not read), three signals compare it with the waiver (waiver-cites-missing-issue for any waiver; waiver-cites-issue-closed-before and waiver-cites-issue-not-planned for finding waivers only, since a no-issue waiver cites issues as context), and the new cited-issues check answers. A ratification whose issue was closed before the merge is unratified (“closed before the merge”). links.issue links an issue. references::issue_facts reads the close time and reason; resolve_number is unchanged in what it returns. audit --forge raises the process’s forge request budget from 500 to 500 plus 4 per audited change, since its reads scale with --last; check keeps 500 (Phase 15 Step 6). additive None.
v0.16.0 audit With --forge, each protected edit carries its ratification, judged as ratified-paths judges it: ratified, self-ratified (by the pull request’s own author login), unratified, not-required, never-ratifiable or not-checked, with the ratifying issue and comment (never the login). New signals protected-edit-unratified (look-first) and protected-edit-self-ratified (look-soon), whose records index protected_edits (new list field); the owner-ratification check gets an answer; links.issue_comment links the comment. ratification::Judgement gains the standing ratifications; the gate’s verdicts do not change (Phase 15 Step 6). additive None.
v0.16.0 audit New --forge: reads each change’s merged pull request through the forge client (a token that reads pull requests; offline under DISCIPLINE_NO_NETWORK=1). Directives in its body that the commit message does not carry become records with source pull-request-body (commit-message directives now carry source commit-message), linked to the pull request in the HTML page; pulls lists each pull request with approved_by_other (no login is carried); forge counts what was read. The pull-request-body-directives and independent-review checks turn found or clean, or stay not-checked with the forge’s error when any change could not be read. Without a forge to read, --forge is exit 2 (Phase 15 Step 6). additive None.
v0.16.0 check (report), replay Each override names the finding it lifted: an optional code (gate/code) on outcomes[].overrides[] and on replay --json’s cases_detail[].overrides[], inline markers included; where one directive lifts several findings of one subject, the first the report lists. allow-vacuous-test on a test that raises no finding, and a file-level secrets-argv-ok on a script with no hit, no longer count as applied overrides: they lift nothing, and are listed under unused_directives. additive (the override count can fall where such a directive was counted) None: a report reader that ignores unknown fields is unaffected.
v0.16.0 audit New --format (text, json or html) and -o/--output <file> (--json stays, as --format json). html writes one self-contained page from the same data: summary and checks, then Overview, Protected paths, Configuration, Waivers, Changes, Gates and All records views, with each row linked to the file and line, the file’s diff or the commit on the forge the origin remote names. The JSON gains version, tip, links (forge link templates or null), protected_edits (kind protected-edit: paths the parent configuration protects under ratified-paths), and each record’s change_index, subject and, for a loosening or tightening, the option’s line; schema_version stays 1 (Phase 15 Step 5). additive None.
v0.16.0 audit discipline audit opens with Needs a decision: signals over the records (guard-gate-loosened, hidden-directive, config-unreadable, loosened-without-pull-request, loosening-without-waiver, waived-then-loosened, loosened-not-restored, baseline-grew), each with a rank and a next action. The JSON gains signals, checks (every signal found or clean, and what git cannot tell not-checked with the reason) and tightenings (kind config-tightening); schema_version stays 1 (Phase 15 Step 4). additive None. A consumer validating against a copy of the old schema with additionalProperties: false updates its copy.
v0.16.0 CLI New discipline audit --last N [--ref R] [--json] [--reasons]: one record per escape hatch the last N merged changes carried, read from git objects only (directives in commit messages, discipline.toml loosenings, baseline growth, inline discipline:allow(...) markers), each with its evidence (claimed / applied) and input tier; a historical configuration that does not parse is a record, not a failure. Reasons are hashed unless --reasons. Schema discipline.audit.schema.json (Phase 15 Step 3). additive None.
v0.16.0 hook install, doctor hook install --upgrade rewrites a JSON hook file (.claude/settings.json, .codex/hooks.json, .cursor/hooks.json, .github/hooks/discipline.json, .agents/hooks.json, .qwen/settings.json, and with --user Copilot’s user-level hooks/discipline.json) that some release generated: every entry runs a command hook install writes, under an event, matcher and fields a release writes, and the file holds nothing else. The file’s observe mode is kept (--observe can only turn it on), and so is a check timeout longer than the default unless --timeout is given. Without --upgrade such a file that differs from this release’s is reported as written by an earlier release (was: already runs discipline); exit 0 either way. A file with a hook or setting of its own (Claude Code permissions, a Qwen Code model) is never rewritten: --upgrade refuses it, exit 1, with the snippet to merge, when it lacks an entry this release writes, and leaves it as it is otherwise. With --user, a user-level file with a hook of its own is no longer rewritten (was: any file running discipline was), and without --upgrade a hand-merged one reports already runs discipline (was: written by an earlier release). doctor’s pretool-hook fix no longer says to delete and reinstall. additive (--user: stricter, a hand-edited file is kept) None; run hook install --agent <name> --upgrade to add the entries a newer release writes.
v0.16.0 check (report) The JSON report gains unused_directives: each directive the run read (PR body, commit bodies, merged pull-request bodies) that lifted no finding, by name, source and hidden, never with its reason; omitted when empty and when --suite narrows the run. The text report prints one unused directive: line each, the step summary one **Unused directive:** line. It never fails the run (Phase 15 Step 1). additive None. Remove a listed directive, or correct its subject.
v0.16.0 replay --json: each case gains overrides, the overrides its change’s check applied (gate, directive, subject, source, hidden; never the reason text), and the summary gains overrides_by_gate, gate id to the changes that applied an override of it. The text output names the gates a change overrode and counts them per gate. Before, only refused overrides were kept (Phase 15 Step 1). additive None.
v0.16.0 doctor On Gitea and Forgejo, secret-scoping is checked instead of reported as not available: it lists the repository’s and the organisation’s Actions secrets by name, states that a workflow on any branch can read them (neither forge scopes a secret), names the pull-request workflows that read one, and warns on a secret no local workflow reads. stricter (--strict fails on an unread secret) Delete the secrets no workflow reads.
v0.16.0 doctor On GitLab, default-token and secret-scoping are checked instead of reported as not available: default-token warns when the CI job token allowlist is off or a job token may push; secret-scoping names each masked CI/CD variable every pipeline can read (not protected, every environment). New finding forge-token: on GitLab, a warning when the token doctor runs with can do more than read, never expires, or expires within 14 days (information elsewhere). A GitLab refusal now names the fine-grained permission the token lacks. stricter (--strict fails on the new warnings) Protect or environment-scope the masked variables; turn the job-token allowlist on; run doctor with a read_api token that expires.
v0.16.0 hook install, hook run, doctor hook install --agent qwen and --agent codex also write a SessionStart entry that takes the worktree’s lease and a PreToolUse entry running the pre-tool check (Qwen Code: write_file, edit, replace, run_shell_command; Codex: apply_patch, Edit, Write, Bash), as Claude Code’s file does. Both contracts were recorded live (tests/fixtures/pretool/qwen/, tests/fixtures/pretool/codex/). Codex runs the new entries only once they are approved in an interactive session. doctor’s pretool-hook reads .qwen/settings.json and .codex/hooks.json too. additive Remove the two entries from .qwen/settings.json or .codex/hooks.json, or delete the file and reinstall with an earlier release.
v0.16.0 assertion-reduction, vacuous-tests In Rust, a call whose function is chosen at the call ((if SHARED { shared } else { plain })(x), or a match whose every arm names a function) counts the checks every choice runs, directly and through a forwarding wrapper: each count is the smallest over the choices. Before, it counted none, so a refactor that moved a helper’s body behind such a choice read as losing that helper’s checks. A choice that is not a same-file helper still leaves none. fix None.
v0.15.0 action (install) On github.com, with the default download_url and gh on the runner, the downloaded archive must also carry a build provenance attestation signed by this repository’s release.yml for the requested tag (gh attestation verify --signer-workflow --source-ref); a failure stops the install (install_error). Elsewhere a notice says only SHA256SUMS was checked. Before, only SHA256SUMS from the same release was checked, which a replaced asset with a replaced SHA256SUMS passes. Every published release carries the attestation. stricter A runner that cannot reach GitHub’s attestation API or Sigstore’s TUF mirror fails the install: pass binary_path with a binary verified another way, or set download_url to a mirror.
v0.15.0 hook install, hook run New hook install --agent claude-code --pin-sums <SHA256SUMS>: the Claude Code bootstrap carries the release’s two linux-musl digests and checks the download against them, and no longer fetches SHA256SUMS from the release it verifies. Without --pin-sums, a pinned bootstrap is kept (also with --upgrade), never replaced by an unpinned one. In a hook’s own check a pinned bootstrap is recognised as generated, unless its base pinned the same release with other digests. The unpinned bootstrap is unchanged. additive Optional: verify the release’s SHA256SUMS with gh attestation verify, then reinstall the bootstrap with --pin-sums.
v0.15.0 hook install agy’s generated Stop check timeout is 300 s (was 120 s: a check under heavy load outlived it and agy killed it). New --timeout <seconds> sets the check timeout in the agy, Qwen Code and Copilot CLI files (Copilot user-level included) and is refused for other agents. In a hook’s own check, a hook file identical to generated output with a timeout longer than the default is recognised as generated; a shorter one is reported. Generated output changes (agy). additive Regenerate .agents/hooks.json (delete and reinstall) for the longer default.
v0.15.0 assertion-reduction, vacuous-tests In Rust, a same-file helper called with type arguments (insert_mode::<false>(x), self::insert_mode::<true>(x), store::<Vec<u8>>(x)) now counts its checks, directly and through a thin wrapper, as a call without them already did. Before, the call named <false> and matched no helper, so a refactor that moved a helper’s body into a generic function it then called that way read as losing that helper’s checks. A function chosen at the call ((if SHARED { a } else { b })(x)) is still not resolved. fix None.
v0.15.0 assertion-reduction, vacuous-tests In Rust, a same-file helper called inside a test’s macro arguments (assert!(git(&["status"]).status.success()), assert_eq!(self::run(1), 2), format!("{}", run(1))) now counts its checks, as a call outside a macro already did; an assert_helper_fns name called there counts too. Macros that run none of their arguments (stringify!, concat!, concat_idents!, env!, option_env!, cfg!, include!, include_str!, include_bytes!, compile_error!, module_path!) are not read, and method calls are not resolved, inside or outside a macro. Moving a helper call into an assert! is no longer reported as a drop. A test whose only checks sit in a helper called from a macro is no longer vacuous, and one that later loses that call is now reported. changed (fewer false positives; a drop that was hidden is now reported) None; an allow-assertion-drop added for such a move can be dropped.
v0.15.0 hook install, hook run hook run --event pre-tool and --event session-start honour --if-configured: outside a repository with a discipline.toml (looked for in the directory the payload names) they pass silently. hook install --agent copilot --user writes both entries with it. hook install --user reports a user-level file that runs discipline and differs from this release’s, instead of saying it already runs discipline, and --user --upgrade rewrites it. additive discipline hook install --agent copilot --user --upgrade to add the entries to an existing user-level file.
v0.15.0 assertion-reduction The forwarding-wrapper rule (a wrapper may compute locals before its one call and forward them with its own parameters) applies in every pack that resolves helpers, not only Rust: Python, JavaScript / TypeScript, Java, Go, PHP, C/C++, C#, Ruby, Kotlin, Swift, Scala and Objective-C. Each binding binds one name to one value (b = loc(d), const b = loc(d);, int b = loc(d);, b := loc(d), var b = loc(d), $b = loc($d);, val b = loc(d), let b = loc(d)). Its name must be read by the call or a later binding; a member name, selector part or argument label spelled the same is not a read, in Rust too. Destructuring, several names in one declaration, a Kotlin getter or delegate, and a Swift computed or observed property are still work. In C/C++ and Python a wrapper’s other calls (those computing its locals) are still followed as ordinary calls. looser (false positive removed) None; an allow-assertion-drop added for such a refactor can be dropped.
v0.15.0 hook install, hook run New hook run --event session-start: takes the worktree’s lease for the session in the payload, on the branch checked out there; it never blocks, and says on stderr when another live session holds the worktree or the branch. The files hook install writes for Claude Code (SessionStart, after the bootstrap), Copilot CLI (sessionStart), agy (SessionStart) and OpenCode (session.created) run it. additive Remove the session-start entry from the hook file, or discipline lease release after the session starts.
v0.15.0 ci-integrity New findings for workflow changes that expose secrets or a write token: template-injection (${{ github.event.* }}, ${{ github.head_ref }} or ${{ inputs.* }} in a run: script, composite actions included), secrets-inherit, checkout-persists-credentials (actions/checkout without persist-credentials: false in a job that can write; no permissions: block counts as write), secrets-with-third-party-action (a job reading secrets.* runs an action outside actions/, github/ and first_party_action_prefixes; a warning, since a registry login or deploy action needs a secret by design), schedule-trigger-with-secrets. Read from the parsed YAML; by default only an exposure new relative to the base is reported, every one with diff_only = false. A composite action’s metadata file is now read for injection even with pin_actions = false. stricter Bind untrusted values in env: and read them as "$VAR"; pass named secrets instead of inherit; add persist-credentials: false; or lift with allow-ci-weakening: <subject> <reason>. Before setting diff_only = false, record existing ones with discipline baseline --write --base <ref>.
v0.15.0 doctor New findings on SHA pins (#260), GitHub only: imposter-commit (a pinned owner/repo@<sha> must be reachable from a branch or tag of owner/repo itself: the default branch, a branch or tag head, or up to 25 other branches and tags by compare; a Fail when every branch and tag was checked and none contains it, a Warn when the refs were more than that), nested-action-pins (a Warn when the pinned action’s action.yml, or a pinned reusable workflow, uses a tag or branch ref or an image without a digest at that commit; one level deep). A summary line counts the pins resolved and the references not SHA-pinned, which are not checked. Information on Gitea, Forgejo and GitLab. stricter (a Fail; --strict fails on the Warns) Re-pin to a commit on a branch or release tag of the action’s repository; pin an action whose own metadata pins by SHA.
v0.15.0 ci-integrity New key banned_actions and finding ci-integrity/banned-action: every uses: in the scanned files (steps, job-level reusable workflows, composite actions’ nested steps) matching an entry (owner/repo, owner/repo@ref, or { uses, reason }) is reported on every run, whatever diff_only says and whether or not the change touched the file. No directive or inline allow lifts it. Empty by default, so nothing changes until it is set. additive None. To catch a newly banned entry without a pull request, run the check on a schedule (recipe in docs/CONFIGURATION.md).
v0.15.0 assertion-reduction A Rust wrapper may compute locals before its one call and forward them with its own parameters (let bin = locate(dir).unwrap(); run_in(dir, args, &bin)): the wrapped function’s checks now count as the wrapper’s, beside its own. Each binding must be let <name> = <value>; and its name read by the call or a later binding. Any other statement before the call, a local nothing reads, a pattern or let-else binding, or a computed call argument is still work, not a wrapper. Other packs are unchanged. looser (false positive removed) None; an allow-assertion-drop added for such a refactor can be dropped.
v0.15.0 lease install-guard The generated reference-transaction hook passes silently when the installed discipline has no lease command (0.14.x answers unrecognized subcommand). Before, it aborted every ref update in every worktree. install-guard rewrites a guard an earlier build wrote; a foreign hook is still never rewritten. fix Re-run discipline lease install-guard.
v0.15.0 doctor New repository-settings findings (#258): actions-sha-pinning (Warn when sha_pinning_required is false), allowed-actions (Warn on allowed_actions: all), default-token (Warn on default_workflow_permissions: write), actions-approve-prs (Warn when workflows can approve pull requests), immutable-releases (Warn when off), tag-protection (Warn when no active tag ruleset blocking update and deletion covers the latest release tag; on Gitea, Forgejo and GitLab, when no protected-tag pattern covers it), secret-scoping (Warn when a repository-level secret is read only by environment-bound jobs; names only). GitHub’s admin-only settings the token cannot read are a Warn saying could not check with the access needed, never a pass; a forge that cannot be reached is unknown (exit 2). On Gitea, Forgejo and GitLab the GitHub-only settings are information (not available on this forge). stricter (--strict fails on the Warns, including the not-visible ones under a non-admin token) Change the setting, run doctor with an admin token, or run without --strict.
v0.15.0 doctor New local findings: ref-guard (a warning when the repository has several worktrees and no lease guard; information with one), leases (a failure when a lease file does not parse, since the guard then refuses every branch update; stale leases listed as information), pretool-hook (information when a generated agent hook file has no pre-tool entry). stricter (--strict with several worktrees and no guard) Run discipline lease install-guard, or doctor without --strict.
v0.15.0 hook run --event pre-tool, hook install Shell commands are checked too, parsed with the bash grammar: cd into another worktree, git -C / --git-dir / --work-tree into one (read-only subcommands such as status and log pass), an output redirect into one, and a force push of a branch another worktree leases are refused; so are such paths that expand at run time and a command that does not parse. The generated pre-tool entries now match the shell tools (Claude Code Bash, Copilot bash, OpenCode bash; agy already sent every tool). New dependency: tree-sitter-bash (MIT). stricter Regenerate the hook files (hook install --upgrade, or delete and reinstall the JSON ones) to route shell tools to the check.
v0.15.0 hook install The generated hook files for Claude Code (PreToolUse), Copilot CLI (preToolUse), agy (PreToolUse) and OpenCode (tool.execute.before) gain a pre-tool entry running hook run --event pre-tool (with --observe when installed in observe mode), which refuses an edit into another worktree, into a worktree another session leases, or into forbidden_paths before it runs. Without discipline on PATH the entry lets the call through. Generated output changes. stricter (an edit into another worktree is refused) Regenerate with hook install --upgrade (Claude Code, Copilot and agy files are JSON without the generated header: delete and reinstall, or add the entry); install with --observe to watch before enforcing.
v0.15.0 hook run New --event pre-tool: checks the tool call on stdin before it runs and refuses an edit into another worktree of the repository, into a worktree another live session leases, or into scope-confinement’s forbidden_paths, answering in each agent’s recorded deny shape (Claude Code, Codex and Qwen Code: exit 2; Copilot CLI: permissionDecision: "deny"; agy: decision: "deny"; OpenCode: exit 1). hook install does not write it yet. additive None.
v0.15.0 lease New discipline lease install-guard: a reference-transaction git hook (in core.hooksPath, else the common hooks/ directory; an existing hook is never rewritten) that aborts a ref update moving a branch another worktree’s live lease claims, whether it comes from a rebase with --update-refs, branch -f, update-ref or a deletion. Remote-tracking refs and tags are not checked. Without discipline on PATH the hook says so and lets the update through. additive None.
v0.15.0 lease New command discipline lease take / release / list / check: per-worktree claims on branches, kept in the common git directory, so one agent session does not move a branch another worktree’s session is working on. check exits 1 when another worktree’s live lease claims the branch, 2 when a lease cannot be read. additive None.
v0.15.0 action token input The token is handed to discipline on every run (DISCIPLINE_FORGE_TOKEN), not only with comment: true: the gates that read the forge (issue-link verify_references, ratified-paths, review-threads, require_approval) need it, and GitHub’s GraphQL API refuses anonymous reads. The default is still the job’s github.token, with the workflow’s permissions. fix (forge-reading gates work under the action) To keep the binary tokenless, set token: ''.
v0.15.0 doctor agent-permission A login is percent-encoded into the API path (renovate[bot] is renovate%5Bbot%5D). Before, a bot login’s brackets made the client refuse the path, and the finding was unknown, so doctor exited 2. fix None.
v0.15.0 ci-integrity Pinning covers a job-level uses: of a remote reusable workflow (40-character commit SHA, ci-integrity/unpinned-action) and container images: container:, services.<name>.image and uses: docker:// need an @sha256: digest (new code ci-integrity/unpinned-container-image); an image written as an expression is a note. By default only a reference new relative to the base is judged, as before. With diff_only = false every unpinned reference in the scanned files is reported, pre-existing ones included, and the message says added by this change or pre-existing. stricter Pin the new references, or excuse them with allow-ci-weakening: <ref> <reason>. Before setting diff_only = false, record today’s references with discipline baseline --write --base <ref>.
v0.15.0 ci-integrity New finding discipline-version-changed (Discipline Version Chosen By The Change): a change that moves the discipline pin to an older release or a movable ref, or adds a binary / download_url source, reported on the line of the first such pin (the uses:, input, image or include line). An upgrade or a digest move is a note. stricter Keep the pin, or lift one change with allow-gate-weakening: ci-integrity <reason>.
v0.15.0 doctor New findings: workflow-protection (Gitea / Forgejo: a workflow that runs discipline not covered by the branch rule’s protected file patterns is a warning, so --strict fails on it), thread-resolution (GitHub / GitLab, information), agent-permission (only with ratified-paths agent logins; an administrator is a failure). stricter (--strict on Gitea / Forgejo) Add the workflow directories to the protected file patterns, or run without --strict.
v0.15.0 forge reads Every forge read (open-issue state, bench citation freshness, require_approval, merged-pr-body, replay, doctor, check --comment’s lookup) is retried on no answer or a 5xx (three attempts, 0.5 s and 1.5 s apart) and waits out a rate limit that asks for at most 60 s; a run makes at most 500 requests. A failure’s message gains the attempt count. Writes are unchanged. output (fewer transient exit 2) None.
v0.15.0 issue-link New opt-in verify_references: references are looked up on the forge and one must be an issue of this repository; new findings issue-reference-not-found, issue-reference-closed; new keys require_open_issue, reference_repos, accept_pull_references, waiver. Off by default, so nothing changes until it is set. additive None.
v0.15.0 command New turnkey preset base-tests: checks out base branch test paths over the head code tree in an isolated temporary worktree, runs the configured test command, parses JUnit XML outcomes, and reports tests that passed on base but failed on head (command/base-test-failed). Lifted via allow-behavior-change: <test> <reason>. stricter (silent behavior regressions caught) Lift via allow-behavior-change: <test> <reason> or restore expected behavior.
v0.15.0 ignored-tests Detects top-level early returns and skip calls under environment or CI checks in Rust, Python, JavaScript/TypeScript, and Go as Test Conditionally Skipped (Warning for CI environment variables, Note for generic environment variables). Liftable via allow-ignore: <test> <reason>. stricter (silent test avoidance caught) Add an allow-ignore: <test> <reason> directive or run the test unconditionally.
v0.15.0 ignored-tests A sleep entry without a qualifier (sleep(, usleep(, delay(, setTimeout() counts only when it is the whole callee name or its last segment (after ., ::, -> or the start of the callee): retry_delay(1), compute_delay(), nosleep() are no longer ignored-tests/test-sleep-added; sleep(1), delay(10), this.delay(5), std::thread::sleep(d), Task.Delay(5) still are. Trivial-assertion matching (vacuous-tests) is unchanged. looser (false positive removed) None.
v0.15.0 assertion-reduction A literal forwarded by a thin wrapper must hold no call: a helper passing a composite literal or interpolated string that calls (Go []int{f()}, Swift [f()] / ["k": f()] / "\(f())", Objective-C @[f()] / @{@"k": f()}, Kotlin "${f()}") does work, so it is an ordinary helper and no longer counts the wrapped function’s checks as its own. Literals of names, numbers and escapes are still forwarded. stricter (false negative removed) A test whose only check was reached through such a helper can now be reported; call the checked function directly, or compute the value outside the helper and pass it in.
v0.15.0 directives.require_approval On Gitea and Forgejo, reviews of a pull request are read with limit=50&page=N until they reach the forge’s X-Total-Count (or, without that header, until a page comes back empty). Before, one per_page=100 request was sent, which these forges ignore, so only their default first page of 30 reviews was judged and an approval withdrawn past review 30 still counted. Pages that end before the count, overshoot it, a count that changes between pages, a page that only repeats the one before when no count is sent, or more than 20 pages: could not check (exit 2, reason forge). check --comment on these forges stops re-reading their unpaged comment list once a page brings no new comment or the count is reached. GitHub is unchanged. stricter (fail-open removed) None. A pull request with more than 1000 reviews on Gitea or Forgejo is refused rather than judged.
v0.15.0 hook install Generated hook commands (Claude Code, Codex, Cursor, Copilot CLI including --user, agy, Qwen Code) check that discipline is on PATH first: without it they print discipline is not on PATH; the discipline hook did not run (...) on stderr and answer the agent’s pass ({} for Cursor and agy) instead of exiting 127. Aider and OpenCode are unchanged. Generated output changes. output (a missing binary is named) Regenerate with --upgrade (Claude Code, Copilot, agy files are JSON without the generated header: delete and reinstall, or merge the new command).
v0.15.0 hook install The Claude Code bootstrap removes its download directory on exit (trap 'rm -rf "${dir}"' EXIT); before, every run that downloaded left one mktemp -d directory behind (generated output changes). fix Regenerate the bootstrap with --upgrade.
v0.15.0 hook install --cloud-agent writes no .github/workflows/copilot-setup-steps.yml when the repository has remotes and none is on GitHub (Copilot cloud agent runs only there); it says so, naming the remote hosts. A repository without a remote still gets the workflow. The Claude Code bootstrap’s header says Claude Code cloud sessions run only on repositories hosted on GitHub (generated output changes). looser (no dead workflow written) Regenerate the bootstrap with --upgrade. A non-GitHub repository can delete a setup-steps workflow an earlier release wrote.
v0.15.0 hook install, doctor hook install --agent copilot prints a note when Copilot CLI does not trust the folder (it then skips .github/hooks/ without a message), naming both fixes: trust the folder, or --user. New doctor finding copilot-trust: Warn when the repository has a Copilot discipline hook and the Copilot CLI configuration on this machine does not trust the folder, Info when a user-level hook covers it, Pass when trusted; none without a repository hook or without Copilot CLI configuration. stricter (doctor only, a new Warn) Trust the folder, or hook install --agent copilot --user. doctor --strict fails on the Warn.
v0.15.0 hook run hook run --agent copilot --if-configured (the user-level hook) passes silently when the repository has its own Copilot discipline hook (.github/hooks/*.json) and Copilot CLI trusts the folder or one above it (trustedFolders in config.json under the Copilot home, read with its comments): Copilot runs both for the same event, so the check ran twice and observe mode logged two lines. An untrusted folder, or a repository without the hook, is checked as before. looser (a duplicate check removed) None.
v0.15.0 hook install hook install --agent copilot --user --observe writes observe-mode commands (hook run --agent copilot --if-configured --observe); before, --observe was dropped for --user and the user-level hook enforced in every adopted repository. fix (observe mode kept) A user-level file written with --observe by an earlier release enforces: delete hooks/discipline.json in the Copilot home and install it again.
v0.15.0 config-integrity An entry of a list of tables is matched across base and head by its identity (version-lockstep groups by name, manifest-sync rules by manifest and extract_regex, command commands by name). An entry whose only edits tighten it (a source added to a group with every base source unchanged, a watched path added to a rule or an exclusion dropped from it, a forbidden output pattern added to a command) is no longer reported as `groups` lost 1 entr(y/ies). A removed or edited source, a renamed entry, or any other changed field is still a lost entry. looser (false positive removed) None; an allow-gate-weakening: added for a source added to a group can be dropped.
v0.15.0 assertion-reduction A thin wrapper may forward a reference or a collection literal built from names and literals: &[], &[1, 2], &mut x, (x, 1) and () in Rust; [], {}, [a, 1], {a: 1} in JavaScript / TypeScript, Python (also tuples and sets), Ruby and PHP; {} and {1, 2} in C++; &x in C/C++, Objective-C, Go and Swift. Such a helper (hook(r, a) { hook_env(r, a, &[]) }) now counts the wrapped function’s checks, as it already did with a named constant. A collection holding a call, a macro (vec![]), or a unary operator other than & (-x, <-ch) is still work and not a wrapper. looser (false positive removed) None; an allow-assertion-drop added for such a wrapper can be dropped.
v0.14.4 assertion-reduction A same-file helper whose whole body is one call to another same-file function, passing names and literals through (a thin wrapper: install(a) { install_with(a, false) }), counts that function’s checks as its own, in every pack that resolves helpers; up to three wrappers are followed and a cycle stops. A test calling a helper that became such a wrapper keeps its effective-assertion count instead of being reported. In C/C++ and Python a wrapper hop no longer spends one of the three call levels, so checks reached through wrappers past the third level now count. looser (false positive removed) None; an allow-assertion-drop added for a helper refactored into a wrapper can be dropped.
v0.14.3 hook install New --upgrade: rewrites a file an earlier release generated (it carries the Written by \discipline hook install`` header: the Claude Code bootstrap, the Copilot setup step, the OpenCode plugin) to this release. Without it, such a file that differs is reported as outdated instead of as current. A file without the header is never rewritten. additive After a release, hook install --agent <name> ... --upgrade.
v0.14.3 hook run, instruction-smuggling In a hook’s own check (DISCIPLINE_HOOK_RUN, set by hook run), a hook file identical to what hook install of this release writes is a note, not agent-instructions-changed: the hook reads no PR body, so on the branch that adds its own files it could never lift the finding. A hand-edited hook file is still reported; CI is unchanged. looser (hook runs only) None.
v0.14.3 ci-integrity A step that reports (a reporting action, github-script that cannot fail, or a name starting with a reporting verb such as Comment) is no longer a verification step because its name mentions gate / test / check, so narrowing or removing it is not reported; continue-on-error in a job that verifies nothing is a warning, not an error. looser (false positives removed; one error becomes a warning) None.
v0.14.3 replay A change that skipped part of a gate because the configuration names a file the change does not have yet (a version-lockstep group or a manifest-sync rule) is counted in the summary: the text output says how many changes skipped such a check and names the gate and group or manifest; --json adds skipped_by_gate (gate → changes) and a per-case skipped_checks list. additive (new summary line and JSON fields) None.
v0.14.3 doctor An action step with advisory: true marked # discipline:advisory <reason> (on the line above the step or on its uses: line) is reported as non-blocking info naming the reason, not a FAIL. A marker without a reason or with a placeholder reason is ignored. The step still enforces nothing: a job whose only discipline steps are marked advisory still does not count as a blocking check. looser (an intended shadow step is not a FAIL) None; mark a deliberate shadow step to clear its FAIL.
v0.14.2 version-lockstep Drift the base already had is a note, not version-mismatch, when the change touches none of the group’s sources; the next change that touches one must resolve it. looser (a change is not blamed for drift it did not cause) None.
v0.14.2 dependency-delta A pyproject.toml requirement on the project’s own extras (all = ["<project>[a,b]"]) is not reported as a new or wildcard dependency. looser (false positive removed) None.
v0.14.2 time-estimates A lookback (the last 24 hours, also across a soft wrap), this <period>'s, and Q<n> followed by a reporting noun (the Q3 invoice) are not reported. looser (false positives removed) None.
v0.14.2 ci-integrity Fewer false positives, found by consumer adoption runs: a rollup job with if: always() that reads its needs results is not verification-job-masked-by-condition; a set +e whose $? is saved and tested is not exit-code-masked; verification-step-masked-by-condition is reported only when an existing verification step now runs only after a failure (a new if: failure() diagnostic, or a bare always(), is not); a verification job or workflow whose steps pair by body with a job this change added (a rename, split, or move to another file) is a note, not verification-job-removed / verification-workflow-deleted. always() && <condition> on a verification step is now verification-step-narrowed (warning), not masked-by-condition (error). looser (false positives removed); one error becomes a warning None.
v0.14.2 --format agent-prompt, hook run, MCP check_diff Warnings are listed after the blocking findings under ## Warnings (not blocking), with the instruction to fix one only if the change caused it; a report with only warnings says they do not block. Observe mode’s note and log name only the blocking codes. --fail-on-warnings keeps every finding an issue. output (agent text) None.
v0.14.2 hook install A release binary writes the Copilot setup step pinned to its own commit (uses: orieg/discipline@<sha> # v<version>) instead of the tag; hook install warns when git ignores a file it wrote; the Claude Code bootstrap puts ~/.local/bin on PATH through CLAUDE_ENV_FILE when it is not there. additive A setup-steps workflow written by an earlier release keeps its tag ref; pin it by hand or rewrite it.
v0.14.2 doctor The push-trigger finding is Pass, not Info, when merged-pr-body is enabled and every discipline job that runs on push is granted pull-requests: read or write (or read-all / write-all) by its job’s permissions: or, when the job declares none, the workflow’s: that grant is the fix the Info finding asked for. A job-level permissions: without it still reports Info, since it replaces the workflow’s. looser (a satisfied advisory no longer reported) None.
v0.14.2 replay --json Each entry of cases_detail gains findings: the code, severity, file and line of every error and warning of that change’s report, so what blocked a change is read from the replay instead of a check per commit. The message is never included, since a finding can echo secret material. discipline.replay.schema.json gains the field; its schema_version stays 1. additive None. A consumer that validates replay output against a copy of the old schema with additionalProperties: false updates its copy.
v0.14.2 check New environment variable DISCIPLINE_PR_BODY_FILE names the file --pr-body-file would: the flag wins over it, and it wins over PR_BODY. Before, it was silently ignored. The agent hooks (hook run) and replay clear it for the checks they run, as they clear PR_BODY. additive None, unless a pipeline set DISCIPLINE_PR_BODY_FILE for another purpose: its file is now read as the pull-request body.
v0.14.2 baseline --migrate With no baseline file, baseline --migrate prints a note that there is nothing to migrate and exits 0, so an adoption script can run it unconditionally; before, it was an error (exit 2). A baseline file that cannot be read still exits 2. looser None.
v0.14.2 doctor A workflow step that uses the action with install_only: true (as hook install --agent copilot --cloud-agent writes) is no longer counted as a discipline job: its triggers, token and push runs are not reported, so doctor --strict no longer fails on the Copilot setup steps. A step whose install_only is an expression still counts. looser (a false warning removed) None.
v0.14.1 hook run, hook install, MCP New hook run --observe (and hook install --observe, which writes it): the check runs but never blocks; what would have blocked is said on stderr, marked as observe mode, and appended to <git dir>/discipline/hook-observe.log. The text an agent reads when a check cannot run now names the reason and gate (discipline could not check this change (reason: tool-missing, gate miri)), in the hook and in MCP check_diff. additive None.
v0.14.0 version-lockstep, manifest-sync DISCIPLINE_REPLAY_CASE, which lets discipline replay skip a group or rule whose file neither side has, is honoured only when the base is the commit replay builds; set in a CI job or by hand, a missing file stays a configuration error (exit 2). stricter None, unless a pipeline set DISCIPLINE_REPLAY_CASE: remove it.
v0.14.0 dependency-delta uv.lock, composer.lock and Gemfile.lock are read entry by entry, as the other lockfiles are: an entry from a new source, a dropped integrity hash and a deleted lockfile are findings, and the package count note is filled. Before, the gate never collected these files, though their parsers existed. stricter A Python (uv), PHP or Ruby project can receive lockfile findings it did not before.
v0.14.0 vacuous-tests New directive allow-vacuous-test: <test> <reason> (also discipline:allow(vacuous-tests)) lifts every finding on that one new test; before, no directive could, and only exempt_paths, a helper declaration or the baseline did. looser None.
v0.14.0 replay --json: gates whose overrides fail_on_overrides refused are listed in a case’s own refused_overrides field (with the actor the case was checked as) and in the summary’s refused_overrides_by_gate, instead of in blocking_gates with the reason in detail. Refusal is now judged from the configuration’s override policy, so a change blocked by both an error and a refused override names both. changed (replay --json shape) A consumer reading refused overrides from blocking_gates or detail reads refused_overrides.
v0.14.0 msrv A command that cannot run (not found, cannot start, over the timeout) and a Cargo.toml that cannot be read exit 2 (could not check) instead of reporting a finding; a command that runs and exits non-zero is still a finding. The note for a declaration alone says MSRV declared, not verified. changed (exit 1 becomes 2) A pipeline that treated exit 1 as the MSRV build failing sees exit 2 when the toolchain or command is missing.
v0.14.0 assertion-reduction, vacuous-tests An extra_assert_macros entry written with its ! (assert_matches!) names the same macro as without it; before, it silently never matched, so the tests asserting through it were read as having no assertion. looser None.
v0.14.0 suppression-delta Java: @SuppressWarnings named in a comment or a Javadoc {@code ...} is no longer a new suppression; only the annotation node counts, as documented. looser None.
v0.14.0 configuration, report Renamed configuration keys are supported: an old name listed in KEY_ALIASES is read as the new one with a note in the report’s new deprecations list (deprecated: ... in text, **Deprecated:** in the step summary), and setting both names is a configuration error. No key is renamed yet, so no configuration changes behaviour. additive None.
v0.14.0 report, replay JSON Schemas of the check --format json report and the replay --json summary ship as discipline.report.schema.json and discipline.replay.schema.json, generated by discipline docs and checked by docs --check; a test pins every field and validates real output. The output itself does not change. additive None.
v0.14.0 report, agent surfaces Every finding has a registered code, gate/code: a new code field on each JSON report violation, the [gate/code] heading in the agent-prompt report (hooks, discipline mcp), and a Findings: list in discipline explain, which also accepts a code or a [gate/code] line. Titles, fingerprints and SARIF rule ids are unchanged. changed (agent-prompt heading) A script matching [gate] in agent-prompt text matches [gate/.
v0.14.0 baseline, report, SARIF, GitLab Fingerprint version 2 keys on the finding’s code (v2:gate/code:path:hash), not its title: baseline --write writes version = 2 with rule = "gate/code". A version-1 file still matches, with a deprecated: line, until discipline baseline --migrate rewrites it; config-integrity accepts that migration alone without a directive. Each finding in --format json gains fingerprint; SARIF ruleId is the finding code (one rule per code, gate as a tag) with partialFingerprints; the GitLab Code Quality check_name is the code and its fingerprint is the baseline fingerprint. Source-parse findings are coded under assertion-reduction whichever AST gate reports them. changed (SARIF rule ids, GitLab fingerprints, baseline format) Code scanning and GitLab re-open their alerts once under the new ids; run discipline baseline --migrate in a change of its own.
v0.14.0 agent surfaces The Repair: line of the agent-prompt report (hooks, discipline mcp) is chosen per finding code, then per gate: a dropped lockfile hash, a loosened constraint, a sleep or a retry added to a test, a missing benchmark baseline and 40 other kinds get their own repair instead of their gate’s. No repair tells the agent to regenerate golden output, and repairs named for gate ids that do not exist are gone. changed (agent-prompt text) None.
v0.14.0 miri, sanitizers A run that could not start (the tool not found, over its timeout) or that reports its toolchain unavailable exits 2 (could not check) whatever the directives say; before, a start failure was a finding an execution, toolchain or nightly waiver lifted, and a waiver also lifted an unavailable toolchain. allow-miri: / allow-sanitizers: now lift only findings from a run that happened. stricter A job without the toolchain disables the gate in its configuration instead of waiving it.
v0.14.0 findings, report, agent surfaces Finding titles are display text: 80 are reworded to one grammar (Title Case, ASCII, no data, one verb per meaning; docs/GATES.md “Finding Codes”), and the 25 that carried data or repeated their message get a fixed title, the data staying in the message. Titles that named a waiver (“Without Directive”, “Without Override”) no longer do. The code, fingerprint and SARIF rule of every finding are unchanged, and a fingerprint-version-1 baseline still matches the renamed findings under their old titles. GATES.md lists every code with its title. changed (titles) A consumer matching a title matches the finding’s code.
v0.14.0 check A run that could not check (exit 2) prints the JSON report on stdout under --format json (before, stdout was empty) and writes the same report to --json-out and a JSON --output-file: no outcomes, and a could_not_check object with the reason (configuration, baseline, repository, tool-missing, tool-timeout, toolchain-unavailable, forge, gate, internal), the gate that could not run and the error. Before, --json-out held a single engine outcome with the error as its violation; JUnit, SARIF and GitLab reports still do. Every JSON report and replay summary starts with schema_version (1). The error on stderr is unchanged. changed A consumer that read the error from outcomes[0] of an exit-2 --json-out report reads could_not_check.detail. One that treated any stdout under --format json as a completed check reads the exit code first.
v0.14.0 replay could_not_check_by_reason is keyed by the reason code the child check reported (forge for a change whose merged pull request could not be read), no longer by the last line of its error; each case that could not be checked carries reason, and the text summary lists each change with its error under its reason. changed A consumer that matched error text in the keys reads cases_detail[].detail.
v0.14.0 MCP check_diff A check that could not run carries reason and gate in structuredContent, beside status: could_not_check. additive None.
v0.14.0 MCP check_diff structuredContent carries schema_version and, when the check ran, findings: each finding’s code, severity, title, location, message, repair and fingerprint, without the remediation. The tool declares it as its outputSchema. additive None.
v0.14.0 agent-prompt, hook run, MCP Each problem is quoted in a fenced block one backtick longer than any backtick run in it (- Problem: is followed by the block instead of the text), and so is the error of a run that could not check; titles and locations are kept to one line. Directive redaction is case- and spacing-insensitive (ALLOW-SWALLOW:, discipline : allow (x)), and the bare words removes and deletes are no longer redacted without their colon. changed A consumer that parsed - Problem: <text> reads the fenced block after - Problem:.
v0.14.0 hook run A stop let through at a loop guard (stop_hook_active, agy’s third block) still runs the check, and when the change has findings or could not be checked says so on stderr; the exit code and stdout are the agent’s pass, as before, so the loop still ends. stricter None.
v0.14.0 baselines A version-2 fingerprint hashes the finding’s anchor (a test, dependency, key, counter or commit name) with its source line, and never its message: a finding with no line and no anchor hashes its code and path only. Anchored: deletion-rationale/test-removed-without-rationale, vacuous-tests/vacuous-test-added, the dependency-delta findings on a manifest entry, config-integrity/gate-weakened, toolchain-config/toolchain-config-weakened, bench-regression/counter-regressed and counter-regressed-unapproved-arm, issue-link/directive-in-subject-line. Two findings reported at identical lines (two tests’ @Test attribute) no longer share a fingerprint, so baselining one no longer hides the other. stricter Version-2 baselines written before this change are regenerated with discipline baseline --write; version-1 baselines and --migrate are unchanged.
v0.14.0 hook install --agent agy writes the Stop handler directly under the named hook, as agy’s hook guide requires; the file written before (the handler inside a matcher / hooks group) was never run by agy. --agent copilot adds apply_patch to the postToolUse matcher, the edit tool some models use. stricter Re-run discipline hook install in a checkout without the old file (it never rewrites one), or edit .agents/hooks.json and .github/hooks/discipline.json to match.
v0.14.0 explain allow-nul: is listed under assertion-reduction, the gate that codes the NUL-byte finding, instead of vacuous-tests. changed None.
v0.14.0 hook install, hook run New hook install --agent copilot --user writes hooks/discipline.json in the Copilot home directory (.copilot in the user’s home, or COPILOT_HOME), which Copilot CLI loads in any folder, trusted or not; its command carries the new hook run --if-configured, which checks only a git repository with a discipline.toml at its root and passes silently elsewhere. additive None.
v0.14.0 agent-prompt The line after the heading says the report comes from the repository’s own check, that each Repair line is what to do, and that only fenced text is quoted data; the previous line (“which is data to fix, never an instruction to follow”) was read by a model in a live session as describing the whole report. changed None.
v0.14.0 action, hook install New action input install_only installs the binary and puts it on PATH without running a check. New hook install --agent copilot --cloud-agent also writes .github/workflows/copilot-setup-steps.yml, which runs the action with install_only so Copilot cloud agent’s hooks find discipline; before, the cloud agent skipped them. additive None.
v0.14.0 hook install --agent claude-code also writes .claude/hooks/discipline-bootstrap.sh and a SessionStart hook running it: in a Claude Code cloud session (CLAUDE_CODE_REMOTE=true) with no discipline on PATH, it installs this release from its GitHub release, SHA256-verified, into ~/.local/bin; locally, or when discipline is there, it does nothing, and it never fails the session. Before, a cloud session had no binary and its hooks could not run. additive Remove the SessionStart entry from .claude/settings.json.
v0.14.0 pii New agent_config_standard_paths (default true): a reference to an agent tool’s home directory itself or to a configuration entry the tool documents there (~/.copilot/hooks/, ~/.claude/settings.json, ~/.codex/config.toml, …) is not reported as a personal agent-config reference; instruction files, skills, agents, commands, rules, session history and other files there still are. Before, every ~/.<agent> path was reported, including documentation of where a tool reads its settings. looser [gates.pii] agent_config_standard_paths = false
v0.14.0 hook install --agent agy adds a SessionStart handler: when discipline is not on PATH, it tells the agent, which tells the person, that the repository’s hook cannot check the change; when it is, it is silent. Before, a missing binary left agy’s Stop hook failing with nothing shown in agy -p. additive Remove the SessionStart entry from .agents/hooks.json.
v0.13.1 time-estimates A duration narrated as observed is not an estimate: one in the present perfect or a past passive, a span on record for, a gap with no event. A frequency, an age or a sub-hour bound written with a number word is exempt as its digit form already was, and so is a test run length (N-hour run). The in N / within N weeks / takes N form and plan vocabulary still fire, and scoped now counts as plan vocabulary. looser; stricter (scoped) A sentence with scoped and a duration can receive a finding it did not before.
v0.13.1 replay Each replayed change is checked with its merged pull request’s author as --actor, as the action does, and an actor in the replaying shell is ignored. Under fail_on_overrides = true, an override by an allowed author is no longer refused, and a change blocked only by refused overrides names those gates and the reason instead of listing no gate. On a Gitea repository with allowed_override_actors: 8 of 19 blocked changes were such refusals. Superseded in the next release: refused overrides get their own refused_overrides field. looser (replay only) None.
v0.13.0 Objective-C Apple enum heads (typedef NS_ENUM(...), NS_OPTIONS, CF_ENUM) and annotation / availability macros are rewritten byte for byte before the parse, [languages.c] macros applies, and a .h header is read with the C, C++ or Objective-C grammar that leaves the fewest error regions; an Objective-C parse error is a warning, as in C. On a public Objective-C library: 156 files with 5,114 error regions become 6 with 26. looser (parse errors no longer block); stricter (code in those files is now read) An Objective-C project can receive findings in code that was unread.
v0.13.0 assertion-reduction Source File Could Not Be Fully Parsed is a warning, not an error, for a file that could hide no test: outside a test path, no tests on either side, and not Rust. In a test file, a file with tests, or Rust it still blocks. looser None.
v0.13.0 Swift Waiting on XCTest expectations (fulfillment(of:), wait(for:timeout:), waitForExpectations(timeout:)) counts as an assertion. looser None.
v0.13.0 action A workflow that pins the action by commit SHA (as ci-integrity requires) or a branch runs the binary of the release in the action’s own Cargo.toml, as @v0 already did, instead of the latest release. An explicit version: still wins. changed None; set version: to choose another release.
v0.13.0 check --config ../candidate.toml, a relative path that escapes the repository, no longer exits 2; it is treated like an absolute path outside the repository (v0.12.1). looser None.
v0.13.0 check DISCIPLINE_NO_NETWORK=1 also skips the CI-only git fetch that deepens a shallow clone when the base does not resolve, and says so in the diagnostics. stricter Unset DISCIPLINE_NO_NETWORK to let a shallow CI clone fetch its base.
v0.13.0 assertion-reduction A newly added NUL byte is also lifted by the marker of the gate that reports it (discipline:allow(assertion-reduction): <path> <reason>), as its remediation says; the old discipline:allow(vacuous-tests) form still works. looser None.
v0.12.3 Python Same-file helpers are followed up to three calls deep (was one), as in C/C++, a recursive helper counted once: a check that moves from the function a test drives into a validator that function calls is no longer an assertion drop. looser (assertion counts) None.
v0.12.2 C/C++ A test (or helper) that calls same-file functions through a table it builds (std::vector<std::pair<std::string, void (*)()>> tests = {{"get", TestGet}}, {check_a, &check_b}) counts their checks; the three-call helper depth of v0.12.1 had turned such a refactor into an assertion drop. looser None.
v0.12.2 [tests] functions A declared Python name that starts with _ (_self_test) is a test and its body test scope; the private-name rule used to override the declaration. looser None.
v0.12.1 error-swallowing A Python handler that catches only parse errors and does nothing but continue (a loop skipping lines that do not parse) is Unparseable Input Skipped, a warning at most, instead of a blocking Empty Error Handler Added. A bare return in a handler followed by a failing statement after the try is the expect-this-to-raise idiom and is no longer reported. looser None.
v0.12.1 C/C++ Same-file helpers are followed up to three calls deep (was one), a recursive helper counted once; abort, exit, std::terminate and __builtin_trap count as fatal assertions; extern "C" guards under #ifdef __cplusplus no longer leave error regions in C headers; a .h header the C grammar cannot read is re-read as C++ when that leaves fewer error regions. looser (assertion counts); stricter (code in C++ headers and guarded C headers is now read) A C or C++ header can receive findings it did not before.
v0.12.1 error-swallowing A handler whose try body ends in a statement that always fails (assert False, raise, pytest.fail(...), JUnit fail(...)) is the expect-this-to-raise idiom and is no longer reported. looser None.
v0.12.1 error-swallowing A Python except for KeyboardInterrupt, SystemExit or GeneratorExit alone is no longer Empty Error Handler Added: these are stop and exit requests, not errors. Mixed with an error type, or as BaseException, it is still reported. looser None.
v0.12.1 config-integrity, command, test-floor, --policy-from base A --config path outside the repository no longer exits 2 (repo path ... should be relative): the base side falls back to the base discipline.toml, and config-integrity notes that it compares nothing for an operator’s file the change cannot edit. looser None.
v0.12.0 assertion-reduction New Assertion Bound Loosened: an existing test whose assertion keeps its shape while its numeric bound or tolerance moves the way that accepts more (< 1.5 -> < 5.0, rel=1e-6 -> rel=1e-2), read for Python, JS/TS, Rust and Go. At the gate’s severity; lifted by allow-assertion-drop:. stricter A change that relaxes a timing or tolerance bound in a test needs allow-assertion-drop: <test> <reason>.
v0.12.0 check --commit X and --commit-range A..B exit 2 when X / B is not the commit checked out; before, the base came from the flag but the change was read from the checkout, so a different change was judged without a word. A range with no end (A..) is unchanged. stricter Check out the commit first, or pass --base <before> with it checked out.
v0.12.0 instruction-smuggling New instruction_files key: globs of a repository’s own agent-instruction files (a runtime prompt an MCP server loads), reported like AGENTS.md when edited or deleted. Removing an entry is a config-integrity weakening. additive None; empty unless configured.
v0.12.0 dependency-delta A go.mod requirement marked // indirect is no longer New Direct Dependency Added; bans, wildcards and source changes still apply to it. looser None.
v0.12.0 ignored-tests A Go t.Skip / t.Skipf / t.SkipNow inside an if (if testing.Short() { ... }) is a conditional skip, a note naming the condition, instead of Test Arrives Ignored; such a test now counts toward test-floor. if true stays unconditional. looser None.
v0.12.0 agent-scratch .cursor/mcp.json (Cursor’s project MCP server list) joins the default exempt_paths. instruction-smuggling still reports a change to it. looser None.
v0.12.0 instruction-smuggling A zero-width space right after @ in the PR title, body or a commit message (GitHub’s mention guard in Dependabot bodies) is no longer Invisible Characters In Change Description. looser None.
v0.12.0 C/C++ Extension macros the grammar could not read are rewritten byte for byte before parsing: the Zend argument-info, parameter-parsing, module-globals, function-table, ini, hash-iteration and dependency macros and PHP_METHOD / PHP_FUNCTION / module-hook heads, CPython PyObject_HEAD and thread-state macros, Ruby C API export markers; new [languages.c] macros / function_macros for a repository’s own. Code inside them, which was a parse error region, is now read by stub-bodies, error-swallowing and the assertion gates; a macro-headed function is named Class_method. Adding an entry is a config-integrity weakening (subject languages). stricter A C extension can receive findings inside functions that were unread before, and fewer parse-warning notes.
v0.12.0 replay A change that would be blocked but whose merged pull request could not be read (the forge answered with an error, such as a rate limit) is could_not_check, not blocked, and is left out of errors_by_gate. could_not_check_by_reason (JSON) and the text summary group the changes that could not be checked by their error. stricter A replay without a forge token can report fewer blocked and more unchecked changes: set DISCIPLINE_FORGE_TOKEN or GH_TOKEN.
v0.12.0 version-lockstep, manifest-sync Under discipline replay only, a configured file that neither side of the replayed change has skips its group or rule with a note; before, the whole change could not be checked. Outside replay a missing file still exits 2. looser (replay only) None.
v0.12.0 instruction-smuggling Deleting a file that agent-scratch reports as tracked scratch state (.claude/scheduled_tasks.lock) is no longer Agent Instructions Changed. Additions and edits, and deletions of hook files exempt from agent-scratch, are still reported. looser None.
v0.12.0 error-swallowing A PHP @call() whose result decides a branch (an if / while / ? : condition, a comparison such as @f() === false, the left operand of && / \|\|) is no longer Error Silenced: the caller reads the failure from the return value. A bare @call();, an assigned or cast result, and @f() ?: x are still reported. looser None.
v0.12.0 vacuous-tests A PHP top-level function named test* is a test only in a test path or under a [tests] paths glob; before, testsCovering() in examples/ was a vacuous test. Such a function’s body is now also read by error-swallowing, which skips test code. PHPUnit methods and @test / #[Test] functions are unchanged. looser (vacuous-tests); stricter (error-swallowing) A test* helper outside a test path can receive error-swallowing findings: move it under a test path or mark it @test.
v0.11.0 docs docs/CONFIGURATION.md gains a release-gate recipe (GitHub Actions and GitLab CI, tag push: pack to a file, archive-contents with a no-source preset, publish only on a pass); tests/test_release_recipe.rs runs both snippets’ configuration through the binary. additive None.
v0.11.0 archive-contents New preset key: no-source-npm, -jvm, -dotnet, -go, -python, -rust add a documented forbidden-name list (source maps, env files, VCS and CI metadata, tests, keys, registry credentials; src/, source extensions and debug symbols for the binary-shipping ecosystems) to forbidden_patterns; no-source is their union and turns the content scan on. An unknown preset exits 2. additive None; no preset unless configured.
v0.11.0 archive-contents New opt-in scan_contents (and max_entry_bytes, default 16 MiB): a .map entry or an inline base64 sourceMappingURL whose sourcesContent embeds original source is Source Leaked In Archive; a map without it is Source Map Shipped (warning); oversized and undecodable entries are named in notes. additive None; off unless scan_contents = true.
v0.11.0 archive-contents Entries are read from wheels, .jar / .war / .ear / .aar, .apk, NuGet, .vsix, .xpi, .ipa, .tar.xz, .tar.zst, gems (their data.tar.gz, prefixed data/), .deb and .rpm, with the format taken from the magic bytes. Disk images, installers, bare binaries and a name whose bytes are another format exit 2 naming the format; before, an unknown name was tried as gzip-tar then zip. A pax global header is no longer counted as an entry, and a corrupt compressed trailer now fails the run. stricter An archive_path that matched a mislabelled file now exits 2: name the file for what it is.
v0.11.0 Objective-C New language pack: .m / .mm files are analysed by the assertion, test, stub, error-swallowing, suppression and prose gates instead of being named as not analysed. stricter As Swift and Scala.
v0.11.0 report notes .dart, .lua, .ex / .exs, .hs, .zig, .erl, .clj, .fs, .jl and .nim files are named in the AST gates’ notes as not analysed (they have no pack); before, they passed without a note. stricter (notes only) None; no finding or exit code changes.
v0.11.0 Scala New language pack: .scala / .sc files are analysed by the assertion, test, stub, error-swallowing, suppression and prose gates instead of being named as not analysed. stricter As Swift: a repository with Scala code can receive findings it did not before.
v0.11.0 Swift New language pack: .swift files are analysed by the assertion, test, stub, error-swallowing, suppression and prose gates instead of being named as not analysed. stricter A repository with Swift code can receive findings it did not before; run discipline replay or discipline baseline before upgrading a gating pipeline.
v0.11.0 assertion-reduction, vacuous-tests (Rust, JS / TS, Go, Java, Kotlin, C#, Ruby) A same-file helper named in a dispatch table the test runs (array or slice literal, initializer, method or callable reference, symbol array) resolves like a direct call, as Python’s already did. looser None; removing an entry from the table is still a drop.
v0.11.0 error-swallowing (Go, C / C++) Discards are sorted by callee name as in Rust: a known-fallible callee stays Result Discarded; a Go callee whose second value is an ok flag, and a type assertion, map index or channel receive, are no longer reported; any other callee is the new Value Discarded at warning. looser None; a discard of an unlisted fallible callee that blocked now warns.
v0.11.0 assertion-reduction, vacuous-tests (JS / TS, PHP) Same-file helpers called from a test are resolved one level deep, as in the other packs: their assertions and throws count, and a drop that coincides with more calls to failing helpers is read as a refactor. A test whose only assertions sit in such a helper is no longer vacuous. looser None; configure assert_helper_fns only for helpers in another file.
v0.11.0 CLI, action New check --comment (DISCIPLINE_COMMENT, action inputs comment and token): one pull-request comment with the report, edited on every run; the only forge write, off by default. additive None; set comment: true and grant the token pull-requests: write to use it.
v0.11.0 CLI New discipline replay --last N: replays the last N merged changes through a configuration in a throwaway repository and reports what it would have blocked, per change and per gate; pull-request bodies are read through the merged-pr-body lookup when a token allows. additive None.
v0.11.0 integrations A Renovate preset (github>orieg/discipline//renovate/discipline) that moves every discipline pin in one update, and a documented VS Code problem matcher. additive None.
v0.11.0 CLI New discipline explain <gate-id\|finding line>: the gate’s rule, languages, state under this configuration, lifting directive and reference link. additive None.
v0.11.0 CLI New discipline mcp: an MCP server over stdio with three read-only tools (check_diff, list_gates, explain_finding) whose output carries no waiver syntax; check_diff takes no argument and judges the change like the hook does. additive None.
v0.11.0 unsafe-safety-comment examined counts only the files a pack read unsafe sites from (Rust); changed Go, C# and Swift files are named in the notes as not analysed. Before, every analysed file was counted and none was named. reclassified (notes and counts only) None.
v0.11.0 vacuous-tests, assertion-reduction A thrown skip (throw XCTSkip(...), raise unittest.SkipTest, TestNG SkipException, JUnit TestAbortedException) no longer ends the block for unreachable-code detection, so a skipped test is reported once, as ignored, and not also as vacuous. looser None.
v0.11.0 agent-scratch .claude/settings.json, .cursor/hooks.json and .aider.conf.yml (the shared hook files discipline hook install writes) are in the default exempt_paths. looser None; a repository that treats these as scratch lists them in paths and removes them from exempt_paths with [] reset syntax.
v0.11.0 instruction-smuggling Agent-instruction files now include QWEN.md, opencode.json and everything under .cursor/ (was .cursor/rules/), .gemini/, .agents/, .qwen/, .opencode/ and .github/hooks/: every hook file discipline hook install writes. Deleting an agent-instruction file is reported like an edit (it was skipped), so a change that removes its own hook is reported. stricter allow-agent-instructions: <file> <reason> for an intended change.
v0.11.0 CLI New discipline hook run --agent <claude-code\|codex\|cursor\|aider\|copilot\|agy\|qwen\|opencode> answers an agent’s hook with the agent-prompt findings in that agent’s contract, judged by the base ref’s configuration and reading no directive; discipline hook install --agent <name> writes the agent’s configuration only where none exists. additive None.
v0.10.2 error-swallowing (Rust) let _ = <call> is sorted by callee name: a known-fallible callee (try_*, checked_*, *_checked, send, recv, sync_all, flush, lock, write! / writeln!, …) is Result Discarded as before; a known accessor (get_or_init, entry, or_insert*, unwrap_or*, …) is no longer reported; any other callee is the new Value Discarded, at warning when the gate is at error. looser None; a discard whose fallible callee is not on the known-fallible list, which blocked before, now warns.
v0.10.2 assertion-reduction A resolved same-file helper counts its failure exits (Rust panic! / unreachable!, Go panic(, Java / C# / Kotlin throw, Ruby raise / fail). A drop in a test that now calls more failing helpers is recorded in the notes, not reported. Python resolves same-file functions named in a list, tuple or set in the test body (a dispatch table). looser None; a refactor that moved checks into helpers stops blocking. Removing a helper call, or an inline assertion with the helper calls unchanged, still blocks.
v0.10.1 doctor (Gitea / Forgejo) review, code-owner-review and last-push-approval are read from the branch protection (required_approvals, block_on_official_review_requests, dismiss_stale_approvals) instead of being reported as Warn whatever the settings; without an admin token the two rule findings are Info (“not visible”) and the count comes from the branch summary. reclassified None; a Gitea repository that had these rules set stops failing --strict on them.
v0.10.0 directives require_approval reads GitLab approvals: the merge request from CI_MERGE_REQUEST_IID and CI_MERGE_REQUEST_SOURCE_BRANCH_SHA, approvals only when the merge request’s sha is the head being checked, the author never an approver. Previously a GitLab run with require_approval stopped with “not implemented”. none (new platform) None.
v0.10.0 golden-output An added snapshot whose test already existed and is not added by the change is Snapshot Added For Existing Test (Jest, insta, syrupy / pytest-snapshot); a snapshot arriving with its test is not. stricter allow-golden-update: <path> <reason>.
v0.10.0 ci-integrity (GitLab) include: local: files are followed on both sides; other include kinds are named as not read. An existing verification job that gains or changes rules: / only: / except: is Verification Job Narrowed. stricter allow-gate-weakening: ci-integrity <reason>.
v0.10.0 toolchain-config clippy.toml thresholds, allow- and disallow-lists and allow-* booleans are judged; a gained or swapped inherited configuration (extends, plugins, preset, extend, linters.presets) is Toolchain Configuration Changed (not analysed) at warning. stricter allow-toolchain-weakening: <key or path> <reason>.
v0.10.0 dependency-delta pnpm-lock.yaml, Yarn 2+ yarn.lock, poetry.lock, uv.lock, composer.lock and Gemfile.lock are read entry by entry: a new source host and a dropped integrity hash are findings there too, and the “not analysed” note for those formats is gone. stricter allow-dependency: <package> <reason>.
v0.10.0 ci-integrity A run: step that drops --frozen-lockfile, --immutable, --require-hashes, --frozen or --no-update is Frozen Install Flag Dropped; npm ci replaced by npm install is Install Command Softened. stricter allow-gate-weakening: ci-integrity <reason>.
v0.10.0 doctor New push-trigger finding: a discipline job that runs on push to the default branch is Warn when the repository allows squash or rebase merges (the merge commit drops the pull request’s body) and merged-pr-body is not in directives.sources, Info when the source is on (the push run reads the body given a token that can read pull requests), Pass when only merge commits are allowed, Unknown when the merge methods cannot be read; --local-only reports the trigger without the merge method. --strict fails on the Warn. stricter (doctor only) Restrict the step to pull_request, keep merged-pr-body with a token, or put directives in commit messages.
v0.10.0 directives New source merged-pr-body, in the default directives.sources: on a push event with no pull-request body, the body of the merged pull request each pushed commit arrived through is read through the forge (GitHub, Gitea, Forgejo, GitLab) under the PR-body trust rules; the notes name the pull request. A lookup the forge refuses is could not check (exit 2) unless directives.degrade_offline = true; DISCIPLINE_NO_NETWORK and an unidentifiable forge are notes. A push run in CI therefore makes read-only forge requests it did not make before (at most 20). Older binaries reject degrade_offline. looser (a push run now honours reviewed waivers) directives.sources = ["pr-body", "commits"] restores the previous behaviour.
v0.10.0 directives New source merged-pr-body, in the default directives.sources: on a push event with no pull-request body, the body of the merged pull request each pushed commit arrived through is read through the forge (GitHub, Gitea, Forgejo, GitLab) under the PR-body trust rules; the notes name the pull request. A lookup the forge refuses is a named note and the finding stands (directives.degrade_offline, default true), or could not check (exit 2) when that is false; DISCIPLINE_NO_NETWORK and an unidentifiable forge are notes. A push run in CI therefore makes read-only forge requests it did not make before (at most 20). Older binaries reject degrade_offline. looser (a push run now honours reviewed waivers) directives.sources = ["pr-body", "commits"] restores the previous behaviour.
v0.10.0 ci-integrity A verification step that gains a step-level if:, or whose if: changes, is Verification Step Narrowed at warning (the always() / failure() forms stay Conditional Masking on Verification Step). stricter (warning) allow-gate-weakening: ci-integrity <reason>.
v0.10.0 check on a push event A finding whose remediation names a directive says that the run is a push, that PR-body directives are not in scope, and which sources a push reads; the gate’s notes carry the same note. Output only. none None.
v0.10.0 assertion-reduction, vacuous-tests An assertion inside a constant-false if or after an unconditional return / panic! / pytest.fail() / throw at the same block level counts 0 (a skip call is not a terminator: ignored-tests owns it) in every language pack. A test that only asserts there is vacuous; moving an assertion there is a reduction. stricter allow-assertion-drop: / allow-vacuous: as for any finding, or delete the dead assertion.
v0.10.0 test-budget Rust, Python and JS/TS budgets are read from the syntax tree (Fact::Budgets): a named integer in a configuration position, including inside a proptest! token tree. A budget-like word in a string, a comment or an unrelated assignment (min_tests = 40, a bare tests = 300) no longer counts, so a fixture quoting one is not a reduction and cannot be one. narrower None; workflow and script budgets are unchanged.
v0.10.0 packaging Shell completions for bash, zsh and fish are generated by discipline docs --write into completions/ (committed, checked by docs --check), shipped in the release tarballs, and installed by the APT and RPM packages, the Homebrew formula (generate_completions_from_executable) and the Portfile. The README’s Installation section documents the tarball / install.sh / cargo install case. additive None; a hand-installed script is superseded by the packaged one on the next upgrade.
v0.10.0 stub-bodies A stub marker preceded only by logging lines or assignments that call nothing is a stub (fn f() { log::warn!("todo"); todo!() }); a marker preceded by a call is still substantive. stricter allow-stub: <name> <reason>, or [gates.stub-bodies] enabled = false.
v0.10.0 error-swallowing A new handler whose every statement only logs the error is Empty Error Handler Added (“logs it, and does nothing else”); a handler that logs and re-raises, returns or records the failure is not. stricter allow-swallow: <path> <reason>, or [gates.error-swallowing] enabled = false.
v0.10.0 language packs New Kotlin pack (.kt, .kts) behind the default lang-kotlin feature: JUnit / TestNG / Kotest tests, assertions, skips, @Suppress, and function, handler and prose facts. Previously those files were named in a note as not analysed. stricter Build without lang-kotlin, or exempt the paths under the gate’s exempt_paths.
v0.10.0 stub-bodies, error-swallowing, instruction-smuggling The PHP, Ruby, C and C++ packs supply function, handler and prose facts: a stub body, an empty catch / rescue, PHP @call() and Ruby call rescue nil (new title Error Silenced), C/C++ (void)call(), and instruction-like text in comments and strings of those languages are reported. Previously those files were named in a note as not analysed. A top-level PHP function is now read as a test when its name starts with test (the pack matched an old grammar kind and missed them). stricter Exempt a path under the gate’s exempt_paths, or record the site with the gate’s directive.
v0.10.0 action The actor input defaults to the pull request author (github.event.pull_request.user.login), falling back to github.actor off a pull request. Previously it was the triggering login, so on an edited event an allow-listed editor of someone else’s pull request waived fail_on_overrides for an override they could have written themselves. reclassified Pass actor: explicitly to keep judging the triggering login.
v0.10.0 documentation The job-container recipe in docs/CONFIGURATION.md (Gitea / Forgejo, no uses:) was wrong in two ways: it named a docker:// image under runs-on, which matches no runner label, so the job never ran; git clone landed on the default branch, so a job that did run compared the base with itself and passed every pull request. The recipe now runs on a registered label with container: image: pinned by tag and digest, fetches refs/pull/<n>/head, verifies the checkout against the event’s head commit, and passes the base, title, body and the pull request author as the actor. It is executed in CI. stricter A repository that copied the old recipe has a gate that never ran or never failed: replace the job with the current recipe and re-run it on an open pull request.
v0.10.0 baseline --whole-tree Gates whose rule describes a change (dependency-delta, ignored-tests, config-integrity, ci-integrity, build-hooks, error-swallowing, stub-bodies, suppression-delta, test-floor, test-budget, golden-output, deletion-rationale, assertion-reduction, scope-confinement, commit-provenance, bench-regression, ci-skip-set, toolchain-config; instruction-smuggling’s instruction-file rule) are not evaluated in a whole-tree run and record nothing. Symlinks are no longer enumerated as changed files. narrower Re-write an existing whole-tree baseline; stale entries for those gates are reported as stale.
v0.10.0 provenance-tags New options ratio_satisfied_by, deterministic_units, diff_only; defaults keep the built-in rule. looser when set None.
v0.10.0 configuration New [tests] table (functions, paths) declares test scope for every gate that separates test code from production code. Older binaries reject the table. looser when set Upgrade every binary that reads the file together.
v0.10.0 error-swallowing The expect-this-to-raise idiom, a let _ = binding of a non-call, and handlers in Cargo tests/ / benches/ / examples/ are no longer reported. looser None; these were false positives.
v0.10.0 unsafe-safety-comment A # Safety rustdoc section documents an unsafe trait / unsafe fn. A comment run above a site is now walked correctly when it contains bare /// lines (a line comment’s end position is at column 0 of the next row and was read as spanning it). looser None; documented sites were false positives.
v0.10.0 stub-bodies Methods of abc.ABC classes and base-class methods overridden by a same-file subclass are not stubs. looser None.
v0.10.0 time-estimates Past-interval phrasing (N-hour gap between, gap of N days, N minutes between each) is a term of art. looser None.
v0.9.0 ignored-tests A test that gains a hard-coded delay (thread::sleep, time.sleep, setTimeout, …) is Test Sleeps (warning). stricter allow-ignore: <test> <reason>.
v0.9.0 vacuous-tests A new test whose every assertion holds for nearly any value (is not None, toBeDefined, .is_ok(), assertNotNull, …) is Test Asserts Only Trivial Properties (warning). stricter Assert on the value.
v0.9.0 instruction-smuggling The PR title and body and every commit message in the range are scanned for instruction phrases (warning) and invisible characters (blocking); directive lines are skipped. examined counts them. stricter allow-agent-instructions: pr-body <reason>.
v0.8.0 doctor Three review findings from branch rules: review (approving-review count), code-owner-review, last-push-approval (or stale-review dismissal). Each is a warning where the rule is absent, so --strict runs on a branch without review rules now fail. stricter Add the review rule, or read the finding as advice without --strict.
v0.8.0 ignored-tests A test that gains a retry / flaky marker (@pytest.mark.flaky, jest.retryTimes, @RetryingTest, …) is reported as Test Retries On Failure. stricter allow-ignore: <test> <reason>.
v0.8.0 vacuous-tests, assertion-reduction Mock usage is read from test bodies. A new test asserting only on a double’s interactions is reported (Test Asserts Only On Mocks, warning); an existing test whose doubles rose without a stronger assertion on real output is reported (Mocking Grew Without Stronger Assertions, warning). New options mock_setup_fns / mock_assert_fns on both gates. stricter allow-assertion-drop: <test> <reason> for the delta; assert on the result for the vacuity class.
v0.8.0 suppression-delta Sites come from the language packs and are a base-versus-head delta per file: a moved suppression, or one inside a string, is no longer reported; Java @SuppressWarnings, @ts-nocheck and //lint:ignore now are; Ruby and PHP suppressions are read for the first time. Files whose head side does not parse are named as not analysed. reclassified None; findings that were false positives disappear, and a few new ones appear in Java, PHP and Ruby.
v0.8.0 test-floor The static count is of tests that run: an unconditionally ignored / skipped test is no longer counted, on the base or the head side. Counts can drop. stricter Re-baseline min_tests / constant floors; the gate notes state how many tests were left out.
v0.8.0 dependency-delta Lockfiles are read, not only sized: an entry from a new source, a dropped integrity hash, a manifest changed without its tracked lockfile, and a deleted lockfile are violations. stricter allow-dependency: <package-or-lockfile> <reason>.
v0.8.0 golden-output Default paths add **/__snapshots__/**, **/*.ambr, **/*.golden, **/*.approved.*; a rewrite with no output-producing change is titled “Golden Output Regenerated Without Source Change”. stricter Set paths to the previous four globs.
v0.8.0 ci-integrity GitLab pipelines (.gitlab-ci.yml, .gitlab/ci/*.yml) are in the default workflows and are diffed for allow_failure, when: manual, masked script lines, --advisory, deleted verification jobs. stricter Remove the GitLab globs from workflows.
v0.8.0 directives New options directives.max_overrides and directives.require_approval, and the --policy-from flag / policy_from action input. All default to the previous behaviour. Older binaries reject the two new keys. stricter when set Upgrade every binary that reads the file together.
v0.8.0 report The JSON report gains policy_failures when an override budget or approval refuses a run; a run can now exit 1 with errors: 0. reclassified Read the exit code or policy_failures, not errors. The SARIF, JUnit and GitLab reports carry each refusal under a policy/ rule id (docs/CONFIGURATION.md, “Policy Refusals”).
v0.8.0 config-integrity mode = "advisory" introduced by the change under review is reported under [meta] and is not honoured: the run keeps its enforcing exit code until the setting is on the base side. stricter allow-gate-weakening: meta <reason>, or merge the mode change on its own.
v0.8.0 config-integrity The gate runs whenever the base configuration enables it, even if the head configuration or --disable turns it off, and reports at the stricter of the base and head severity. stricter allow-gate-weakening: config-integrity <reason>; the new setting applies from the next change.
v0.8.0 config-integrity Every gate option has a loosening direction. Newly reported: a lowered min_tests / min_assertions_per_test, a raised max_increase / tolerance, a removed floor or cap, shrunk workflows, forbidden_paths, deny_dependencies, manifests, required_paths, forbidden_patterns, forbid_output, corpus_dirs, fuzz_targets, extra_secret_patterns, required_suites, placeholders, commands, rules, groups; grown allowed_suppressions, excluded_jobs, first_party_action_prefixes, allowed_override_actors; an emptied allow_dependencies / allowed_paths; allow_cross_host, allow_wildcards, allow_increase switched on; a changed or removed command, test_command, canary_command, preset, count_pattern, zero_items_pattern, pattern, deny_file, rollup_job, sanitizer, archive_path, constant_*, documented_job_count_*. stricter allow-gate-weakening: <gate> <reason>.
v0.8.0 ci-integrity advisory: true added to the discipline action step, or --advisory added to a discipline check / discipline diff run line, is a violation. stricter allow-gate-weakening: ci-integrity <reason>.
v0.8.0 ci-integrity The default workflows globs also cover .gitea/workflows/ and .forgejo/workflows/. stricter Set workflows to the previous two .github/workflows/ globs.
v0.7.2 doctor On Gitea, doctor reads the instance version; below 1.26 (which ignores a workflow’s permissions:) the token finding is information, not a warning, and names the upgrade. narrower None.
v0.7.1 forge access An SSH remote’s host is resolved through ~/.ssh/config (Host alias to HostName, with Include) before the forge’s API address is built; doctor prints that address (forge_url in JSON) and points an unreachable host at DISCIPLINE_FORGE_URL. reclassified None; set DISCIPLINE_FORGE_URL if the API is served elsewhere than the SSH host.
v0.7.0 configuration New keys (superseded_registry, pending_issue_repos, mode, citation_*, [gates.ci-skip-set], …) are rejected by older binaries, which refuse unknown keys. stricter Upgrade every binary that reads the file (pre-commit rev:, pinned images) together.
v0.7.0 test-floor test_command without min_tests or a constant_* floor exits 2 instead of passing. stricter Set min_tests, or remove test_command.
v0.7.0 time-estimates allow_patterns exempt only the text they match, not the whole line. stricter Widen the pattern to cover the text to exempt.
v0.7.0 test detection Python tests follow pytest/unittest collection (self_test is not a test; methods count only in Test* / TestCase classes); the C# name-only fallback is removed. Test counts can drop. stricter Re-baseline min_tests / constant floors after upgrading.
v0.7.0 bench-regression A malformed exempt_arms glob exits 2; an entry that matches no arm in the run is an error at any gate severity. stricter Fix or remove the entry.
v0.7.0 miri, sanitizers A missing or unsupported toolchain exits 2 (action status: error) instead of reporting undefined behaviour or a race (exit 1). reclassified Install the toolchain component, or disable the gate.
v0.7.0 baseline baseline --write records blocking findings only; --all-severities restores the previous output. --write refuses to replace an unreadable baseline (exit 2). narrower Pass --all-severities to keep warnings and notes.
v0.7.0 submodules A submodule pointer change is skipped instead of exiting 2. looser None needed.
v0.7.0 ci-integrity A renamed CI step is paired with its original by body similarity (at least 0.60, same verification markers) instead of being reported as deleted. looser None; the rename is listed in the gate notes.
v0.7.0 pr-checklist A ticked test box is backed by a test function added or extended in any analysed file, not only by a changed test file. looser None.
v0.7.0 provenance-tags The superseded registry is the base registry plus the head registry; a pending statement citing only another repository’s issue is a violation unless listed in pending_issue_repos. stricter List the tracking repository in pending_issue_repos.
v0.7.0 report A gate that examined nothing because its input is absent is “not evaluated”, not passed: the summary adds “N not evaluated” and the passed_gates output drops by one. reclassified Read status / exit code, not passed_gates.
v0.7.0 forge access Forge features call the API over HTTPS in-process: gh and curl are no longer used and DISCIPLINE_GH is gone. GitHub tokens come from GH_TOKEN or GITHUB_TOKEN. reclassified Pass GH_TOKEN / GITHUB_TOKEN to the job.
v0.7.0 action uses: orieg/discipline@v0 downloads the binary of the release the tag points at, not the newest release. reclassified None.
v0.7.0 ci-skip-set With workflow left at its default, the gate reads the running workflow (GITHUB_WORKFLOW_REF) or the first ci.yml under .github/, .gitea/ or .forgejo/workflows/, instead of always .github/workflows/ci.yml. reclassified Set workflow to keep a fixed path.
v0.7.0 submodules A skipped submodule pointer change is named in the deletion-rationale (and scope-confinement) notes. narrower None.
v0.7.0 packaging MacPorts: the Portfile is a release asset (the in-tree copy is gone); the in-tree Homebrew formula is removed (the release generates it and updates the tap). reclassified Fetch Portfile from the release.

Planned Default Changes

Default changes decided for a later major version. They are not in force: each moves into the ledger above, with its configuration to restore, in the release that makes it.

Planned for Gate Current default Planned default Direction Reason
next major (2.0 if decided after 1.0, else 1.0) vacuous-tests min_assertions_per_test unset: a new test with one effective assertion, or none that is not vacuous by the gate’s own rule, passes min_assertions_per_test = 1 stricter Per-test assertion density has no floor by default; test-floor’s test-density-regression ratchets a file’s total from the baseline, but a new test file has none recorded (#936). A default that becomes stricter is a major-version change for a consumer whose tests have none (docs/ARCHITECTURE.md §3.1); [gates.vacuous-tests] min_assertions_per_test can be set to 1 now.

Outstanding Checks & Known Limitations