From b41601323bea7d3a380d3144c06f13030e573abf Mon Sep 17 00:00:00 2001 From: Vasilev Dmitrii Date: Thu, 3 Sep 2026 17:03:39 +0700 Subject: [PATCH 1/3] feat(tri): tri gates preview -- ask each blocking context its own question Four contexts can block a merge here. One of them, `check`, gained a local preview last pass. Measured for the other three: * `validate` had NO local reader of any kind. A broken tracked JSON turns it red while verify.sh, scripts/pre-commit and tri hooks pre-commit say nothing about JSON. * `check-linked-issue` reads the PULL REQUEST title and body. The only local stand-in, tri hooks l1-check, reads the last COMMIT -- a different subject. On #3013 the PR body carried the reference and the squashed commit did not. * And the vocabularies disagreed. Both CI gates run (Closes?|Fixes?|Resolves?|Refs?|Updates?)\s*#[0-9]+; the local check ran (Closes|Fixes|Resolves|Reference)\s+#(\d+) -- missing Refs, which Law L1 names and this repository writes, and inventing Reference, which neither gate accepts. Over the last 20 commits on master: 4 matches against 33. The command delegates every row and reads the issue pattern OUT of issue-gate.yml rather than transcribing it, so a fifth vocabulary cannot appear. PROXY and UNAVAILABLE are printed and are never counted as passes. Refs #2994 Co-Authored-By: Claude Opus 5 --- cli/tri/src/gates.rs | 373 +++++++++++++++++- cli/tri/src/hooks.rs | 77 +++- cli/tri/src/nownote.rs | 30 ++ ...3-the-four-blocking-contexts-asked-here.md | 7 + 4 files changed, 470 insertions(+), 17 deletions(-) create mode 100644 docs/now/2026-09-03-the-four-blocking-contexts-asked-here.md diff --git a/cli/tri/src/gates.rs b/cli/tri/src/gates.rs index 6a7a1c068..4aeb3609e 100644 --- a/cli/tri/src/gates.rs +++ b/cli/tri/src/gates.rs @@ -165,6 +165,15 @@ pub enum GatesCmd { /// context WAS required, and no evidence that it still is. It is not: /// `coverage` failed on 32 of the last 40 merged pull requests, and all 40 /// merged. + /// Ask each REQUIRED context its own question, here, before pushing. + /// + /// Not a fifth opinion: every row runs the gate's own implementation, or + /// says it could not and counts that as a failure rather than a pass. + Preview { + /// Compare against this revision (the PR's base). + #[arg(long, default_value = "origin/master")] + base: String, + }, Required { /// owner/repo. Defaults to the repository of the working directory. #[arg(long)] @@ -2288,6 +2297,7 @@ pub fn run(cmd: &GatesCmd) -> Result<()> { }; unmeasured(&list, *stale_days) } + GatesCmd::Preview { base } => preview(base), GatesCmd::Required { repo } => required(repo.as_deref()), GatesCmd::Dead { repos, min_runs } => { let list: Vec = if repos.is_empty() { @@ -2480,7 +2490,13 @@ fn has_auto_default_run(text: &str, default_branch: &str) -> bool { // `branches:` written as a block list under `push:`. if in_push && branches.is_some() && trimmed.starts_with("- ") { if let Some(b) = branches.as_mut() { - b.push(trimmed[2..].trim().trim_matches('"').trim_matches('\'').to_string()); + b.push( + trimmed[2..] + .trim() + .trim_matches('"') + .trim_matches('\'') + .to_string(), + ); } } } @@ -2492,7 +2508,9 @@ fn has_auto_default_run(text: &str, default_branch: &str) -> bool { // `push:` with nothing under it fires on every branch. None => true, Some(list) if list.is_empty() => true, - Some(list) => list.iter().any(|b| branch_pattern_matches(b, default_branch)), + Some(list) => list + .iter() + .any(|b| branch_pattern_matches(b, default_branch)), } } @@ -2709,7 +2727,11 @@ fn unmeasured(repos: &[String], stale_days: u64) -> Result<()> { human dispatched them, so \"is it red on the default branch too?\" has no\n\ standing answer when one of them goes red on a pull request.\n", no_auto.len(), - if default_branch_seen.is_empty() { "the default branch" } else { default_branch_seen.as_str() } + if default_branch_seen.is_empty() { + "the default branch" + } else { + default_branch_seen.as_str() + } ); println!( " {:<10} {:<9} {:<8} {}", @@ -2865,7 +2887,10 @@ fn claims(root: &std::path::Path) -> Vec<(String, String)> { let end = seg.find(")\n").unwrap_or(seg.len()); for m in seg[..end].split('"').skip(1).step_by(2) { if m.ends_with(".yml") { - out.push((m.to_string(), "check_pr_branch_filters.py MERGE_CRITICAL".into())); + out.push(( + m.to_string(), + "check_pr_branch_filters.py MERGE_CRITICAL".into(), + )); } } } @@ -2921,7 +2946,11 @@ fn required(repo: Option<&str>) -> Result<()> { "--jq", r#".[]|select(.type=="required_status_checks")|.parameters.required_status_checks[].context"#, ])?; - let req: Vec = listing.lines().map(|l| l.trim().to_string()).filter(|l| !l.is_empty()).collect(); + let req: Vec = listing + .lines() + .map(|l| l.trim().to_string()) + .filter(|l| !l.is_empty()) + .collect(); if req.is_empty() { anyhow::bail!( "no required contexts came back for {slug}. That is either a branch with \ @@ -3172,7 +3201,10 @@ mod tests { ); let mention = src.find("MERGE_CRITICAL").expect("docstring mention"); let assignment = src.find("MERGE_CRITICAL = (").expect("assignment"); - assert!(mention < assignment, "the mention comes first -- that is the trap"); + assert!( + mention < assignment, + "the mention comes first -- that is the trap" + ); assert_eq!( src[mention..assignment].matches('"').count() % 2, 1, @@ -3190,7 +3222,11 @@ mod tests { .map(|m| m.to_string()) .collect() }; - assert_eq!(pick(assignment), vec!["a.yml", "b.yml"], "anchored on the assignment"); + assert_eq!( + pick(assignment), + vec!["a.yml", "b.yml"], + "anchored on the assignment" + ); assert!( pick(mention).is_empty(), "anchored on the name it must find nothing, or this test proves nothing" @@ -4147,8 +4183,14 @@ mod auto_default_run_tests { #[test] fn the_inline_on_form_is_read() { - assert!(has_auto_default_run("on: [push, pull_request]\njobs: {}\n", "master")); - assert!(!has_auto_default_run("on: [pull_request]\njobs: {}\n", "master")); + assert!(has_auto_default_run( + "on: [push, pull_request]\njobs: {}\n", + "master" + )); + assert!(!has_auto_default_run( + "on: [pull_request]\njobs: {}\n", + "master" + )); } /// COUNTEREXAMPLE. `harness-scratch.yml` recommends `push: branches: @@ -4281,9 +4323,15 @@ mod pr_context_tests { /// nothing, so "the reading can be taken" is false for them. #[test] fn the_three_marker_families_are_all_seen() { - assert!(text_reads_pr_context("env:\n PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}\n")); - assert!(text_reads_pr_context("run: echo ${{ github.event.pull_request.title }}\n")); - assert!(text_reads_pr_context("run: gh pr view ${{ github.event.number }}\n")); + assert!(text_reads_pr_context( + "env:\n PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}\n" + )); + assert!(text_reads_pr_context( + "run: echo ${{ github.event.pull_request.title }}\n" + )); + assert!(text_reads_pr_context( + "run: gh pr view ${{ github.event.number }}\n" + )); } /// A workflow that only ever reads the ref or the SHA is measurable by @@ -4305,3 +4353,304 @@ mod pr_context_tests { assert!(!text_reads_pr_context(y)); } } + +// --------------------------------------------------------------------------- +// `tri gates preview` -- the four questions that can block a merge. +// --------------------------------------------------------------------------- + +/// What a local reading of one required context came to. +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum Reading { + /// The gate's own implementation ran here and was satisfied. + Pass, + /// The gate's own implementation ran here and refused. + Fail, + /// Its subject does not exist locally, so a stand-in was read instead. + /// Never a pass: a proxy answers a different question. + Proxy, + /// Could not be run. Also never a pass. + Unavailable, +} + +impl Reading { + fn tag(self) -> &'static str { + match self { + Reading::Pass => "PASS ", + Reading::Fail => "FAIL ", + Reading::Proxy => "PROXY ", + Reading::Unavailable => "UNAVAILABLE", + } + } + /// Only `Pass` is a pass. Stated as a function because the whole point of + /// this command is that three other things have been read as one. + pub fn is_pass(self) -> bool { + matches!(self, Reading::Pass) + } +} + +/// The issue-linking pattern, read out of the workflow that enforces it. +/// +/// Transcribing it would create a fifth vocabulary. There were already two: +/// both CI gates run `(Closes?|Fixes?|Resolves?|Refs?|Updates?)\s*#[0-9]+`, +/// and `tri hooks l1-check` ran `(Closes|Fixes|Resolves|Reference)\s+#(\d+)` +/// -- missing `Refs`, which is this repository's normal spelling, and adding +/// `Reference`, which neither gate accepts. Over the last 20 commits on master +/// the two matched **4** references and **33**. +/// +/// Reading the pattern from `issue-gate.yml` means the day someone edits the +/// gate, this follows. Returning `None` when it cannot be found is the whole +/// safety property: an unreadable gate is `UNAVAILABLE`, not `PASS`. +pub fn issue_pattern(yaml: &str) -> Option { + for line in yaml.lines() { + let l = line.trim(); + if !l.contains("grep") || !l.contains("#[0-9]+") { + continue; + } + // The pattern is the single-quoted argument on that line. + let start = l.find('\'')?; + let rest = &l[start + 1..]; + let end = rest.find('\'')?; + let pat = &rest[..end]; + if pat.contains("#[0-9]+") { + return Some(pat.to_string()); + } + } + None +} + +fn preview(base: &str) -> Result<()> { + let root = repo_root()?; + let mut rows: Vec<(&str, Reading, String)> = Vec::new(); + + // 1. `check` -- the shape of the docs/now entry this change adds. + let r = match crate::nownote::check_added(base) { + Ok(true) => (Reading::Pass, "the docs/now entry this change adds".into()), + Ok(false) => ( + Reading::Fail, + "the docs/now entry this change adds (none, or malformed)".into(), + ), + Err(e) => (Reading::Unavailable, format!("{e}")), + }; + rows.push(("check", r.0, r.1)); + + // 2. `check-now-freshness` -- the gate's own shell script, given the range + // it reads from the pull-request environment in CI. + let script = root.join("scripts/ci/now-sync-gate-diff.sh"); + let r = if !script.is_file() { + ( + Reading::Unavailable, + format!("{} is missing", script.display()), + ) + } else { + let head = rev(&root, "HEAD")?; + let b = rev(&root, base)?; + let out = std::process::Command::new("bash") + .arg(&script) + .current_dir(&root) + .env("PR_BASE_SHA", &b) + .env("PR_HEAD_SHA", &head) + .env("GITHUB_EVENT_NAME", "pull_request") + .output(); + match out { + Ok(o) if o.status.success() => ( + Reading::Pass, + "an entry is ADDED and dated in the window".into(), + ), + Ok(_) => ( + Reading::Fail, + "an entry is ADDED and dated in the window".into(), + ), + Err(e) => (Reading::Unavailable, format!("{e}")), + } + }; + rows.push(("check-now-freshness", r.0, r.1)); + + // 3. `validate` -- every tracked JSON parses, ratcheted against a ledger. + // Measured: this context had NO local reader of any kind. A broken + // tracked JSON turned it red while `verify.sh`, `scripts/pre-commit` + // and `tri hooks pre-commit` said nothing about JSON at all. + let json = root.join("tools/check_json_parses.py"); + let r = if !json.is_file() { + ( + Reading::Unavailable, + format!("{} is missing", json.display()), + ) + } else { + match std::process::Command::new("python3") + .arg(&json) + .current_dir(&root) + .output() + { + Ok(o) if o.status.success() => { + (Reading::Pass, "every tracked JSON parses (ledgered)".into()) + } + Ok(_) => (Reading::Fail, "every tracked JSON parses (ledgered)".into()), + Err(e) => (Reading::Unavailable, format!("{e}")), + } + }; + rows.push(("validate", r.0, r.1)); + + // 4. `check-linked-issue` -- the gate reads the PULL REQUEST title and + // body. Locally there may be no pull request, and the commit messages + // are a different subject: a PR body can carry the reference while no + // commit does, which is exactly what #3013 did. + let yaml = std::fs::read_to_string(root.join(".github/workflows/issue-gate.yml")); + let r = match (yaml.ok().as_deref().and_then(issue_pattern), pr_text(&root)) { + (None, _) => ( + Reading::Unavailable, + "issue-gate.yml does not state a pattern this can read".into(), + ), + (Some(pat), Some(text)) => { + let re = regex::Regex::new(&format!("(?i){pat}")) + .map_err(|e| anyhow::anyhow!("issue-gate.yml pattern does not compile: {e}"))?; + if re.is_match(&text) { + ( + Reading::Pass, + "this branch's pull-request title and body".into(), + ) + } else { + ( + Reading::Fail, + "this branch's pull-request title and body".into(), + ) + } + } + (Some(pat), None) => { + let re = regex::Regex::new(&format!("(?i){pat}")) + .map_err(|e| anyhow::anyhow!("issue-gate.yml pattern does not compile: {e}"))?; + let msgs = commit_messages(&root, base).unwrap_or_default(); + let hit = re.is_match(&msgs); + ( + Reading::Proxy, + format!( + "no pull request for this branch, so the COMMITS were read \ + instead ({}). The gate does not read them.", + if hit { + "they carry a reference" + } else { + "they carry none" + } + ), + ) + } + }; + rows.push(("check-linked-issue", r.0, r.1)); + + println!("THE FOUR CONTEXTS THAT CAN BLOCK A MERGE, ASKED HERE\n"); + for (name, reading, subject) in &rows { + println!(" {} {:<20} {}", reading.tag(), name, subject); + } + let passed = rows.iter().filter(|r| r.1.is_pass()).count(); + println!( + "\n {passed} of {} answered PASS by the gate's own implementation.", + rows.len() + ); + println!( + " PROXY and UNAVAILABLE are not passes. A local check that reports a\n \ + pass it did not earn is the shape this repository keeps finding: five\n \ + readers of docs/now/ all checked freshness while the blocking one\n \ + checked shape, and one of them went green BECAUSE of the file the gate\n \ + rejects." + ); + if rows.iter().any(|r| r.1 == Reading::Fail) { + anyhow::bail!("a required context would refuse this change"); + } + Ok(()) +} + +fn rev(root: &std::path::Path, r: &str) -> Result { + let out = std::process::Command::new("git") + .args(["rev-parse", r]) + .current_dir(root) + .output() + .context("failed to invoke git rev-parse")?; + if !out.status.success() { + anyhow::bail!("git rev-parse {r} failed -- the revision is wrong, not the tree"); + } + Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) +} + +/// The pull request's title and body, if this branch has one open. +fn pr_text(root: &std::path::Path) -> Option { + let out = std::process::Command::new("gh") + .args([ + "pr", + "view", + "--json", + "title,body", + "--jq", + ".title + \"\\n\" + .body", + ]) + .current_dir(root) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if s.is_empty() { + None + } else { + Some(s) + } +} + +fn commit_messages(root: &std::path::Path, base: &str) -> Result { + let out = std::process::Command::new("git") + .args(["log", "--pretty=%B", &format!("{base}..HEAD")]) + .current_dir(root) + .output() + .context("failed to invoke git log")?; + Ok(String::from_utf8_lossy(&out.stdout).to_string()) +} + +#[cfg(test)] +mod preview_tests { + use super::*; + + #[test] + fn the_pattern_is_read_out_of_the_gate_that_enforces_it() { + let root = repo_root().expect("tests run inside the repository"); + let yaml = std::fs::read_to_string(root.join(".github/workflows/issue-gate.yml")) + .expect("issue-gate.yml is the file this command reads"); + let pat = issue_pattern(&yaml).expect("issue-gate.yml states a pattern"); + let re = regex::Regex::new(&format!("(?i){pat}")).expect("it compiles"); + // What the gate accepts, measured against the gate's own grep. + for m in [ + "Closes #1", + "Refs #1", + "Ref #1", + "Updates #1", + "refs #1", + "Closes#1", + ] { + assert!(re.is_match(m), "the gate accepts {m:?}"); + } + // And what it does not. `Reference` was in the local preview's old + // vocabulary and is in neither gate; `Fix` is rejected because + // `Fixes?` is `Fixe` plus an optional `s`. + for m in ["Reference #1", "Fix #1", "see #1", "issue 1"] { + assert!(!re.is_match(m), "the gate rejects {m:?}"); + } + } + + #[test] + fn a_workflow_with_no_pattern_reads_as_none_rather_than_as_anything() { + assert_eq!( + issue_pattern("jobs:\n x:\n steps:\n - run: true\n"), + None + ); + // A grep line that is not the issue check must not be mistaken for it. + assert_eq!(issue_pattern(" - run: grep -q 'hello' file\n"), None); + } + + /// The one line the whole command rests on. Three readings are not passes, + /// and this repository has repeatedly read one of them as one. + #[test] + fn only_pass_is_a_pass() { + assert!(Reading::Pass.is_pass()); + assert!(!Reading::Fail.is_pass()); + assert!(!Reading::Proxy.is_pass()); + assert!(!Reading::Unavailable.is_pass()); + } +} diff --git a/cli/tri/src/hooks.rs b/cli/tri/src/hooks.rs index 959faa61d..5df3880d6 100644 --- a/cli/tri/src/hooks.rs +++ b/cli/tri/src/hooks.rs @@ -69,9 +69,35 @@ pub fn l1_check() -> Result<()> { Ok(()) } +/// The vocabulary BOTH CI gates use, character for character. +/// +/// `issue-gate.yml` (the required `check-linked-issue` context, over the PR +/// title and body) and `l1-traceability.yml` (over the commits) each run +/// +/// ```text +/// grep -qiE "(Closes?|Fixes?|Resolves?|Refs?|Updates?)\s*#[0-9]+" +/// ``` +/// +/// This function is a LOCAL PREVIEW of those, and it used to run +/// `(?i)(Closes|Fixes|Resolves|Reference)\s+#(\d+)` -- wrong in both +/// directions at once. It missed `Refs`, `Ref`, `Updates`, `Update` and every +/// singular/plural variant the gates accept, and it invented `Reference`, +/// which neither gate accepts. It also demanded whitespace where the gates +/// allow none, so `Closes#5` passed CI and failed here. +/// +/// Measured over the last 20 commit messages on master: the old vocabulary +/// matched **4** references, the gates' matched **33**. This repository writes +/// `Refs #N` as its normal spelling -- Law L1 names it -- so the preview was +/// rejecting the convention it exists to enforce, and doing it on every +/// commit. +/// +/// No word boundary, deliberately: `grep -E` has none either, so `prefs #1` +/// matches in CI. A preview that is stricter than its gate sends people to fix +/// something the gate does not object to, which is how a preview gets ignored. +const L1_PATTERN: &str = r"(?i)(closes?|fixes?|resolves?|refs?|updates?)\s*#(\d+)"; + fn check_commit_message(msg: &str) -> Result<()> { - let re = Regex::new(r"(?i)(Closes|Fixes|Resolves|Reference)\s+#(\d+)") - .expect("static regex always compiles"); + let re = Regex::new(L1_PATTERN).expect("static regex always compiles"); match re.captures(msg) { Some(caps) => { let issue = caps.get(2).map(|m| m.as_str()).unwrap_or("?"); @@ -81,7 +107,10 @@ fn check_commit_message(msg: &str) -> Result<()> { None => { eprintln!("L1 VIOLATION: Commit missing issue reference"); eprintln!("Commit message: {}", msg.trim()); - eprintln!("Required pattern: Closes #N | Fixes #N | Resolves #N | Reference #N"); + eprintln!( + "Required pattern (both CI gates, case-insensitive): \ + Close(s) | Fix(es) | Resolve(s) | Ref(s) | Update(s) followed by #N" + ); Err(anyhow!("L1 traceability violation")) } } @@ -201,9 +230,47 @@ mod tests { assert!(check_commit_message("fix: bar\n\nfixes #1\n").is_ok()); } + /// `Refs #N` is this repository's normal spelling and BOTH gates accept it. + /// A test used to pin the opposite here, with no reason stated, and the + /// preview rejected every commit that followed the convention. + #[test] + fn l1_accepts_every_spelling_the_gates_accept() { + for m in [ + "Closes #1", + "Close #1", + "Fixes #1", + "Fixe #1", + "Resolves #1", + "Resolve #1", + "Refs #1", + "Ref #1", + "Updates #1", + "Update #1", + "refs #1", + "Closes#1", + ] { + assert!( + check_commit_message(&format!("feat: foo\n\n{m}\n")).is_ok(), + "the gates accept {m:?} and this preview must not be stricter" + ); + } + } + + /// And not looser: `Reference` was in the old list and is in neither gate. #[test] - fn l1_rejects_refs() { - assert!(check_commit_message("feat: foo\n\nRefs #1\n").is_err()); + fn l1_rejects_what_the_gates_reject() { + // `Fix #1` is here because the GATES reject it: `Fixes?` is `Fixe` + // plus an optional `s`, so the bare `Fix` never matches -- while the + // gate's own comment beside that regex promises "Fix(es)". Mirroring + // includes mirroring the quirk; a preview that accepted `Fix #1` would + // pass a commit CI then blocks. Filed rather than silently widened: a + // blocking rule's vocabulary is the owner's to change. + for m in ["Reference #1", "Fix #1", "see #1", "issue 1", "Closes # 1"] { + assert!( + check_commit_message(&format!("feat: foo\n\n{m}\n")).is_err(), + "the gates reject {m:?} and this preview must not be looser" + ); + } } #[test] diff --git a/cli/tri/src/nownote.rs b/cli/tri/src/nownote.rs index 613dc05a6..a700324a7 100644 --- a/cli/tri/src/nownote.rs +++ b/cli/tri/src/nownote.rs @@ -353,6 +353,36 @@ fn gate_script(root: &Path) -> Result { Ok(script) } +/// Did the entries this branch adds satisfy the gate? For `tri gates preview`, +/// which needs the verdict rather than a process exit, and quietly. +/// +/// **An empty set is a FAIL here and an OK in `tri now check`, and that is not +/// an inconsistency.** `tri now check` runs mid-work, where a commit that adds +/// no entry has no shape to judge. `gates preview` asks what the gate would +/// say about this branch AS A PULL REQUEST, and the gate's own words on an +/// empty set are *"FAIL: this change adds no docs/now/ entry"*. Mirroring the +/// gate includes mirroring what it does with nothing. +pub fn check_added(base: &str) -> Result { + let root = repo_root()?; + let script = gate_script(&root)?; + let files = git_paths( + &root, + &["diff", "--name-only", "--diff-filter=A"], + Some(&format!("{base}...HEAD")), + )?; + if files.is_empty() { + return Ok(false); + } + let out = std::process::Command::new("python3") + .arg(&script) + .arg("--check-files") + .args(&files) + .current_dir(&root) + .output() + .context("failed to run tools/check_now_entry_shape.py")?; + Ok(out.status.success()) +} + /// The shape of what the index adds -- the pre-commit hook's entry point. pub fn check_staged() -> Result<()> { check(&[], true, "origin/master") diff --git a/docs/now/2026-09-03-the-four-blocking-contexts-asked-here.md b/docs/now/2026-09-03-the-four-blocking-contexts-asked-here.md new file mode 100644 index 000000000..d715d126d --- /dev/null +++ b/docs/now/2026-09-03-the-four-blocking-contexts-asked-here.md @@ -0,0 +1,7 @@ +# NOW -- The four blocking contexts, asked here (2026-09-03) + +## The four blocking contexts, asked here (Refs #2994) + +- tri gates preview runs each REQUIRED context's own implementation locally: check via the shape gate, check-now-freshness via its shell script, validate via check_json_parses.py, check-linked-issue via the pattern read out of issue-gate.yml. +- Measured: validate had ZERO local reader -- a broken tracked JSON turns it red while verify.sh, scripts/pre-commit and tri hooks pre-commit say nothing about JSON at all. +- Measured: tri hooks l1-check accepted 4 references in the last 20 master commits where both CI gates accept 33 -- it missed Refs, this repository's normal spelling, and invented Reference, which neither gate accepts. From 2c1e8d3dd394063fdff4febcbff299899389942f Mon Sep 17 00:00:00 2001 From: Vasilev Dmitrii Date: Thu, 3 Sep 2026 17:05:11 +0700 Subject: [PATCH 2/3] fix(ci): the NOW freshness gate could not run on macOS at all date -u -d yesterday is GNU-only; on BSD it prints 'illegal option -- d' and under set -e the gate exits 1. A contributor on a Mac could not ask this gate its question, and the failure was indistinguishable from a refusal -- which is how tri gates preview first reported a false FAIL for this context. scripts/pre-commit and scripts/verify.sh already carry the two-form lookup; this is the same one. CI is Linux, so the first form still decides there. Refs #2994 Co-Authored-By: Claude Opus 5 --- scripts/ci/now-sync-gate-diff.sh | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/scripts/ci/now-sync-gate-diff.sh b/scripts/ci/now-sync-gate-diff.sh index a76333378..df7eaf171 100755 --- a/scripts/ci/now-sync-gate-diff.sh +++ b/scripts/ci/now-sync-gate-diff.sh @@ -68,9 +68,17 @@ fi # The window includes TOMORROW so a contributor east of UTC (e.g. UTC+07) who # names the entry with their LOCAL calendar date is not rejected while UTC is # still on the previous day. Identical to the window the old gate enforced. +# GNU date first, then BSD/macOS -- the same two-form lookup scripts/pre-commit +# and scripts/verify.sh already use for exactly these three values. +# +# Without the fallback this script cannot RUN on a Mac: `date -u -d yesterday` +# prints `date: illegal option -- d` and, under `set -e`, the gate exits 1. A +# contributor there cannot ask this gate its question at all, and the answer +# they get if they try is indistinguishable from a refusal. CI is Linux, so +# the first form still decides there and this changes no verdict. TODAY=$(date -u +%Y-%m-%d) -YESTERDAY=$(date -u -d yesterday +%Y-%m-%d) -TOMORROW=$(date -u -d tomorrow +%Y-%m-%d) +YESTERDAY=$(date -u -d yesterday +%Y-%m-%d 2>/dev/null || date -u -v-1d +%Y-%m-%d) +TOMORROW=$(date -u -d tomorrow +%Y-%m-%d 2>/dev/null || date -u -v+1d +%Y-%m-%d) QUALIFIED="" while IFS= read -r f; do From b548b8e16b0236be53dc7f7763018d4335be54d2 Mon Sep 17 00:00:00 2001 From: Vasilev Dmitrii Date: Thu, 3 Sep 2026 17:05:58 +0700 Subject: [PATCH 3/3] docs(skill): 439-440 -- the other three blocking contexts, and a mutation run over an empty set 439: validate had NO local reader; check-linked-issue reads the PR body while the local stand-in reads the last commit; and the vocabularies disagreed 4 against 33 over the last 20 master commits. Plus: the freshness gate itself could not run on macOS, and that failure is indistinguishable from a refusal. 440: cargo test takes a substring, so 'preview_tests|l1_' matched nothing and four mutation kills were scored over an empty sample, every line reading ok. The guard must sum passed AND failed. Refs #2994 Co-Authored-By: Claude Opus 5 --- .claude/skills/ci-gates/SKILL.md | 87 ++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/.claude/skills/ci-gates/SKILL.md b/.claude/skills/ci-gates/SKILL.md index a65e01464..201cdb82d 100644 --- a/.claude/skills/ci-gates/SKILL.md +++ b/.claude/skills/ci-gates/SKILL.md @@ -11158,3 +11158,90 @@ implementation under test into a probe commit and then deleted it. Recovered from the reflog, whole. **A probe that mutates shared state is not an observation** -- probe on a throwaway branch, stage explicit paths, and never `-A` while the thing being measured is uncommitted. + +## 439. The other three blocking contexts: no reader, a different subject, a different vocabulary + +ยง437 fixed one of the four contexts that can block a merge here. The method it +used -- plant an artefact the gate rejects, run every local reader, tabulate +what each one opened -- was then pointed at the other three. All three were +wrong, in three different ways. + +**`validate` had no local reader of any kind.** Its subject is *every tracked +JSON file parses*, ratcheted against a ledger. Planted a syntax error into a +tracked JSON: the gate exits 1, and `scripts/verify.sh`, `scripts/pre-commit` +and `tri hooks pre-commit` say nothing about JSON. `verify.sh` reports seal, +warnings, test, gate-preview and reseal -- five readings, and none of them is +this one. Not a wrong preview: an absent one, which is invisible because the +list of what it *does* report looks thorough. + +**`check-linked-issue` reads a different subject.** The gate reads the PULL +REQUEST title and body. The only local stand-in, `tri hooks l1-check`, reads +the last COMMIT message. On the previous pass's own PR the body carried the +reference and the squashed commit did not -- so the gate passed and the local +check failed, on the same change. + +**And a different vocabulary, wrong in both directions at once.** Both CI gates +run `(Closes?|Fixes?|Resolves?|Refs?|Updates?)\s*#[0-9]+`. The local one ran +`(Closes|Fixes|Resolves|Reference)\s+#(\d+)`: it missed `Refs`, which Law L1 +names and which this repository writes on nearly every commit, and it invented +`Reference`, which neither gate accepts; it also demanded whitespace where the +gates allow none. **Over the last 20 commit messages on master the two matched +4 references and 33.** That is why `tri hooks pre-commit` had been exiting 1 on +ordinary commits -- twice misattributed to probe commits before anyone measured +it, by me. + +**A preview that cannot RUN is not a preview.** `scripts/ci/now-sync-gate-diff.sh` +-- the freshness gate itself -- calls `date -u -d yesterday`, which is GNU-only. +On a Mac it prints `date: illegal option -- d` and, under `set -e`, exits 1. So +the gate was unrunnable on a contributor's machine, and the failure was +**indistinguishable from a refusal**: same exit code, and the first attempt to +delegate to it reported a false FAIL for that reason. The two-form lookup +already existed in `scripts/pre-commit` and `scripts/verify.sh`; the gate did +not have it. + +The repair is `tri gates preview`: four rows, each run by the gate's own +implementation. Two properties are the whole design. + +* **The pattern is READ OUT of `issue-gate.yml`, not transcribed.** There were + already two vocabularies; a third would have been mine. If the pattern cannot + be found the row is `UNAVAILABLE`. +* **Three of the four readings are not passes.** `FAIL`, `PROXY` (no pull + request here, so the commits were read -- a different subject, said out loud) + and `UNAVAILABLE` all print, and only `PASS` counts. `is_pass()` is a + function rather than a comparison precisely because this repository has read + each of the other three as a pass at least once. + +One deliberate asymmetry, stated where it lives: an empty `docs/now/` set is a +`FAIL` in `gates preview` and an `OK` in `tri now check`. The gate's own words +on an empty set are *"FAIL: this change adds no docs/now/ entry"*, and +`gates preview` asks what the gate would say about this branch as a pull +request; `tri now check` runs mid-work, where there is nothing to judge. + +## 440. `cargo test` takes a substring, and four kills were scored over an empty set + +The mutation run for the above printed, for every mutant: + +```text +test result: ok. 0 passed; 0 failed; 0 ignored; 458 filtered out +``` + +`cargo test -p tri 'preview_tests|l1_'` -- the filter is a **substring**, not an +alternation, so it matched nothing and four "kills" were measured over an empty +sample. The word `ok` was on every line. This is the same shape as +`cargo test ` printing `ok. 0 passed` and being read as a pass, which is +already on this page; met again through the door of a filter that looks like a +regex. + +The guard is one line and belongs in every mutation harness: **refuse a run +whose sample is smaller than the number of tests you expect.** + +```sh +[ "$total" -lt "$expected" ] && echo "SAMPLE $total < $expected -- not a measurement" +``` + +And the first version of that guard was itself wrong: it summed only `passed`, +so a genuinely RED mutant -- where the total splits across `passed` and +`failed` -- reported `SAMPLE TOO SMALL` instead of a kill. **A guard over a +count has to add up every bucket the count can land in**, which is the +parts-sum-to-the-whole rule from the other side. Total = passed + failed; +compare that.