Python API

The package is importable as rules_requirements and uses only the Python standard library. The top-level package re-exports the most used names:

from rules_requirements import build_matrix, load_model, read_model, validate
from rules_requirements import ingest, report

model = load_model("requirements/")  # raises ValidationError on errors
evidence = ingest.collect(["bazel-testlogs/**/test.xml"])
matrix = build_matrix(model, evidence, current_build={"dut_git_sha": "abc123"})
with open("report.html", "w", encoding="utf-8") as fh:
    fh.write(report.render_html(matrix))

# Who owns each case, and each entity's verification set:
owner = matrix.attribution.owner  # CaseKey -> one entity id
for member in matrix.attribution.members_of("REQ-1"):
    print(member.key, member.state, member.via)

build_matrix always runs rules_requirements.attribution.attribute(), the one function that decides which entity a test case verifies; verdicts read nothing else. Evidence.for_id (the cases that declare an id) is deprecated.

Model

The requirements model: user needs, requirements, risks, mitigations and test methods, and the references between them.

The shape follows the design-control and risk-management vocabulary of IEC 62304 (software life cycle), ISO 14971 (risk management) and IEC 60601-1 (programmable electrical medical systems, §14), deliberately kept generic:

  • User need (UN) — what a user must be able to do. Validated when the requirements that satisfy it are verified (design validation, 21 CFR 820.30(g)).

  • Requirement (REQ) — a verifiable statement the product must meet. It satisfies user needs and/or refines a parent requirement (system -> software decomposition, IEC 62304 §5.2), and names the method (test method or verification level) its verification demands.

  • Risk (RISK) — a hazard / hazardous situation / harm chain with an estimated severity and likelihood (ISO 14971 §5).

  • Mitigation (MIT) — a risk control measure (ISO 14971 §7.1) that mitigates risks and is implemented_by requirements — so the effectiveness of the control is verified exactly like any requirement (ISO 14971 §7.2, “verification of risk control measures”).

  • Test method (TM) — a named verification procedure at a given rigor level (IEC 62304 §5.7.1 “establish tests … and procedures”).

References always point from the more specific object to the more general one (REQ.satisfies -> UN, MIT.mitigates -> RISK, MIT.implemented_by -> REQ); the reverse views are computed. That keeps a single source of truth per trace and makes the model safe to edit object-by-object.

class rules_requirements.model.Location(path: str = '', line: int = 0)[source]

Bases: object

class rules_requirements.model.Note(text: str, kind: str = 'comment', status: str = 'open', author: str = '', created: str = '', id: str = '', extra: tuple[tuple[str, Any], ...] = ())[source]

Bases: object

A free-form annotation on an object: a gap an agent found, a question for a reviewer, a TODO that should drive the next implementation cycle.

class rules_requirements.model.VerifiedBy(target: str, cases: tuple[str, ...] = (), whole: bool = False, level: str = '', reason: str = '', legacy: bool = False, extra: tuple[tuple[str, Any], ...] = (), spelling: str = '', problem: str = '', location: rules_requirements.model.Location = field(default_factory=...), authored: Any = None)[source]

Bases: object

One item of verified_by (requirements, mitigations) or validated_by (user needs): the cases of one target an entity claims.

Authored as {target, cases: [selector, ...]} (selectors: case_selectors) or {target, whole: true, reason} for a target that reports no per-case results. The legacy forms — a bare label, {target} or {target, level} — read as a whole claim with legacy set (rule bare-target-reference).

A claim only claims: which entity owns a case is decided by attribution alone, and claims of two entities that can select one case are a shared-case error.

property label: str

The target as written.

property selectors: tuple[str | None, ...]

The item’s selectors; (None,) for a whole-target claim.

class rules_requirements.model.Claim(entity: str, kind: str, target: str, pattern: str | None, literal: bool, level: str, index: int, location: rules_requirements.model.Location = field(default_factory=...), legacy: bool = False, relation: str = 'verified_by')[source]

Bases: object

One selector of one entity: the unit the shared-case check and attribution work on. pattern is None for a whole-target claim.

describe() → str[source]

The selector as a message shows it.

matches(path: str) → bool[source]

Whether this claim selects case path of its target (a whole claim selects every path; a selector never selects [target]).

class rules_requirements.model.Entity(id: str, title: str, description: str = '', status: str = '', owner: str = '', tags: tuple[str, ...] = (), notes: tuple[rules_requirements.model.Note, ...] = (), location: rules_requirements.model.Location = field(default_factory=...))[source]

Bases: object

references() → Iterator[tuple[str, str]][source]

(relation, target id) for every outgoing reference.

class rules_requirements.model.UserNeed(id: str, title: str, description: str = '', status: str = '', owner: str = '', tags: tuple[str, ...] = (), notes: tuple[rules_requirements.model.Note, ...] = (), location: rules_requirements.model.Location = field(default_factory=...), rationale: str = '', validated_by: tuple[rules_requirements.model.VerifiedBy, ...] = ())[source]

Bases: Entity

class rules_requirements.model.Requirement(id: str, title: str, description: str = '', status: str = '', owner: str = '', tags: tuple[str, ...] = (), notes: tuple[rules_requirements.model.Note, ...] = (), location: rules_requirements.model.Location = field(default_factory=...), rationale: str = '', category: str = '', satisfies: tuple[str, ...] = (), refines: tuple[str, ...] = (), method: str = '', verified_by: tuple[rules_requirements.model.VerifiedBy, ...] = (), modules: tuple[str, ...] = ())[source]

Bases: Entity

references() → Iterator[tuple[str, str]][source]

(relation, target id) for every outgoing reference.

class rules_requirements.model.Risk(id: str, title: str, description: str = '', status: str = '', owner: str = '', tags: tuple[str, ...] = (), notes: tuple[rules_requirements.model.Note, ...] = (), location: rules_requirements.model.Location = field(default_factory=...), hazard: str = '', hazardous_situation: str = '', harm: str = '', severity: str = '', likelihood: str = '', residual_severity: str = '', residual_likelihood: str = '', residual: str = '', mitigated_by: tuple[str, ...] = ())[source]

Bases: Entity

references() → Iterator[tuple[str, str]][source]

(relation, target id) for every outgoing reference.

class rules_requirements.model.Mitigation(id: str, title: str, description: str = '', status: str = '', owner: str = '', tags: tuple[str, ...] = (), notes: tuple[rules_requirements.model.Note, ...] = (), location: rules_requirements.model.Location = field(default_factory=...), type: str = '', mitigates: tuple[str, ...] = (), implemented_by: tuple[str, ...] = (), verified_by: tuple[rules_requirements.model.VerifiedBy, ...] = ())[source]

Bases: Entity

references() → Iterator[tuple[str, str]][source]

(relation, target id) for every outgoing reference.

class rules_requirements.model.TestMethod(id: str, title: str, description: str = '', status: str = '', owner: str = '', tags: tuple[str, ...] = (), notes: tuple[rules_requirements.model.Note, ...] = (), location: rules_requirements.model.Location = field(default_factory=...), level: str = '', procedure: str = '')[source]

Bases: Entity

rules_requirements.model.claim_items(ent: Entity) → tuple[VerifiedBy, ...][source]

The verified_by / validated_by items of ent (() for kinds that cannot claim cases).

class rules_requirements.model.Model(config: rules_requirements.config.Config = field(default_factory=...), project: Mapping[str, Any] = field(default_factory=...), user_needs: Mapping[str, rules_requirements.model.UserNeed] = field(default_factory=...), requirements: Mapping[str, rules_requirements.model.Requirement] = field(default_factory=...), risks: Mapping[str, rules_requirements.model.Risk] = field(default_factory=...), mitigations: Mapping[str, rules_requirements.model.Mitigation] = field(default_factory=...), test_methods: Mapping[str, rules_requirements.model.TestMethod] = field(default_factory=...), parse_errors: tuple[str, ...] = (), unknown_fields: tuple[str, ...] = (), config_file: str = '', root: str = '')[source]

Bases: object

requirements_for_risk(risk_id: str) → list[Requirement][source]

Requirements that implement any mitigation of risk_id.

demanded_level(req: Requirement) → str[source]

The level a requirement’s verification demands.

method may name a test method (whose level then applies) or a level directly; empty falls back to config.default_level.

is_verifiable(entity_id: str) → bool[source]

Whether entity_id is a user need, requirement or mitigation — an entity that can own test cases.

claims() → list[Claim][source]

Every claim of every entity, in a stable order: user needs, requirements, mitigations (by id), then item and selector order.

lock_path(shown: bool = False) → str[source]

Where config.sets_lock points (”” without one): a path to open, or with shown the path as locations show it.

with_entity(entity: Entity) → Model[source]

A copy of the model with entity added or replaced (in its own section). Raises ValueError when its id names an entity of another kind: one id names exactly one entity.

rules_requirements.model.load_text(text: str) → list[Any][source]

The documents of one model file’s text, loaded the way model files are (line numbers, duplicate-key check, alias expansion limit).

rules_requirements.model.model_files(paths: str | Iterable[str]) → list[str][source]

Expand files and directories into the sorted list of model files.

rules_requirements.model.parse_entity(kind: str, raw: Mapping[str, Any], location: Location, errors: list[str], unknown: list[str], nested: list[str] | None = None, main_repo: str = '') → Entity | None[source]

Build one entity from its YAML mapping. Shape errors go to errors, unknown keys to unknown (reported under the unknown-field rule); unknown keys inside notes and verified_by / validated_by items go to nested if given, else to unknown (they are kept on the item either way). Claim targets are normalized with main_repo.

rules_requirements.model.parse_documents(docs: Iterable[tuple[str, Any]]) → tuple[Model, list[str]][source]

Merge (path, document) pairs into one model.

Returns the model and a list of extra warnings (currently always empty; unknown keys are recorded on model.unknown_fields for validation).

A document is either a section document (a mapping with any of config, project, user_needs, requirements, risks, mitigations, test_methods) or a single-object document (a mapping with kind: requirement etc. plus the object’s fields) — the layout the web editor writes, one object per file.

rules_requirements.model.read_model(paths: str | Iterable[str], root: str = '') → tuple[Model, list[str]][source]

Read model files without raising; returns (model, extra warnings).

Unknown fields are kept on model.unknown_fields and reported by validate() like any other issue.

root makes recorded source paths relative (stable across machines).

rules_requirements.model.load_model(paths: str | Iterable[str], root: str = '') → Model[source]

Load and fully validate the model; raise ValidationError on errors.

Project-level configuration of the requirements model.

Every knob that differs between projects lives here, so the same tooling can serve a project that calls its requirements REQ-0001 and one that calls them PR-12. A model file may carry a config: section; anything it omits falls back to the defaults below.

The defaults encode a conventional medical-device-software vocabulary:

  • verification levels ordered by rigor (analysis < simulation < sil < hil < hitl) plus the unordered inspection (IEC 62304 §5.5–5.7 verification activities; the ordering is what lets a report say “under-verified”);

  • ordinal severities and likelihoods for risk estimation (ISO 14971 §5.5);

  • an optional acceptability threshold on severity x likelihood for risk evaluation (ISO 14971 §6 / §7.4 residual risk).

class rules_requirements.config.Level(name: str, rank: int | None, description: str = '')[source]

Bases: object

A verification rigor level.

rank is None for unordered levels (e.g. inspection): they are incomparable, so an unordered demand is met only by evidence at exactly that level, and unordered evidence never satisfies an ordered demand.

class rules_requirements.config.Config(prefixes: Mapping[str, str] = field(default_factory=...), id_pattern: str = '{prefix}-\\d+', levels: tuple[rules_requirements.config.Level, ...] = (Level(name='analysis', rank=1, description='Static argument: derivation, review of a proof, static analysis.'), Level(name='simulation', rank=2, description='Host-side unit or simulation test.'), Level(name='sil', rank=3, description='Software-in-the-loop: the integrated software against simulated I/O.'), Level(name='hil', rank=4, description='Hardware-in-the-loop: a component on real hardware.'), Level(name='hitl', rank=5, description='Full system on real hardware, end to end.'), Level(name='inspection', rank=None, description='Manual or visual sign-off recorded as evidence.')), default_level: str = 'simulation', default_provided_level: str = 'simulation', autonomous_max_level: str = 'sil', pyramid_min_level: str = 'hil', pyramid_cheap_levels: tuple[str, ...] = ('analysis', 'simulation'), severities: tuple[str, ...] = ('negligible', 'low', 'medium', 'high', 'critical'), likelihoods: tuple[str, ...] = ('rare', 'unlikely', 'possible', 'likely', 'certain'), acceptable_risk_score: int | None = None, high_severities: tuple[str, ...] = ('high', 'critical'), rules: Mapping[str, str] = field(default_factory=...), annotation_patterns: tuple[str, ...] = (), attribution: str = 'hybrid', main_repo: str = '', sets_lock: str = '', flaky: str = 'under-verify', set_consistency: str = 'warn', variants: tuple[tuple[str, ...], ...] = ())[source]

Bases: object

pattern(kind: str) → str[source]

The id regex (unanchored) for kind.

{prefix} is substituted literally, so patterns may use braces of their own ({prefix}-\d{4}).

any_id_regex() → Pattern[str][source]

Matches any entity id of any kind, as a word inside free text.

Use finditer(...).group(0): a pattern may contain capture groups.

variant_groups() → tuple[tuple[str, ...], ...][source]

variants with every label normalized (bad labels left out; validation reports them as bad-target).

rules_requirements.config.parse_config(raw: Mapping[str, Any] | None, errors: list[str]) → Config[source]

Build a Config from a config: mapping, collecting errors.

Structural, referential and coverage validation of a Model.

Every finding is an Issue with a stable code so tooling (CI, the web editor, agents) can filter and link to documentation. Shape and reference problems are always errors; coverage findings follow config.rules.

The claim checks are the static half of “a test case verifies at most one requirement”: claims of two entities that can select one case are a shared-case error with a concrete witness case, and so are claims on two targets declared to run the same test code (same-code-multiple-owners). Neither can be configured off. The verification-set lock (config.sets_lock) is checked against the claims too.

class rules_requirements.validate.Issue(severity: str, code: str, message: str, entity: str = '', location: rules_requirements.model.Location = field(default_factory=...))[source]

Bases: object

exception rules_requirements.validate.ValidationError(issues: list[Issue])[source]

Bases: Exception

The model has errors. Carries every issue, not just the first.

rules_requirements.validate.validate(model: Model, strict: bool = False, known_targets: Collection[str] | None = None) → list[Issue][source]

All issues in model. strict promotes warnings to errors.

known_targets (the labels bazel query 'tests(//...)' prints, in any spelling) makes a claim, a config.variants entry or a lock target naming any other label an unknown-target error; pseudo-targets (suite:, record:) are exempt.

class rules_requirements.validate.ClaimConflict(code: str, first: rules_requirements.model.Claim, second: rules_requirements.model.Claim, example: str | None)[source]

Bases: object

Two claims of two entities that can select one test case: the pair a shared-case (one target) or same-code-multiple-owners (targets of one config.variants group) error reports, as data for tools that must name the case (the web editor’s save guard).

rules_requirements.validate.overlapping_claims(model: Model) → Iterator[tuple[str, Claim, Claim, str | None]][source]

Every pair of claims that can select one test case, as (code, a, b, example): shared-case (two entities, one target), same-code-multiple-owners (two entities, two targets of one config.variants group) or redundant-selector (one entity, one target). example is a case path both select (”” when any case will do; None for two whole-target claims).

The one implementation of the pairing: validate() reports these pairs and claim_conflicts() hands them to the editor’s save guard, so the two can never disagree. Claims with a bad selector are skipped (they are a bad-selector error of their own).

rules_requirements.validate.claim_conflicts(model: Model) → list[ClaimConflict][source]

Every pair of claims of two entities that can select one case — exactly the witnesses validate() reports as shared-case and same-code-multiple-owners errors (both read overlapping_claims()).

Case selectors: which per-case results of a target a claim names.

A verified_by item {target, cases: [...]} lists selectors. The grammar is deliberately closed — * is the only wildcard — because it makes the question “can two claims ever select the same case?” exactly decidable (witness()), so shared-case is a static error with a concrete example instead of a report-time surprise.

  • A selector matches the whole canonical case path (rules_requirements.case_keys), case-sensitively.

  • * matches any string, the empty string, ::, / and blanks included; ** is the same as *.

  • \* is a literal star and \\ a literal backslash; any other backslash is a bad-selector.

  • ?, [ and ] are literals: pytest ids such as test_x[exc0-False] contain them (unlike fnmatch, where test_x[*] would not match test_x[a]).

  • A selector is never empty, never padded with blanks, and never the synthetic path [target] (claim a target’s single synthetic result with whole: true). Attribution never lets a selector match a synthetic or target-scope result.

  • A selector is in Unicode NFC, like every case path, and never ends with an [rr:ID] name tag (ingest strips those from case names). Either would never match: it would read as a missing case forever, and two spellings of one case would compare as two cases.

