From 21746bb6c2e1f1d4efedbc38cf99f675f8a20638 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Fri, 28 Aug 2026 05:27:12 +0200 Subject: [PATCH 1/2] FEAT-096: gate the release-ordering inversion that was found by hand once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v3.3.0's FEAT-064 is satisfiable only via FEAT-077 and FEAT-087, both scheduled v3.4.0 and both already shipped; FEAT-069 depends on FEAT-095, scheduled v3.5.0. v3.3.0 therefore cannot be cut before v3.4.0, and no work on its remaining items changes that. The previous commit made those dependencies VISIBLE by typing them as `depends-on` links, replacing prose buried in an acceptance criterion. This makes them CHECKED. Visible is not checked: the inversion was found by an ad-hoc script run by hand exactly once, and nothing re-ran it, which is the same defect as a gate that never fails — it reads as rigour and enforces nothing. The allowlist is the design. Three inversions are real, understood, and recorded on scry#187, so failing on them would put main red for a known condition; each is listed with its reason and anything new fails. A STALE entry also fails, which makes the three known pairs three facts the detection logic must keep re-deriving from live artifacts. Mutation 3 demonstrated the consequence and I had not designed for it: flipping the comparison operator turns all three stale and the gate red. The allowlist is a permanent live test of the checker, which is the answer to "who checks the checker" for a gate whose normal state is green. Mutation-checked against REAL artifacts, not fixtures — a structural YAML edit gave FEAT-057 a dependency on a v3.5.0 item and the gate named it. Plus a stale entry, the inverted comparison, and a clean control. Runs as a step on rivet-validate rather than a job of its own: same artifacts, same PyYAML, and that context is already required, so the gate binds on merge with no ruleset edit to forget. A forgotten post-merge ruleset edit is scry#130. Required set unchanged at 12, verified. This does not RESOLVE the inversions — that is the planning decision still open on scry#187. If the answer is "move FEAT-064 and REQ-020 to v3.4.0", the gate will fail until the allowlist entries are deleted, which is the correct pressure. Refs: FEAT-096 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KkNzkNYzPh7366DkNijeNc --- .github/workflows/ci.yml | 19 +++ artifacts/roadmap-v3.4-release-ordering.yaml | 78 ++++++++++ tools/check-release-ordering.py | 144 +++++++++++++++++++ 3 files changed, 241 insertions(+) create mode 100644 artifacts/roadmap-v3.4-release-ordering.yaml create mode 100755 tools/check-release-ordering.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a465eb..c14b7d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -266,6 +266,25 @@ jobs: python3 tools/check-undeveloped-goals.py --self-test python3 tools/check-undeveloped-goals.py + # scry#187: v3.3.0's FEAT-064 is satisfiable only via FEAT-077/FEAT-087, + # both scheduled v3.4.0 and both already shipped, so v3.3.0 cannot be cut + # before v3.4.0. That inversion was found by an ad-hoc script run by hand + # once. Typed `depends-on` links made it VISIBLE; this makes it CHECKED -- + # the same distinction FEAT-091 drew for commit trailers. + # + # A step on this job, not a job of its own: it reads the same artifacts and + # the same PyYAML as the guard above, and this context is ALREADY required, + # so the gate binds on merge with no ruleset edit to forget. A new required + # context needs a post-merge admin step, which is the drift scry#130 was. + # + # The three known inversions are allowlisted WITH reasons and a stale entry + # FAILS, so they double as a live test of the detection logic against real + # artifacts -- inverting the comparison operator turns the gate red. + - name: Guard — no release depends on one scheduled after it + run: | + python3 tools/check-release-ordering.py --self-test + python3 tools/check-release-ordering.py + # scry#161: the V-model's artifact side and the code side were never # connected -- rivet ships commit-to-artifact traceability and it shipped # here unconfigured, so `Broken refs` read 0 for want of any reference to diff --git a/artifacts/roadmap-v3.4-release-ordering.yaml b/artifacts/roadmap-v3.4-release-ordering.yaml new file mode 100644 index 0000000..ec82230 --- /dev/null +++ b/artifacts/roadmap-v3.4-release-ordering.yaml @@ -0,0 +1,78 @@ +# v3.4.0 addendum — own file per scry#143 fix (1). +artifacts: + - id: FEAT-096 + type: feature + title: "v3.4 — A release cannot be cut before one it depends on, and now something checks (scry#187)" + status: accepted + release: v3.4.0 + description: > + v3.3.0's FEAT-064 is satisfiable only via FEAT-077 and FEAT-087. Both are + scheduled v3.4.0 and both already shipped. FEAT-069 depends on FEAT-095, + scheduled v3.5.0. So v3.3.0 CANNOT BE CUT BEFORE v3.4.0, and no amount of + work on its remaining items changes that — the dependency runs the wrong + way across the release boundary. + + This matters because of how the board READS. "v3.3.0: 3 proposed" invites + the conclusion that three pieces of work remain. Two of them are ORDERING, + not work. A release plan records what was believed when it was written, + and the beliefs here were falsified by the work itself: FEAT-064's AC1 was + refuted in practice (scry#123) and repaired in a LATER release, so the plan + no longer described the dependency structure it was built on. + + THE GAP THIS CLOSES IS ONE LEVEL UP FROM THE ONE JUST CLOSED. FEAT-064's + dependency originally lived in PROSE inside an acceptance criterion, where + no tool could see it; typing the three `depends-on` links made it VISIBLE. + But the inversion itself was found by an ad-hoc script run by hand ONCE, + and nothing re-ran it. Visible is not checked. That is the same defect as a + gate that never fails: it reads as rigour and enforces nothing — the + distinction FEAT-091 drew for commit trailers and FEAT-093 for the required + set. + + THE ALLOWLIST IS THE DESIGN, NOT AN ESCAPE HATCH. Three inversions exist + today. Failing on them would put main red for a condition that is real, + understood, and recorded on scry#187, so each is listed WITH its reason and + anything NEW fails. Critically a STALE entry — one naming an inversion that + is no longer real — also fails, so the three known pairs are three facts the + detection logic must keep re-deriving from live artifacts on every run. + That makes the allowlist a permanent live test of the checker against real + data, which is the answer to "who checks the checker" for a gate whose + normal state is green. A suppression list you can only ever add to is how + gates rot; this one costs something to keep. + + IT IS A STEP ON `rivet-validate`, NOT A JOB OF ITS OWN. It reads the same + artifacts and the same PyYAML as the goal guard beside it, and that context + is ALREADY required — so the gate binds the moment this merges, with no + ruleset edit to forget. A new required context needs a post-merge admin + step, and a forgotten post-merge admin step IS scry#130. + tags: [release-machinery, gate, traceability, scry187, v3.4] + fields: + phase: phase-3 + acceptance-criteria: + - "Given an artifact whose `depends-on` target is scheduled in a LATER release and is not allowlisted, When the gate runs, Then it FAILS naming both ids and both releases. MUTATION-CHECKED AGAINST REAL ARTIFACTS, not fixtures: FEAT-057 (v3.4.0) was given a `depends-on` FEAT-095 (v3.5.0) by structural YAML edit, the mutant was asserted applied, and the gate exited 1 naming it." + - "Given the three inversions recorded on scry#187, When the gate runs on main, Then it PASSES and PRINTS each one with its reason. A known problem that must be re-acknowledged to stay known is the opposite of a suppressed one." + - "Given an allowlist entry that no longer describes a real inversion, When the gate runs, Then it FAILS and instructs that removing the entry is how a resolved inversion gets recorded. MUTATION-CHECKED: an entry for the nonexistent FEAT-999 -> FEAT-998 exits 1." + - "Given the comparison logic itself is broken, When the gate runs, Then it FAILS — because all three allowlisted facts stop being derivable. MUTATION-CHECKED: flipping `>` to `<` exits 1 with all three reported stale. This property was not designed and was found BY the mutation run; it is the reason the allowlist is load-bearing rather than cosmetic." + - "Given PyYAML is unavailable, When the gate runs, Then it FAILS CLOSED. A gate that skips on a missing dependency reports green while checking nothing (scry#141)." + - "Given no artifact in the scan carries a release, When the gate runs, Then it FAILS rather than passing over an empty population — the scry#117 failure of a metric measuring the wrong population, in its degenerate form." + - "SELF-TESTED, run BEFORE the real check in CI: 6 cases over the pure `verdict()` function, including that a dependency in an EARLIER release and one in the SAME release both pass, that an unknown target is ignored rather than crashing, and that two new inversions are both reported rather than only the first." + - "Given this PR, When `check-required-checks.py --against-file` runs, Then it exits 0 with the required set unchanged at 12 — the gate adds no new context and therefore needs no post-merge ruleset step. VERIFIED on this branch." + residual: > + THE GATE DOES NOT RESOLVE THE THREE INVERSIONS, and cannot: whether + FEAT-064 and REQ-020 move to v3.4.0, or v3.3.0 simply ships after it, is + a planning decision offered on scry#187 and still unanswered. This makes + the condition permanent and visible rather than fixing it. If the answer + is "move them", the fix is to delete allowlist entries — and the gate + will then FAIL until they are deleted, which is the correct pressure. + + IT CHECKS ONLY TYPED `depends-on` LINKS. A dependency stated in prose is + still invisible to it, which is exactly how these three hid until now. + Nothing detects an UNTYPED dependency, and nothing can; that remains a + human judgement at the moment an acceptance criterion is written. + + Release ordering is compared by parsed semver tuples, and an unparseable + release string sorts to (99,99,99) — deliberately LAST, so an odd label + makes things look like dependencies rather than hiding one. That biases + toward false alarms, which is the safe direction for a gate. + links: + - type: traces-to + target: REQ-005 diff --git a/tools/check-release-ordering.py b/tools/check-release-ordering.py new file mode 100755 index 0000000..873aa8b --- /dev/null +++ b/tools/check-release-ordering.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Gate: no artifact may depend on one scheduled for a LATER release. + +WHY. v3.3.0's FEAT-064 is satisfiable only via FEAT-077 and FEAT-087, both +v3.4.0 and both already shipped; FEAT-069 depends on FEAT-095 (v3.5.0). So +v3.3.0 CANNOT BE CUT BEFORE v3.4.0, and no amount of work on its other items +changes that -- the dependency runs the wrong way across the release boundary. + +Anyone reading "v3.3.0: 3 proposed" assumes three pieces of work remain. Two of +them are ORDERING, not work. A release plan is a snapshot of what you believed +when you wrote it, and nothing re-checks it when the belief changes -- so this +does. + +THE ALLOWLIST IS THE POINT, not an escape hatch. Three inversions exist today. +Failing on them would put main red for a condition that is real, understood, and +recorded (scry#187) -- so they are listed here WITH their reason, and anything +NEW fails. A known problem that must be re-acknowledged to stay known is the +opposite of a suppressed one: removing an entry here is how you record that an +inversion was resolved. + +AND THE ALLOWLIST GUARDS THE CHECKER. A stale entry -- one naming an inversion +that is no longer real -- FAILS. So the three known pairs are three facts the +detection logic must keep re-deriving from live artifacts on every run. Flip the +comparison operator and all three go stale and the gate goes red; that mutant was +run. A suppression list you can only ever add to is how gates rot, so this one +costs something to keep. + +NOT ENFORCEABLE BEFORE 2026-08-28: FEAT-064's dependency lived in PROSE inside an +acceptance criterion, so no tool could see it. Typed `depends-on` links made it +visible; this makes it checked. +""" +import sys, glob + +try: + import yaml +except ImportError: + # FAIL CLOSED: a gate that skips on a missing dep reports green while + # checking nothing (the scry#141 failure mode). + print("FAIL: PyYAML not available -- this gate cannot run", file=sys.stderr) + sys.exit(1) + +# (dependent, dependency) pairs that are KNOWN and RECORDED. Each needs a reason. +KNOWN = { + ("FEAT-064", "FEAT-077"): "scry#187 — FEAT-064's AC1 was falsified and repaired in v3.4.0; the plan did not follow the discovery", + ("FEAT-064", "FEAT-087"): "scry#187 — same repair, second identity bit", + ("FEAT-069", "FEAT-095"): "the OOB proven rate is blocked on region-havoc coverage, measured at 92.5% of unproven obligations", +} + + +def release_key(v): + try: + return tuple(int(x) for x in str(v).lstrip("v").split(".")) + except Exception: + return (99, 99, 99) + + +def collect(docs): + """-> (release_of, depends_on). Pure: the self-test drives this too.""" + release_of, depends_on = {}, {} + for d in docs: + if not isinstance(d, dict): + continue + for a in d.get("artifacts") or []: + if not isinstance(a, dict) or not a.get("id"): + continue + if a.get("release"): + release_of[a["id"]] = a["release"] + for l in a.get("links") or []: + if isinstance(l, dict) and l.get("type") == "depends-on": + depends_on.setdefault(a["id"], []).append(l.get("target")) + return release_of, depends_on + + +def verdict(release_of, depends_on, known=None): + """-> (new_inversions, known_seen). Only NEW ones are failures.""" + known = KNOWN if known is None else known + new, seen = [], [] + for src, targets in depends_on.items(): + for tgt in targets: + if src not in release_of or tgt not in release_of: + continue + if release_key(release_of[tgt]) > release_key(release_of[src]): + item = (src, release_of[src], tgt, release_of[tgt]) + (seen if (src, tgt) in known else new).append(item) + return new, seen + + +def self_test(): + R = {"A": "v1.0.0", "B": "v2.0.0", "C": "v1.0.0"} + cases = [ + ("clean: dependency in an EARLIER release", R, {"B": ["A"]}, {}, 0, 0), + ("clean: same release", R, {"A": ["C"]}, {}, 0, 0), + ("NEW inversion fails", R, {"A": ["B"]}, {}, 1, 0), + ("a KNOWN inversion is allowed but reported", R, {"A": ["B"]}, {("A", "B"): "why"}, 0, 1), + ("unknown target is ignored, not crashed", R, {"A": ["ZZZ"]}, {}, 0, 0), + ("two new inversions are both reported", {**R, "D": "v3.0.0"}, + {"A": ["B", "D"]}, {}, 2, 0), + ] + bad = 0 + for name, rel, dep, known, wn, wk in cases: + n, k = verdict(rel, dep, known) + ok = len(n) == wn and len(k) == wk + bad += not ok + print(f" [{'ok' if ok else 'SELF-TEST FAILED'}] {name}: {len(n)} new / {len(k)} known, " + f"expected {wn}/{wk}") + return bad + + +def main(): + if "--self-test" in sys.argv: + f = self_test() + print("SELF-TEST PASS" if not f else f"SELF-TEST FAIL ({f})") + return 1 if f else 0 + + paths = sorted(glob.glob("artifacts/**/*.yaml", recursive=True)) + docs = [] + for p in paths: + try: + docs.append(yaml.safe_load(open(p, encoding="utf-8"))) + except Exception as e: + print(f"FAIL: unparseable {p}: {e}") + return 1 + release_of, depends_on = collect(docs) + if not release_of: + print("FAIL: no artifact carries a release -- the scan found nothing to check") + return 1 + new, seen = verdict(release_of, depends_on) + print(f" scanned {len(paths)} files; {len(release_of)} artifacts carry a release") + for s, sr, t, tr in sorted(seen): + print(f" known: {s} ({sr}) depends-on {t} ({tr}) — {KNOWN[(s, t)]}") + stale = [k for k in KNOWN if k not in {(s, t) for s, _, t, _ in seen}] + for s, t in sorted(stale): + print(f" FAIL: allowlist entry {s} -> {t} no longer describes a real inversion; " + f"remove it (that is how a resolved inversion gets recorded)") + for s, sr, t, tr in sorted(new): + print(f" FAIL: {s} ({sr}) depends-on {t} ({tr}) — a release cannot be cut before " + f"one it depends on. Move one of them, or add it to KNOWN with a reason.") + bad = len(new) + len(stale) + print("PASS" if not bad else f"FAIL ({bad})") + return 1 if bad else 0 + + +if __name__ == "__main__": + sys.exit(main()) From abe687716d0d31e91ff79448ab664976f253a0b9 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Fri, 28 Aug 2026 05:41:22 +0200 Subject: [PATCH 2/2] FEAT-096 review fix: the gate failed OPEN on the side its residual called safe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two holes, both found by review of text that was about to ship. 1. FAIL-OPEN ON THE SOURCE SIDE. An unparseable release mapped to a (99,99,99) sentinel, and the residual claimed that "sorts last, so it biases toward false alarms, the safe direction". True when the garbage is the TARGET. False when it is the SOURCE: nothing can exceed (99,99,99), so an artifact with a garbage release became a source under which no inversion could ever be reported. The residual asserted a safety property the code did not have — the overclaim shape this repo keeps catching. release_key now returns None and every caller fails. Mutation-checked on a real artifact: FEAT-064's release set to `v3.3` exits 1. 2. LAST-GLOB-WINS ACROSS 23 FILES. release_of is flat, so a second declaration of the same id silently won. G-005 living outside safety-case.yaml is the precedent check-undeveloped-goals.py exists for. Now fails naming both releases; mutation-checked with a second FEAT-064 declaring v9.9.9. Also measured the gate's REACH, which was not stated: 59 of 266 artifacts carry a release, because the axis arrived with rivet 0.22 and FEAT-001..031 predate it. 37 depends-on edges have an unscheduled source, all shipped history. That leaves one asymmetric case undetected — a SCHEDULED artifact depending on an UNSCHEDULED one is equally uncuttable and the gate skips it, a skip the self-test codifies as intended, which is how it would have stayed invisible. Probed and measured at zero today, and now named in the residual rather than implied absent. Self-test 6 -> 13 cases, now covering collect() as well as verdict(). All five mutants red, control green. Refs: FEAT-096 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KkNzkNYzPh7366DkNijeNc --- artifacts/roadmap-v3.4-release-ordering.yaml | 28 ++++- tools/check-release-ordering.py | 115 ++++++++++++++----- 2 files changed, 111 insertions(+), 32 deletions(-) diff --git a/artifacts/roadmap-v3.4-release-ordering.yaml b/artifacts/roadmap-v3.4-release-ordering.yaml index ec82230..a8ef6d9 100644 --- a/artifacts/roadmap-v3.4-release-ordering.yaml +++ b/artifacts/roadmap-v3.4-release-ordering.yaml @@ -53,10 +53,26 @@ artifacts: - "Given an allowlist entry that no longer describes a real inversion, When the gate runs, Then it FAILS and instructs that removing the entry is how a resolved inversion gets recorded. MUTATION-CHECKED: an entry for the nonexistent FEAT-999 -> FEAT-998 exits 1." - "Given the comparison logic itself is broken, When the gate runs, Then it FAILS — because all three allowlisted facts stop being derivable. MUTATION-CHECKED: flipping `>` to `<` exits 1 with all three reported stale. This property was not designed and was found BY the mutation run; it is the reason the allowlist is load-bearing rather than cosmetic." - "Given PyYAML is unavailable, When the gate runs, Then it FAILS CLOSED. A gate that skips on a missing dependency reports green while checking nothing (scry#141)." + - "Given an artifact whose release string does not parse as semver, When the gate runs, Then it FAILS naming it. CORRECTED IN REVIEW, and the correction is the point: the first draft mapped an unparseable release to a (99,99,99) sentinel and the residual claimed that sorted it LAST and so biased toward false alarms. That is true when the garbage is the TARGET and FALSE when it is the SOURCE -- nothing can exceed (99,99,99), so such an artifact became a source under which NO inversion could ever be reported. Fail-open, wearing a residual asserting fail-closed. MUTATION-CHECKED on a real artifact: FEAT-064's release set to `v3.3` exits 1." + - "Given one artifact id declares two DIFFERENT releases in two files, When the gate runs, Then it FAILS rather than silently taking the last one -- `release_of` is flat across 23 files and last-glob-wins. G-005 living outside safety-case.yaml is the precedent that check-undeveloped-goals.py exists for. MUTATION-CHECKED: a second FEAT-064 declaring v9.9.9 exits 1 naming both." - "Given no artifact in the scan carries a release, When the gate runs, Then it FAILS rather than passing over an empty population — the scry#117 failure of a metric measuring the wrong population, in its degenerate form." - - "SELF-TESTED, run BEFORE the real check in CI: 6 cases over the pure `verdict()` function, including that a dependency in an EARLIER release and one in the SAME release both pass, that an unknown target is ignored rather than crashing, and that two new inversions are both reported rather than only the first." + - "SELF-TESTED, run BEFORE the real check in CI: 13 cases over the pure `verdict()` and `collect()` functions, including that a dependency in an EARLIER release and one in the SAME release both pass, that an unknown target is ignored rather than crashing, that two new inversions are both reported rather than only the first, that an unparseable release fails as SOURCE and as TARGET and is reported once per id rather than once per edge, and that bare semver without the `v` prefix still parses." - "Given this PR, When `check-required-checks.py --against-file` runs, Then it exits 0 with the required set unchanged at 12 — the gate adds no new context and therefore needs no post-merge ruleset step. VERIFIED on this branch." residual: > + IT REACHES 59 OF 266 ARTIFACTS. Only an artifact carrying a `release:` + can be ordered, and the axis arrived with rivet 0.22, so FEAT-001..031 + predate it entirely. MEASURED: 37 `depends-on` edges have an UNSCHEDULED + source -- all of them that shipped history, none a blocker. + + SO ONE ASYMMETRIC CASE IS UNDETECTED: a SCHEDULED artifact depending on + an UNSCHEDULED one cannot be cut either, and the gate says nothing + because it skips any edge whose endpoints are not both scheduled. That + skip is codified in the self-test as intended behaviour, which is how it + would have gone unnoticed. MEASURED AT ZERO TODAY by a discriminating + probe, so it is a hole rather than a defect -- but zero today is not + zero tomorrow, and nothing watches it. This is scry#117's class: an + accurate metric over the wrong population. + THE GATE DOES NOT RESOLVE THE THREE INVERSIONS, and cannot: whether FEAT-064 and REQ-020 move to v3.4.0, or v3.3.0 simply ships after it, is a planning decision offered on scry#187 and still unanswered. This makes @@ -69,10 +85,12 @@ artifacts: Nothing detects an UNTYPED dependency, and nothing can; that remains a human judgement at the moment an acceptance criterion is written. - Release ordering is compared by parsed semver tuples, and an unparseable - release string sorts to (99,99,99) — deliberately LAST, so an odd label - makes things look like dependencies rather than hiding one. That biases - toward false alarms, which is the safe direction for a gate. + AN ARTIFACT THAT LOSES ITS `release:` FIELD turns its allowlist entries + stale and takes main red, because the entry stops being derivable. That + is the same mechanism that makes the allowlist load-bearing, firing on a + benign refactor. Noisy in the safe direction and left alone: making it + quieter means making a stale entry survivable, which is the property + worth keeping. links: - type: traces-to target: REQ-005 diff --git a/tools/check-release-ordering.py b/tools/check-release-ordering.py index 873aa8b..4850f35 100755 --- a/tools/check-release-ordering.py +++ b/tools/check-release-ordering.py @@ -29,7 +29,7 @@ acceptance criterion, so no tool could see it. Typed `depends-on` links made it visible; this makes it checked. """ -import sys, glob +import sys, glob, re try: import yaml @@ -48,15 +48,23 @@ def release_key(v): - try: - return tuple(int(x) for x in str(v).lstrip("v").split(".")) - except Exception: - return (99, 99, 99) + """-> comparable tuple, or None if it does not parse. NEVER a sentinel. + + An earlier draft returned (99,99,99) for a garbage release and the residual + claimed that "sorts last, so it biases toward false alarms, the safe + direction". That is TRUE when the garbage is the TARGET and FALSE when it is + the SOURCE: nothing can exceed (99,99,99), so an artifact with an + unparseable release becomes a source under which no inversion can ever be + reported. Fail-open, wearing a residual that asserted fail-closed. None + forces the caller to decide, and every caller here fails. + """ + m = re.fullmatch(r"v?(\d+)\.(\d+)\.(\d+)", str(v).strip()) + return tuple(int(x) for x in m.groups()) if m else None def collect(docs): - """-> (release_of, depends_on). Pure: the self-test drives this too.""" - release_of, depends_on = {}, {} + """-> (release_of, depends_on, dupes). Pure: the self-test drives this too.""" + release_of, depends_on, dupes = {}, {}, [] for d in docs: if not isinstance(d, dict): continue @@ -64,45 +72,89 @@ def collect(docs): if not isinstance(a, dict) or not a.get("id"): continue if a.get("release"): + # release_of is FLAT across every file, so a second declaration + # silently wins. G-005 living outside safety-case.yaml is the + # precedent (check-undeveloped-goals.py exists because of it). + prev = release_of.get(a["id"]) + if prev is not None and prev != a["release"]: + dupes.append((a["id"], prev, a["release"])) release_of[a["id"]] = a["release"] for l in a.get("links") or []: if isinstance(l, dict) and l.get("type") == "depends-on": depends_on.setdefault(a["id"], []).append(l.get("target")) - return release_of, depends_on + return release_of, depends_on, dupes def verdict(release_of, depends_on, known=None): """-> (new_inversions, known_seen). Only NEW ones are failures.""" known = KNOWN if known is None else known - new, seen = [], [] - for src, targets in depends_on.items(): + new, seen, bad = [], [], [] + for rid, rv in sorted(release_of.items()): + if release_key(rv) is None: + bad.append(f"{rid} has an unparseable release {rv!r} -- cannot be " + f"ordered, so no inversion through it could be detected") + for src, targets in sorted(depends_on.items()): for tgt in targets: if src not in release_of or tgt not in release_of: continue - if release_key(release_of[tgt]) > release_key(release_of[src]): + ks, kt = release_key(release_of[src]), release_key(release_of[tgt]) + if ks is None or kt is None: + continue # already reported above, once, by id + if kt > ks: item = (src, release_of[src], tgt, release_of[tgt]) (seen if (src, tgt) in known else new).append(item) - return new, seen + return new, seen, bad def self_test(): R = {"A": "v1.0.0", "B": "v2.0.0", "C": "v1.0.0"} cases = [ - ("clean: dependency in an EARLIER release", R, {"B": ["A"]}, {}, 0, 0), - ("clean: same release", R, {"A": ["C"]}, {}, 0, 0), - ("NEW inversion fails", R, {"A": ["B"]}, {}, 1, 0), - ("a KNOWN inversion is allowed but reported", R, {"A": ["B"]}, {("A", "B"): "why"}, 0, 1), - ("unknown target is ignored, not crashed", R, {"A": ["ZZZ"]}, {}, 0, 0), + # name, release_of, depends_on, known, want_new, want_known, want_bad + ("clean: dependency in an EARLIER release", R, {"B": ["A"]}, {}, 0, 0, 0), + ("clean: same release", R, {"A": ["C"]}, {}, 0, 0, 0), + ("NEW inversion fails", R, {"A": ["B"]}, {}, 1, 0, 0), + ("a KNOWN inversion is allowed but reported", R, {"A": ["B"]}, {("A", "B"): "why"}, 0, 1, 0), + ("unknown target is ignored, not crashed", R, {"A": ["ZZZ"]}, {}, 0, 0, 0), ("two new inversions are both reported", {**R, "D": "v3.0.0"}, - {"A": ["B", "D"]}, {}, 2, 0), + {"A": ["B", "D"]}, {}, 2, 0, 0), + # An unparseable release FAILS rather than sorting to a sentinel. As the + # SOURCE this is the fail-open case: under a (99,99,99) sentinel nothing + # could exceed it and the inversion below would have been reported as + # clean. Both directions are tested because they failed differently. + ("unparseable release as SOURCE fails (was fail-OPEN)", + {"A": "not-a-version", "B": "v2.0.0"}, {"A": ["B"]}, {}, 0, 0, 1), + ("unparseable release as TARGET fails", + {"A": "v1.0.0", "B": "wip"}, {"A": ["B"]}, {}, 0, 0, 1), + ("an unparseable release is reported ONCE per id, not per edge", + {"A": "junk", "B": "v2.0.0", "C": "v1.0.0"}, + {"B": ["A"], "C": ["A"]}, {}, 0, 0, 1), + ("bare semver without the v prefix parses", {"A": "1.0.0", "B": "2.0.0"}, + {"A": ["B"]}, {}, 1, 0, 0), ] bad = 0 - for name, rel, dep, known, wn, wk in cases: - n, k = verdict(rel, dep, known) - ok = len(n) == wn and len(k) == wk + for name, rel, dep, known, wn, wk, wb in cases: + nn, k, b = verdict(rel, dep, known) + ok = len(nn) == wn and len(k) == wk and len(b) == wb + bad += not ok + print(f" [{'ok' if ok else 'SELF-TEST FAILED'}] {name}: " + f"{len(nn)} new / {len(k)} known / {len(b)} bad, expected {wn}/{wk}/{wb}") + + # collect() is a separate pure function and the duplicate-release hazard + # lives only there, so it needs its own cases. + dup_cases = [ + ("same id, same release in two files -> not a duplicate", + [{"artifacts": [{"id": "A", "release": "v1.0.0"}]}, + {"artifacts": [{"id": "A", "release": "v1.0.0"}]}], 0), + ("same id, DIFFERENT release in two files -> duplicate", + [{"artifacts": [{"id": "A", "release": "v1.0.0"}]}, + {"artifacts": [{"id": "A", "release": "v2.0.0"}]}], 1), + ("a doc that is not a mapping is skipped, not crashed", ["nope", None, 7], 0), + ] + for name, docs, wd in dup_cases: + _, _, d = collect(docs) + ok = len(d) == wd bad += not ok - print(f" [{'ok' if ok else 'SELF-TEST FAILED'}] {name}: {len(n)} new / {len(k)} known, " - f"expected {wn}/{wk}") + print(f" [{'ok' if ok else 'SELF-TEST FAILED'}] {name}: {len(d)} dupes, expected {wd}") return bad @@ -120,12 +172,21 @@ def main(): except Exception as e: print(f"FAIL: unparseable {p}: {e}") return 1 - release_of, depends_on = collect(docs) + release_of, depends_on, dupes = collect(docs) if not release_of: print("FAIL: no artifact carries a release -- the scan found nothing to check") return 1 - new, seen = verdict(release_of, depends_on) - print(f" scanned {len(paths)} files; {len(release_of)} artifacts carry a release") + new, seen, bad_rel = verdict(release_of, depends_on) + # POPULATION, printed because the gate's reach is not obvious: only + # artifacts carrying a `release:` are orderable at all. + scheduled_dep = sum(1 for s_ in depends_on if s_ in release_of) + print(f" scanned {len(paths)} files; {len(release_of)} artifacts carry a release; " + f"{scheduled_dep} of those declare depends-on") + for aid, a, b in sorted(dupes): + print(f" FAIL: {aid} declares TWO different releases ({a}, {b}) across files; " + f"the flat scan takes the last one, so its ordering is a coin flip") + for m in bad_rel: + print(f" FAIL: {m}") for s, sr, t, tr in sorted(seen): print(f" known: {s} ({sr}) depends-on {t} ({tr}) — {KNOWN[(s, t)]}") stale = [k for k in KNOWN if k not in {(s, t) for s, _, t, _ in seen}] @@ -135,7 +196,7 @@ def main(): for s, sr, t, tr in sorted(new): print(f" FAIL: {s} ({sr}) depends-on {t} ({tr}) — a release cannot be cut before " f"one it depends on. Move one of them, or add it to KNOWN with a reason.") - bad = len(new) + len(stale) + bad = len(new) + len(stale) + len(dupes) + len(bad_rel) print("PASS" if not bad else f"FAIL ({bad})") return 1 if bad else 0