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. Itsatisfiesuser needs and/orrefinesa parent requirement (system -> software decomposition, IEC 62304 §5.2), and names themethod(test method or verification level) its verification demands.Risk (
RISK) — a hazard / hazardous situation / harm chain with an estimatedseverityandlikelihood(ISO 14971 §5).Mitigation (
MIT) — a risk control measure (ISO 14971 §7.1) thatmitigatesrisks and isimplemented_byrequirements — 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.Note(text: str, kind: str = 'comment', status: str = 'open', author: str = '', created: str = '', id: str = '', extra: tuple[tuple[str, Any], ...] = ())[source]¶
Bases:
objectA 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:
objectOne item of
verified_by(requirements, mitigations) orvalidated_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 withlegacyset (rulebare-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-caseerror.
- 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:
objectOne selector of one entity: the unit the
shared-casecheck and attribution work on.patternisNonefor a whole-target claim.
- 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
- 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
- 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
- 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
- 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_byitems ofent(() 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.
methodmay name a test method (whoselevelthen applies) or a level directly; empty falls back toconfig.default_level.
- is_verifiable(entity_id: str) bool[source]¶
Whether
entity_idis 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.
- 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 tounknown(reported under theunknown-fieldrule); unknown keys inside notes andverified_by/validated_byitems go tonestedif given, else tounknown(they are kept on the item either way). Claim targets are normalized withmain_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_fieldsfor 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 withkind: requirementetc. 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_fieldsand reported byvalidate()like any other issue.rootmakes 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
ValidationErroron 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 unorderedinspection(IEC 62304 §5.5–5.7 verification activities; the ordering is what lets a report say “under-verified”);ordinal
severitiesandlikelihoodsfor risk estimation (ISO 14971 §5.5);an optional acceptability threshold on
severity x likelihoodfor risk evaluation (ISO 14971 §6 / §7.4 residual risk).
- class rules_requirements.config.Level(name: str, rank: int | None, description: str = '')[source]¶
Bases:
objectA verification rigor level.
rankisNonefor 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}).
- rules_requirements.config.parse_config(raw: Mapping[str, Any] | None, errors: list[str]) Config[source]¶
Build a
Configfrom aconfig: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:
ExceptionThe 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.strictpromotes warnings to errors.known_targets(the labelsbazel query 'tests(//...)'prints, in any spelling) makes a claim, aconfig.variantsentry or a lock target naming any other label anunknown-targeterror; 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:
objectTwo claims of two entities that can select one test case: the pair a
shared-case(one target) orsame-code-multiple-owners(targets of oneconfig.variantsgroup) 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 oneconfig.variantsgroup) orredundant-selector(one entity, one target).exampleis 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 andclaim_conflicts()hands them to the editor’s save guard, so the two can never disagree. Claims with a bad selector are skipped (they are abad-selectorerror 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 asshared-caseandsame-code-multiple-ownerserrors (both readoverlapping_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 abad-selector.?,[and]are literals: pytest ids such astest_x[exc0-False]contain them (unlikefnmatch, wheretest_x[*]would not matchtest_x[a]).A selector is never empty, never padded with blanks, and never the synthetic path
[target](claim a target’s single synthetic result withwhole: 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:
ValueErrorpatternis not a selector (rulebad-selector).
- rules_requirements.case_selectors.tokens(pattern: str) Tuple[str | None, ...][source]¶
patternas one-character literals andSTAR(runs of*collapse into one); raisesBadSelectoron a bad escape.
- rules_requirements.case_selectors.check(pattern: str) None[source]¶
Raise
BadSelectorunlesspatternis a valid selector.
- rules_requirements.case_selectors.is_literal(pattern: str) bool[source]¶
Whether
patternnames 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
patternmatches the whole ofpath.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
Noneif no path can match both.Exact for this grammar: a dynamic programme over (position in
p, position inq), 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, sobazel-testlogs/external/<repo>~/paths match a model written with apparent names);//pis//p:pand@ris@r//:r;the pseudo-targets
suite:<testsuite name>(JUnit outside a testlogs tree) andrecord:<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:
ValueErrorlabelis not a build label or pseudo-target (rulebad-target).
- rules_requirements.labels.is_pseudo(label: str) bool[source]¶
Whether
labelis asuite:/record:pseudo-target.
- rules_requirements.labels.is_repo_name(name: str) bool[source]¶
Whether
namecan be aconfig.main_repo(an apparent repo name).
- rules_requirements.labels.normalize_label(label: str, main_repo: str = '') str[source]¶
The canonical spelling of
label; raisesBadTarget.main_repois the apparent name the main repository has in other modules (config.main_repo), so@<main_repo>//p:nis//p:n.
- rules_requirements.labels.try_normalize(label: str, main_repo: str = '') str | None[source]¶
normalize_label(), orNonefor 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:
ValueErrorThe lock cannot be read (rule
lock-invalid);problemslists every reason.
- class rules_requirements.lock.LockEntry(target: str, path: str, owner: str, line: int = 0, target_line: int = 0)[source]¶
Bases:
object
- class rules_requirements.lock.Lock(entries: tuple[rules_requirements.lock.LockEntry, ...] = (), path: str = '', none: bool = False)[source]¶
Bases:
objectA parsed lock: one entry per locked case, each naming one owner.
- rules_requirements.lock.parse_lock(text: str, path: str = '', main_repo: str = '') Lock[source]¶
Parse a lock’s text; raises
LockErrorlisting every problem.
- rules_requirements.lock.load_lock(path: str, main_repo: str = '', shown: str = '') Lock[source]¶
Read and parse the lock at
path; raisesLockError.
- rules_requirements.lock.NO_LOCK = Lock(entries=(), path='<no lock>', none=True)¶
Pass as
lock=tobuild_matrix()(orattribute()) for “no lock”, whateverconfig.sets_locknames (rr report --no-lock): the sets are not pinned (anunpinned-setsgap), as without a configured lock.Nonereads the configured lock; an emptyLockpins every set to nothing. Recognized by itsnoneflag (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
lockisNO_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_locknames (None without one); raisesLockError.
- 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()topathatomically: 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:
objectWhat
rr sets lockwould write, and why it may not.lockkeeps every entryremovedlists unless removals were allowed, so a crashed or filtered run never shrinks a set silently.
- 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
changedand 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 isremovedwhen its case path now runs under another target (JUnit moved into a testlogs tree), and always underallow_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 inlock, unchanged, unlessallow_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 whenentryis filed under asuite:/record:pseudo-target the evidence does not hold and a target that ran gives the same case path the same owner; None otherwise.ownerandtargetsare 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-runmember 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:
requirementThe 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 onlyrules_requirements.attribution.attribute()resolves. A case naming more than one distinct id (a repeated property,requirement="A,B", the pluralrequirements, 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.levelThe 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.fileThe test source the case came from (
TestCase.file).rr.scope/rr.syntheticrr.scope=targetmarks a result about the whole target run (an exit status, a load error, an unreadable report), never a test case;rr.synthetic=truemarks 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
fileattribute).
- 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//yanda/../bcollapse, asposixpath.normpathdoes); a*.runfiles/<workspace>/orbazel-out/<cfg>/bin/prefix is stripped, else the$BUILD_WORKSPACE_DIRECTORYprefix 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:
objectOne raw result, as an ingestor read it.
declaredholds 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 (seerules_requirements.ingest).requirementsis 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 bothdeclared=andrequirements=explicitly is a TypeError, whateverdeclaredholds ((), or another case’sdeclared);dataclasses.asdict()names the fielddeclared).
- rules_requirements.ingest.apply_properties(case: TestCase, props: Iterable[tuple[str, str]]) TestCase[source]¶
Fold raw
(name, value)properties into the typed fields ofcase.Every
requirement/requirementsvalue is split on commas and whitespace, and the distinct ids — together with the case name’s[rr:ID]tags — are recorded inTestCase.declared, in order. Nothing is decided about ownership: two ids stay two ids.
- class rules_requirements.ingest.Ingestor[source]¶
Bases:
objectBase class for evidence readers. Subclasses set
nameand implementsniff()andingest().
- 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:
objectSomething 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.
- 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:
objectEvery test case from every evidence file, plus per-target rollups.
- 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.lognext totest.xmlinbazel-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.
- rules_requirements.ingest.junit.is_bazel_generated(suite: Element) bool[source]¶
Whether
suiteis the report Bazel writes for a test that wrote none.Bazel’s
generate-xml.shfingerprint: 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
pathis a report Bazel itself names inside a testlogs tree:<target dir>/test.xml(also undershard_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 underrequirementsor a list underrequirement. 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": {...}};testis 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 amulti-tagcase.
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.
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 oneCaseRowper key byindex_cases().A target with no per-case output has one case,
[target](SYNTHETIC_PATH): Bazel’s generatedtest.xml, or a result one of our writers marksrr.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:
objectEvery 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:
NamedTupleWhere in a Bazel test run a report sits.
0means “not sharded” / “a single run” / “the final attempt” (test.xml).
- 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 getsUNNAMED_PATH.
- rules_requirements.case_keys.declared_of(case: TestCase) tuple[str, ...][source]¶
The ids a raw case declares: its
declaredtags plus any[rr:ID]name tags (also for a hand-builtTestCase). 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
CaseRowper key, sorted by key.main_reponormalizes every target askey_of()does (so two spellings of one target are one key);Nonekeeps them as recorded.Attempts (
test_attempts/attempt_N.xmlnext totest.xml): the final report is authoritative; an earlier failure under a final pass makes the rowflaky. 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 casesflaky.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
CaseKeya 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 pytestnodeid.Mirrors pytest’s own
mangle_test_address(the JUnitclassname/namesplit), sopkg/test_m.py::TestK::test_sandpkg/test_m.py::test_a[x]map to the same paths a--junitxmlreport would file them under —pkg.test_m.TestK::test_sandpkg.test_m::test_a[x]— including the parametrization id.
- rules_requirements.case_keys.normalize_target(target: str, main_repo: str = '') str[source]¶
targetin the spelling claims use (normalize_label()withconfig.main_repo), so@@//p:n,//pand a canonical@repo~//p:nfile 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-reportreads 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, asshard_i_of_n_run_k_of_m) andtest_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); atest.xmlis the final attempt (attempt=0).
- rules_requirements.case_keys.run_targets(evidence: Evidence) set[str][source]¶
Every target
evidencehas a result file for: the target of each caseindex_cases()files (a synthetic whole-run result included), and that of each ingested report that holds no case at all (atest.xmlwith 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-testlogspaths,rr wrap --target, a record’starget:); otherwiserecord:<file stem>for records andsuite:<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//yanda/../bcollapse, asposixpath.normpathdoes); a*.runfiles/<workspace>/orbazel-out/<cfg>/bin/prefix is stripped, else the$BUILD_WORKSPACE_DIRECTORYprefix 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:
objectOne 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:
objectThe 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
ownermust end up declaring:[owner], or none fornone.
- 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_byedits the worksheet’s decisions imply, per (target, requirement).actionisremove(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) orno-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:
objectOne 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:
objectThe outcome of
verify().
- rules_requirements.migrate.unkeyed(doc: Mapping[str, Any], evidence: Evidence) str[source]¶
Why
evidencecannot be matched with the worksheetdocby target, else “”: the worksheet’s cases belong to build targets (//pkg:name) but the evidence files no case under any build target, only undersuite:/record:pseudo-targets. A JUnit file’s build target comes from its path (bazel-testlogs/<pkg>/<name>/test.xml, or a directory namedtestlogs), 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 applyagainst the worksheetdoc.Every case the worksheet decides must declare exactly its owner in
evidence(no id fornone). With abaseline(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 fromevidence. 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_missingone whose target has no result file at all inevidence(a HITL or manual target CI does not run) is only listed inmissing. 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, whichrr migrate applycannot edit (it lists the edits): it is not an offence. With amodelit isuntaggedwhen the entities whose claims select it (asattribute()selects: a case selector only the cases it matches, a whole claim every case of its target) are exactly its owner (none fornone), elsependinga model edit (a target split between owners needs case selectors:rr migrate apply --stage model); without one it isuntagged, its owner unchecked. “Before” is the baseline when it has the case, else the worksheet: a group that lists notagshas no tagged case. With a model, a decided case declaring exactly its owner that claims of other entities select is listed inalso_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 Bazelpy_test,rr_node_test,rr_case.htest 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.jsonworksheet; raiseWorksheetError.
- 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 amodel, 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:
objectWhat
rr migrate apply --stage modelwrites, and why it may not.additionsmaps each entity to{target: [selector, ...]}: the selectors that make every case it owns through a tag (attribution: hybrid) a case it claims;wholethe targets it owns only through their synthetic[target]result.datais each changed entity’s new plain form (rules_requirements.edit.entity_to_dict()), andmodelthe model with those entities, inattribution: model, whose owner tablecheck_model_stage()proved unchanged.refusedlists why nothing may be written (a quarantine, a worksheet decision the evidence contradicts, a changed owner, a static claim error).unseenholds 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_worksheetlists those keys), and every unseen decided case must be selected, statically, by exactly its decided owner (by no claim when decidednone), 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()overevidence; nothing here assigns one. Every case owned through a tag gets a selector of its owner (a literal per case, or withcompressa*glob where that is exact), thencheck_model_stage()proves the owner table unchanged underattribution: modeland the claims statically disjoint.A case the worksheet decided but
evidencedoes 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 fornone). Nothing is lost silently.
- rules_requirements.migrate.check_model_stage(before: Model, after: Model, evidence: Evidence) list[str][source]¶
Why
aftermay not replacebefore: an owner that changed (the model-mode attribution ofaftermust give every case the owner the attribution ofbeforegave 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
noneloses 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:
objectThe names pytest collects as tests:
python_functionsandpython_classes(each pattern a prefix or a glob, as in pytest). The defaultstest/Testalways count too: matching more names only makes the codemod refuse more.
- rules_requirements.tag_codemod.test_names(root: str) TestNames[source]¶
The
python_functions/python_classes/python_filesconfigured for pytest atroot(pytest.ini, pyproject.toml, tox.ini, setup.cfg), together with the defaults.
- exception rules_requirements.tag_codemod.Unsupported[source]¶
Bases:
ExceptionA 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:
objectOne
rrdeclaration in the source.
- 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.
- 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
pathssettles: those the codemod found defined in one of them and whose owner their tags carry. Cases left toverified_by(untagged), cases in files not written (held back, refused, outsideonly) and cases not found are not settled by the write: the collection check holds them to their before-ids instead.
- 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:
objectThe
rrdeclarations and the tests of one Python source file.- mark_inherited(sub: ClassDef, why: str) None[source]¶
subinherits 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.Innerpath, as far as they exist.
- static applies(d: Decl, test: TestFn) bool[source]¶
Whether
dreachestestas the pytest plugin resolves it: markers reach every test below their scope;@rr.verifieson 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.
- rules_requirements.tag_codemod.rewrite(tf: TestFile, owners: Mapping[str, str | None], line_length: int = 88) tuple[str, list[str]][source]¶
Rewrite
tfso each test inowners(qualname -> one id, or None for none) declares exactly that id; tests not inownerskeep their trace. Returns(new text, change descriptions); raisesUnsupportedwhen 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 belowonlyprefixes.
- 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
rootper the worksheet’s decisions (CaseKey -> id | "none" | "?"). Nothing is written; the caller writes the filesApplyResult.to_write()returns.Every Python file under
root(not only those belowonly) 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 atroot, seetest_names()).A case resolves only to a module pytest collects (
python_files), and only to the source its evidence names whenfiles_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 anif __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, orrr migrate verifyagainst 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_byselectors 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 (
erroralso 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:
objectThe result of
attribute(): read-only, and checked.owneris THE function from case keys to entity ids;membersare each verifiable entity’s verification set; the entities a quarantined case names each hold it as aquarantinedmember.- 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.owneris 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
quarantinedmember.
- exception rules_requirements.attribution.AttributionInvariantError(problems: Sequence[str])[source]¶
Bases:
AssertionErrorRaised only by
Attribution.check_invariant(): a bug in this module (or a hand-builtAttribution), 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:
objectA 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:
objectOne test case after every observation of its key was merged (
resolve_cases()): the unit attribution gives at most one owner.artifactis the identity it was stamped with (artifactslists every distinct stamp its observations carry: two evidence roots from two builds give two).declaredis the union of the ids its observations name — tags, never an owner.
- 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:
objectOne member of an entity’s verification set.
owned (
passed/failed/error/skipped): a case the entity owns, with itsresult;missing/not-run: a selector that matched nothing, or a lock entry, whose case is absent (keyis 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.
- 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:
objectA 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:
objectHow one target ran in this evidence.
taintholds 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 readserrorinstead.
- 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.resolve_cases()merges the evidence into one result per key.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.Per key, the first rule that applies: more than one declared id → quarantine
multi-tag; claims of more than one entity → quarantineattribution-conflict; one claimant → it owns the key (tag-mismatchif 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 ismisdirected-evidenceand an undefined oneunknown-id: neither owns anything.Owned keys of one test code (the same source file and path, or one
config.variantsgroup and path) in different targets with different owners are quarantinedsame-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 aresame-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 isambiguous-source, always an error: an ambiguity fails closed. Equal paths with different owners where one is filed under asuite:/record:pseudo-target and a source file is unknown are quarantinedsame-code-multiple-owners(a pseudo-target cannot be pinned to a build target).Members: each entity’s owned keys; a pseudo-member per selector that matched nothing (
missingif the target ran,not-runif not,errorif it is tainted or its only result is a failed synthetic one); its lock entries (lockadds expected members only, never an owner); the quarantined keys that name it.
lockis the verification-set lock to expect members from (None orNO_LOCK: no lock; this function reads no files).current_buildmarks members stamped with another buildstale.
- 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
CaseResultper key.Targets are normalized (
config.main_repo) exactly as claims are. The final attempt wins (an earlier failure under a final pass isflaky), 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 readmissing, notnot-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 underflaky: 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 underset_consistency: enforce).UNDER-VERIFIED— the whole set passed, but a member is stale (stamped with another build thancurrent_build), passed only on a retry (flaky: under-verify, the default), or the best level is below the demanded one (itsmethod).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:
objectOne 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:
objectSomething 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:
objectWhat
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:
objectThe 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
Verdictfor 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.lockis the verification-set lock; without one, the lockconfig.sets_locknames is read (a lock that cannot be read is alock-invalidissue, and the sets are not pinned);NO_LOCKreads none (the sets are not pinned:unpinned-sets).references(fromrules_requirements.annotations.scan()) adds implementation/verification source links and theno-implementationgap; 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):
a member is quarantined → INVALID;
a member failed or errored (a taint included), or a passed member was flaky under
flaky: fail→ FAILED;no members, or none of them ran → UNVERIFIED;
a member is missing, not run, skipped or moved, or the set mixes builds under
set_consistency: enforce→ INCOMPLETE (providedis the best passed level so far);a member is stale, flaky under
flaky: under-verify, or the best level is belowdemanded→ UNDER-VERIFIED;otherwise → VERIFIED,
providedthe 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
rootto scan (git-aware whenrootis 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 givenfiles, 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")…): rulemulti-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_hintof 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:
objectThe lane a report was built for (
rr report --lane/--lane-targets).It never changes a verdict: it stamps the report with
nameand, whentargetsis 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
attributionblock: mode,main_repoandvariants(whatrr check-reportneeds 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
unverifiedorincompletegap whose open members are all not-run members of out-of-lane targets.rr report --queue-outleaves them out; the report still lists them (withlane_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
owneris one id ornull(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 ofcaseswhose 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: anerrorone names no case of the report and sits on a tainted or synthetic-only target, and amissingornot-runone names no case of the report (only amovedorquarantinedmember may);the
evidence[]compatibility view of each entity is exactly the view of its owned members, so a 0.3.x reader ofevidence[]sees the same partition;no quarantined case is owned; a quarantine’s
entitiesis a list of ids, the entities holding the case as aquarantinedmember 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
keyis no canonical case key (Nonewhen it is one).rr reportfiles every case under one spelling (key_of()): the target is normalized (@//p:n,@@//p:n,@<main_repo>//p:nand//pare//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
targetis not the one spellingrr reportfiles cases under (None: it is).
- exception rules_requirements.checkreport.ReportError[source]¶
Bases:
ValueErrorThe file is no v2 report at all (unreadable, not JSON, another schema).
- exception rules_requirements.checkreport.AmbiguousReportError[source]¶
Bases:
ReportErrorThe 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-reportexits 1.
- rules_requirements.checkreport.load_report(path: str) dict[str, Any][source]¶
Read a JSON report; raises
ReportErrorwhen it is no v2 report, andAmbiguousReportErrorwhen an object names a key twice.
- rules_requirements.checkreport.check_report(doc: Mapping[str, Any]) list[str][source]¶
Every way
docbreaks 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
- 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. Withentities, only the cases of those entities (the ones drawn).
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]atlevel.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]¶
textwith XML-invalid characters replaced by#xNN(like pytest).
- rules_requirements.hooks.junit_writer.source_file(path: str) str[source]¶
pathrelative to the workspace root, asrr.filerecords 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_DIRECTORYor 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:
objectCollects test cases and writes them as one JUnit
<testsuite>.fileis the source file of the harness, written as therr.fileproperty 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.
requirementis the one id the case verifies (orNone). A list or tuple there, or in the deprecatedrequirements=keyword, still records every id it holds, with a deprecation warning (aMultipleRequirementsWarningwhen it names several: the case is then quarantined).fileoverrides 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
nameas 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.tagsmaps 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 atpath(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. Wherefcntlexists (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()thatis_infrastructuredoes 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 asnot reached, each through its own case and requirement.Rig or setup trouble (an exception before
setup_done(), or oneis_infrastructureclaims): one untagged<suite>::rigerror 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>::harnesserror.Interrupted or exited:
KeyboardInterrupt(an operator’s Ctrl-C) andSystemExitwith code 0 orNoneare never the device’s, whateveris_infrastructuresays: they are recorded as rig trouble, and a clean exit after every check was recorded adds nothing. ASystemExitwith 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:
ValueErrorThe 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:
objectA 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
suiteprefixes 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.
- writer: the
- 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_infrastructureclaims 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
nameas failed (the device’s failure); does not raise.
- skipped(name: str, reason: str) None[source]¶
Record check
nameas deliberately skipped (it verifies nothing this run).
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 |
RR-E103 |
|
RR-E104 |
Malformed id: a comma, whitespace, or empty. |
- exception rules_requirements.hooks.ids.MultipleRequirementsWarning[source]¶
Bases:
DeprecationWarningA 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
requirementvalue 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)declarationsclsgets, nearest first: ONE scope.own_ids(klass)gives the idsklassdeclares 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)withSub(Base):Sub’s).
- rules_requirements.hooks.ids.check_id(value: Any, where: str = 'requirement') str[source]¶
valueif 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
subjectdeclaring severalidsfor one test case.
- rules_requirements.hooks.ids.warn_multiple(subject: str, ids: list[str], stacklevel: int = 2) None[source]¶
Warn that
subjectdeclares severalidsfor one test case.stacklevelis as forwarnings.warn()called by the caller ofwarn_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’sitem.add_marker,@rr.verifies), its class (a subclass’s own declaration before its base’s), then the module or packagepytestmark. 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 alevelwins, 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-requirementwarning).The marker is also available as
@pytest.mark.requirements(...).unittest.TestCasemethods decorated withrules_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.
idsare 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; seemain().
- 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 fortest: nearest wins.The method’s
@rr.verifiesreplaces its class’s (and a subclass’s replaces its base class’s):idsis 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
clsdeclares 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:
TextTestResultCollects every outcome into a
JUnitWriter.- 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().
- rules_requirements.hooks.unittest.run(suite: TestSuite, junit_xml: str = '', suite_name: str = 'unittest', verbosity: int = 2) bool[source]¶
Run
suite; write JUnit tojunit_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-testsRun test executables inside a build action (
rr_evidence), emulating the environmentbazel testprovides, and lay their JUnit out likebazel-testlogs(testlogs/<pkg>/<name>/test.xml) so target labels are recovered exactly as for real test logs.goldenCompare 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
mainfiles the Bazel macros generate.Arguments are baked into the generated file (instead of a test’s
argsattribute) because Bazel only passesargsunderbazel test/run— a test executed byrr_evidencewould 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.
render_entity()— canonical YAML for one entity.update_entity(),insert_entity(),delete_entity()— text-in/text-out edits of one file.verify()— the post-edit check used by the web editor for every write.entity_to_dict()/dict_to_entity()— the plain-data form used by the web API and the agents.
- exception rules_requirements.edit.EditError[source]¶
Bases:
ValueErrorAn 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_byitems they are kept (a file may carry them).
- rules_requirements.edit.normalize(kind: str, data: Mapping[str, Any]) dict[str, Any][source]¶
dataas 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: valuefield, each prefixed withpad.
- 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_itemrenders it as a sequence entry:prefix(default"- "at columnindent) starts the first line and every field is indented withpad(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 intext(section list item or whole document).
- rules_requirements.edit.update_entity(text: str, entity_id: str, data: Mapping[str, Any]) str[source]¶
Change
entity_idtodatawith 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 intext(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_textdiffers fromold_textexactly as intended.expectmaps entity ids to their intended data (None= removed; akindkey 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.aliasesmaps renamed ids (new -> old). RaisesEditErrorotherwise — 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
textholds no YAML content at all (only comments,---).
- rules_requirements.edit.verified_by_from(items: Any, main_repo: str = '') tuple[VerifiedBy, ...][source]¶
verified_by/validated_byitems from plain data, read the way the model reads them; raisesEditErroron 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
oldtonew(sorted by kind, then id).ignorelists 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:
ExceptionA request the workspace refuses (bad input, conflict, unsafe path).
datais 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:
objectA problem an edit would introduce: why the save guard refuses it.
caseis the test case that would get two owners (<target>#<path>, or<target>withany casewhen every case of it would), andownerits owner now: from the attribution (owner_viaattribution), 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:
objectWhat
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(), asbuild_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
kindgoes: 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 (noticesofCheck).notesare kept unlessdatanames them (the form editor does not); withversion(fromentity_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
dataasentity_id(an update when it exists, else a create ofkind): 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
candidateover 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-caseandsame-code-multiple-ownerswitnesses ofclaim_conflicts());a
bad-selectororbad-targetof aneditedentity;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-ownersby source file).
aliasesmaps renamed ids (new -> old);lockis 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.targetkeeps one target’s cases;qthose whose key or owner contains it (any case);stateisowned,unowned(no owner and not quarantined),quarantined,unlocked(owned, and absent from a configured lock) orcoarse(selected by a whole-target claim of a target that reports per-case results);lanekeeps the cases of the targets lanelaneruns.
- lock_status(entity: str = '') dict[str, Any][source]¶
Whether the configured lock is out of date: the entries
rr sets lockwould add, change or remove over the loaded evidence (only those ofentity, when given).{}without a configured lock;missingwhen it cannot be read.
- update_lock(*, allow_removals: bool = False, dry_run: bool = False) dict[str, Any][source]¶
rr sets lockover 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 unlessallow_removalsconfirms it. Withdry_runnothing 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
caseto entityto(”” ornone: 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);togains 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 toto. Withdry_runnothing 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).
- rules_requirements.server.workspace.with_line_endings(orig: str, text: str) str[source]¶
text(LF line endings) with the line endings oforigrestored: every line kept or changed fromorigkeeps its own ending; added lines get the ending most oforiguses.
- 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
Hostheader is not a local name (DNS rebinding),requires the
X-RR-Requestheader on every mutating request (a cross-site form cannot set it; CORS is never granted), andoptionally 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:
objectRoute 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, withkind) 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=1isstate=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).
- class rules_requirements.server.app.HTTPStatus(*values)[source]¶
Bases:
IntEnumHTTP 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
hostexposes 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: passtokenor one is generated (seeneeds_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:
objectRuns workflows on background threads and keeps their results.
- 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:
ExceptionThe model could not produce a usable answer.
Bases:
LLMErrorNo LLM is configured (SDK missing, or disabled).
- class rules_requirements.agents.llm.ClaudeLLM(model: str = '', effort: str = '', max_tokens: int = 64000, client: Any = None)[source]¶
Bases:
objectClaude via the
anthropicSDK.
- rules_requirements.agents.llm.default_llm(enabled: bool = True, model: str = '', effort: str = '') LLM | None[source]¶
A
ClaudeLLMif possible, elseNone(llm_statussays why).
The built-in workflows.
completenessGaps 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_adequacyDoes test X actually assert what requirement Y states?
implementation_reviewDoes the annotated code implement requirement Y?
mitigation_adequacyDo the requirements behind a mitigation actually control risk W (and is the residual estimate plausible)?
risk_discoveryHazards the analysis may be missing.
assistantFree-form instruction -> proposed model operations.
assign_casesFor 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:
objectWhat a workflow can see.
- property attribution: Attribution | None¶
Who owns each test case — the only source of an entity’s tests.
- set_line(entity: str) str[source]¶
set 3/4 passed · 1 not-runfor a verifiable entity (”” otherwise).
- 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 ofworksheet(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; withworksheetevery proposal is also written into that.rrplanfor 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 ornone) indoc; returns the case entry. A decidedowneris 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 atrel(created when absent; never a file of the model atmodel_paths); returns its path relative torootand 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 toroot), refusing anything outsideroot, not a.rrplan/.jsonfile, or a file the model loader would read as part of the model (model_paths, relative torootor absolute): a worksheet there would corrupt it.