The module is named case_selectors rather than selectors: the latter would shadow the standard library module (imported by subprocess and asyncio) whenever the package directory itself is on sys.path.

rules_requirements.case_selectors.STAR: None = None

The wildcard in a token sequence (every other token is one character).

exception rules_requirements.case_selectors.BadSelector[source]

Bases: ValueError

pattern is not a selector (rule bad-selector).

rules_requirements.case_selectors.tokens(pattern: str) → Tuple[str | None, ...][source]

pattern as one-character literals and STAR (runs of * collapse into one); raises BadSelector on a bad escape.

rules_requirements.case_selectors.check(pattern: str) → None[source]

Raise BadSelector unless pattern is a valid selector.

rules_requirements.case_selectors.is_literal(pattern: str) → bool[source]

Whether pattern names exactly one case path (no unescaped *).

rules_requirements.case_selectors.literal_path(pattern: str) → str[source]

The case path a literal selector names (escapes resolved).

rules_requirements.case_selectors.escape(path: str) → str[source]

The literal selector for exactly path.

rules_requirements.case_selectors.matches(pattern: str, path: str) → bool[source]

Whether pattern matches the whole of path.

Greedy matching with backtracking to the last star only: O(len(pattern) * len(path)) in the worst case, never exponential.

rules_requirements.case_selectors.witness(p: str, q: str) → str | None[source]

A case path both selectors match, or None if no path can match both.

Exact for this grammar: a dynamic programme over (position in p, position in q), O(len(p) * len(q)). The witness only uses characters the selectors spell out, and is a shortest one.

rules_requirements.case_selectors.overlaps(p: str, q: str) → bool[source]

Whether some case path matches both selectors.

One spelling per build target.

The model names targets (verified_by, config.variants, the lock) and evidence files results under them (bazel-testlogs paths, rr wrap --target, records). The same target has many spellings — //p, //p:p, @@//p:p, @splanc//p:p from inside splanc, and the canonical @@rules_requirements+//p:n (Bazel 8) or @@rules_requirements~//p:n (Bazel 7) of a module repo — and a claim must match its evidence whatever spelling either side used, or two spellings of one target would read as two targets (and could be claimed by two requirements). normalize_label() maps every spelling to one:

  • the main repository is //p:n (@@//, @// and @<main_repo>// are dropped);

  • another module repository is @<apparent name>//p:n (the canonical ~/+ decoration is dropped, so bazel-testlogs/external/<repo>~/ paths match a model written with apparent names);

  • //p is //p:p and @r is @r//:r;

  • the pseudo-targets suite:<testsuite name> (JUnit outside a testlogs tree) and record:<stem> (records without a target) pass through.

Anything else (:n, p:n, a pattern such as //p/..., an empty string) raises BadTarget, reported as bad-target.

Repositories created by module extensions keep their canonical name, always written @@ (@@rules_python~~pip~pypi//...): a test target there needs an alias in a module repository to be claimed under a stable name.

exception rules_requirements.labels.BadTarget[source]

Bases: ValueError

label is not a build label or pseudo-target (rule bad-target).

rules_requirements.labels.is_pseudo(label: str) → bool[source]

Whether label is a suite: / record: pseudo-target.

rules_requirements.labels.is_repo_name(name: str) → bool[source]

Whether name can be a config.main_repo (an apparent repo name).

rules_requirements.labels.normalize_label(label: str, main_repo: str = '') → str[source]

The canonical spelling of label; raises BadTarget.

main_repo is the apparent name the main repository has in other modules (config.main_repo), so @<main_repo>//p:n is //p:n.

rules_requirements.labels.try_normalize(label: str, main_repo: str = '') → str | None[source]

normalize_label(), or None for a bad label.

rules_requirements.labels.read_known_targets(text: str) → tuple[list[str], list[str]][source]

The labels listed one per line (bazel query 'tests(//...)' output), as written, and the lines that are not labels. Blank lines and # comments are skipped.

The verification-set lock (verification.rrlock).

A generated file (rr sets lock --write) that pins which cases each entity’s verification set holds, so a deleted, renamed or filtered test reads as a missing member instead of silently shrinking a set:

# Generated by `rr sets lock --write`; review its diff like a golden file.
schema: rules_requirements/verification-lock/v1
cases:
  //web:improv_provision_test:
    "improv_provision::provisionViaBle: survives a GATT flake via retry": PR-29
  //requirements:model_test:
    "[target]": PR-25

Each case maps to exactly one scalar id: a list value, a repeated key (the model loader’s duplicate-key check), one case under two spellings of its target, or a case path that is not canonical (NFC, no surrounding blanks, no [rr:ID] name tag ending it — case_path()) is lock-invalid, so the file cannot express two owners.

The lock never creates ownership. Which entity owns a case is decided by rules_requirements.attribution.attribute() alone; a lock entry only adds an expected member to its entity’s verification set: missing when the target ran without the case, not-run when the target did not run, and moved (with lock-owner-changed) when the case now has another owner or none. plan_lock() computes the lock rr sets lock writes from an attribution; render_lock() is its deterministic text.

The extension is deliberately not one model_files() reads, so a lock next to the model never becomes part of it.

exception rules_requirements.lock.LockError(problems: list[str])[source]

Bases: ValueError

The lock cannot be read (rule lock-invalid); problems lists every reason.

class rules_requirements.lock.LockEntry(target: str, path: str, owner: str, line: int = 0, target_line: int = 0)[source]

Bases: object

property case: str

<target>#<path>, the case key’s string form.

class rules_requirements.lock.Lock(entries: tuple[rules_requirements.lock.LockEntry, ...] = (), path: str = '', none: bool = False)[source]

Bases: object

A parsed lock: one entry per locked case, each naming one owner.

owner_of(target: str, path: str) → str | None[source]

The one id target#path is locked to, or None.

of(entity: str) → tuple[LockEntry, ...][source]

The entries entity owns.

rules_requirements.lock.parse_lock(text: str, path: str = '', main_repo: str = '') → Lock[source]

Parse a lock’s text; raises LockError listing every problem.

rules_requirements.lock.load_lock(path: str, main_repo: str = '', shown: str = '') → Lock[source]

Read and parse the lock at path; raises LockError.

rules_requirements.lock.NO_LOCK = Lock(entries=(), path='<no lock>', none=True)

Pass as lock= to build_matrix() (or attribute()) for “no lock”, whatever config.sets_lock names (rr report --no-lock): the sets are not pinned (an unpinned-sets gap), as without a configured lock. None reads the configured lock; an empty Lock pins every set to nothing. Recognized by its none flag (is_no_lock()), so a copy or a pickled round trip of it still means “no lock”.

rules_requirements.lock.is_no_lock(lock: Lock | None) → bool[source]

Whether lock is NO_LOCK (or a copy of it): read no lock at all.

rules_requirements.lock.configured_lock(model: Model) → Lock | None[source]

The lock config.sets_lock names (None without one); raises LockError.

rules_requirements.lock.render_lock(entries: Lock | Iterable[LockEntry]) → str[source]

The lock file’s text: deterministic (targets and paths in natural order), one "<path>": <id> line per case, every path quoted. Parsing it back gives the same entries.

rules_requirements.lock.write_lock(path: str, entries: Lock | Iterable[LockEntry]) → None[source]

Write render_lock() to path atomically: a failed write never leaves half a lock behind.

class rules_requirements.lock.LockPlan(lock: rules_requirements.lock.Lock, added: tuple[rules_requirements.lock.LockEntry, ...] = (), removed: tuple[rules_requirements.lock.LockEntry, ...] = (), changed: tuple[tuple[rules_requirements.lock.LockEntry, rules_requirements.lock.LockEntry], ...] = (), refused: tuple[str, ...] = (), allow_removals: bool = False)[source]

Bases: object

What rr sets lock would write, and why it may not.

lock keeps every entry removed lists unless removals were allowed, so a crashed or filtered run never shrinks a set silently.

property blocked: bool

Writing must not happen: a quarantine, or removals that were not allowed.

rules_requirements.lock.plan_lock(model: Model, attribution: Attribution, previous: Lock | None = None, *, allow_removals: bool = False) → LockPlan[source]

The lock for attribution (computed over the evidence at hand).

  • A quarantine refuses the lock (refused): it has no owner to lock.

  • Every target present in the evidence is locked to exactly the owned keys observed. An owner change is listed in changed and allowed: the model change that caused it is reviewed in the same diff.

  • A target absent from the evidence keeps the entries that still match a claim of their locked owner (in hybrid mode also an entry whose owner claims nothing there and no other claim selects: it may be tag-owned), moves an entry exactly one other entity’s claims now select to that entity (an owner change, in changed), gains each literal selector’s case, and [target] for a whole-target claim whose entity has no entry there yet.

  • A suite:/record: pseudo-target names no build target, so its absence cannot be told from a move: in hybrid mode its entry that no claim selects is removed when its case path now runs under another target (JUnit moved into a testlogs tree), and always under allow_removals. A build target’s entry is never dropped for its target’s absence alone (another lane may run it).

  • Entries dropped from a target that ran, and stale entries of absent targets (no claim selects them), are removed; they stay in lock, unchanged, unless allow_removals.

The lock only records owners attribution already decided; it never decides one.

rules_requirements.lock.moved_from_pseudo(entry: LockEntry, owner: Mapping[CaseKey, str], targets: Mapping[str, TargetRun]) → CaseKey | None[source]

The case key that now holds entry’s case when entry is filed under a suite:/record: pseudo-target the evidence does not hold and a target that ran gives the same case path the same owner; None otherwise. owner and targets are an attribution’s.

That is JUnit moved from outside a testlogs tree into one: the old pseudo-target’s entry is stale, and would otherwise stay an expected not-run member of its owner’s set for good.

Evidence

Test-evidence ingestion: turn test reports into TestCase records.

JUnit XML is the standard interchange format — every hook in this project emits it, and most test runners can produce it. Other formats plug in through the Ingestor API:

from rules_requirements.ingest import Ingestor, TestCase, register

class TapIngestor(Ingestor):
    name = "tap"
    suffixes = (".tap",)

    def sniff(self, path, head):
        return head.lstrip().startswith(b"TAP version")

    def ingest(self, path):
        ...  # yield TestCase(...)

register(TapIngestor())

Ingestors can also be published by other packages under the rules_requirements.ingestors entry-point group, or loaded ad hoc with the CLI’s --ingestor module:attr flag.

Traceability travels inside the evidence as test-case properties:

requirement

The entity id the case declares it verifies — a tag, recorded in TestCase.declared. Ingest never decides ownership: a tag is a cross-check (or, in hybrid mode, a claim for an unclaimed case) that only rules_requirements.attribution.attribute() resolves. A case naming more than one distinct id (a repeated property, requirement="A,B", the plural requirements, a list in a Rust trace line, two [rr:ID] name tags) keeps every id, and is quarantined there (multi-tag): a test case verifies at most one requirement.

level

The verification rigor the case provides (e.g. hil).

artifact.<key>

Identity of the thing under test (firmware build id, DUT git SHA, …) used to detect stale evidence.

rr.file

The test source the case came from (TestCase.file).

rr.scope / rr.synthetic

rr.scope=target marks a result about the whole target run (an exit status, a load error, an unreadable report), never a test case; rr.synthetic=true marks a target’s single whole-run result.

Only level and artifact.* set on a <testsuite> (or an enclosing scope) reach its cases. A suite-level requirement is not inherited: it is kept in TestCase.suite_declared and reported as suite-level-requirement (Evidence.issues).

rules_requirements.ingest.FILE_PROPERTY = 'rr.file'

The test source a case came from (our writers; else the file attribute).

rules_requirements.ingest.SYNTHETIC_PROPERTY = 'rr.synthetic'

true: the target’s single whole-run result (no per-case output).

rules_requirements.ingest.SCOPE_PROPERTY = 'rr.scope'

target: a result about the whole target run (exit status, load error), not a test case.

rules_requirements.ingest.split_ids(value: str) → list[str][source]

The ids in one requirement value: split on commas and whitespace, blanks dropped.

rules_requirements.ingest.name_tags(name: str) → list[str][source]

Ids declared by [rr:ID] tags in a case name, in order ([rr:A,B] gives two).

rules_requirements.ingest.workspace_relative(path: str) → str[source]

The one spelling of a test source path (rr.file), so that one source file is one code identity however a producer spelled it.

Backslashes become /; the path is normalized (./x, x//y and a/../b collapse, as posixpath.normpath does); a *.runfiles/<workspace>/ or bazel-out/<cfg>/bin/ prefix is stripped, else the $BUILD_WORKSPACE_DIRECTORY prefix when that is set. Any other absolute path is kept (same-code detection compares it with a relative spelling by suffix). "" stays "".

class rules_requirements.ingest.TestCase(name: str, status: str, classname: str = '', declared: Iterable[str] = (), level: str = '', artifact: Mapping[str, str] | None = None, message: str = '', duration: float = 0.0, source: str = '', target: str = '', properties: Mapping[str, str] | None = None, suite: str = '', file: str = '', line: int = 0, suite_declared: Iterable[str] = (), *, requirements: Iterable[str] | None = None)[source]

Bases: object

One raw result, as an ingestor read it.

declared holds the requirement ids the evidence names for this case — tags, in order, without duplicates. It is plain data: nothing here says which requirement the case verifies (see rules_requirements.ingest). requirements is a deprecated read/write alias of it (and a deprecated keyword of the constructor: dataclasses.replace(case, requirements=...) works as in 0.2, while passing both declared= and requirements= explicitly is a TypeError, whatever declared holds ((), or another case’s declared); dataclasses.asdict() names the field declared).

property requirements: tuple[str, ...]

Deprecated alias of declared (tags, not owners).

property scope: str

target for a result about the whole target run, else case.

property synthetic: bool

The target’s single whole-run result (Bazel’s generated report, or rr.synthetic).

rules_requirements.ingest.apply_properties(case: TestCase, props: Iterable[tuple[str, str]]) → TestCase[source]

Fold raw (name, value) properties into the typed fields of case.

Every requirement/requirements value is split on commas and whitespace, and the distinct ids — together with the case name’s [rr:ID] tags — are recorded in TestCase.declared, in order. Nothing is decided about ownership: two ids stay two ids.

class rules_requirements.ingest.Ingestor[source]

Bases: object

Base class for evidence readers. Subclasses set name and implement sniff() and ingest().

sniff(path: str, head: bytes) → bool[source]

Whether this ingestor understands path (head = first 4 KiB).

rules_requirements.ingest.register(ingestor: Ingestor) → Ingestor[source]

Register (or replace) an ingestor by its name.

rules_requirements.ingest.load_ingestor(spec: str) → Ingestor[source]

Import module:attr (a class or instance) and register it.

rules_requirements.ingest.ingestor_for(path: str, only: Iterable[str] | None = None) → Ingestor | None[source]

The first registered ingestor that recognises path.

class rules_requirements.ingest.IngestIssue(code: str, source: str, target: str, scope: str, ids: tuple[str, ...])[source]

Bases: object

Something about the evidence worth a warning (never a verdict).

suite-level-requirement: a <testsuite> (or a <testcase> holding nested cases) names a requirement. It used to reach every case below; since v0.3 it reaches none of them.

property detail: str

What is wrong, without a severity (attribution assigns the configured one).

class rules_requirements.ingest.Evidence(cases: list[rules_requirements.ingest.TestCase] = field(default_factory=...), files: list[str] = field(default_factory=...), skipped_files: list[str] = field(default_factory=...), target_status: dict[str, str] = field(default_factory=...), issues: list[rules_requirements.ingest.IngestIssue] = field(default_factory=...))[source]

Bases: object

Every test case from every evidence file, plus per-target rollups.

for_id(entity_id: str) → list[TestCase][source]

Cases whose evidence declares entity_id — tags, not ownership.

Deprecated: no verdict reads it. The cases an entity owns are its members in rules_requirements.attribution.Attribution.members_of() (build_matrix(...).attribution).

rules_requirements.ingest.expand(paths: Iterable[str]) → list[str][source]

Files, directories (recursive) and globs -> sorted unique file list.

rules_requirements.ingest.collect(paths: Iterable[str], only: Iterable[str] | None = None) → Evidence[source]

Ingest every recognisable evidence file under paths.

Directories are walked; files no ingestor recognises (e.g. test.log next to test.xml in bazel-testlogs) are skipped silently.

JUnit XML ingestor — the standard evidence format.

Accepts the common dialects: a bare <testsuite> or <testsuites> root, nested suites, xunit2 per-testcase <properties> (pytest, our writers, googletest >= 1.10) and property attributes on <testcase> (older googletest RecordProperty output). Of suite-level <properties>, only level and artifact.* stamps reach the cases below; a suite-level requirement does not (it is recorded as suite-level-requirement).

A <testcase> holding <testcase> children (subtests, as some runners nest them) is a scope, not a case: each child becomes a case whose classname is the parent’s path joined with `` > `` (the shape rr_node_test gives node:test subtests), and a failure of the parent itself that none of its children explains becomes a target-scope <hooks> error.

A <testcase> whose subtests sit in a <testsuite> of their own is a scope the same way. A nested case’s own classname attribute is ignored: its classname is always its parent’s path, so a subtest keys under the case that ran it.

A report that cannot be read at all is one target-scope error, so whatever the target verified reads as tainted, never as missing. A file Bazel names as a report (test.xml or test_attempts/attempt_N.xml in a testlogs tree) is read whatever its first bytes are: empty, binary or with its root element after a long prolog, it is still the target’s report.

Bazel writes one test.xml per test target under bazel-testlogs; the target label is recovered from that path so verified_by target traces work.

class rules_requirements.ingest.junit.JUnitIngestor[source]

Bases: Ingestor

sniff(path: str, head: bytes) → bool[source]

Whether this ingestor understands path (head = first 4 KiB).

rules_requirements.ingest.junit.is_bazel_generated(suite: Element) → bool[source]

Whether suite is the report Bazel writes for a test that wrote none.

Bazel’s generate-xml.sh fingerprint: a <testsuite name=N> holding exactly one <testcase name=N status="run"> without a classname, and a <system-out> that starts with “Generated test.log”. Such a result says only how the whole target ended — it has no per-case identity.

rules_requirements.ingest.junit.is_testlogs_report(path: str) → bool[source]

Whether path is a report Bazel itself names inside a testlogs tree: <target dir>/test.xml (also under shard_i_of_n / run_k_of_n) or <target dir>/test_attempts/attempt_N.xml.

Such a file is JUnit by contract, whatever its first bytes say, so it is never skipped: an empty, binary or late-rooted one is read (and, if it cannot be, becomes the target’s <unreadable> error).

rules_requirements.ingest.junit.target_from_path(path: str) → str[source]

.../bazel-testlogs/pkg/sub/name/test.xml -> //pkg/sub:name.

Also handles the resolved form (bazel-out/<cfg>/testlogs/...), the external-repo form (testlogs/external/<repo>/pkg/name) and sharded / retried runs (.../name/shard_1_of_4/test.xml, run_2_of_3, test_attempts/attempt_1.xml). Returns “” outside a testlogs tree.

Rust libtest (cargo test / rust_test) plain-text output ingestor.

libtest has no stable machine-readable output, but its default “pretty” format is stable and line-oriented:

running 3 tests
test parse::rejects_empty ... ok
test parse::accepts_celsius ... FAILED
test slow::soak ... ignored, needs hardware

failures:

---- parse::accepts_celsius stdout ----
thread 'parse::accepts_celsius' panicked at src/lib.rs:12:5: ...

Requirement traces come from the rr::verifies! macro in the rules_requirements Rust crate, which appends one JSON line per call to the file named by $RR_TRACE_FILE (see merge_trace()). The rules_requirements.hooks.wrap test wrapper sets that variable, runs the binary, and writes JUnit — so this ingestor is mostly used by the wrapper, and directly only for *.libtest.txt captures.

rules_requirements.ingest.libtest.trace_ids(rec: dict[str, Any]) → list[str][source]

Every id one rr::verifies! trace line names, in any shape a producer writes: "requirement": "<id>" (0.3), the 0.2 list "requirements": [...], and also a string under requirements or a list under requirement. Values are returned as written (apply_properties() splits "A,B"); nothing a line names is dropped, so a line naming two ids always reaches attribution as a multi-tag, never as one id or none.

rules_requirements.ingest.libtest.merge_trace(cases: list[TestCase], trace_text: str) → list[TestCase][source]

Apply rr::verifies! trace lines to the matching cases.

Each line is JSON {"test": "<module::name>", "requirement": "<id>", "level": "...", "artifact": {...}}; test is the libtest thread name, which is the test’s full path. The 0.2 list form ("requirements": [...]) is still read. Every id becomes a declared tag of the case, so a list of two, two lines for one test with different ids (two calls), or a whitespace- or comma-separated id is a multi-tag case.

class rules_requirements.ingest.libtest.LibtestIngestor[source]

Bases: Ingestor

Native evidence records (*.rr.yaml / *.rr.json).

For evidence no test runner produces — a signed-off inspection, an analysis report, a bench measurement recorded by hand — write a records file:

target: record:panel_inspection     # optional; default record:<file stem>
evidence:
  - name: enclosure-label-legible
    status: passed
    requirement: REQ-12
    level: inspection
    artifact: {board_rev: C}
    properties: {signed_by: "J. Doe", date: "2026-09-01", record: "QA-114"}

Each entry becomes one TestCase, filed under the document’s target: (an entry’s own target: overrides it; without either, the key’s target is record:<file stem>). Its requirement is a single-id tag; the legacy requirements: [REQ-12] is still read, and a record naming several ids keeps them all, which makes it a multi-tag (quarantined) case: a record verifies at most one requirement.

class rules_requirements.ingest.records.RecordsIngestor[source]

Bases: Ingestor

Case identity: the key every per-case result is filed under.

A test case is identified by CaseKey — the build target that ran it and its path within that target, <classname>::<name>:

//web:clocksync_test#clocksync::bestSample keeps the min-RTT sample
//pi/server/tests:server_test#pi.server.tests.test_handler::test_configure[a]

The key is what a requirement claims and what attribution maps to exactly one owner, so it must not depend on how a case is tagged or executed:

  • Re-tagging never renames a case. [rr:ID] tags inside a case name are stripped from the path (case_path()); name_tags() reads them.

  • Retries, repeats, shards and evidence roots are not identity. They are execution dimensions (run_dims_from_path()), folded into one CaseRow per key by index_cases().

  • A target with no per-case output has one case, [target] (SYNTHETIC_PATH): Bazel’s generated test.xml, or a result one of our writers marks rr.synthetic=true.

Evidence that cannot be pinned to a build target gets a pseudo-target — record:<stem> for records without a target:, suite:<testsuite name> for JUnit outside a bazel-testlogs tree (see target_of()).

rules_requirements.case_keys.SYNTHETIC_PATH = '[target]'

Path of the single result of a target that reported no per-case results.

rules_requirements.case_keys.UNNAMED_PATH = '[unnamed]'

Path of a case whose classname and name are both empty (a key’s path is never empty).

class rules_requirements.case_keys.CaseKey(target: str, path: str)[source]

Bases: object

(target, path); str() is <target>#<path>.

Labels cannot contain # and pseudo-targets are sanitized (pseudo_target()), so the first # always separates the two parts — the path itself may contain anything, # and :: included.

class rules_requirements.case_keys.CaseRow(key: rules_requirements.case_keys.CaseKey, status: str, declared: tuple[str, ...] = (), level: str = '', synthetic: bool = False, target_scope: bool = False, file: str = '', line: int = 0, flaky: bool = False, attempts: int = 1, duplicate: bool = False, message: str = '', sources: tuple[str, ...] = (), cases: tuple[rules_requirements.ingest.TestCase, ...] = ())[source]

Bases: object

Every observation of one CaseKey, folded into one result.

class rules_requirements.case_keys.RunDims(shard: int = 0, shards: int = 0, run: int = 0, runs: int = 0, attempt: int = 0)[source]

Bases: NamedTuple

Where in a Bazel test run a report sits. 0 means “not sharded” / “a single run” / “the final attempt” (test.xml).

shard: int

Alias for field number 0

shards: int

Alias for field number 1

run: int

Alias for field number 2

runs: int

Alias for field number 3

attempt: int

Alias for field number 4

rules_requirements.case_keys.case_path(classname: str, name: str) → str[source]

The canonical path of a case: <classname>::<name>, or <name>.

Both parts are taken as the XML parser returns them (already unescaped), normalized to Unicode NFC with surrounding whitespace stripped; internal whitespace is kept. [rr:ID] tags are removed from the name. The result is never split again, so names containing :: or `` > `` are fine. A case with neither gets UNNAMED_PATH.

rules_requirements.case_keys.declared_of(case: TestCase) → tuple[str, ...][source]

The ids a raw case declares: its declared tags plus any [rr:ID] name tags (also for a hand-built TestCase). Tags, never owners.

Every value is split on commas and whitespace ("PR-1, PR-2" is two ids, whatever produced it), so a case naming two ids reads as a multi-tag.

rules_requirements.case_keys.file_of(case: TestCase) → str[source]

The test source a case came from (rr.file), workspace-relative and in its one spelling (workspace_relative()); “” if unknown.

rules_requirements.case_keys.index_cases(evidence: Evidence | Iterable[TestCase], *, main_repo: str | None = None) → dict[CaseKey, CaseRow][source]

One CaseRow per key, sorted by key.

main_repo normalizes every target as key_of() does (so two spellings of one target are one key); None keeps them as recorded.

  • Attempts (test_attempts/attempt_N.xml next to test.xml): the final report is authoritative; an earlier failure under a final pass makes the row flaky. A key seen only in earlier attempts of a run that has a final report (a crashed attempt’s [target] result) is not a case: its failure makes the run’s passing cases flaky.

  • Runs (--runs_per_test) and evidence roots: the worst status wins — every repetition must pass.

  • Shards: their cases are unioned; one key in two shards (or twice in one report) is a duplicate, folded worst-of — except a whole-run result ([target], rr.scope=target), which every shard has.

Declared ids are the union over every observation. No ownership is decided here.

rules_requirements.case_keys.is_synthetic(case: TestCase) → bool[source]

The target’s single generated result (Bazel’s fingerprint or rr.synthetic).

rules_requirements.case_keys.is_target_scope(case: TestCase) → bool[source]

A result about the whole target run (rr.scope=target), not a test case.

rules_requirements.case_keys.is_unscoped(target: str) → bool[source]

A suite: pseudo-target: evidence no build label can be matched to.

rules_requirements.case_keys.key_of(case: TestCase, main_repo: str | None = None) → CaseKey[source]

The CaseKey a raw ingested case is filed under.

With main_repo (config.main_repo, "" for none) the target is normalized (normalize_target()), as attribution files every case; without it the target is kept as the evidence recorded it.

rules_requirements.case_keys.name_tags(name: str) → list[str][source]

Ids declared by [rr:ID] tags in a case name, in order ([rr:A,B] gives two).

rules_requirements.case_keys.nodeid_to_case_path(nodeid: str) → str[source]

The case_path() of a pytest nodeid.

Mirrors pytest’s own mangle_test_address (the JUnit classname / name split), so pkg/test_m.py::TestK::test_s and pkg/test_m.py::test_a[x] map to the same paths a --junitxml report would file them under — pkg.test_m.TestK::test_s and pkg.test_m::test_a[x] — including the parametrization id.

rules_requirements.case_keys.normalize_target(target: str, main_repo: str = '') → str[source]

target in the spelling claims use (normalize_label() with config.main_repo), so @@//p:n, //p and a canonical @repo~//p:n file under the same key a model names. A target that is no label (pseudo-targets pass through) is kept as recorded. # (the key separator) is replaced first, so the result is its own normal form (rr check-report reads a key as canonical when this leaves it unchanged).

rules_requirements.case_keys.pseudo_target(prefix: str, name: str) → str[source]

record:<name> / suite:<name>, with # (the key separator) replaced.

rules_requirements.case_keys.run_dims_from_path(path: str) → RunDims[source]

Parse shard_i_of_n, run_k_of_n (also combined, as shard_i_of_n_run_k_of_m) and test_attempts/attempt_N.xml.

.../name/shard_1_of_4_run_2_of_3/test_attempts/attempt_1.xml -> RunDims(shard=1, shards=4, run=2, runs=3, attempt=1); a test.xml is the final attempt (attempt=0).

rules_requirements.case_keys.run_targets(evidence: Evidence) → set[str][source]

Every target evidence has a result file for: the target of each case index_cases() files (a synthetic whole-run result included), and that of each ingested report that holds no case at all (a test.xml with an empty <testsuite>: pytest collected nothing). Such a report’s target comes from its path (bazel-testlogs/<pkg>/ <name>/test.xml); else it is keyed as a case of it would be: by each of its <testsuite> names (suite:<name>), and by the file stem (suite: / record: + stem) only for an unnamed suite or a report with none. A target here has run.

rules_requirements.case_keys.target_of(case: TestCase) → str[source]

The target half of a case’s key.

The build label when the evidence is pinned to one (bazel-testlogs paths, rr wrap --target, a record’s target:); otherwise record:<file stem> for records and suite:<testsuite name> (or the report’s file stem) for anything else.

rules_requirements.case_keys.workspace_relative(path: str) → str[source]

The one spelling of a test source path (rr.file), so that one source file is one code identity however a producer spelled it.

Backslashes become /; the path is normalized (./x, x//y and a/../b collapse, as posixpath.normpath does); a *.runfiles/<workspace>/ or bazel-out/<cfg>/bin/ prefix is stripped, else the $BUILD_WORKSPACE_DIRECTORY prefix when that is set. Any other absolute path is kept (same-code detection compares it with a relative spelling by suffix). "" stays "".

Migration

Migrating to one owner per test case: the attribution worksheet.

Today a test case counts toward every id it is tagged with and toward every requirement whose verified_by names its target — the union. rr migrate plan computes that union over a model and its evidence and lists every evidence unit (one CaseKey) that counts toward two or more entities, and every target named by two or more requirements. The result is a worksheet (.rrplan, YAML) on which the owners of those requirements record one decision per case:

  • an entity id — the one requirement (user need, mitigation) it verifies;

  • none — it verifies none of them;

  • ? — still open (the initial value).

Decisions are made per group (one test module/class of one target) and overridden per case. rr migrate apply --stage tags then rewrites the test sources to match (rules_requirements.tag_codemod); model_edits() lists the verified_by edits the decisions imply.

Nothing here changes a verdict: the worksheet is data for people.

class rules_requirements.migrate.Unit(row: rules_requirements.case_keys.CaseRow, tags: tuple[str, ...], claims: tuple[str, ...], ignored: tuple[str, ...] = (), proposed: str = '', reason: str = '')[source]

Bases: object

One evidence unit and what it counts toward under today’s union.

class rules_requirements.migrate.Plan(units: list[rules_requirements.migrate.Unit], claimers: dict[str, list[str]], rows: dict[rules_requirements.case_keys.CaseKey, rules_requirements.case_keys.CaseRow] = field(default_factory=...))[source]

Bases: object

The union-semantics census a worksheet is rendered from.

rules_requirements.migrate.census(model: Model, evidence: Evidence) → Plan[source]

What every evidence unit counts toward under today’s semantics.

rules_requirements.migrate.worksheet(plan: Plan, *, inputs: Mapping[str, Iterable[str]] | None = None, previous: Mapping[str, Any] | None = None) → dict[str, Any][source]

The worksheet document (JSON-compatible) for plan.

previous (an earlier worksheet) carries its decisions over to the cases and groups that are still contested.

rules_requirements.migrate.decisions(doc: Mapping[str, Any] | None) → dict[CaseKey, str][source]

Each contested case’s effective decision: its own owner, else its group’s.

rules_requirements.migrate.owner_ids(owner: str) → list[str][source]

The ids a case decided owner must end up declaring: [owner], or none for none.

rules_requirements.migrate.case_files(doc: Mapping[str, Any] | None) → dict[CaseKey, str][source]

Each contested case’s test source (its row’s file), where the evidence named one.

rules_requirements.migrate.model_edits(doc: Mapping[str, Any]) → list[dict[str, str]][source]

The verified_by edits the worksheet’s decisions imply, per (target, requirement).

action is remove (no case of the target is left for it), keep (it still owns cases there, and no case went to anyone else), split (it keeps some cases while others went elsewhere — a whole-target reference cannot express that; it needs case selectors, from v0.3), open (undecided cases remain) or no-evidence (the evidence given has no result of the target, so nothing can be said: never a reason to remove the reference).

class rules_requirements.migrate.Offence(key: rules_requirements.case_keys.CaseKey, reason: str, expected: list[str], found: list[str] | None)[source]

Bases: object

One case the new evidence contradicts the worksheet on.

class rules_requirements.migrate.Verification(offences: list[rules_requirements.migrate.Offence] = field(default_factory=...), decided: int = 0, missing: list[rules_requirements.case_keys.CaseKey] = field(default_factory=...), untagged: list[rules_requirements.case_keys.CaseKey] = field(default_factory=...), pending: list[tuple[rules_requirements.case_keys.CaseKey, list[str]]] = field(default_factory=...), also_claimed: list[tuple[rules_requirements.case_keys.CaseKey, list[str]]] = field(default_factory=...), unchanged: int = 0, new: int = 0)[source]

Bases: object

The outcome of verify().

rules_requirements.migrate.unkeyed(doc: Mapping[str, Any], evidence: Evidence) → str[source]

Why evidence cannot be matched with the worksheet doc by target, else “”: the worksheet’s cases belong to build targets (//pkg:name) but the evidence files no case under any build target, only under suite: / record: pseudo-targets. A JUnit file’s build target comes from its path (bazel-testlogs/<pkg>/<name>/test.xml, or a directory named testlogs), so evidence copied into a directory of another name is keyed by suite name and no case matches.

rules_requirements.migrate.verify(doc: Mapping[str, Any], evidence: Evidence, baseline: Evidence | None = None, *, allow_missing: bool = False, model: Model | None = None) → Verification[source]

Check test evidence produced after rr migrate apply against the worksheet doc.

Every case the worksheet decides must declare exactly its owner in evidence (no id for none). With a baseline (the evidence from before the rewrite), every other case must declare the same ids as before, and no case of the baseline may be missing from evidence. A target-scope result (a whole run’s exit status) carries the union of its cases’ ids, which the decisions change: it must still be there, its ids are not compared.

A case with no result is an offence, but with allow_missing one whose target has no result file at all in evidence (a HITL or manual target CI does not run) is only listed in missing. A case missing from a target that did run stays an offence: it was renamed or lost. A target has run when the evidence has any result file for it (run_targets()): a case, a synthetic whole-run result, or a report with no testcase at all (pytest collected nothing).

Only declared ids are checked. A decided case that declared no id before the rewrite and declares none after counts only through verified_by, which rr migrate apply cannot edit (it lists the edits): it is not an offence. With a model it is untagged when the entities whose claims select it (as attribute() selects: a case selector only the cases it matches, a whole claim every case of its target) are exactly its owner (none for none), else pending a model edit (a target split between owners needs case selectors: rr migrate apply --stage model); without one it is untagged, its owner unchecked. “Before” is the baseline when it has the case, else the worksheet: a group that lists no tags has no tagged case. With a model, a decided case declaring exactly its owner that claims of other entities select is listed in also_claimed: the claims decide its owner (tag-mismatch), or quarantine it when two entities claim it, so its tag alone does not make it its owner’s.

Cases are matched by CaseKey (index_cases()), so the evidence of a Bazel py_test, rr_node_test, rr_case.h test or anything else rr ingests is checked alike.

exception rules_requirements.migrate.WorksheetError[source]

Bases: ValueError

rules_requirements.migrate.load_worksheet(path: str) → dict[str, Any][source]

Read a .rrplan (YAML) or .json worksheet; raise WorksheetError.

rules_requirements.migrate.check_worksheet(doc: Mapping[str, Any], model: Model | None = None) → list[str][source]

Problems with a (possibly hand-edited) worksheet; empty when usable.

Each owner must be a string: ?, none (any case), or one of the entities the case counts toward today — a decision chooses among the existing claims, it never adds one. With a model, the id must also be a requirement, user need or mitigation of it.

rules_requirements.migrate.plan_paths(paths: Iterable[str]) → list[str][source]

Input paths as recorded in the worksheet: relative to the current directory when below it.

class rules_requirements.migrate.ModelStage(additions: dict[str, dict[str, list[str]]] = field(default_factory=...), whole: dict[str, list[str]] = field(default_factory=...), data: dict[str, dict[str, Any]] = field(default_factory=...), model: rules_requirements.model.Model | None = None, owners: int = 0, refused: list[str] = field(default_factory=...), unseen: dict[str, str] = field(default_factory=...), from_worksheet: list[str] = field(default_factory=...), skipped: dict[str, str] = field(default_factory=...))[source]

Bases: object

What rr migrate apply --stage model writes, and why it may not.

additions maps each entity to {target: [selector, ...]}: the selectors that make every case it owns through a tag (attribution: hybrid) a case it claims; whole the targets it owns only through their synthetic [target] result. data is each changed entity’s new plain form (rules_requirements.edit.entity_to_dict()), and model the model with those entities, in attribution: model, whose owner table check_model_stage() proved unchanged. refused lists why nothing may be written (a quarantine, a worksheet decision the evidence contradicts, a changed owner, a static claim error).

unseen holds the worksheet’s decided cases the evidence given does not hold (another lane’s tests, say), with their decisions. Their owner comes from the worksheet: a case decided for an entity gets a literal selector of it (from_worksheet lists those keys), and every unseen decided case must be selected, statically, by exactly its decided owner (by no claim when decided none), or the stage is refused.

rules_requirements.migrate.model_stage(model: Model, evidence: Evidence, doc: Mapping[str, Any] | None = None, *, compress: bool = False) → ModelStage[source]

Explicit selectors for every current owner (rr migrate apply --stage model).

The owners are decided by rules_requirements.attribution.attribute() over evidence; nothing here assigns one. Every case owned through a tag gets a selector of its owner (a literal per case, or with compress a * glob where that is exact), then check_model_stage() proves the owner table unchanged under attribution: model and the claims statically disjoint.

A case the worksheet decided but evidence does not hold keeps the worksheet’s owner: a literal selector of the decided entity (unless one of its claims selects it already), and a refusal unless exactly that entity’s claims select it (none for none). Nothing is lost silently.

rules_requirements.migrate.check_model_stage(before: Model, after: Model, evidence: Evidence) → list[str][source]

Why after may not replace before: an owner that changed (the model-mode attribution of after must give every case the owner the attribution of before gave it, through a claim), a quarantine, or a static claim error (check_claims: shared-case, same-code, a bad selector or target).

rr migrate apply --stage tags: split multi-id test tags, per a worksheet.

Python tests declare what they verify with @pytest.mark.rr / @pytest.mark.requirements (on a function, a class, or as a module or class pytestmark) and @rr.verifies (function or class). Ids from every scope accumulate today, so a module-level pytestmark naming two ids makes every test in the module count toward both.

Given the owners decided on an attribution worksheet (rules_requirements.migrate), this codemod rewrites the declarations so each test names exactly its decided owner:

  • a scope declaration (module or class) whose tests all went to one owner is narrowed to that one id, keeping its level / artifact;

  • otherwise it is removed, and each test under it gets its own single-id declaration (same spelling, same level / artifact);

  • a test decided none loses its tags.

It works on the syntax tree (ast) and edits whole source lines, so everything else in the file — comments, formatting, other markers — is left as it was. Every rewrite is checked before it is written: the new file is parsed again and each test’s effective (ids, level, artifact) must be exactly the intended one, or the file is left untouched. New lines are rendered the way black formats them, so running black afterwards changes nothing.

It fails closed: a file is refused — reported, never half-migrated — when a test it would change is undecided (?, unless unassigned="drop"), when the parametrizations of one test were decided differently (split those by hand with pytest.param(..., marks=...)), when a declaration is not a literal the codemod can read, when a decided test or a scope around it carries a decorator or pytestmark element that may hold ids the codemod cannot read (@AB for AB = pytest.mark.rr(...), a helper’s decorator), or when a change would reach a test the static view cannot see: tests a class inherits from another class (of the file or of another module), tests defined inside an if/try/with/loop block, decided cases of the module the file does not define (inherited from another module, or generated), and any test name (pytest’s python_functions / python_classes) bound other than by a def / class statement: assignment, for / with target, walrus, import, del, global, except ... as, match capture, attribute or globals() store, setattr (and from inside a function: global, attribute stores and setattr on anything but the method’s own self, namespace dictionaries, exec / eval), or a class that may be a unittest.TestCase bound to another name. The internal check can only vouch for the tests it sees, so those are refused up front.

Across files, every Python file under the root is indexed (in its own encoding, as Python reads it) for what it imports and subclasses: a file whose changed classes or tests another file imports or subclasses is refused, together with that file (importlib.import_module("m") with a literal name is an import of m). A file that cannot be read or parsed, an import call whose module cannot be named, and a class whose base cannot be resolved statically may hide either: when anything (for the latter, any class) would change, they are refused with the changed files. A decided case’s own file that cannot be read or parsed is refused. Last, each decided case’s attribution is derived again from the rewritten sources and compared with the worksheet. The caller writes all or nothing (ApplyResult.to_write()).

class rules_requirements.tag_codemod.TestNames(functions: tuple[str, ...] = ('test',), classes: tuple[str, ...] = ('Test',), files: tuple[str, ...] = ('test_*.py', '*_test.py'))[source]

Bases: object

The names pytest collects as tests: python_functions and python_classes (each pattern a prefix or a glob, as in pytest). The defaults test / Test always count too: matching more names only makes the codemod refuse more.

any(name: str) → bool[source]

A name that may hold a test or a test class.

test_module(rel: str) → bool[source]

A file pytest collects tests from (python_files: globs).

rules_requirements.tag_codemod.test_names(root: str) → TestNames[source]

The python_functions / python_classes / python_files configured for pytest at root (pytest.ini, pyproject.toml, tox.ini, setup.cfg), together with the defaults.

exception rules_requirements.tag_codemod.Unsupported[source]

Bases: Exception

A declaration the codemod cannot rewrite safely.

class rules_requirements.tag_codemod.Decl(scope: str, holder: ast.AST, call: ast.Call, callee: str, ids: list[str], level: str, artifact: dict[str, str], site: str, stmt: ast.AST | None = None)[source]

Bases: object

One rr declaration in the source.

property is_verifies: bool

@rr.verifies: an attribute, not a pytest marker. On a class it applies to that class’s own tests only, never to a nested class’s.

class rules_requirements.tag_codemod.TestFn(node: ast.FunctionDef | ast.AsyncFunctionDef, classes: tuple[ast.ClassDef, ...])[source]

Bases: object

class rules_requirements.tag_codemod.FileResult(path: str, status: str, reasons: list[str] = field(default_factory=...), changes: list[str] = field(default_factory=...), old_text: str = '', new_text: str = '', encoding: str = 'utf-8')[source]

Bases: object

class rules_requirements.tag_codemod.ApplyResult(files: list[rules_requirements.tag_codemod.FileResult] = field(default_factory=...), unresolved: list[tuple[rules_requirements.case_keys.CaseKey, str]] = field(default_factory=...), unmatched: list[tuple[rules_requirements.case_keys.CaseKey, str]] = field(default_factory=...), untagged: list[rules_requirements.case_keys.CaseKey] = field(default_factory=...), outside: list[rules_requirements.case_keys.CaseKey] = field(default_factory=...), mismatched: list[tuple[rules_requirements.case_keys.CaseKey, str]] = field(default_factory=...), unmatched_files: set[str] = field(default_factory=...), depends: dict[str, set[str]] = field(default_factory=...), located: dict[str, list[rules_requirements.case_keys.CaseKey]] = field(default_factory=...))[source]

Bases: object

property changed: list[FileResult]

The files rewritten in memory; to_write() says which may be written.

property blocked: bool

Something was refused, unmatched or mismatched: the run is not clean.

to_write(partial: bool = False) → list[FileResult][source]

The changed files to write. All or nothing by default: nothing when anything was refused or unmatched. With partial, the changed files that hold no unmatched case and are not linked (depends) to a refused file or to one holding an unmatched case. Never anything when a rewritten source does not give a decided case its owner.

settled(paths: Iterable[str]) → list[CaseKey][source]

The decided cases that writing paths settles: those the codemod found defined in one of them and whose owner their tags carry. Cases left to verified_by (untagged), cases in files not written (held back, refused, outside only) and cases not found are not settled by the write: the collection check holds them to their before-ids instead.

held_back(partial: bool = False) → dict[str, str][source]

Why each changed file that to_write() leaves out is held back.

class rules_requirements.tag_codemod.TestFile(text: str, path: str = '<string>', names: TestNames = TestNames(functions=('test',), classes=('Test',), files=('test_*.py', '*_test.py')), reached: Iterable[str] = (), trust_main_guard: bool = False)[source]

Bases: object

The rr declarations and the tests of one Python source file.

mark_inherited(sub: ClassDef, why: str) → None[source]

sub inherits tests or declarations the static view does not follow: distrust its traces, and change nothing that reaches its tests.

opaque_scope(test: TestFn) → str | None[source]

Why test, or a scope around it, may carry ids the codemod cannot read.

blind_class(test: TestFn) → str | None[source]

Why test’s static trace cannot be trusted, if it cannot.

mark_unseen(classes: list[str], why: str) → None[source]

A decided case of this module whose test is not defined here (inherited from another module, or generated): the declarations of the scopes it sits in reach it unseen, and a class of that path may inherit declarations too.

resolve_classes(names: list[str]) → list[ClassDef][source]

The classes of this file along a Outer.Inner path, as far as they exist.

static applies(d: Decl, test: TestFn) → bool[source]

Whether d reaches test as the pytest plugin resolves it: markers reach every test below their scope; @rr.verifies on a class only the tests of that class itself (item.cls).

chain(test: TestFn, decls: Iterable[Decl] | None = None) → list[list[Decl]][source]

The declarations applying to test, nearest scope first.

trace(test: TestFn, decls: Iterable[Decl] | None = None) → tuple[tuple[str, ...], str, tuple[tuple[str, str], ...]][source]

(ids, level, artifact) as the pytest plugin resolves them.

reached_by(d: Decl) → list[TestFn][source]

The tests d applies to.

rules_requirements.tag_codemod.rewrite(tf: TestFile, owners: Mapping[str, str | None], line_length: int = 88) → tuple[str, list[str]][source]

Rewrite tf so each test in owners (qualname -> one id, or None for none) declares exactly that id; tests not in owners keep their trace. Returns (new text, change descriptions); raises Unsupported when that cannot be done safely.

rules_requirements.tag_codemod.python_files(root: str, only: Iterable[str] = ()) → list[str][source]

Candidate test sources under root (relative paths), optionally only below only prefixes.

rules_requirements.tag_codemod.apply_tags(decided: Mapping[CaseKey, str], root: str, *, only: Iterable[str] = (), unassigned: str = 'refuse', line_length: int = 88, names: TestNames | None = None, files_of: Mapping[CaseKey, str] | None = None, trust_main_guard: bool = False) → ApplyResult[source]

Rewrite the Python tests under root per the worksheet’s decisions (CaseKey -> id | "none" | "?"). Nothing is written; the caller writes the files ApplyResult.to_write() returns.

Every Python file under root (not only those below only) is indexed for what it imports and subclasses: a file whose changed classes or tests another file imports or subclasses is refused, with that file. Each decided case’s attribution is then re-derived from the rewritten sources and compared with the worksheet (mismatched). names: the test names pytest collects (default: as configured for pytest at root, see test_names()).

A case resolves only to a module pytest collects (python_files), and only to the source its evidence names when files_of (CaseKey -> rr.file, as the worksheet’s case rows carry it) knows one: a C++ or Rust case whose path merely reads like a Python module, or a case of a record, is “not a Python test” and left alone.

trust_main_guard (opt-in, default off): leave out of the static guards the code only an if __name__ == "__main__": block runs (_not_at_import). That exclusion is best-effort – static analysis cannot prove what Python runs at import – so only use it together with a definitive check (the collection check, or rr migrate verify against fresh test evidence before merging). By default that code is judged like any other.

Attribution

Attribution: the one place that decides which entity a test case verifies.

A test case verifies at most one requirement; a set of test cases may together verify one. Everything else in rules_requirements only produces the inputs of this module:

  • the model produces claims — the verified_by / validated_by selectors of requirements, user needs and mitigations (claims());

  • evidence produces declared ids — the tags a case names (TestCase.declared), never an owner.

attribute() turns both into an Attribution whose owner maps each CaseKey to one entity id. A mapping is a function: no case can have two owners. Ambiguity fails closed — a case whose evidence names more than one id (multi-tag), that claims of two entities select (attribution-conflict), or whose test code is owned by two entities in two targets (same-code-multiple-owners) is quarantined: it owns nothing, and every entity it names reads INVALID.

The verdicts (rules_requirements.trace.build_matrix()), the reports, the editor and the agents read an entity’s cases from Attribution.members_of() and from nothing else, and every Attribution is checked by Attribution.check_invariant().

rules_requirements.attribution.OWNED_STATES = ('passed', 'failed', 'error', 'skipped')

States of a member the entity owns: the merged result of its case (error also for a taint).

class rules_requirements.attribution.Attribution(mode: str, cases: Mapping[rules_requirements.case_keys.CaseKey, rules_requirements.attribution.CaseResult], owner: Mapping[rules_requirements.case_keys.CaseKey, str], via: Mapping[rules_requirements.case_keys.CaseKey, str], members: Mapping[str, tuple[rules_requirements.attribution.Member, ...]], quarantined: tuple[rules_requirements.attribution.Quarantine, ...], targets: Mapping[str, rules_requirements.attribution.TargetRun], issues: tuple[rules_requirements.attribution.AttributionIssue, ...] = (), lock: rules_requirements.lock.Lock | None = None, claimed_by: Mapping[rules_requirements.case_keys.CaseKey, tuple[rules_requirements.model.Claim, ...]] = field(default_factory=...))[source]

Bases: object

The result of attribute(): read-only, and checked.

owner is THE function from case keys to entity ids; members are each verifiable entity’s verification set; the entities a quarantined case names each hold it as a quarantined member.

property entities: tuple[str, ...]

The verifiable entities (user needs, requirements, mitigations).

members_of(entity: str) → tuple[Member, ...][source]

The verification set of entity (empty for anything else).

check_invariant() → None[source]

Assert the one-owner invariant; raises AttributionInvariantError.

  • owner is a function from case keys to one entity id each;

  • the owned members (states passed/failed/skipped, and error for a case in the evidence — by state, whatever result they carry) partition the owned keys: each owned key is a member of exactly its owner, with that key’s result;

  • no key is both owned and quarantined;

  • every entity a quarantine names holds that key as a quarantined member.

exception rules_requirements.attribution.AttributionInvariantError(problems: Sequence[str])[source]

Bases: AssertionError

Raised only by Attribution.check_invariant(): a bug in this module (or a hand-built Attribution), never a property of the input.

class rules_requirements.attribution.AttributionIssue(code: str, message: str, severity: str = 'warning', key: rules_requirements.case_keys.CaseKey | None = None, entities: tuple[str, ...] = (), declared: tuple[str, ...] = (), target: str = '')[source]

Bases: object

A finding of attribution that is not a quarantine: a tag that disagrees with the model, a missing lock entry, a duplicate case, …

class rules_requirements.attribution.CaseResult(key: rules_requirements.case_keys.CaseKey, status: str, level: str = '', artifact: Mapping[str, str] = field(default_factory=...), declared: tuple[str, ...] = (), synthetic: bool = False, flaky: bool = False, attempts: int = 1, duplicate: bool = False, file: str = '', line: int = 0, sources: tuple[str, ...] = (), message: str = '', artifacts: tuple[Mapping[str, str], ...] = (), cases: tuple[rules_requirements.ingest.TestCase, ...] = ())[source]

Bases: object

One test case after every observation of its key was merged (resolve_cases()): the unit attribution gives at most one owner.

artifact is the identity it was stamped with (artifacts lists every distinct stamp its observations carry: two evidence roots from two builds give two). declared is the union of the ids its observations name — tags, never an owner.

stale(current: Mapping[str, str] | None) → bool[source]

Whether any of its stamps differs from current on a shared key.

class rules_requirements.attribution.Member(entity: str, key: rules_requirements.case_keys.CaseKey | None, selector: str, via: str, state: str, level: str = '', stale: bool = False, flaky: bool = False, result: rules_requirements.attribution.CaseResult | None = None, target: str = '', origin: str = '', reason: str = '')[source]

Bases: object

One member of an entity’s verification set.

  • owned (passed / failed / error / skipped): a case the entity owns, with its result;

  • missing / not-run: a selector that matched nothing, or a lock entry, whose case is absent (key is the case a literal selector or a lock entry names; None for a glob or whole claim);

  • moved: a lock entry whose case now has another owner or none;

  • quarantined: a quarantined case that names the entity.

property owned: bool

Whether this is a case the entity owns (it counts as its evidence).

property name: str

The case key, or <target> <selector> for a member without one.

class rules_requirements.attribution.Quarantine(key: rules_requirements.case_keys.CaseKey, code: str, entities: tuple[str, ...], detail: str, declared: tuple[str, ...] = (), claims: tuple[rules_requirements.model.Claim, ...] = ())[source]

Bases: object

A case that owns nothing because its attribution is ambiguous.

class rules_requirements.attribution.TargetRun(target: str, ran: bool = True, synthetic_only: bool = False, taint: tuple[rules_requirements.ingest.TestCase, ...] = ())[source]

Bases: object

How one target ran in this evidence.

taint holds its failing target-scope results (rr.scope=target: an exit status, a load error, an unreadable report, a failing root hook), and the failed synthetic result of a shard or run that crashed while other repetitions reported per-case results. They are never members and never count as passing; every member claimed on the target reads error instead.

property taint_message: str

<name> (<shard/run>): <first line of its message> of each target-scope failure; the slot (shard_2_of_4, run_3_of_5) says which repetition failed, when the target was sharded or repeated.

rules_requirements.attribution.attribute(model: Model, evidence: Evidence, *, current_build: Mapping[str, str] | None = None, lock: Lock | None = None) → Attribution[source]

Decide the owner of every test case in evidence, and each verifiable entity’s verification set. The only function that assigns ownership.

  1. resolve_cases() merges the evidence into one result per key.

  2. Every claim selects keys: a whole claim every case result of its target (its synthetic [target] result only if the target reported nothing else); a selector the non-synthetic results whose path it matches.

  3. Per key, the first rule that applies: more than one declared id → quarantine multi-tag; claims of more than one entity → quarantine attribution-conflict; one claimant → it owns the key (tag-mismatch if the case declares another id); hybrid mode and one declared requirement, user need or mitigation → it owns the key; model mode and one such id → unclaimed-tag. A declared risk or test-method id is misdirected-evidence and an undefined one unknown-id: neither owns anything.

  4. Owned keys of one test code (the same source file and path, or one config.variants group and path) in different targets with different owners are quarantined same-code-multiple-owners. A source file is compared in its canonical spelling, relative to the workspace root (model.root) when it lies below it; another absolute path matches the one relative path it unambiguously ends with (at a path boundary). Equal paths with different owners whose source files are unknown or differ are same-path-multiple-owners; when one of their files is such an absolute path that ends with two recorded relative ones (it may be either file), the issue is ambiguous-source, always an error: an ambiguity fails closed. Equal paths with different owners where one is filed under a suite:/record: pseudo-target and a source file is unknown are quarantined same-code-multiple-owners (a pseudo-target cannot be pinned to a build target).

  5. Members: each entity’s owned keys; a pseudo-member per selector that matched nothing (missing if the target ran, not-run if not, error if it is tainted or its only result is a failed synthetic one); its lock entries (lock adds expected members only, never an owner); the quarantined keys that name it.

  6. Attribution.check_invariant().

lock is the verification-set lock to expect members from (None or NO_LOCK: no lock; this function reads no files). current_build marks members stamped with another build stale.

rules_requirements.attribution.is_stale(artifact: Mapping[str, str], current: Mapping[str, str] | None) → bool[source]

Evidence is stale when it recorded an artifact identity that differs from the current build on any shared key. No identity (or no reference) -> fresh.

rules_requirements.attribution.resolve_cases(evidence: Evidence, config: Config) → tuple[dict[CaseKey, CaseResult], dict[str, TargetRun], list[AttributionIssue]][source]

Merge raw test cases into one CaseResult per key.

Targets are normalized (config.main_repo) exactly as claims are. The final attempt wins (an earlier failure under a final pass is flaky), the worst run wins, shards are unioned and evidence roots merged (index_cases()). Target-scope results are not cases: a failing one taints its target (TargetRun). So does a failed synthetic [target] result of a target that also reported per-case results: a shard or run that crashed before writing its report, beside the repetitions that did (the worst run wins). A target also counts as run when it left a report without any case (an empty suite), so its claimed cases read missing, not not-run.

Tracing and reports

Join the model with test evidence (and optionally source annotations) into a traceability matrix, and derive the gaps that remain.

A test case verifies at most one requirement. Which entity a case verifies is decided by rules_requirements.attribution.attribute() alone; this module reads nothing but the resulting Attribution. Each requirement (user need, mitigation) is verified by its verification set: the cases it owns, the cases its literal selectors and the lock expect, and every quarantined case that names it (members_of()).

Verification (IEC 62304 §5.5-5.7): is each requirement proven, the way it needs to be proven? verdict_from_members(), the first rule that applies:

  • INVALID — a member is quarantined: a case naming it is ambiguous (several ids, several claimants, one test code with several owners), so it verifies nothing through it until the case has one owner.

  • FAILED — a member failed or errored (a target-scope failure taints every member of its target), or passed only on a retry under flaky: fail.

  • UNVERIFIED — no members, or none of them ran.

  • INCOMPLETE — a member is missing, did not run, was skipped or moved (or its builds are mixed under set_consistency: enforce).

  • UNDER-VERIFIED — the whole set passed, but a member is stale (stamped with another build than current_build), passed only on a retry (flaky: under-verify, the default), or the best level is below the demanded one (its method).

  • VERIFIED — otherwise: the whole set passed together, fresh, at the demanded rigor.

  • PARTIAL — (decomposed requirements) its refinements are only partly verified.

Validation (design validation, IEC 62304 §5.1 / 21 CFR 820.30(g)): each user need rolls up the requirements that satisfy it -> VALIDATED, PARTIAL, FAILED or UNVALIDATED.

Risk control (ISO 14971 §7.2): each mitigation rolls up the requirements that implement it (VERIFIED/PARTIAL/FAILED/UNVERIFIED) and each risk rolls up its mitigations -> MITIGATED, PARTIAL, FAILED or OPEN.

Rollups (refines, satisfies, implemented_by, mitigates) propagate verdicts between entities, never cases: an INVALID entity rolls up like a FAILED one and an INCOMPLETE one like a PARTIAL one, and each verdict says whether it rests on its own set (basis: own), on other entities’ verdicts (derived, with derived_from) or on both.

class rules_requirements.trace.EvidenceRef(name: str, status: str, level: str, target: str = '', source: str = '', message: str = '', stale: bool = False, kind: str = 'case')[source]

Bases: object

One piece of evidence as it applies to one entity: a view of one owned member of its verification set (kept for 0.3.x readers of evidence).

class rules_requirements.trace.Gap(kind: str, entity: str, message: str, route: str = 'autonomous', demanded: str = '', provided: str = '')[source]

Bases: object

Something the model or its evidence is missing — a unit of work.

class rules_requirements.trace.Matrix(model: rules_requirements.model.Model, verdicts: dict[str, rules_requirements.trace.Verdict], gaps: list[rules_requirements.trace.Gap], evidence: rules_requirements.ingest.Evidence, unknown_evidence: dict[str, list[str]] = field(default_factory=...), annotations_scanned: bool = False, attribution: rules_requirements.attribution.Attribution | None = None)[source]

Bases: object

class rules_requirements.trace.SetVerdict(status: str, provided: str = '', stale: bool = False, flaky: bool = False, mixed_builds: tuple[str, ...] = (), pyramid_violation: bool = False, reasons: tuple[str, ...] = ())[source]

Bases: object

What verdict_from_members() concludes about one verification set.

class rules_requirements.trace.Verdict(id: str, kind: str, status: str, demanded: str = '', provided: str = '', stale: bool = False, pyramid_violation: bool = False, evidence: list[rules_requirements.trace.EvidenceRef] = field(default_factory=...), implemented_in: list[rules_requirements.annotations.Reference] = field(default_factory=...), verified_in: list[rules_requirements.annotations.Reference] = field(default_factory=...), members: tuple[rules_requirements.attribution.Member, ...] = (), basis: str = '', derived_from: list[str] = field(default_factory=...), flaky: bool = False, mixed_builds: tuple[str, ...] = (), reasons: tuple[str, ...] = ())[source]

Bases: object

The traceability state of one entity.

rules_requirements.trace.build_matrix(model: Model, evidence: Evidence, current_build: Mapping[str, str] | None = None, references: list[Reference] | None = None, *, lock: Lock | None = None) → Matrix[source]

Compute a Verdict for every entity and the list of gaps.

Always runs attribute() and checks its invariant: verdicts read only the attribution’s verification sets, never the evidence’s tags. lock is the verification-set lock; without one, the lock config.sets_lock names is read (a lock that cannot be read is a lock-invalid issue, and the sets are not pinned); NO_LOCK reads none (the sets are not pinned: unpinned-sets).

references (from rules_requirements.annotations.scan()) adds implementation/verification source links and the no-implementation gap; omit it to trace from test evidence alone. They are documentation: they never add a member.

rules_requirements.trace.classify(passed_levels: list[str], failed: bool, demanded: str, c: Config) → tuple[str, str][source]

(status, best provided level) for passing levels against a demand.

rules_requirements.trace.find_gaps(matrix: Matrix) → list[Gap][source]

Everything that stands between the model and a complete V&V argument.

rules_requirements.trace.is_stale(artifact: Mapping[str, str], current: Mapping[str, str] | None) → bool[source]

Evidence is stale when it recorded an artifact identity that differs from the current build on any shared key. No identity (or no reference) -> fresh.

rules_requirements.trace.route_for(level: str, c: Config) → str[source]

<= autonomous_max_level: an agent can close it; else a human/bench gate.

rules_requirements.trace.verdict_from_members(members: Sequence[Member], demanded: str, config: Config) → SetVerdict[source]

The verdict of one verification set (the first rule that applies):

  1. a member is quarantined → INVALID;

  2. a member failed or errored (a taint included), or a passed member was flaky under flaky: fail → FAILED;

  3. no members, or none of them ran → UNVERIFIED;

  4. a member is missing, not run, skipped or moved, or the set mixes builds under set_consistency: enforce → INCOMPLETE (provided is the best passed level so far);

  5. a member is stale, flaky under flaky: under-verify, or the best level is below demanded → UNDER-VERIFIED;

  6. otherwise → VERIFIED, provided the best level of the set.

Find traceability annotations in source code.

The universal form is a comment (or docstring) tag:

# @rr(REQ-0001): Implements isolated access to secure data
class SecureStore: ...

// @rr.verifies(REQ-0002)
TEST(Parser, RejectsEmpty) { ... }

@rr(...) means implements in production code and verifies in test files (test_*.py, *_test.*, tests/ …); @rr.implements / @rr.verifies state it explicitly. The language hooks are recognised too: @pytest.mark.rr(...) / @pytest.mark.requirements(...), rr::verifies!(...) (Rust), RR_VERIFIES(...) (googletest) and RR_CASE(name, "ID") (rr_case.h). A verifies annotation naming several ids is deprecated (rule multi-verifies-annotation, multi_verifies()): a test case verifies at most one requirement.

Each Reference records the ids, the relation, the location, the trailing description and — when a definition follows the tag — the symbol it annotates (class SecureStore, Parser.RejectsEmpty), which is what lets a reviewer browse from a requirement to the exact code that implements it.

class rules_requirements.annotations.Reference(ids: tuple[str, ...], relation: str, path: str, line: int, text: str = '', symbol: str = '')[source]

Bases: object

rules_requirements.annotations.extract(text: str, path: str, config: Config) → list[Reference][source]

All references in one file’s text.

rules_requirements.annotations.candidate_files(root: str, include: Iterable[str] = (), exclude: Iterable[str] = ()) → list[str][source]

Files under root to scan (git-aware when root is a checkout).

rules_requirements.annotations.scan(root: str, config: Config, include: Iterable[str] = (), exclude: Iterable[str] = (), files: Iterable[str] | None = None) → list[Reference][source]

Scan root (or the given files, relative to it) for references.

rules_requirements.annotations.multi_verifies(refs: Iterable[Reference]) → list[tuple[Reference, str]][source]

(reference, message) for every verifies annotation naming more than one id (@rr.verifies(REQ-1, REQ-2), @rr(REQ-1, REQ-2) in a test file, RR_VERIFIES("A", "B") …): rule multi-verifies-annotation.

Annotations never attribute a case (they are display-only), but a test case verifies at most one requirement, so the annotation should name one id: split the test, or annotate the one requirement it verifies.

rules_requirements.annotations.unknown_references(refs: Iterable[Reference], model: Model) → list[tuple[Reference, str]][source]

(reference, id) for every referenced id the model does not define.

Render a Matrix as JSON, Markdown or HTML.

The JSON document (rules_requirements/report/v2) is the canonical form: fully deterministic (sorted, no timestamps, no machine-specific paths), so it can be checked into a repository as a golden file and diffed in review. Markdown and HTML are views of it.

Every case row, member and count is read from the matrix’s Attribution; nothing here derives ownership from tags or targets. cases is the inverse matrix (each case key and its one owner, or none): the input of rr check-report (rules_requirements.checkreport), which re-proves from the JSON alone that no case is owned twice.

rules_requirements.report.OUT_OF_LANE = 'out of lane'

lane_hint of a not-run member whose target this lane does not run (--lane-targets): it is expected in another lane’s evidence.

class rules_requirements.report.Lane(name: str = '', targets: frozenset[str] | None = None)[source]

Bases: object

The lane a report was built for (rr report --lane/--lane-targets).

It never changes a verdict: it stamps the report with name and, when targets is given, labels the not-run members of every other target “out of lane (expected elsewhere)” and keeps the gaps those members alone cause out of the work queue.

rules_requirements.report.attribution_dict(att: Attribution, lane: Lane = Lane(name='', targets=None), config: Config | None = None) → dict[str, Any][source]

The attribution block: mode, main_repo and variants (what rr check-report needs to re-prove key spellings and same-code ownership), lock, lane, per-target counts, quarantines (with every claim’s origin), issues and granularity.

rules_requirements.report.case_rows(att: Attribution) → list[dict[str, Any]][source]

The inverse matrix: every case key with its one owner (or null).

rules_requirements.report.member_dict(member: Member, lane: Lane = Lane(name='', targets=None)) → dict[str, Any][source]

One member of a verification set as the report writes it.

rules_requirements.report.out_of_lane_gaps(matrix: Matrix, lane: Lane) → list[Gap][source]

The gaps caused only by members this lane does not run: an unverified or incomplete gap whose open members are all not-run members of out-of-lane targets. rr report --queue-out leaves them out; the report still lists them (with lane_hint).

rules_requirements.report.set_line(members: Iterable[Member], lane: Lane = Lane(name='', targets=None)) → str[source]

set 17/23 passed · 6 not run (out of lane): one entity’s set in a line.

rules_requirements.report.set_summary(members: Iterable[Member]) → dict[str, Any][source]

{complete, members, passed, failed, error, skipped, missing, not_run, moved, quarantined}.

rules_requirements.report.unowned_cases(d: dict[str, Any]) → dict[str, list[dict[str, Any]]][source]

Per target, the cases no entity owns and none is quarantined (the granularity backlog).

rr check-report: re-prove the one-owner partition from a published report.

A test case verifies at most one requirement. rules_requirements.attribution.attribute() guarantees it by construction, and check_invariant() checks it in process. This module is the independent audit (layer L6): it reads nothing but the JSON a report wrote (rules_requirements/report/v2) and checks that

  • the file names every object key once (a duplicate key could name two owners for one case: load_report() refuses it);

  • every case is listed once, and its owner is one id or null (a scalar, never a list) naming a user need, requirement or mitigation of the report;

  • every member says whether its entity owns it (owned); no case key is an owned member of two entities, each owned member is a row of cases whose owner is that entity, and every owned case is a member of its owner (the owned members partition the owned cases). A member that is not owned is a pseudo-member: an error one names no case of the report and sits on a tainted or synthetic-only target, and a missing or not-run one names no case of the report (only a moved or quarantined member may);

  • the evidence[] compatibility view of each entity is exactly the view of its owned members, so a 0.3.x reader of evidence[] sees the same partition;

  • no quarantined case is owned; a quarantine’s entities is a list of ids, the entities holding the case as a quarantined member are exactly the verifiable ones it names, each of them reads INVALID, and the ids it names follow from its code (the declared ids and the claims);

  • the counts agree: the summary, every entity’s set, the per-target counts and the granularity against the rows they count.

check_report() returns the problems (empty: the partition holds).

rules_requirements.checkreport.key_problem(key: Any, main_repo: str = '') → str | None[source]

Why key is no canonical case key (None when it is one).

rr report files every case under one spelling (key_of()): the target is normalized (@//p:n, @@//p:n, @<main_repo>//p:n and //p are //p:p; pseudo-targets pass through), and the path is NFC with no surrounding blanks and no [rr:ID] name tag at the end of the case name. Any other spelling would make one test case two keys, each of which could have an owner.

rules_requirements.checkreport.target_problem(target: str, main_repo: str = '') → str | None[source]

Why target is not the one spelling rr report files cases under (None: it is).

exception rules_requirements.checkreport.ReportError[source]

Bases: ValueError

The file is no v2 report at all (unreadable, not JSON, another schema).

exception rules_requirements.checkreport.AmbiguousReportError[source]

Bases: ReportError

The file names an object key twice, so readers may disagree on its content (one owner to a last-wins parser, another to a first-wins one): the partition cannot be proven from it. rr check-report exits 1.

rules_requirements.checkreport.load_report(path: str) → dict[str, Any][source]

Read a JSON report; raises ReportError when it is no v2 report, and AmbiguousReportError when an object names a key twice.

rules_requirements.checkreport.check_report(doc: Mapping[str, Any]) → list[str][source]

Every way doc breaks the one-owner partition or disagrees with its own counts.

rules_requirements.checkreport.summarize(doc: Mapping[str, Any]) → str[source]

N case(s): O owned by E entities, Q quarantined, U unowned.

The trace graph: every entity as a node, every reference as an edge.

Exported as Graphviz DOT, Mermaid, JSON, or a self-contained SVG with a layered layout (needs -> requirements -> mitigations -> risks) whose node order is refined by the barycenter heuristic to keep edge crossings down.

With cases() (rr graph --cases) every owned test case is a node too, with exactly one in-edge (verifies): from the one entity attribute() gave it to. A test case verifies at most one requirement, so no case node can have two.

class rules_requirements.graph.Node(id: str, kind: str, title: str, status: str = '')[source]

Bases: object

class rules_requirements.graph.Edge(source: str, target: str, relation: str)[source]

Bases: object

rules_requirements.graph.cases(attribution: Attribution, entities: Collection[str] = ()) → tuple[list[Node], list[Edge]][source]

A node per owned test case (status: its result) and its one in-edge, <owner> verifies <case>, read from the attribution alone. With entities, only the cases of those entities (the ones drawn).

rules_requirements.graph.layout(nodes: list[Node], edges: list[Edge], sweeps: int = 4) → dict[str, tuple[float, float]][source]

Top-left (x, y) per node id: one column per kind, barycenter ordering.

Hooks

The in-code traceability API: from rules_requirements import rr.

@rr.verifies(...) marks a test (function, method or TestCase class) as verifying entities; @rr.implements(...) marks production code as implementing them. Both are no-ops at runtime apart from recording metadata, and both are recognised by the source scanner — as is the comment form # @rr(REQ-1): description.

rules_requirements.rr.verifies(*ids: Any, level: str = '', artifact: dict[str, str] | None = None) → Callable[[F], F][source]

Declare that the decorated test verifies the ONE requirement ids[0] at level.

The id is a declared tag; which requirement the case verifies is decided by attribution. The nearest declaration wins: a method’s decorator replaces its class’s, and a subclass’s replaces its base class’s (the level and artifact keys are still inherited when the nearer declaration does not set them).

A test case verifies at most one requirement. Several ids — extra arguments, a comma or whitespace list, or stacked decorators naming different ids — are deprecated: every id is still recorded, with a MultipleRequirementsWarning, so attribution quarantines the case and it counts for none of them.

rules_requirements.rr.implements(*ids: Any) → Callable[[F], F][source]

Declare that the decorated function/class implements ids.

rules_requirements.rr.unittest_main(module: str = '__main__') → None[source]

unittest.main() replacement that writes traceability JUnit.

Write traceability-tagged JUnit XML from any hand-rolled test harness.

Useful for on-hardware (HIL/HITL) runners that are plain programs rather than test-framework suites:

from rules_requirements.hooks.junit_writer import JUnitWriter

report = JUnitWriter("bench_e2e", default_level="hitl",
                     artifact={"firmware_build_id": build_id})
with report.case("flash_and_boot", requirement="REQ-13"):
    flash(dut)               # an exception records a failure, then re-raises
report.write(os.environ.get("XML_OUTPUT_FILE", "bench_e2e.xml"))

Each case names at most one requirement: a test case verifies at most one requirement. The id is a declared tag; which requirement the case verifies is decided by attribution. The pre-0.2 list form (report.case("x", ["REQ-13", "REQ-21"])) still records every id, with a MultipleRequirementsWarning, so attribution quarantines the case and it counts for none of them.

Recorded cases are final: JUnitWriter.cases is a read-only tuple of frozen cases, so a harness cannot re-attribute a case after recording it.

The artifact identity is stamped on every case (artifact.<key> properties) so the report can mark evidence from an older build as stale, and the harness’s source file (rr.file) identifies the test code that ran. For runs made of ordered steps and checks, see CheckPlan.

rules_requirements.hooks.junit_writer.FILE_PROPERTY = 'rr.file'

Property naming the source file of the test code that produced a case.

rules_requirements.hooks.junit_writer.xml_safe(text: str) → str[source]

text with XML-invalid characters replaced by #xNN (like pytest).

rules_requirements.hooks.junit_writer.source_file(path: str) → str[source]

path relative to the workspace root, as rr.file records it.

Under Bazel a test runs from its runfiles tree (<x>.runfiles/<repo>/...) or the output tree (bazel-out/<cfg>/bin/...); both prefixes are stripped, so the result is the path of the source in the workspace (external/<repo>/... for another repository’s file). Elsewhere it is relative to $BUILD_WORKSPACE_DIRECTORY or the current directory when under it, else absolute. "" stays "".

class rules_requirements.hooks.junit_writer.JUnitWriter(suite: str, classname: str = '', default_level: str = '', artifact: dict[str, str] = field(default_factory=...), file: str | None = None)[source]

Bases: object

Collects test cases and writes them as one JUnit <testsuite>.

file is the source file of the harness, written as the rr.file property of every case; by default the running script (sys.argv[0]) relative to the workspace. Pass "" to write none.

property cases: Tuple[_Case, ...]

The recorded cases, read-only: a recorded case cannot be removed, replaced or re-attributed afterwards (record a new case instead).

add(name: str, requirement: str | Iterable[str] | None = None, status: str = 'passed', message: str = '', duration: float = 0.0, level: str = '', artifact: dict[str, str] | None = None, classname: str = '', *, requirements: Iterable[str] | None = None, file: str | None = None) → None[source]

Record one case.

requirement is the one id the case verifies (or None). A list or tuple there, or in the deprecated requirements= keyword, still records every id it holds, with a deprecation warning (a MultipleRequirementsWarning when it names several: the case is then quarantined). file overrides the writer’s source file.

case(name: str, requirement: str | Iterable[str] | None = None, level: str = '', artifact: dict[str, str] | None = None, *, requirements: Iterable[str] | None = None, classname: str = '', file: str | None = None) → Iterator[None][source]

Record name as passed, or failed if the block raises (re-raised).

not_reached(names: Iterable[str], reason: str, classname: str = '', *, tags: Mapping[str, str] | None = None, level: str = '', artifact: dict[str, str] | None = None) → None[source]

Record planned cases a device failure kept from running.

Each name becomes its own failed case, "not reached: <reason>", so the requirement each one verifies fails through its own case. tags maps a name to the one id that case verifies.

write(path: str, append: bool = False) → None[source]

Write the JUnit XML to path.

With append, the cases are added to the JUnit already at path (to this writer’s suite there, else as a new suite); a missing or empty file is written afresh. The new file replaces the old one atomically and keeps its permission bits; like any replacement it needs a writable directory, but not a writable file. Where fcntl exists (not on Windows) appends from concurrent processes (rr case ... &) are serialised by a lock on the file, so none is lost; a read-only file on NFS and a dangling symlink, which cannot be locked themselves, are serialised by a sidecar lock file (.<name>.lock) next to them, removed again.

A hardware run as ordered steps and single-owner checks.

On-hardware (HITL) harnesses are usually a sequence of steps — flash, boot, provision, connect — each followed by assertions. A step is an action: it verifies nothing by itself and is owned by no requirement. A check is one assertion and becomes one JUnit case, <suite>.<step>::<check>, which verifies at most one requirement:

from rules_requirements.hooks.checkplan import CheckPlan
from rules_requirements.hooks.junit_writer import JUnitWriter

report = JUnitWriter("hitl_e2e", default_level="hitl")
plan = CheckPlan(
    report,
    {"flash_boot": ["ble_advertising"], "websocket_checks": ["ws_connect", "rename"]},
    tags={"flash_boot.ble_advertising": "REQ-13", "websocket_checks.rename": "REQ-35"},
    is_infrastructure=lambda exc: isinstance(exc, RigError),
)
try:
    with plan.run():
        reserve_rig()                  # rig/setup trouble until setup_done()
        plan.setup_done()
        with plan.step("flash_boot"):
            flash(dut)                 # a failure here is the device's
            with plan.check("ble_advertising"):
                assert BLE_MARKER in serial_log()
        with plan.step("websocket_checks"):
            ...
finally:
    report.write(os.environ["XML_OUTPUT_FILE"])

How a run that stops early is recorded:

  • Device failure (any exception after CheckPlan.setup_done() that is_infrastructure does not claim): a check that raised is failed with the exception, and every planned check that has not run — the rest of the failing step and every later step — is failed as not reached, each through its own case and requirement.

  • Rig or setup trouble (an exception before setup_done(), or one is_infrastructure claims): one untagged <suite>::rig error case, and every planned check that has not run is skipped (not run: rig trouble). Nothing the device did is failed, and the checks that never ran keep their requirements from reading verified.

  • Harness bug (an unknown step or check name, or a planned check never executed by a run that otherwise ended normally): the check is recorded as error, and unknown names as an untagged <suite>::harness error.

  • Interrupted or exited: KeyboardInterrupt (an operator’s Ctrl-C) and SystemExit with code 0 or None are never the device’s, whatever is_infrastructure says: they are recorded as rig trouble, and a clean exit after every check was recorded adds nothing. A SystemExit with any other code is classified like any other exception.

Inside with plan.check(name): the block may record the check’s result itself (plan.skipped(name, reason) for a check that cannot run on this board); that result is the check’s only case, whatever the block does next.

Recorded cases are final. On rig trouble the passed checks keep their tags: a requirement whose verification set also holds a check that was skipped reads INCOMPLETE (neither VERIFIED nor FAILED) through that skipped member. (0.2 withdrew the passed checks’ tags instead, which sets make redundant.)

exception rules_requirements.hooks.checkplan.HarnessError[source]

Bases: ValueError

The harness used its CheckPlan wrongly (an unknown step or check name, a check outside a step, a check recorded twice): a bug in the harness, not in the device or the rig.

class rules_requirements.hooks.checkplan.CheckPlan(writer: ~rules_requirements.hooks.junit_writer.JUnitWriter, steps: ~typing.Mapping[str, ~typing.Sequence[str]], *, tags: ~typing.Mapping[str, str] | None = None, is_infrastructure: ~typing.Callable[[BaseException], bool] = <function _never>)[source]

Bases: object

A hardware run as ordered steps (actions, owned by nobody) and checks (assertions, one JUnit case each, at most one requirement each).

Args:
writer: the JUnitWriter

the cases go to; its suite prefixes every case’s classname.

steps: step name -> its check names, in run order. Check names are

unique within a step; step names contain no . (it separates the step from the check in "<step>.<check>").

tags: "<step>.<check>" -> the ONE requirement id that check

verifies (a declared tag; the model may also claim the case).

is_infrastructure: whether an exception is rig or setup trouble rather

than the device’s failure. Anything it does not claim after setup_done() counts against the device.

setup_done() → None[source]

Mark the end of setup (rig reserved, credentials and bundle checked).

From here on, a stop is the device’s unless is_infrastructure claims it. Entering a step implies it.

run() → Iterator[CheckPlan][source]

Wrap the whole run: a normal end calls finish(); an exception is recorded as a device failure, rig trouble or harness bug (see the module docs) and re-raised.

step(name: str) → Iterator[None][source]

Run one step. The step itself records nothing; its checks do.

check(name: str) → Iterator[None][source]

Run one check of the current step: passed if the block completes, failed (then re-raised) if it raises; rig trouble records nothing. A result the block records for this check itself (skipped, failed, passed) is kept as the check’s only case.

passed(name: str, message: str = '') → None[source]

Record check name (of the current step, or "<step>.<check>") as passed.

failed(name: str, message: str) → None[source]

Record check name as failed (the device’s failure); does not raise.

skipped(name: str, reason: str) → None[source]

Record check name as deliberately skipped (it verifies nothing this run).

finish() → None[source]

End a run that completed: every planned check never recorded is a harness bug, recorded as error. Idempotent.

pending() → list[tuple[str, str]][source]

Planned (step, check) pairs not recorded yet, in run order.

One test case, one requirement: id checks shared by every hook.

A test case counts toward at most one requirement. The hooks only ever declare ids: which requirement a case verifies is decided by attribution alone, never by a hook. Hooks that still accept several ids per case (the pre-0.2 list forms) keep recording all of them in 0.3, with a MultipleRequirementsWarning; the evidence then names several ids, so attribution quarantines the case and it counts for none of them. The single-id APIs reject anything that is not exactly one well-formed id.

Error codes printed by the hooks:

RR-E101

One case names more than one id.

RR-E102

A raw requirement property bypassed the single-id API.

RR-E103

RR_VERIFIES was called outside a running test.

RR-E104

Malformed id: a comma, whitespace, or empty.

exception rules_requirements.hooks.ids.MultipleRequirementsWarning[source]

Bases: DeprecationWarning

A test case declares more than one requirement id (deprecated since 0.2).

The ids are still all recorded, and attribution quarantines the case (from 0.3): it verifies none of them, and every requirement it names reads INVALID. Filter on this class to silence or escalate the deprecation, e.g. -W error::rules_requirements.hooks.ids.MultipleRequirementsWarning.

rules_requirements.hooks.ids.split_ids(value: Any) → list[str][source]

Every distinct id in value: a string, or a list, tuple or set of them.

A string is split on commas and whitespace, as ingest splits a declared requirement value from 0.3: "REQ-1 REQ-2" names two ids (before 0.3 it was read as one malformed id). Empty parts are dropped.

rules_requirements.hooks.ids.inherited_ids(cls: Any, own_ids: Callable[[Any], list[str]]) → list[tuple[Any, list[str]]][source]

The (class, ids) declarations cls gets, nearest first: ONE scope.

own_ids(klass) gives the ids klass declares itself (not inherited). A class that declares an id has that id: its bases’ are replaced, nearest wins. A class that declares none gets those of every direct base, each resolved the same way, so two bases that no nearer class overrides form a single scope: if they name different ids the class names both (a multi-id declaration, quarantined downstream), and never just the first one in the MRO. A class reached along several paths (a diamond) counts once, and a class that is a base of another contributing class is overridden by it (class C(Sub, Base) with Sub(Base): Sub’s).

rules_requirements.hooks.ids.check_id(value: Any, where: str = 'requirement') → str[source]

value if it is exactly one well-formed id, else ValueError.

A list or tuple is RR-E101 (one case, one requirement); a string with a comma or whitespace in it, or an empty one, is RR-E104. Anything else is a TypeError.

rules_requirements.hooks.ids.multiple_warning(subject: str, ids: list[str]) → MultipleRequirementsWarning[source]

The warning for subject declaring several ids for one test case.

rules_requirements.hooks.ids.warn_multiple(subject: str, ids: list[str], stacklevel: int = 2) → None[source]

Warn that subject declares several ids for one test case.

stacklevel is as for warnings.warn() called by the caller of warn_multiple (2: the caller’s caller).

pytest plugin: @pytest.mark.rr("REQ-1", level="hil").

A test case verifies at most one requirement, so each test declares at most one id: the plugin writes one <property name="requirement"> (plus level, artifact.* and rr.file, the test file relative to the workspace) on its JUnit <testcase>. The id is a declared tag: which requirement the case verifies is decided by attribution, never by the hook.

  • Scopes resolve nearest first, and only the nearest scope that names an id is written: pytest.param(..., marks=...), then the test function (decorators, a conftest’s item.add_marker, @rr.verifies), its class (a subclass’s own declaration before its base’s), then the module or package pytestmark. A nearer id replaces a farther one; it is never added to it. Sibling base classes that no nearer class overrides are one scope: two naming different ids make the case multi-id (below), never resolved by MRO order. The nearest marker naming a level wins, and artifact keys resolve nearest-first.

  • A declaration naming several ids (several arguments, a list, a comma or whitespace inside one argument, or two declarations in one scope) is deprecated: it warns with MultipleRequirementsWarning, and every id is still written, so attribution quarantines the case and it counts for none of them. The warning is raised once per declaration, when the first test it applies to sets up, attributed to the marker’s test, class or module, so an escalated warning (-W error) errors that test alone.

  • A raw record_property("requirement", ...) (or "requirements") bypasses these rules: the property is dropped and the test fails with RR-E102. Use the marker. record_testsuite_property("requirement", ...) lands on the <testsuite>, which ingest gives to no case (suite-level-requirement warning).

  • The marker is also available as @pytest.mark.requirements(...).

  • unittest.TestCase methods decorated with rules_requirements.rr.verifies() are honoured too.

  • artifact={"key": "value"} stamps artifact identity for staleness checks.

Run pytest with --junitxml=... -o junit_family=xunit2 (the pytest_runner does this for Bazel). Installed with pip, the plugin auto-registers through the pytest11 entry point; under Bazel pass it explicitly (the runner does).

rules_requirements.hooks.pytest_plugin.trace_of(item: Any) → tuple[list[str], str, dict[str, str]][source]

(ids, level, artifact) declared for a collected test item.

ids are those of the NEAREST scope that names any (param marks, then the function, its class, the module, a package): a nearer declaration replaces a farther one, it does not add to it. That is one id, or none; several only for a deprecated multi-id declaration (several ids in one marker, or two declarations in one scope), which the hook records in full so attribution quarantines the case. The nearest declaration naming a level wins, and artifact keys resolve nearest-first.

Bazel py_test entry point that runs pytest with traceability on.

Bazel sets $XML_OUTPUT_FILE for every test and keeps whatever JUnit the test writes there. This runner points pytest’s --junitxml at it (xunit2, so per-testcase properties survive) and loads the rr marker plugin. A suite’s main becomes:

from rules_requirements.hooks.pytest_runner import main

if __name__ == "__main__":
    raise SystemExit(main(__file__))

or use the rr_py_test macro, which generates exactly that.

rules_requirements.hooks.pytest_runner.main_argv(argv: list[str]) → int[source]

Run pytest with argv (paths and options) exactly; see main().

rules_requirements.hooks.pytest_runner.main(anchor: str, extra_args: list[str] | None = None) → int[source]

Run pytest over the directory containing anchor (or argv paths).

Traceability for plain unittest suites.

Decorate tests (or whole TestCase classes) with rules_requirements.rr.verifies():

import unittest
from rules_requirements import rr

class ThermostatTest(unittest.TestCase):
    @rr.verifies("REQ-3", level="simulation")
    def test_rejects_setpoint_above_limit(self): ...

if __name__ == "__main__":
    rr.unittest_main()

main() runs the module’s tests (or discovers under a directory) with a result collector that writes JUnit XML — with requirement / level properties, and rr.file (the test’s source file) — to $XML_OUTPUT_FILE (Bazel) or --junit-xml PATH. The nearest declaration wins: a method’s decorator replaces its class’s. A class or module fixture error (setUpClass) and a failing subtest carry the same single id as their test. The same decorators are honoured when pytest collects the TestCase.

rules_requirements.hooks.unittest.trace_of(test: TestCase) → Any[source]

{"ids", "level", "artifact"} declared for test: nearest wins.

The method’s @rr.verifies replaces its class’s (and a subclass’s replaces its base class’s): ids is one id, or none; several only for a deprecated multi-id declaration, recorded in full so attribution quarantines the case. Base classes that no nearer class overrides are one scope (class_ids()): two naming different ids are a multi-id declaration too, not resolved by MRO order. The nearest declaration naming a level wins, and artifact keys resolve nearest-first.

rules_requirements.hooks.unittest.class_ids(cls: Any) → list[str][source]

The ids cls declares or inherits through @rr.verifies.

Its own declaration replaces its bases’; with none, every base that no nearer class overrides counts, so sibling bases naming different ids give several ids (quarantined downstream), never the first in the MRO.

class rules_requirements.hooks.unittest.JUnitResult(stream: Any, descriptions: bool, verbosity: int, writer: JUnitWriter)[source]

Bases: TextTestResult

Collects every outcome into a JUnitWriter.

startTest(test: TestCase) → None[source]

Called when the given test is about to be run

stopTest(test: TestCase) → None[source]

Called when the given test has been run

addSuccess(test: TestCase) → None[source]

Called when a test has completed successfully

addFailure(test: TestCase, err: Any) → None[source]

Called when an error has occurred. ‘err’ is a tuple of values as returned by sys.exc_info().

addError(test: TestCase, err: Any) → None[source]

Called when an error has occurred. ‘err’ is a tuple of values as returned by sys.exc_info().

addSubTest(test: TestCase, subtest: Any, err: Any) → None[source]

Called at the end of a subtest. ‘err’ is None if the subtest ended successfully, otherwise it’s a tuple of values as returned by sys.exc_info().

addSkip(test: TestCase, reason: str) → None[source]

Called when a test is skipped.

addExpectedFailure(test: TestCase, err: Any) → None[source]

Called when an expected failure/error occurred.

addUnexpectedSuccess(test: TestCase) → None[source]

Called when a test was expected to fail, but succeed.

rules_requirements.hooks.unittest.run(suite: TestSuite, junit_xml: str = '', suite_name: str = 'unittest', verbosity: int = 2) → bool[source]

Run suite; write JUnit to junit_xml (or $XML_OUTPUT_FILE).

rules_requirements.hooks.unittest.main(module: str | None = '__main__', argv: list[str] | None = None) → int[source]

Entry point: run a module’s tests, or --discover DIR.

Run a test binary and convert its output into traceability JUnit.

Used by the rr_wrapped_test / rr_rust_test Bazel macros, and usable directly:

python -m rules_requirements.hooks.wrap --format libtest -- ./my_tests --test-threads=4
python -m rules_requirements.hooks.wrap --format junit --junit-in out/junit.xml -- ./run_suite.sh

It sets $RR_TRACE_FILE (where rr::verifies! records traces), runs the binary with the remaining arguments, echoes its output, parses it with the chosen format, merges the traces, writes JUnit to $XML_OUTPUT_FILE (or --junit-xml), and exits with the binary’s exit code — so the wrapper never turns a failing test green or a passing one red.

The exit-status error case a non-zero exit adds when no reported test failed declares no requirement: it is a target-scope result (rr.scope=target) that taints every case claimed on the target. A test that traced and then died without a result keeps its own declared id.

--format junit is for runners that write JUnit themselves, to a fixed path (--junit-in): the wrapper copies that file to the output and adds the same exit-status error case when the runner exits non-zero although no case in its report failed. A missing or non-JUnit report is recorded as one error case, and a report without cases from a clean exit as one synthetic passed case, so a run never ends without evidence.

Build-time helpers behind the Bazel rules in @rules_requirements//rr:defs.bzl.

run-tests

Run test executables inside a build action (rr_evidence), emulating the environment bazel test provides, and lay their JUnit out like bazel-testlogs (testlogs/<pkg>/<name>/test.xml) so target labels are recovered exactly as for real test logs.

golden

Compare a generated file with a checked-in golden (rr_golden_test), or overwrite the golden with --update (bazel run :<name>.update).

rules_requirements.bazel.run_generated_main(entry: str, baked_args: list[str]) → int[source]

Body of the main files the Bazel macros generate.

Arguments are baked into the generated file (instead of a test’s args attribute) because Bazel only passes args under bazel test / run — a test executed by rr_evidence would otherwise lose them. Runfiles-relative paths resolve against the working directory, which is the workspace’s runfiles directory in every context the macros run in.

Editing, versioning and agents

Surgical edits of model files: change one object, leave the rest alone.

Model files are hand-written and reviewed in diffs, often with comments and section banners. Rewriting a whole file from the parsed model would destroy all of that, so these functions locate one entity’s text span using the YAML parser’s marks and splice freshly rendered text into exactly that span — field by field, so unchanged fields keep their original text byte for byte.

Every rendered scalar and block is re-parsed to confirm it round-trips (with a double-quoted fallback), and verify() re-parses the whole edited file to confirm that the edit changed exactly what was intended. Layouts that cannot be spliced safely — flow-style sections (requirements: [{...}, {...}]) and JSON files — are refused with EditError instead of being rewritten.

exception rules_requirements.edit.EditError[source]

Bases: ValueError

An edit that cannot be applied safely to this file’s layout.

rules_requirements.edit.entity_to_dict(ent: Entity) → dict[str, Any][source]

The entity as plain data, in canonical field order, omitting empties.

rules_requirements.edit.dict_to_entity(kind: str, data: Mapping[str, Any], location: Location | None = None) → tuple[Entity | None, list[str]][source]

Parse plain data into an entity; returns (entity, problems).

Keys this model does not define are problems at the top level; inside notes and verified_by items they are kept (a file may carry them).

rules_requirements.edit.normalize(kind: str, data: Mapping[str, Any]) → dict[str, Any][source]

data as the model would read it back (canonical plain form).

rules_requirements.edit.render_field(key: str, value: Any, pad: str) → list[str][source]

Lines for one key: value field, each prefixed with pad.

rules_requirements.edit.render_entity(kind: str, data: Mapping[str, Any], indent: int = 0, list_item: bool = True, with_kind: bool = False, prefix: str = '', pad: str | None = None) → str[source]

Canonical YAML text for one entity (ends with a newline).

list_item renders it as a sequence entry: prefix (default "- " at column indent) starts the first line and every field is indented with pad (default: to the column after the dash). Otherwise it is rendered as a top-level mapping (one-object files).

rules_requirements.edit.render_file(kind: str, data: Mapping[str, Any]) → str[source]

A one-object model file for a new entity.

class rules_requirements.edit.Span(start: int, end: int, indent: int, list_item: bool, kind: str, prefix: str = '', pad: str = '', flow: bool = False)[source]

Bases: object

rules_requirements.edit.locate(text: str, entity_id: str) → Span | None[source]

Find entity_id’s span in text (section list item or whole document).

rules_requirements.edit.update_entity(text: str, entity_id: str, data: Mapping[str, Any]) → str[source]

Change entity_id to data with the smallest possible text change.

Fields whose value is unchanged keep their original text (formatting, comments); changed fields are re-rendered in place; removed fields are dropped; new fields are appended after the last existing one. A flow mapping on its own line, or an entity using merge keys (<<: *base), is re-rendered as a whole — unless that would lose keys this model does not define or comments inside it, in which case the edit is refused.

rules_requirements.edit.delete_entity(text: str, entity_id: str) → str[source]

Remove entity_id (and one now-redundant blank line).

A one-object document is removed together with its --- separator; other documents in the same file stay.

rules_requirements.edit.insert_entity(text: str, kind: str, data: Mapping[str, Any]) → str[source]

Append a new entity to kind’s section in text (creating it if needed).

rules_requirements.edit.verify(old_text: str, new_text: str, expect: Mapping[str, Mapping[str, Any] | None], rel: str = '', aliases: Mapping[str, str] | None = None) → None[source]

Check that new_text differs from old_text exactly as intended.

expect maps entity ids to their intended data (None = removed; a kind key pins the entity kind). Every other entity, the configuration and the project metadata must read back unchanged; the edit must not add parse errors; top-level keys and keys this model does not define must keep their values (except inside removed entities); and no comment may disappear except from removed entities and from the fields the edit changes. aliases maps renamed ids (new -> old). Raises EditError otherwise — the caller then leaves the file untouched.

rules_requirements.edit.append_document(text: str, kind: str, data: Mapping[str, Any]) → str[source]

Add a one-object document for a new entity to a multi-document file.

rules_requirements.edit.is_blank(text: str) → bool[source]

Whether text holds no YAML content at all (only comments, ---).

rules_requirements.edit.verified_by_from(items: Any, main_repo: str = '') → tuple[VerifiedBy, ...][source]

verified_by / validated_by items from plain data, read the way the model reads them; raises EditError on an item without a target.

Semantic diff of two models: which entities were added, removed or changed, field by field — the “what changed in the system definition” view of a branch, independent of how the YAML happens to be laid out.

class rules_requirements.diff.EntityChange(id: str, kind: str, change: str, title: str = '', fields: dict[str, tuple[Any, Any]] = field(default_factory=...))[source]

Bases: object

rules_requirements.diff.diff_models(old: Model, new: Model, ignore: tuple[str, ...] = ()) → list[EntityChange][source]

Entity-level changes from old to new (sorted by kind, then id).

ignore lists field names to leave out of the comparison (e.g. notes).

rules_requirements.diff.render_text(changes: list[EntityChange]) → str[source]

A compact, human-readable change list.

The web editor’s view of a repository: model files, evidence, annotations and git history, with every mutation going through surgical text edits.

All paths handed out or accepted are relative to the workspace root; anything resolving outside it (or into .git) is refused.

A test case verifies at most one requirement. Every save builds the model the edit would produce and runs the static claim checks (validate()) and attribute() over the loaded evidence before anything is written. An edit that would let one test case verify two entities (shared-case, same-code-multiple-owners, a new attribution-conflict), break a selector or a target (bad-selector, bad-target), claim a whole target without a reason (whole-target-reference), give a requirement a second parent (two refines parents, multi-parent-refines; or a mitigation it implements and another parent, multi-parent-implements; while those rules are errors) or contradict the verification-set lock (lock-owner-changed, lock-invalid) is refused with a 409 that names the case and its current owner (Conflict). Which entity owns a case is read from the Attribution alone; the editor never derives it from tags or targets.

A save is checked and written under an advisory lock on the workspace root, after making sure no file it read changed on disk since: two editors (two rr serve processes) on one checkout cannot both pass the guard with edits that together give a case two owners.

exception rules_requirements.server.workspace.WorkspaceError(message: str, status: int = 400, data: Mapping[str, Any] | None = None)[source]

Bases: Exception

A request the workspace refuses (bad input, conflict, unsafe path).

data is extra JSON the API returns with the message (the conflicts of a refused save).

class rules_requirements.server.workspace.Conflict(code: str, message: str, case: str = '', entities: tuple[str, ...] = (), owner: str = '', owner_via: str = '')[source]

Bases: object

A problem an edit would introduce: why the save guard refuses it.

case is the test case that would get two owners (<target>#<path>, or <target> with any case when every case of it would), and owner its owner now: from the attribution (owner_via attribution), the lock (lock), or the one claim that selects it today (claim); “” when nothing owns it yet.

class rules_requirements.server.workspace.Check(conflicts: list[rules_requirements.server.workspace.Conflict], model: rules_requirements.model.Model | None = None, attribution: rules_requirements.attribution.Attribution | None = None, issues: list[rules_requirements.validate.Issue] = field(default_factory=...), notices: list[dict[str, Any]] = field(default_factory=...))[source]

Bases: object

What Workspace.check() found for a candidate edit.

class rules_requirements.server.workspace.Snapshot(model: rules_requirements.model.Model, issues: list[rules_requirements.validate.Issue], warnings: list[str], matrix: rules_requirements.trace.Matrix, references: list[rules_requirements.annotations.Reference] | None, signature: tuple[Any, ...] = (), _lock_plan: Any = None)[source]

Bases: object

property attribution: Attribution | None

Who owns each test case: attribute(), as build_matrix() ran it.

class rules_requirements.server.workspace.Workspace(root: str, model_paths: list[str], evidence_paths: list[str] = field(default_factory=...), current_build: Mapping[str, str] = field(default_factory=...), scan: bool = True, author: str = '', lanes: Mapping[str, Collection[str]] = field(default_factory=...))[source]

Bases: object

snapshot(refresh: bool = False) → Snapshot[source]

The current model, validation and trace matrix (cached until files change).

static version(ent: Any) → str[source]

A short fingerprint of an entity’s content (optimistic concurrency).

file_for_new(kind: str) → str[source]

Where a new entity of kind goes: next to its siblings.

One-object-per-file layouts get a new file in the siblings’ directory; section files get the entity appended to the file holding most of the kind (or, with no siblings, the first model file).

update(entity_id: str, data: Mapping[str, Any], version: str = '') → list[dict[str, Any]][source]

Replace an entity’s fields with data; returns the save’s notices (notices of Check).

notes are kept unless data names them (the form editor does not); with version (from entity_payload()) a concurrent change since the caller loaded the entity is a 409 instead of being lost.

precheck(entity_id: str, data: Mapping[str, Any], kind: str = '', file: str = '') → dict[str, Any][source]

A dry run of saving data as entity_id (an update when it exists, else a create of kind): the problems the save guard would refuse it for, the validation issues of the entity, and its verification set as attribution would compute it. Nothing is written.

check(candidate: Model, *, edited: Collection[str] = (), aliases: Mapping[str, str] | None = None, lock: Lock | str | None = 'configured') → Check[source]

What saving candidate over the current model would introduce.

Only new problems count, so an edit is never blocked by a conflict it did not cause (it is still reported by validation):

  • a pair of claims of two entities that can select one case (the shared-case and same-code-multiple-owners witnesses of claim_conflicts());

  • a bad-selector or bad-target of an edited entity;

  • a lock entry another entity’s claims would select (lock-owner-changed);

  • a case attribute() would quarantine over the loaded evidence (attribution-conflict, same-code-multiple-owners by source file).

aliases maps renamed ids (new -> old); lock is the lock the save would leave (by default the configured one, read from disk).

cases(target: str = '', q: str = '', state: str = '', lane: str = '') → dict[str, Any][source]

The case ledger: every test case of the loaded evidence with its one owner (or none), how it got it, and its quarantine — read from the Attribution.

target keeps one target’s cases; q those whose key or owner contains it (any case); state is owned, unowned (no owner and not quarantined), quarantined, unlocked (owned, and absent from a configured lock) or coarse (selected by a whole-target claim of a target that reports per-case results); lane keeps the cases of the targets lane lane runs.

lock_status(entity: str = '') → dict[str, Any][source]

Whether the configured lock is out of date: the entries rr sets lock would add, change or remove over the loaded evidence (only those of entity, when given). {} without a configured lock; missing when it cannot be read.

update_lock(*, allow_removals: bool = False, dry_run: bool = False) → dict[str, Any][source]

rr sets lock over the loaded evidence: lock every owned case to its owner (plan_lock() — the lock only records what attribution decided). A quarantine refuses it (409); an entry to remove (its case absent from a target that ran, or no claim selects it) is kept unless allow_removals confirms it. With dry_run nothing is written.

attribution_payload() → dict[str, Any][source]

The attribution at a glance: mode, lock, per-target counts, every quarantine and issue, and each verifiable entity’s set.

move_case(case: str, to: str, *, expand: bool = False, dry_run: bool = False) → dict[str, Any][source]

Give test case case to entity to (”” or none: to no one) by editing the model’s claims — the only way a case changes owner.

The claims that select it now give it up (a literal selector is dropped; a glob, or a whole-target claim of a target with per-case results, is rewritten into the literal selectors of the other cases it selects in the loaded evidence, which needs expand); to gains a literal selector (a whole-target claim for a target that reports only its single result). A configured lock entry of the case is re-locked in the same transaction. The edit then passes the save guard like any other, and attribution over the candidate model must give the case to to. With dry_run nothing is written: the plan and its problems are returned.

referrers(entity_id: str) → list[tuple[str, str]][source]

(referring id, relation) for every reference to entity_id.

delete(entity_id: str, force: bool = False) → list[str][source]

Delete an entity (force: also drop every reference to it); returns the cases whose lock entries were dropped with it.

rename(old_id: str, new_id: str) → list[str][source]

Change an id and every model reference to it, in one transaction.

Source annotations are not rewritten (the scan reports them). A one-object file named after the old id is renamed with it, and so are its entries in the verification-set lock.

source(rel: str, max_bytes: int = 524288) → list[str][source]

Lines of a text file inside the workspace (for the code browser).

model_at(ref: str) → Model[source]

The model as committed at ref.

WORKTREE is the files on disk; EMPTY is no model at all (to diff a repository’s first commit, or a model’s whole history).

tag(name: str, message: str = '', ref: str = 'HEAD', author: str = '') → None[source]

Name a baseline: an annotated tag on ref.

rules_requirements.server.workspace.with_line_endings(orig: str, text: str) → str[source]

text (LF line endings) with the line endings of orig restored: every line kept or changed from orig keeps its own ending; added lines get the ending most of orig uses.

rules_requirements.server.workspace.entity_payload(ws: Workspace, entity_id: str) → dict[str, Any][source]

Everything the UI shows for one entity.

rr serve — the interactive requirements editor.

A small standard-library HTTP server: a JSON API over a Workspace (model, evidence, annotations, git) and the agent JobManager, plus a static single-page UI. It edits files in your checkout, so by default it

  • binds to 127.0.0.1 only,

  • rejects requests whose Host header is not a local name (DNS rebinding),

  • requires the X-RR-Request header on every mutating request (a cross-site form cannot set it; CORS is never granted), and

  • optionally requires Authorization: Bearer <token> (--token).

class rules_requirements.server.app.Api(ws: Workspace, llm: LLM | None = None, llm_enabled: bool = True, author: str = '')[source]

Bases: object

Route table + handlers, independent of the HTTP plumbing (easy to test).

precheck(params: dict[str, str], query: dict[str, list[str]], body: Any) → Any[source]

A dry run of saving a draft: what the save guard would refuse, and the entity’s verification set as it would be. _new (or an id not in the model, with kind) prechecks a create.

list_cases(params: dict[str, str], query: dict[str, list[str]], body: Any) → Any[source]

The case ledger, from the attribution: ?target=&q=&state=&lane= (unowned=1 is state=unowned).

move_case(params: dict[str, str], query: dict[str, list[str]], body: Any) → Any[source]

Give a case to another owner (or none) through a model edit that passes the checks.

lock_status(params: dict[str, str], query: dict[str, list[str]], body: Any) → Any[source]

Whether the verification-set lock is out of date (?entity= for one set).

update_lock(params: dict[str, str], query: dict[str, list[str]], body: Any) → Any[source]

rr sets lock --write over the loaded evidence; removals need allow_removals.

apply_finding(params: dict[str, str], query: dict[str, list[str]], body: Any) → Any[source]

Elevate a finding: a note on its entity, or the proposed object.

class rules_requirements.server.app.HTTPStatus(*values)[source]

Bases: IntEnum

HTTP status codes and reason phrases

Status codes from the following RFCs are all observed:

  • RFC 7231: Hypertext Transfer Protocol (HTTP/1.1), obsoletes 2616

  • RFC 6585: Additional HTTP Status Codes

  • RFC 3229: Delta encoding in HTTP

  • RFC 4918: HTTP Extensions for WebDAV, obsoletes 2518

  • RFC 5842: Binding Extensions to WebDAV

  • RFC 7238: Permanent Redirect

  • RFC 2295: Transparent Content Negotiation in HTTP

  • RFC 2774: An HTTP Extension Framework

  • RFC 7725: An HTTP Status Code to Report Legal Obstacles

  • RFC 7540: Hypertext Transfer Protocol Version 2 (HTTP/2)

  • RFC 2324: Hyper Text Coffee Pot Control Protocol (HTCPCP/1.0)

  • RFC 8297: An HTTP Status Code for Indicating Hints

  • RFC 8470: Using Early Data in HTTP

exception rules_requirements.server.app.HttpError(status: int, message: str, data: dict[str, Any] | None = None)[source]

Bases: Exception

rules_requirements.server.app.needs_token(host: str) → bool[source]

Whether binding to host exposes the server beyond this machine.

rules_requirements.server.app.serve(api: Api, host: str = '127.0.0.1', port: int = 8080, token: str = '', allowed_hosts: set[str] | None = None, ready: Callable[[str], None] | None = None) → ThreadingHTTPServer[source]

Start the server on a background thread and return it (.shutdown() to stop).

Off loopback the Host check is no protection (any client can send Host: localhost), so a token is then mandatory: pass token or one is generated (see needs_token()).

Agentic workflows over a requirements model.

A workflow inspects the model, its evidence and the source tree and returns Finding objects. Each finding names the entity it concerns and may carry a proposal (a new user need / requirement / risk / mitigation). In the web editor a finding can be elevated to a note on its entity (driving the next implementation cycle — open notes appear in the gap queue) or turned into the proposed object.

Deterministic workflows need no LLM; the others use llm.

class rules_requirements.agents.Finding(workflow: str, severity: str, category: str, title: str, detail: str = '', entity: str = '', refs: list[str] = field(default_factory=...), proposal: dict[str, Any] | None = None, source: str = 'rule', id: str = '', status: str = 'open')[source]

Bases: object

class rules_requirements.agents.Job(id: str, workflow: str, params: dict[str, Any], status: str = 'queued', log: list[str] = field(default_factory=...), findings: list[rules_requirements.agents.Finding] = field(default_factory=...), error: str = '', started: float = 0.0, finished: float = 0.0, result: Any = None)[source]

Bases: object

class rules_requirements.agents.JobManager(workflows: dict[str, Workflow], context_factory: Callable[[Job], Any], llm: LLM | None)[source]

Bases: object

Runs workflows on background threads and keeps their results.

transition(finding_id: str, allowed: tuple[str, ...], to: str) → Finding[source]

Atomically move a finding from one of allowed states to to; raises ValueError (with the current state) otherwise.

class rules_requirements.agents.Workflow(id: str, title: str, description: str, run: Callable[[...], list[rules_requirements.agents.Finding]], needs_llm: bool = False, params: dict[str, str] = field(default_factory=...))[source]

Bases: object

LLM access for the agentic workflows.

Only the LLM protocol is used by the workflows, so tests (and other providers) can supply their own. ClaudeLLM talks to Claude through the official anthropic SDK, which is an optional dependency (pip install anthropic, or the agents extra): without it the deterministic workflows still run and the LLM-backed ones report themselves unavailable.

Requests use adaptive thinking, JSON-schema structured output, streaming (the inputs can be large source excerpts) and the server-side refusal fallback (fallbacks: "default"), and check stop_reason before reading content.

exception rules_requirements.agents.llm.LLMError[source]

Bases: Exception

The model could not produce a usable answer.

exception rules_requirements.agents.llm.LLMUnavailable[source]

Bases: LLMError

No LLM is configured (SDK missing, or disabled).

class rules_requirements.agents.llm.LLM(*args, **kwargs)[source]

Bases: Protocol

json(system: str, prompt: str, schema: dict[str, Any]) → Any[source]

Answer prompt with a JSON value conforming to schema.

class rules_requirements.agents.llm.ClaudeLLM(model: str = '', effort: str = '', max_tokens: int = 64000, client: Any = None)[source]

Bases: object

Claude via the anthropic SDK.

rules_requirements.agents.llm.default_llm(enabled: bool = True, model: str = '', effort: str = '') → LLM | None[source]

A ClaudeLLM if possible, else None (llm_status says why).

The built-in workflows.

completeness

Gaps in the model and its traceability: validation issues, unverified / under-verified / stale / failing requirements, uncontrolled risks, missing implementation links — plus, with an LLM, a review of whether the requirements cover the needs at all.

test_adequacy

Does test X actually assert what requirement Y states?

implementation_review

Does the annotated code implement requirement Y?

mitigation_adequacy

Do the requirements behind a mitigation actually control risk W (and is the residual estimate plausible)?

risk_discovery

Hazards the analysis may be missing.

assistant

Free-form instruction -> proposed model operations.

assign_cases

For unowned or quarantined test cases (or a worksheet’s open rows): one proposed owner each, or none — written into the attribution worksheet for a person to decide, never into the model.

Every workflow reads a requirement’s tests from its verification set (Attribution.members_of), never from the tags in the evidence: a case quarantined or owned by another entity is no test of it. No workflow edits ownership: proposals never carry verified_by / validated_by, and case owners are only proposed in the worksheet (rules_requirements.agents.worksheet).

class rules_requirements.agents.workflows.Context(model: rules_requirements.model.Model, matrix: rules_requirements.trace.Matrix, root: str, references: list[rules_requirements.annotations.Reference] | None = None, issues: list[Any] = field(default_factory=...), model_paths: tuple[str, ...] = (), _texts: dict[str, list[str]] = field(default_factory=...), _files: list[str] | None = None)[source]

Bases: object

What a workflow can see.

property attribution: Attribution | None

Who owns each test case — the only source of an entity’s tests.

members_of(entity: str) → tuple[Member, ...][source]

entity’s verification set (members_of()).

set_line(entity: str) → str[source]

set 3/4 passed · 1 not-run for a verifiable entity (”” otherwise).

snippet(rel: str, line: int, max_lines: int = 120) → str[source]

The definition starting at/after line (1-based), with line numbers.

locate_case(case: TestCase) → tuple[str, int] | None[source]

Best-effort source location of a test case from its name.

rules_requirements.agents.workflows.to_proposal(raw: dict[str, Any] | None, model: Model) → dict[str, Any] | None[source]

Model-valid entity data from an LLM proposal, or None.

rules_requirements.agents.workflows.linked_tests(ctx: Context, req_id: str, max_tests: int = 8) → list[tuple[str, str]][source]

(label, source excerpt) of a requirement’s tests: first the cases of its verification set (Context.members_of() — never the tags in the evidence: a case quarantined or owned by another entity is no test of it), then test code annotated with its id, labelled as such (an annotation documents, it verifies nothing).

rules_requirements.agents.workflows.assistant(ctx: Context, job: Job, llm: LLM | None, instruction: str = '', focus: Any = None, **_: Any) → list[Finding][source]

Turn a free-form instruction into proposed operations (never applied here).

rules_requirements.agents.workflows.cases_to_assign(ctx: Context, cases: Any = None, worksheet: str = '', job: Job | None = None) → list[tuple[CaseKey, tuple[str, ...]]][source]

The cases to propose an owner for, each with the entities in question (a quarantine’s entities, else the claimants; () when any entity may do).

cases (<target>#<path> keys) or the open rows of worksheet (when it exists); by default every quarantined and every unowned case. All read from the attribution, never from the evidence’s tags.

rules_requirements.agents.workflows.assign_cases(ctx: Context, job: Job, llm: LLM | None, cases: Any = None, worksheet: str = '', limit: int = 25, batch: int = 8, **_: Any) → list[Finding][source]

Propose exactly one owner (or none) for each unowned or quarantined case. A proposal is a finding; with worksheet every proposal is also written into that .rrplan for a person to decide. Never touches the model, a lock or a test source.

Agent proposals of case owners, written into the attribution worksheet.

An agent never edits ownership: it neither changes a verified_by / validated_by claim nor a test’s tag. What it may do is propose one owner (or none) for a test case, as the proposed and reason of that case in a .rrplan worksheet (rules_requirements.migrate), next to proposed_by. A person then decides — writes owner: — and the decision reaches the model through rr migrate apply or an editor save, both of which pass the one-owner checks. owner is never written here.

exception rules_requirements.agents.worksheet.WorksheetPathError[source]

Bases: ValueError

rules_requirements.agents.worksheet.propose(doc: dict[str, Any], key: CaseKey, owner: str, reason: str, *, candidates: Iterable[str] = (), status: str = '', by: str = 'rr-agent/assign_cases') → dict[str, Any][source]

Record the proposal key -> owner (an id or none) in doc; returns the case entry. A decided owner is never touched.

rules_requirements.agents.worksheet.record(root: str, rel: str, proposals: Iterable[Mapping[str, Any]], by: str = 'rr-agent/assign_cases', model_paths: Iterable[str] = ()) → tuple[str, int][source]

Write proposals ({case, owner, rationale, candidates, status}) into the worksheet at rel (created when absent; never a file of the model at model_paths); returns its path relative to root and the number recorded.

rules_requirements.agents.worksheet.resolve(root: str, rel: str, model_paths: Iterable[str] = ()) → str[source]

The absolute path of worksheet rel (relative to root), refusing anything outside root, not a .rrplan / .json file, or a file the model loader would read as part of the model (model_paths, relative to root or absolute): a worksheet there would corrupt it.