diff --git a/.trinity/seals/lanes_TriLanes.json b/.trinity/seals/lanes_TriLanes.json new file mode 100644 index 0000000000..4969674a0d --- /dev/null +++ b/.trinity/seals/lanes_TriLanes.json @@ -0,0 +1,32 @@ +{ + "built_by": "t27c-bootstrap@0.5.2+b8781fb75", + "closure": [ + { + "spec": "specs/tri/lab/receipt.t27", + "spec_hash": "sha256:00d9eec8d7be84dde596c9990b3a5574de24aff9f7a94579fc620eb585712441" + } + ], + "config": "gen=c,rust,verilog,zig;tests=zig", + "gen_hash_c": "sha256:f08bc5e43e2b648ebf9456020e086df45592fa4186cafe1002e34714df6c015f", + "gen_hash_rust": "sha256:f459ec369d21af88cb41113b4c453c930ad514b0a8d10b317b46c3872c06f29c", + "gen_hash_verilog": "sha256:379d076d49247e518f1830a17feb71e478a589071494208d9d0c71d651788e99", + "gen_hash_zig": "sha256:ef1ebda3983c805a06a7f57ab27489e76d7999949a7d27d6c53449acd3a631d4", + "module": "TriLanes", + "ring": 12, + "seal_schema": 2, + "sealed_at": "2026-10-10T13:52:57Z", + "sealed_by": "t27c-bootstrap@0.5.2", + "spec_hash": "sha256:8b1ff75510259ba9bbaf0a51e619db5f6b48228f31c99c65d3b2db292efa3b21", + "spec_path": "specs/tri/lanes/lanes.t27", + "tests": { + "failed": 0, + "failing": [], + "forced": false, + "passed": 8, + "total": 8 + }, + "toolchain": { + "test_runner": "sha256:1982ae948e24d00c5d221ccec12dfce1eb01204b53e37b0f857f93b16d64629d", + "zig": "0.16.0" + } +} \ No newline at end of file diff --git a/cli/tri/src/main.rs b/cli/tri/src/main.rs index 45348f739b..61cae98133 100644 --- a/cli/tri/src/main.rs +++ b/cli/tri/src/main.rs @@ -45,6 +45,7 @@ mod red; mod report; // t27c gen-rust of specs/tri/test/report.t27 (#7369): every rule of reading `t27c test-report` #[path = "../../../gen/rust/tri/lab/receipt.rs"] #[allow(dead_code, unused_parens)] mod lab; // t27c gen-rust of specs/tri/lab/receipt.t27: every rule of `tri lab receipt` #[path = "../../../gen/rust/tri/actors/metrics.rs"] #[allow(dead_code, unused_parens)] mod act; // t27c gen-rust of specs/tri/actors/metrics.t27: every rule of `tri actors` +#[path = "../../../gen/rust/tri/lanes/lanes.rs"] #[allow(dead_code, unused_parens)] mod lanes; // t27c gen-rust of specs/tri/lanes/lanes.t27: every rule of `tri lanes` mod renum; mod reseal; mod rtl; @@ -352,6 +353,19 @@ enum Commands { #[command(subcommand)] action: seals::SealsCmd, }, + /// A loop's lanes: its open pull requests into the base and its worktrees, each judged; a red, conflicting or stale lane is an alarm (rules: specs/tri/lanes/lanes.t27). + Lanes { + /// The repository: owner/name, or a name of gHashTag's. + #[arg(long, default_value = lanes::REPO)] repo: String, + /// The branch the lanes merge into. + #[arg(long, default_value = lanes::BASE)] base: String, + /// The git clone whose worktrees are the lanes. + #[arg(long, default_value = ".")] clone: String, + /// Only the clone's worktrees whose path starts with this are lanes. + #[arg(long, default_value = "")] dir: String, + /// Minutes without a commit or push after which a lane is stale. + #[arg(long, default_value_t = lanes::STALE_MINUTES)] stale: u64, + }, /// Pure-Rust ports of repository commit / push gates. Hooks { #[command(subcommand)] @@ -1229,6 +1243,7 @@ fn main() -> Result<()> { Commands::Jumps { action } => jumps::run(action)?, Commands::Vsim { action } => vsim::run(action)?, Commands::Seals { action } => seals::run(action)?, + Commands::Lanes { repo, base, clone, dir, stale } => std::process::exit(cmd_lanes(repo, base, clone, dir, *stale)? as i32), Commands::Hooks { action } => hooks::run(action)?, Commands::Lab { specs, sha, .. } => std::process::exit(cmd_lab(specs.as_deref(), sha)? as i32), Commands::Actors { url, bound, replay } => std::process::exit(cmd_actors(url, *bound, *replay)? as i32), @@ -1237,6 +1252,31 @@ fn main() -> Result<()> { Ok(()) } +/// `tri lanes`: gh's open pull requests into the base, the clone's worktrees and branch times, and what specs/tri/lanes/lanes.t27 reads in them. +fn cmd_lanes(repo: &str, base: &str, clone: &str, dir: &str, stale: u64) -> Result { + fn run(c: &str, a: &[&str]) -> Result<&'static str> { Ok(String::from_utf8_lossy(&Command::new(c).args(a).output()?.stdout).into_owned().leak()) } + let full = format!("{}{repo}", if lanes::needs_owner(repo.to_string().leak()) { lanes::OWNER } else { "" }); + let prs: &'static str = serde_json::from_str::(run("gh", &["pr", "list", "-R", &full, "--base", base, "--state", "open", "--limit", lanes::PR_LIMIT, "--json", lanes::PR_FIELDS])?).ok().and_then(|v| serde_json::to_string_pretty(&v).ok()).unwrap_or_default().leak(); + let (wts, refs, done) = (run("git", &["-C", clone, "worktree", "list", "--porcelain"])?, run("git", &["-C", clone, "for-each-ref", lanes::REF_FORMAT, "refs/heads"])?, run("gh", &["pr", "list", "-R", &full, "--base", base, "--state", "merged", "--limit", lanes::PR_LIMIT, "--json", lanes::MERGED_FIELDS])?); + let (now, dir, mut alarms): (u64, &'static str, u64) = (std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_secs(), dir.to_string().leak(), 0); + let age = |a: u64| if lanes::age_known(a) { a.to_string() } else { lanes::UNKNOWN.to_string() }; + let mut o = lanes::next_pr(prs, 0); + while o < prs.len() { + let e = lanes::pr_end(prs, o); + let w = lanes::worktree_of(wts, dir, prs, o, e); + let a = lanes::age_minutes(now, lanes::later(lanes::pr_updated(prs, o, e), lanes::commit_time(refs, wts, w))); let v = lanes::pr_verdict(prs, o, e, a, stale); alarms += lanes::is_alarm(v) as u64; + println!("{} checks={} age_min={} worktree={} {}", (0..lanes::PR_CELLS).map(|k| format!("{}={}", lanes::pr_label(k), &prs[lanes::pr_from(prs, o, e, k)..lanes::pr_to(prs, o, e, k)])).collect::>().join(" "), lanes::ci_name(lanes::ci(prs, o, e)), age(a), &wts[lanes::path_from(wts, w)..lanes::path_to(wts, w)], lanes::why(v)); + o = lanes::next_pr(prs, e); + } + let mut w = lanes::next_worktree(wts, 0, dir); + while w < wts.len() { + let a = lanes::age_minutes(now, lanes::commit_time(refs, wts, w)); let v = lanes::worktree_verdict(wts, w, a, stale, lanes::merged(done, wts, w)); + if !lanes::has_pr(prs, wts, w) { alarms += lanes::is_alarm(v) as u64; println!("worktree={} branch={} age_min={} {}", &wts[lanes::path_from(wts, w)..lanes::path_to(wts, w)], &wts[lanes::branch_from(wts, w)..lanes::branch_to(wts, w)], age(a), lanes::why(v)); } + w = lanes::next_worktree(wts, lanes::record_end(wts, w), dir); + } + let x = lanes::exit_code(!prs.is_empty(), alarms); println!("{}", lanes::exit_why(x)); Ok(x) +} + fn cmd_serve(addr: &str) -> Result<()> { use axum::routing::{get, post}; use axum::Router; diff --git a/docs/now/2026-10-10-tri-lanes.md b/docs/now/2026-10-10-tri-lanes.md new file mode 100644 index 0000000000..5de8d6d346 --- /dev/null +++ b/docs/now/2026-10-10-tri-lanes.md @@ -0,0 +1,8 @@ +# NOW -- tri lanes: a loop's lanes, each judged; red, conflicting and stale lanes are alarms (2026-10-10) + +## specs/tri/lanes/lanes.t27, cli/tri/src/main.rs (Closes #8622; night-loop ledger gHashTag/trios#1729, item 10) + +- `tri lanes [--repo trios] [--base actors-next-2] [--clone ] [--dir ] [--stale 45]` prints one row per open PR into the base (merge state, checks green / pending / RED, age, its worktree) and one per lane worktree with no open PR. The verdicts RED, CONFLICT and STALE are the ledger's anomalies and exit 1. A worktree whose branch was merged into the base is MERGED, and a detached one is not a lane. If gh gives no PR list, the exit is 2. +- Every rule is in the spec (8 tests, 0 FAIL, 0 vacuous; 16 negative controls, each FAILs by name or by the invariant). The age is the minutes since the later of the PR's updatedAt and the local branch's last commit. The ledger says "no file change in 45 min", but this reads commits and pushes, not uncommitted edits. +- Run on the loop's clone: against `--base actors-next`, lanes 1-9 read MERGED and the five fresh lanes read NO PR, exit 0. Against trios `main`, 3 PRs read RED and 4 read STALE, exit 1. +- Hand-written foreign lines: `cli/tri/src/main.rs` gains 40 and `scripts/gen_w320.py` loses 45, so net -5. The deleted script's port, `specs/port/scripts/gen_w320.t27`, is verified by the lab. diff --git a/gen/rust/tri/lanes/lanes.rs b/gen/rust/tri/lanes/lanes.rs new file mode 100644 index 0000000000..ce28c1969d --- /dev/null +++ b/gen/rust/tri/lanes/lanes.rs @@ -0,0 +1,635 @@ +// Generated from .t27 spec +// DO NOT EDIT — generated by t27c +// NOT LOWERED BY THIS BACKEND: 8 test(s), 1 invariant(s), 0 function(s) with no body. +// This backend emits declarations only. The spec's checks live in +// the Zig and Verilog outputs; do not read this file as verified. + +pub const OWNER: &'static str = "gHashTag/"; + +pub const REPO: &'static str = "trios"; + +pub const BASE: &'static str = "actors-next-2"; + +pub const STALE_MINUTES: u64 = 45; + +pub const PR_FIELDS: &'static str = "number,headRefName,isDraft,mergeStateStatus,statusCheckRollup,updatedAt"; + +pub const PR_LIMIT: &'static str = "200"; + +pub const MERGED_FIELDS: &'static str = "headRefName"; + +pub const REF_FORMAT: &'static str = "--format=%(refname)%09%(committerdate:unix)"; + +pub const AGE_UNKNOWN: u64 = 4294967295; + +pub const UNKNOWN: &'static str = "?"; + +pub const CI_GREEN: u8 = 0; + +pub const CI_PENDING: u8 = 1; + +pub const CI_RED: u8 = 2; + +pub const V_READY: u8 = 0; + +pub const V_PENDING: u8 = 1; + +pub const V_BLOCKED: u8 = 2; + +pub const V_BEHIND: u8 = 3; + +pub const V_NO_PR: u8 = 4; + +pub const V_DETACHED: u8 = 5; + +pub const V_RED: u8 = 6; + +pub const V_CONFLICT: u8 = 7; + +pub const V_STALE: u8 = 8; + +pub const V_MERGED: u8 = 9; + +pub const EXIT_OK: u8 = 0; + +pub const EXIT_ALARM: u8 = 1; + +pub const EXIT_UNREAD: u8 = 2; + +pub const PR_CELLS: u8 = 4; + +pub fn needs_owner(repo: &'static str) -> bool { + let mut i: usize = 0; + while (i < repo.len()) { + if (repo.as_bytes()[(i) as usize] == 47) { + return false; + } + i = (i + 1); + } + return true; +} + +pub fn pr_key(k: u8) -> &'static str { + if (k == 0) { + return "number"; + } + if (k == 1) { + return "headRefName"; + } + if (k == 2) { + return "isDraft"; + } + return "mergeStateStatus"; +} + +pub fn pr_label(k: u8) -> &'static str { + if (k == 0) { + return "pr"; + } + if (k == 1) { + return "branch"; + } + if (k == 2) { + return "draft"; + } + return "merge"; +} + +pub fn next_pr(text: &'static str, at: usize) -> usize { + let mut k: usize = at; + while (k < text.len()) { + let end: usize = line_end(text, k); + if line_is(text, k, end, 2, "{") { + return k; + } + if line_is(text, k, end, 0, "]") { + return text.len(); + } + k = (end + 1); + } + return text.len(); +} + +pub fn pr_end(text: &'static str, at: usize) -> usize { + let mut k: usize = (line_end(text, at) + 1); + while (k < text.len()) { + let end: usize = line_end(text, k); + if line_is(text, k, end, 2, "}") { + return end; + } + k = (end + 1); + } + return text.len(); +} + +pub fn pr_from(text: &'static str, o: usize, e: usize, k: u8) -> usize { + return value_from(text, key_at(text, o, e, 4, pr_key(k))); +} + +pub fn pr_to(text: &'static str, o: usize, e: usize, k: u8) -> usize { + return value_to(text, key_at(text, o, e, 4, pr_key(k))); +} + +pub fn failed_word(text: &'static str, from: usize, to: usize) -> bool { + return ((((((span_is(text, from, to, "FAILURE") || span_is(text, from, to, "ERROR")) || span_is(text, from, to, "TIMED_OUT")) || span_is(text, from, to, "CANCELLED")) || span_is(text, from, to, "ACTION_REQUIRED")) || span_is(text, from, to, "STARTUP_FAILURE")) || span_is(text, from, to, "STALE")); +} + +pub fn ci(text: &'static str, o: usize, e: usize) -> u8 { + let mut pending: bool = false; + let mut at: usize = o; + while ((at < e) && (at < text.len())) { + let end: usize = line_end(text, at); + let c: usize = key_value(text, at, end, 8, "conclusion"); + let s: usize = key_value(text, at, end, 8, "state"); + let st: usize = key_value(text, at, end, 8, "status"); + if ((c <= end) && failed_word(text, value_from(text, c), value_to(text, c))) { + return CI_RED; + } + if (s <= end) { + if failed_word(text, value_from(text, s), value_to(text, s)) { + return CI_RED; + } + if (span_is(text, value_from(text, s), value_to(text, s), "PENDING") || span_is(text, value_from(text, s), value_to(text, s), "EXPECTED")) { + pending = true; + } + } + if ((st <= end) && (span_is(text, value_from(text, st), value_to(text, st), "COMPLETED") == false)) { + pending = true; + } + at = (end + 1); + } + if pending { + return CI_PENDING; + } + return CI_GREEN; +} + +pub fn ci_name(c: u8) -> &'static str { + if (c == CI_GREEN) { + return "green"; + } + if (c == CI_PENDING) { + return "pending"; + } + return "RED"; +} + +pub fn days_from_civil(y: u64, m: u64, d: u64) -> u64 { + let mut yy: u64 = y; + if (m <= 2) { + yy = (yy - 1); + } + let era: u64 = (yy / 400); + let yoe: u64 = (yy - (era * 400)); + let mut mp: u64 = (m + 9); + if (m > 2) { + mp = (m - 3); + } + let doy: u64 = (((((153 * mp) + 2) / 5) + d) - 1); + let doe: u64 = ((((yoe * 365) + (yoe / 4)) - (yoe / 100)) + doy); + return (((era * 146097) + doe) - 719468); +} + +pub fn digits(text: &'static str, from: usize, to: usize) -> u64 { + if ((to <= from) || ((to - from) > 18)) { + return 0; + } + let mut n: u64 = 0; + let mut i: usize = from; + while (i < to) { + if ((text.as_bytes()[(i) as usize] < 48) || (text.as_bytes()[(i) as usize] > 57)) { + return 0; + } + n = ((n * 10) + ((text.as_bytes()[(i) as usize] as u64) - 48)); + i = (i + 1); + } + return n; +} + +pub fn iso_seconds(text: &'static str, from: usize, to: usize) -> u64 { + if ((((((((to - from) != 20) || (text.as_bytes()[((from + 4)) as usize] != 45)) || (text.as_bytes()[((from + 7)) as usize] != 45)) || (text.as_bytes()[((from + 10)) as usize] != 84)) || (text.as_bytes()[((from + 13)) as usize] != 58)) || (text.as_bytes()[((from + 16)) as usize] != 58)) || (text.as_bytes()[((from + 19)) as usize] != 90)) { + return 0; + } + let y: u64 = digits(text, from, (from + 4)); + let mo: u64 = digits(text, (from + 5), (from + 7)); + let d: u64 = digits(text, (from + 8), (from + 10)); + if (((((y < 1970) || (mo < 1)) || (mo > 12)) || (d < 1)) || (d > 31)) { + return 0; + } + return ((((days_from_civil(y, mo, d) * 86400) + (digits(text, (from + 11), (from + 13)) * 3600)) + (digits(text, (from + 14), (from + 16)) * 60)) + digits(text, (from + 17), (from + 19))); +} + +pub fn pr_updated(text: &'static str, o: usize, e: usize) -> u64 { + let v: usize = key_at(text, o, e, 4, "updatedAt"); + return iso_seconds(text, value_from(text, v), value_to(text, v)); +} + +pub fn starts(w: &'static str, at: usize, lit: &'static str) -> bool { + if ((at + lit.len()) > w.len()) { + return false; + } + return span_is(w, at, (at + lit.len()), lit); +} + +pub fn record_end(w: &'static str, at: usize) -> usize { + let mut k: usize = at; + while (k < w.len()) { + let end: usize = line_end(w, k); + if (end == k) { + return k; + } + k = (end + 1); + } + return w.len(); +} + +pub fn next_worktree(w: &'static str, at: usize, dir: &'static str) -> usize { + let mut k: usize = at; + while (k < w.len()) { + let end: usize = line_end(w, k); + if (((k > 0) && starts(w, k, "worktree ")) && starts(w, (k + 9), dir)) { + return k; + } + k = (end + 1); + } + return w.len(); +} + +pub fn path_from(w: &'static str, at: usize) -> usize { + if (at >= w.len()) { + return w.len(); + } + return (at + 9); +} + +pub fn path_to(w: &'static str, at: usize) -> usize { + if (at >= w.len()) { + return w.len(); + } + return line_end(w, at); +} + +pub fn branch_from(w: &'static str, at: usize) -> usize { + let stop: usize = record_end(w, at); + let mut k: usize = at; + while (k < stop) { + if starts(w, k, "branch refs/heads/") { + return (k + 18); + } + k = (line_end(w, k) + 1); + } + return w.len(); +} + +pub fn branch_to(w: &'static str, at: usize) -> usize { + let b: usize = branch_from(w, at); + if (b >= w.len()) { + return w.len(); + } + return line_end(w, b); +} + +pub fn on_branch(w: &'static str, at: usize, text: &'static str, from: usize, to: usize) -> bool { + let b: usize = branch_from(w, at); + let be: usize = branch_to(w, at); + if ((b >= w.len()) || ((be - b) != (to - from))) { + return false; + } + let mut i: usize = 0; + while (i < (to - from)) { + if (w.as_bytes()[((b + i)) as usize] != text.as_bytes()[((from + i)) as usize]) { + return false; + } + i = (i + 1); + } + return true; +} + +pub fn worktree_of(w: &'static str, dir: &'static str, text: &'static str, o: usize, e: usize) -> usize { + let mut at: usize = next_worktree(w, 0, dir); + while (at < w.len()) { + if on_branch(w, at, text, pr_from(text, o, e, 1), pr_to(text, o, e, 1)) { + return at; + } + at = next_worktree(w, record_end(w, at), dir); + } + return w.len(); +} + +pub fn has_pr(text: &'static str, w: &'static str, at: usize) -> bool { + let mut o: usize = next_pr(text, 0); + while (o < text.len()) { + let e: usize = pr_end(text, o); + if on_branch(w, at, text, pr_from(text, o, e, 1), pr_to(text, o, e, 1)) { + return true; + } + o = next_pr(text, e); + } + return false; +} + +pub fn commit_time(refs: &'static str, w: &'static str, at: usize) -> u64 { + if (at >= w.len()) { + return 0; + } + let b: usize = branch_from(w, at); + let be: usize = branch_to(w, at); + if (b >= w.len()) { + return 0; + } + let mut k: usize = 0; + while (k < refs.len()) { + let end: usize = line_end(refs, k); + if ((starts(refs, k, "refs/heads/") && (((k + 11) + (be - b)) < end)) && (refs.as_bytes()[(((k + 11) + (be - b))) as usize] == 9)) { + let mut same: bool = true; + let mut i: usize = 0; + while (i < (be - b)) { + if (refs.as_bytes()[(((k + 11) + i)) as usize] != w.as_bytes()[((b + i)) as usize]) { + same = false; + } + i = (i + 1); + } + if same { + return digits(refs, ((k + 12) + (be - b)), end); + } + } + k = (end + 1); + } + return 0; +} + +pub fn merged(m: &'static str, w: &'static str, at: usize) -> bool { + let b: usize = branch_from(w, at); + let be: usize = branch_to(w, at); + if (b >= w.len()) { + return false; + } + let mut k: usize = 0; + while ((((k + 15) + (be - b)) + 1) <= m.len()) { + if (span_is(m, k, (k + 15), "\"headRefName\":\"") && (m.as_bytes()[(((k + 15) + (be - b))) as usize] == 34)) { + let mut same: bool = true; + let mut i: usize = 0; + while (i < (be - b)) { + if (m.as_bytes()[(((k + 15) + i)) as usize] != w.as_bytes()[((b + i)) as usize]) { + same = false; + } + i = (i + 1); + } + if same { + return true; + } + } + k = (k + 1); + } + return false; +} + +pub fn later(a: u64, b: u64) -> u64 { + if (a > b) { + return a; + } + return b; +} + +pub fn age_minutes(now: u64, then: u64) -> u64 { + if ((then == 0) || (then > now)) { + return AGE_UNKNOWN; + } + return ((now - then) / 60); +} + +pub fn age_known(age: u64) -> bool { + return (age != AGE_UNKNOWN); +} + +pub fn pr_verdict(text: &'static str, o: usize, e: usize, age: u64, stale: u64) -> u8 { + let c: u8 = ci(text, o, e); + let mf: usize = pr_from(text, o, e, 3); + let mt: usize = pr_to(text, o, e, 3); + if (c == CI_RED) { + return V_RED; + } + if span_is(text, mf, mt, "DIRTY") { + return V_CONFLICT; + } + if (age_known(age) && (age >= stale)) { + return V_STALE; + } + if (c == CI_PENDING) { + return V_PENDING; + } + if span_is(text, mf, mt, "BEHIND") { + return V_BEHIND; + } + if (span_is(text, mf, mt, "CLEAN") || span_is(text, mf, mt, "HAS_HOOKS")) { + return V_READY; + } + return V_BLOCKED; +} + +pub fn worktree_verdict(w: &'static str, at: usize, age: u64, stale: u64, done: bool) -> u8 { + if (branch_from(w, at) >= w.len()) { + return V_DETACHED; + } + if done { + return V_MERGED; + } + if (age_known(age) && (age >= stale)) { + return V_STALE; + } + return V_NO_PR; +} + +pub fn is_alarm(v: u8) -> bool { + return (((v == V_RED) || (v == V_CONFLICT)) || (v == V_STALE)); +} + +pub fn why(v: u8) -> &'static str { + if (v == V_READY) { + return "ready: checks green, mergeable"; + } + if (v == V_PENDING) { + return "pending: checks running"; + } + if (v == V_BLOCKED) { + return "blocked: a rule of the base holds it (a required review or check, a draft)"; + } + if (v == V_BEHIND) { + return "behind: rebase it on its base"; + } + if (v == V_NO_PR) { + return "no PR yet"; + } + if (v == V_DETACHED) { + return "detached: on no branch, not a lane"; + } + if (v == V_RED) { + return "RED: a check failed"; + } + if (v == V_CONFLICT) { + return "CONFLICT: it does not merge into its base"; + } + if (v == V_MERGED) { + return "merged: a finished lane, its worktree can go"; + } + return "STALE: no commit or push for the stale minutes"; +} + +pub fn exit_code(prs_read: bool, alarms: u64) -> u8 { + if (prs_read == false) { + return EXIT_UNREAD; + } + if (alarms > 0) { + return EXIT_ALARM; + } + return EXIT_OK; +} + +pub fn exit_why(x: u8) -> &'static str { + if (x == EXIT_OK) { + return "exit 0: no lane is red, conflicting or stale"; + } + if (x == EXIT_ALARM) { + return "exit 1: a lane is RED, in CONFLICT or STALE"; + } + return "exit 2: no verdict -- gh gave no pull request list (not logged in, no network, no such repository)"; +} + +pub const PRS: &'static str = "[\n {\n \"headRefName\": \"docs/actors-not-threads\",\n \"isDraft\": false,\n \"mergeStateStatus\": \"BLOCKED\",\n \"number\": 1696,\n \"statusCheckRollup\": [\n {\n \"__typename\": \"CheckRun\",\n \"conclusion\": \"SUCCESS\",\n \"name\": \"Test\",\n \"status\": \"COMPLETED\",\n \"workflowName\": \"CI\"\n }\n ],\n \"updatedAt\": \"2026-10-08T17:34:24Z\"\n },\n {\n \"headRefName\": \"feat/trios-phd-no-image-train\",\n \"isDraft\": false,\n \"mergeStateStatus\": \"DIRTY\",\n \"number\": 958,\n \"statusCheckRollup\": [\n {\n \"__typename\": \"CheckRun\",\n \"conclusion\": \"FAILURE\",\n \"name\": \"Test\",\n \"status\": \"COMPLETED\",\n \"workflowName\": \"CI\"\n }\n ],\n \"title\": \"\\\"conclusion\\\": \\\"FAILURE\\\" in a title is not a check\",\n \"updatedAt\": \"2026-10-02T15:20:57Z\"\n }\n]"; + +pub const WTS: &'static str = "worktree /w/trios\nHEAD 55fb49f1b715c3a06387605b05b521b58b1bd012\nbranch refs/heads/actors-next\n\nworktree /w/lane-drain\nHEAD cd18f6f7de635c83b38a46d3c270227123a1965e\nbranch refs/heads/queen-drain-bound\n\nworktree /w/costflakes-base\nHEAD a6dd63b73de0c6da67c715899756550e2035756e\ndetached\n\nworktree /w/docs\nHEAD 9e7b583e889852126d16a31456166572ca45af6d\nbranch refs/heads/docs/actors-not-threads\n\nworktree /other/x\nHEAD 9e7b583e889852126d16a31456166572ca45af6d\nbranch refs/heads/queen-x\n"; + +pub const REFS: &'static str = "refs/heads/actors-next\t1791600000\nrefs/heads/docs/actors-not-threads\t1791580000\nrefs/heads/queen-drain\t1791000000\nrefs/heads/queen-drain-bound\t1791630000\n"; + +pub const V_NOT_RUN: u8 = 4; + +pub const V_VACUOUS: u8 = 1; + +pub const V_VERIFIED: u8 = 0; + +pub fn key_at(text: &'static str, from: usize, stop: usize, ind: usize, key: &'static str) -> usize { + let mut at: usize = from; + while ((at < stop) && (at < text.len())) { + let end: usize = line_end(text, at); + let v: usize = key_value(text, at, end, ind, key); + if (v <= end) { + return v; + } + at = (end + 1); + } + return (text.len() + 1); +} + +pub fn key_value(text: &'static str, at: usize, end: usize, ind: usize, key: &'static str) -> usize { + let v: usize = (((at + ind) + key.len()) + 4); + if (v > end) { + return (end + 1); + } + let mut k: usize = 0; + while (k < ind) { + if (text.as_bytes()[((at + k)) as usize] != 32) { + return (end + 1); + } + k = (k + 1); + } + if (text.as_bytes()[((at + ind)) as usize] != 34) { + return (end + 1); + } + if (span_is(text, ((at + ind) + 1), (((at + ind) + 1) + key.len()), key) == false) { + return (end + 1); + } + if (((text.as_bytes()[((v - 3)) as usize] != 34) || (text.as_bytes()[((v - 2)) as usize] != 58)) || (text.as_bytes()[((v - 1)) as usize] != 32)) { + return (end + 1); + } + return v; +} + +pub fn line_end(text: &'static str, at: usize) -> usize { + let mut k: usize = at; + while ((k < text.len()) && (text.as_bytes()[(k) as usize] != 10)) { + k = (k + 1); + } + return k; +} + +pub fn line_is(text: &'static str, at: usize, end: usize, ind: usize, lit: &'static str) -> bool { + let mut e: usize = end; + if ((e > at) && (text.as_bytes()[((e - 1)) as usize] == 44)) { + e = (e - 1); + } + if (e < (at + ind)) { + return false; + } + let mut k: usize = 0; + while (k < ind) { + if (text.as_bytes()[((at + k)) as usize] != 32) { + return false; + } + k = (k + 1); + } + return span_is(text, (at + ind), e, lit); +} + +pub fn name(v: u8) -> &'static str { + if (v == V_VERIFIED) { + return "verified"; + } + if (v == V_VACUOUS) { + return "vacuous"; + } + if (v == V_BEHIND) { + return "behind"; + } + if (v == V_BLOCKED) { + return "blocked"; + } + if (v == V_NOT_RUN) { + return "not-run"; + } + if (v == V_RED) { + return "RED"; + } + return "UNREAD"; +} + +pub fn span_is(text: &'static str, from: usize, to: usize, lit: &'static str) -> bool { + if ((to < from) || ((to - from) != lit.len())) { + return false; + } + let mut k: usize = 0; + while (k < lit.len()) { + if (text.as_bytes()[((from + k)) as usize] != lit.as_bytes()[(k) as usize]) { + return false; + } + k = (k + 1); + } + return true; +} + +pub fn value_from(text: &'static str, v: usize) -> usize { + if (v >= text.len()) { + return text.len(); + } + if (text.as_bytes()[(v) as usize] == 34) { + return (v + 1); + } + return v; +} + +pub fn value_to(text: &'static str, v: usize) -> usize { + if (v >= text.len()) { + return text.len(); + } + let mut e: usize = line_end(text, v); + if ((e > v) && (text.as_bytes()[((e - 1)) as usize] == 44)) { + e = (e - 1); + } + if (((text.as_bytes()[(v) as usize] == 34) && (e > (v + 1))) && (text.as_bytes()[((e - 1)) as usize] == 34)) { + e = (e - 1); + } + if (e < value_from(text, v)) { + return value_from(text, v); + } + return e; +} + diff --git a/scripts/gen_w320.py b/scripts/gen_w320.py deleted file mode 100644 index 7af41a28cd..0000000000 --- a/scripts/gen_w320.py +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env python3 -"""Batch append W320 tests and invariants to all IGLA specs.""" - -import glob - -def get_last_block(lines): - test_indices = [i for i, line in enumerate(lines) if line.strip().startswith('test ')] - inv_indices = [i for i, line in enumerate(lines) if line.strip().startswith('invariant ')] - if len(test_indices) < 2 or len(inv_indices) < 1: - return None - last_inv = inv_indices[-1] - tests_before = [t for t in test_indices if t < last_inv] - if len(tests_before) < 2: - return None - start = tests_before[-2] - end = len(lines) - for i in range(last_inv + 1, len(lines)): - stripped = lines[i].strip() - if stripped and (stripped.startswith('test ') or stripped.startswith('invariant ') or stripped.startswith('fn ') or stripped.startswith('pub ') or stripped.startswith('//') or stripped.startswith('type ') or stripped.startswith('struct ')): - end = i - break - return lines[start:end] - -def transform_block(block_lines): - text = '\n'.join(block_lines) - text = text.replace('_w319', '_w320') - text = text.replace('_w318', '_w320') - text = text.replace('_w317', '_w320') - return text + '\n' - -def process_specs(): - for spec_path in sorted(glob.glob('specs/igla/race/*.t27')) + sorted(glob.glob('specs/igla/coder/*.t27')): - with open(spec_path, 'r') as f: - lines = f.read().split('\n') - block = get_last_block(lines) - if not block: - print(f"SKIP: {spec_path}") - continue - new_block = transform_block(block) - with open(spec_path, 'a') as f: - f.write(new_block) - print(f"OK: {spec_path}") - -if __name__ == '__main__': - process_specs() diff --git a/specs/tri/lanes/lanes.t27 b/specs/tri/lanes/lanes.t27 new file mode 100644 index 0000000000..65d0c89657 --- /dev/null +++ b/specs/tri/lanes/lanes.t27 @@ -0,0 +1,575 @@ +// SPDX-License-Identifier: Apache-2.0 +; specs/tri/lanes/lanes.t27 -- what `tri lanes` reads about a loop's lanes, and its verdict per lane +; A night loop (ledger gHashTag/trios#1729) runs its work as lanes: one branch per lane, checked out in +; its own worktree of one clone, each with a pull request into the loop's integration branch +; (`actors-next-2` in gHashTag/trios). Every tick the loop checked by hand for the anomalies the ledger +; names: a red PR, a stale agent (45 min without a change), a lane that conflicts with its base. +; This spec holds every decision of `tri lanes`: the defaults, which pull requests and worktrees are +; read, how a check rollup is judged, how old a lane is, the verdict per lane, which verdicts are +; alarms, and the exit code. The command (cli/tri/src/main.rs, through `t27c gen-rust` in +; gen/rust/tri/lanes/lanes.rs) is glue: it runs gh and git, and prints what the functions return. +; Three texts are read: +; PRS `gh pr list --json ` as serde_json pretty-prints it: an array, a pull request +; ` {` ... ` }` at indent 2, its keys at 4, a check of its statusCheckRollup at 8. +; WTS `git worktree list --porcelain`: records split by a blank line, `worktree ` first, +; then `HEAD ` and `branch refs/heads/` (or `detached`). The first record is the +; clone itself, never a lane. +; REFS `git for-each-ref refs/heads`: `refs/heads/` TAB ``. +; MERGED `gh pr list --state merged --json headRefName` as gh prints it (one line, no spaces): the +; head branches of the base's merged pull requests. A worktree on one of them is a finished +; lane, not a stale one. +; A lane's age is the minutes since the later of its PR's updatedAt and its local branch's last +; commit. The ledger's rule is 45 min without a file change; this reads commits and pushes, not edits +; in a worktree, so a lane editing for 45 min without a commit reads as stale. +; The line readers are specs/tri/lab/receipt.t27's, imported. Fixtures are gh's real answer for +; gHashTag/trios PRs #1696 and #958 (2026-10-10), shortened, and the porcelain of the loop's clone. +; ASCII only (L3) +; phi^2 + 1/phi^2 = 3 | TRINITY + +module TriLanes; + +use tri::lab::receipt::line_end; +use tri::lab::receipt::span_is; +use tri::lab::receipt::line_is; +use tri::lab::receipt::key_value; +use tri::lab::receipt::key_at; +use tri::lab::receipt::value_from; +use tri::lab::receipt::value_to; + +// The defaults: the loop of gHashTag/trios#1729, its integration branch and the ledger's 45 minutes. +pub const OWNER : str = "gHashTag/"; +pub const REPO : str = "trios"; +pub const BASE : str = "actors-next-2"; +pub const STALE_MINUTES : u64 = 45; +pub const PR_FIELDS : str = "number,headRefName,isDraft,mergeStateStatus,statusCheckRollup,updatedAt"; +pub const PR_LIMIT : str = "200"; +pub const MERGED_FIELDS : str = "headRefName"; +pub const REF_FORMAT : str = "--format=%(refname)%09%(committerdate:unix)"; +/// The age of a lane with no time to read. +pub const AGE_UNKNOWN : u64 = 4294967295; +pub const UNKNOWN : str = "?"; + +// A pull request's checks. +pub const CI_GREEN : u8 = 0; +pub const CI_PENDING : u8 = 1; +pub const CI_RED : u8 = 2; + +// The verdict on one lane. RED, CONFLICT and STALE are the ledger's anomalies: alarms. +pub const V_READY : u8 = 0; +pub const V_PENDING : u8 = 1; +pub const V_BLOCKED : u8 = 2; +pub const V_BEHIND : u8 = 3; +pub const V_NO_PR : u8 = 4; +pub const V_DETACHED : u8 = 5; +pub const V_RED : u8 = 6; +pub const V_CONFLICT : u8 = 7; +pub const V_STALE : u8 = 8; +pub const V_MERGED : u8 = 9; + +pub const EXIT_OK : u8 = 0; +pub const EXIT_ALARM : u8 = 1; +pub const EXIT_UNREAD : u8 = 2; + +// A pull request row: these keys, printed `label=value`, then its checks, age and worktree. +pub const PR_CELLS : u8 = 4; + +invariant the_verdicts_are_numbered { assert V_READY == 0 and V_STALE == 8 and V_MERGED == 9 and CI_RED == 2 and EXIT_UNREAD == 2 and PR_CELLS == 4 and STALE_MINUTES == 45; } + +/// A repository named without an owner is gHashTag's. +pub fn needs_owner(repo: string) -> bool { + var i : usize = 0; + while (i < repo.len) { + if (repo[i] == 47) { return false; } + i = i + 1; + } + return true; +} + +pub fn pr_key(k: u8) -> string { + if (k == 0) { return "number"; } + if (k == 1) { return "headRefName"; } + if (k == 2) { return "isDraft"; } + return "mergeStateStatus"; +} + +pub fn pr_label(k: u8) -> string { + if (k == 0) { return "pr"; } + if (k == 1) { return "branch"; } + if (k == 2) { return "draft"; } + return "merge"; +} + +/// The offset of the next pull request ` {` at or after `at`; text.len at the array's end. +pub fn next_pr(text: string, at: usize) -> usize { + var k : usize = at; + while (k < text.len) { + const end : usize = line_end(text, k); + if (line_is(text, k, end, 2, "{")) { return k; } + if (line_is(text, k, end, 0, "]")) { return text.len; } + k = end + 1; + } + return text.len; +} + +/// The end of the pull request that opens at `at`: its ` }` line, or text.len. +pub fn pr_end(text: string, at: usize) -> usize { + var k : usize = line_end(text, at) + 1; + while (k < text.len) { + const end : usize = line_end(text, k); + if (line_is(text, k, end, 2, "}")) { return end; } + k = end + 1; + } + return text.len; +} + +pub fn pr_from(text: string, o: usize, e: usize, k: u8) -> usize { + return value_from(text, key_at(text, o, e, 4, pr_key(k))); +} + +pub fn pr_to(text: string, o: usize, e: usize, k: u8) -> usize { + return value_to(text, key_at(text, o, e, 4, pr_key(k))); +} + +/// Is a check's conclusion (CheckRun) or state (StatusContext) the word of a failed check? A +/// cancelled run is one: on the head commit it means the check did not run, and it blocks as a red one. +pub fn failed_word(text: string, from: usize, to: usize) -> bool { + return span_is(text, from, to, "FAILURE") or span_is(text, from, to, "ERROR") or span_is(text, from, to, "TIMED_OUT") or span_is(text, from, to, "CANCELLED") or span_is(text, from, to, "ACTION_REQUIRED") or span_is(text, from, to, "STARTUP_FAILURE") or span_is(text, from, to, "STALE"); +} + +/// The checks of the pull request text[o..e]: RED when one failed, PENDING when one has not finished +/// (a CheckRun status other than COMPLETED, a StatusContext state PENDING or EXPECTED), else GREEN +/// (no check at all included: nothing failed). +pub fn ci(text: string, o: usize, e: usize) -> u8 { + var pending : bool = false; + var at : usize = o; + while (at < e and at < text.len) { + const end : usize = line_end(text, at); + const c : usize = key_value(text, at, end, 8, "conclusion"); + const s : usize = key_value(text, at, end, 8, "state"); + const st : usize = key_value(text, at, end, 8, "status"); + if (c <= end and failed_word(text, value_from(text, c), value_to(text, c))) { return CI_RED; } + if (s <= end) { + if (failed_word(text, value_from(text, s), value_to(text, s))) { return CI_RED; } + if (span_is(text, value_from(text, s), value_to(text, s), "PENDING") or span_is(text, value_from(text, s), value_to(text, s), "EXPECTED")) { pending = true; } + } + if (st <= end and span_is(text, value_from(text, st), value_to(text, st), "COMPLETED") == false) { pending = true; } + at = end + 1; + } + if (pending) { return CI_PENDING; } + return CI_GREEN; +} + +pub fn ci_name(c: u8) -> string { + if (c == CI_GREEN) { return "green"; } + if (c == CI_PENDING) { return "pending"; } + return "RED"; +} + +/// Days from 1970-01-01 to the civil date y-m-d (Howard Hinnant's days_from_civil), for y >= 1970. +pub fn days_from_civil(y: u64, m: u64, d: u64) -> u64 { + var yy : u64 = y; + if (m <= 2) { yy = yy - 1; } + const era : u64 = yy / 400; + const yoe : u64 = yy - era * 400; + var mp : u64 = m + 9; + if (m > 2) { mp = m - 3; } + const doy : u64 = (153 * mp + 2) / 5 + d - 1; + const doe : u64 = yoe * 365 + yoe / 4 - yoe / 100 + doy; + return era * 146097 + doe - 719468; +} + +/// The decimal number text[from..to], or 0 when it is empty or holds anything but digits. +pub fn digits(text: string, from: usize, to: usize) -> u64 { + if (to <= from or to - from > 18) { return 0; } + var n : u64 = 0; + var i : usize = from; + while (i < to) { + if (text[i] < 48 or text[i] > 57) { return 0; } + n = n * 10 + ((text[i] as u64) - 48); + i = i + 1; + } + return n; +} + +/// Unix seconds of a UTC time `YYYY-MM-DDTHH:MM:SSZ` at text[from..to], as GitHub writes updatedAt; +/// 0 for anything else. +pub fn iso_seconds(text: string, from: usize, to: usize) -> u64 { + if (to - from != 20 or text[from + 4] != 45 or text[from + 7] != 45 or text[from + 10] != 84 or text[from + 13] != 58 or text[from + 16] != 58 or text[from + 19] != 90) { return 0; } + const y : u64 = digits(text, from, from + 4); + const mo : u64 = digits(text, from + 5, from + 7); + const d : u64 = digits(text, from + 8, from + 10); + if (y < 1970 or mo < 1 or mo > 12 or d < 1 or d > 31) { return 0; } + return days_from_civil(y, mo, d) * 86400 + digits(text, from + 11, from + 13) * 3600 + digits(text, from + 14, from + 16) * 60 + digits(text, from + 17, from + 19); +} + +pub fn pr_updated(text: string, o: usize, e: usize) -> u64 { + const v : usize = key_at(text, o, e, 4, "updatedAt"); + return iso_seconds(text, value_from(text, v), value_to(text, v)); +} + +/// Does the line w[at..] start with lit? +pub fn starts(w: string, at: usize, lit: string) -> bool { + if (at + lit.len > w.len) { return false; } + return span_is(w, at, at + lit.len, lit); +} + +/// The end of the worktree record that starts at `at`: the offset of its blank line, or w.len. +pub fn record_end(w: string, at: usize) -> usize { + var k : usize = at; + while (k < w.len) { + const end : usize = line_end(w, k); + if (end == k) { return k; } + k = end + 1; + } + return w.len; +} + +/// The next lane worktree at or after `at`: a `worktree ` line that does not open the porcelain +/// (that record is the clone itself) and whose path starts with dir. w.len when there is none. +pub fn next_worktree(w: string, at: usize, dir: string) -> usize { + var k : usize = at; + while (k < w.len) { + const end : usize = line_end(w, k); + if (k > 0 and starts(w, k, "worktree ") and starts(w, k + 9, dir)) { return k; } + k = end + 1; + } + return w.len; +} + +pub fn path_from(w: string, at: usize) -> usize { + if (at >= w.len) { return w.len; } + return at + 9; +} + +pub fn path_to(w: string, at: usize) -> usize { + if (at >= w.len) { return w.len; } + return line_end(w, at); +} + +/// Where the record's branch name starts (after `branch refs/heads/`); w.len when it has none. +pub fn branch_from(w: string, at: usize) -> usize { + const stop : usize = record_end(w, at); + var k : usize = at; + while (k < stop) { + if (starts(w, k, "branch refs/heads/")) { return k + 18; } + k = line_end(w, k) + 1; + } + return w.len; +} + +pub fn branch_to(w: string, at: usize) -> usize { + const b : usize = branch_from(w, at); + if (b >= w.len) { return w.len; } + return line_end(w, b); +} + +/// Is the worktree at `at` on the branch text[from..to]? +pub fn on_branch(w: string, at: usize, text: string, from: usize, to: usize) -> bool { + const b : usize = branch_from(w, at); + const be : usize = branch_to(w, at); + if (b >= w.len or be - b != to - from) { return false; } + var i : usize = 0; + while (i < to - from) { + if (w[b + i] != text[from + i]) { return false; } + i = i + 1; + } + return true; +} + +/// The lane worktree (under dir) checked out on the pull request's head branch; w.len when none is. +pub fn worktree_of(w: string, dir: string, text: string, o: usize, e: usize) -> usize { + var at : usize = next_worktree(w, 0, dir); + while (at < w.len) { + if (on_branch(w, at, text, pr_from(text, o, e, 1), pr_to(text, o, e, 1))) { return at; } + at = next_worktree(w, record_end(w, at), dir); + } + return w.len; +} + +/// Is the worktree at `at` the head of one of the pull requests? +pub fn has_pr(text: string, w: string, at: usize) -> bool { + var o : usize = next_pr(text, 0); + while (o < text.len) { + const e : usize = pr_end(text, o); + if (on_branch(w, at, text, pr_from(text, o, e, 1), pr_to(text, o, e, 1))) { return true; } + o = next_pr(text, e); + } + return false; +} + +/// Unix seconds of the last commit of the worktree's branch, from REFS; 0 when it has no branch or +/// REFS does not name it. +pub fn commit_time(refs: string, w: string, at: usize) -> u64 { + if (at >= w.len) { return 0; } + const b : usize = branch_from(w, at); + const be : usize = branch_to(w, at); + if (b >= w.len) { return 0; } + var k : usize = 0; + while (k < refs.len) { + const end : usize = line_end(refs, k); + if (starts(refs, k, "refs/heads/") and k + 11 + (be - b) < end and refs[k + 11 + (be - b)] == 9) { + var same : bool = true; + var i : usize = 0; + while (i < be - b) { + if (refs[k + 11 + i] != w[b + i]) { same = false; } + i = i + 1; + } + if (same) { return digits(refs, k + 12 + (be - b), end); } + } + k = end + 1; + } + return 0; +} + +/// Is the worktree's branch the head of a merged pull request in MERGED (`"headRefName":""`)? +pub fn merged(m: string, w: string, at: usize) -> bool { + const b : usize = branch_from(w, at); + const be : usize = branch_to(w, at); + if (b >= w.len) { return false; } + var k : usize = 0; + while (k + 15 + (be - b) + 1 <= m.len) { + if (span_is(m, k, k + 15, "\"headRefName\":\"") and m[k + 15 + (be - b)] == 34) { + var same : bool = true; + var i : usize = 0; + while (i < be - b) { + if (m[k + 15 + i] != w[b + i]) { same = false; } + i = i + 1; + } + if (same) { return true; } + } + k = k + 1; + } + return false; +} + +/// The later of two times; a lane moved when either its PR or its branch did. +pub fn later(a: u64, b: u64) -> u64 { + if (a > b) { return a; } + return b; +} + +/// Whole minutes from then to now; AGE_UNKNOWN when there is no time (0) or it is in the future. +pub fn age_minutes(now: u64, then: u64) -> u64 { + if (then == 0 or then > now) { return AGE_UNKNOWN; } + return (now - then) / 60; +} + +pub fn age_known(age: u64) -> bool { + return age != AGE_UNKNOWN; +} + +/// The verdict on a pull request lane. A failed check is RED and a conflict with the base is +/// CONFLICT, whatever its age; then a lane that has not moved for `stale` minutes is STALE, ready or +/// not (a ready lane nobody merged is the same anomaly). Then: checks running is PENDING, a branch +/// behind its base is BEHIND (rebase it), green and CLEAN (or HAS_HOOKS) is READY, and any other +/// merge state (BLOCKED, UNSTABLE, DRAFT, UNKNOWN) is BLOCKED by a rule of the base branch. +pub fn pr_verdict(text: string, o: usize, e: usize, age: u64, stale: u64) -> u8 { + const c : u8 = ci(text, o, e); + const mf : usize = pr_from(text, o, e, 3); + const mt : usize = pr_to(text, o, e, 3); + if (c == CI_RED) { return V_RED; } + if (span_is(text, mf, mt, "DIRTY")) { return V_CONFLICT; } + if (age_known(age) and age >= stale) { return V_STALE; } + if (c == CI_PENDING) { return V_PENDING; } + if (span_is(text, mf, mt, "BEHIND")) { return V_BEHIND; } + if (span_is(text, mf, mt, "CLEAN") or span_is(text, mf, mt, "HAS_HOOKS")) { return V_READY; } + return V_BLOCKED; +} + +/// The verdict on a worktree no open pull request comes from: DETACHED when it is on no branch (a +/// base snapshot, not a lane), MERGED when its branch was merged (a finished lane), STALE when its +/// branch has not moved for `stale` minutes, else NO_PR. +pub fn worktree_verdict(w: string, at: usize, age: u64, stale: u64, done: bool) -> u8 { + if (branch_from(w, at) >= w.len) { return V_DETACHED; } + if (done) { return V_MERGED; } + if (age_known(age) and age >= stale) { return V_STALE; } + return V_NO_PR; +} + +pub fn is_alarm(v: u8) -> bool { + return v == V_RED or v == V_CONFLICT or v == V_STALE; +} + +pub fn why(v: u8) -> string { + if (v == V_READY) { return "ready: checks green, mergeable"; } + if (v == V_PENDING) { return "pending: checks running"; } + if (v == V_BLOCKED) { return "blocked: a rule of the base holds it (a required review or check, a draft)"; } + if (v == V_BEHIND) { return "behind: rebase it on its base"; } + if (v == V_NO_PR) { return "no PR yet"; } + if (v == V_DETACHED) { return "detached: on no branch, not a lane"; } + if (v == V_RED) { return "RED: a check failed"; } + if (v == V_CONFLICT) { return "CONFLICT: it does not merge into its base"; } + if (v == V_MERGED) { return "merged: a finished lane, its worktree can go"; } + return "STALE: no commit or push for the stale minutes"; +} + +/// The exit code: no verdict when gh gave no pull request list (not even `[]`); ALARM on an anomaly. +pub fn exit_code(prs_read: bool, alarms: u64) -> u8 { + if (prs_read == false) { return EXIT_UNREAD; } + if (alarms > 0) { return EXIT_ALARM; } + return EXIT_OK; +} + +pub fn exit_why(x: u8) -> string { + if (x == EXIT_OK) { return "exit 0: no lane is red, conflicting or stale"; } + if (x == EXIT_ALARM) { return "exit 1: a lane is RED, in CONFLICT or STALE"; } + return "exit 2: no verdict -- gh gave no pull request list (not logged in, no network, no such repository)"; +} + +const PRS : string = "[\n {\n \"headRefName\": \"docs/actors-not-threads\",\n \"isDraft\": false,\n \"mergeStateStatus\": \"BLOCKED\",\n \"number\": 1696,\n \"statusCheckRollup\": [\n {\n \"__typename\": \"CheckRun\",\n \"conclusion\": \"SUCCESS\",\n \"name\": \"Test\",\n \"status\": \"COMPLETED\",\n \"workflowName\": \"CI\"\n }\n ],\n \"updatedAt\": \"2026-10-08T17:34:24Z\"\n },\n {\n \"headRefName\": \"feat/trios-phd-no-image-train\",\n \"isDraft\": false,\n \"mergeStateStatus\": \"DIRTY\",\n \"number\": 958,\n \"statusCheckRollup\": [\n {\n \"__typename\": \"CheckRun\",\n \"conclusion\": \"FAILURE\",\n \"name\": \"Test\",\n \"status\": \"COMPLETED\",\n \"workflowName\": \"CI\"\n }\n ],\n \"title\": \"\\\"conclusion\\\": \\\"FAILURE\\\" in a title is not a check\",\n \"updatedAt\": \"2026-10-02T15:20:57Z\"\n }\n]"; + +const WTS : string = "worktree /w/trios\nHEAD 55fb49f1b715c3a06387605b05b521b58b1bd012\nbranch refs/heads/actors-next\n\nworktree /w/lane-drain\nHEAD cd18f6f7de635c83b38a46d3c270227123a1965e\nbranch refs/heads/queen-drain-bound\n\nworktree /w/costflakes-base\nHEAD a6dd63b73de0c6da67c715899756550e2035756e\ndetached\n\nworktree /w/docs\nHEAD 9e7b583e889852126d16a31456166572ca45af6d\nbranch refs/heads/docs/actors-not-threads\n\nworktree /other/x\nHEAD 9e7b583e889852126d16a31456166572ca45af6d\nbranch refs/heads/queen-x\n"; + +const REFS : string = "refs/heads/actors-next\t1791600000\nrefs/heads/docs/actors-not-threads\t1791580000\nrefs/heads/queen-drain\t1791000000\nrefs/heads/queen-drain-bound\t1791630000\n"; + +// Scenario: the two pull requests are walked once each and their cells read. +test the_prs_are_walked_and_read { + const a : usize = next_pr(PRS, 0); + const ae : usize = pr_end(PRS, a); + const b : usize = next_pr(PRS, ae); + const be : usize = pr_end(PRS, b); + assert a == 2 and next_pr(PRS, be) == PRS.len; + assert span_is(PRS, pr_from(PRS, a, ae, 0), pr_to(PRS, a, ae, 0), "1696"); + assert span_is(PRS, pr_from(PRS, a, ae, 1), pr_to(PRS, a, ae, 1), "docs/actors-not-threads"); + assert span_is(PRS, pr_from(PRS, b, be, 3), pr_to(PRS, b, be, 3), "DIRTY"); + assert span_is(PRS, pr_from(PRS, b, be, 2), pr_to(PRS, b, be, 2), "false"); + assert next_pr("[]", 0) == 2; + assert next_pr("", 0) == 0; +} + +// Scenario: the checks of each pull request, and the check words one at a time. A title that holds +// the words is at indent 4, not a check. +test the_checks_are_judged { + const a : usize = next_pr(PRS, 0); + const ae : usize = pr_end(PRS, a); + const b : usize = next_pr(PRS, ae); + assert ci(PRS, a, ae) == CI_GREEN; + assert ci(PRS, b, pr_end(PRS, b)) == CI_RED; + const run : string = " \"conclusion\": null,\n \"status\": \"IN_PROGRESS\"\n"; + assert ci(run, 0, run.len) == CI_PENDING; + const ctx : string = " \"state\": \"PENDING\"\n"; + assert ci(ctx, 0, ctx.len) == CI_PENDING; + const err : string = " \"state\": \"ERROR\"\n"; + assert ci(err, 0, err.len) == CI_RED; + const cancelled : string = " \"conclusion\": \"CANCELLED\",\n \"status\": \"COMPLETED\"\n"; + assert ci(cancelled, 0, cancelled.len) == CI_RED; + const skipped : string = " \"conclusion\": \"SKIPPED\",\n \"status\": \"COMPLETED\"\n"; + assert ci(skipped, 0, skipped.len) == CI_GREEN; + const title : string = " \"title\": \"FAILURE\",\n \"conclusion\": \"FAILURE\"\n"; + assert ci(title, 0, title.len) == CI_GREEN; + assert ci("", 0, 0) == CI_GREEN; +} + +// Scenario: GitHub's UTC times as Unix seconds, against values `date -u -j -f` gives. +test utc_times_are_read { + assert iso_seconds("1970-01-01T00:00:00Z", 0, 20) == 0; + assert iso_seconds("2026-10-08T17:34:24Z", 0, 20) == 1791480864; + assert iso_seconds("2024-02-29T23:59:59Z", 0, 20) == 1709251199; + assert iso_seconds("2026-10-08 17:34:24Z", 0, 20) == 0; + assert iso_seconds("2026-10-08T17:34:24", 0, 19) == 0; + assert iso_seconds("2026-10-08T17:34:24+", 0, 20) == 0; + assert iso_seconds("2026-13-08T17:34:24Z", 0, 20) == 0; + const a : usize = next_pr(PRS, 0); + assert pr_updated(PRS, a, pr_end(PRS, a)) == 1791480864; + assert digits("12x", 0, 3) == 0 and digits("", 0, 0) == 0 and digits("045", 0, 3) == 45; +} + +// Scenario: the worktrees: the clone's own record is skipped, --dir is a path prefix, a detached one +// has no branch, and a PR's worktree is found by its head branch. +test the_worktrees_are_read { + const l : usize = next_worktree(WTS, 0, "/w/"); + assert span_is(WTS, path_from(WTS, l), path_to(WTS, l), "/w/lane-drain"); + assert span_is(WTS, branch_from(WTS, l), branch_to(WTS, l), "queen-drain-bound"); + const d : usize = next_worktree(WTS, record_end(WTS, l), "/w/"); + assert branch_from(WTS, d) == WTS.len; + const x : usize = next_worktree(WTS, record_end(WTS, d), "/w/"); + assert span_is(WTS, branch_from(WTS, x), branch_to(WTS, x), "docs/actors-not-threads"); + assert next_worktree(WTS, record_end(WTS, x), "/w/") == WTS.len; + assert next_worktree(WTS, record_end(WTS, x), "") < WTS.len; + const a : usize = next_pr(PRS, 0); + const ae : usize = pr_end(PRS, a); + assert worktree_of(WTS, "/w/", PRS, a, ae) == x; + const b : usize = next_pr(PRS, ae); + assert worktree_of(WTS, "/w/", PRS, b, pr_end(PRS, b)) == WTS.len; + assert has_pr(PRS, WTS, x) == true; + assert has_pr(PRS, WTS, l) == false; + assert path_from(WTS, WTS.len) == WTS.len; +} + +// Scenario: a branch's last commit from REFS, by its whole name: queen-drain is not queen-drain-bound. +test a_branch_time_is_its_own { + const l : usize = next_worktree(WTS, 0, "/w/"); + assert commit_time(REFS, WTS, l) == 1791630000; + const d : usize = next_worktree(WTS, record_end(WTS, l), "/w/"); + assert commit_time(REFS, WTS, d) == 0; + const x : usize = next_worktree(WTS, record_end(WTS, d), "/w/"); + assert commit_time(REFS, WTS, x) == 1791580000; + assert commit_time("refs/heads/queen-drain\t1791000000\n", WTS, l) == 0; + assert commit_time("refs/heads/queen-drain-bound71791000000\n", WTS, l) == 0; + assert commit_time(REFS, WTS, WTS.len) == 0; + assert later(3, 5) == 5 and later(5, 3) == 5; + assert age_minutes(1791630000 + 2700, 1791630000) == 45; + assert age_minutes(1791630000 + 2699, 1791630000) == 44; + assert age_minutes(100, 0) == AGE_UNKNOWN; + assert age_minutes(100, 200) == AGE_UNKNOWN; + assert age_known(AGE_UNKNOWN) == false and age_known(0) == true; +} + +// Scenario: a merged lane is found by its whole branch name in gh's compact answer. +test a_merged_lane_is_its_own_branch { + const l : usize = next_worktree(WTS, 0, "/w/"); + const d : usize = next_worktree(WTS, record_end(WTS, l), "/w/"); + assert merged("[{\"headRefName\":\"queen-drain-bound\"},{\"headRefName\":\"x\"}]", WTS, l) == true; + assert merged("[{\"headRefName\":\"x\"},{\"headRefName\":\"queen-drain-bound\"}]", WTS, l) == true; + assert merged("[{\"headRefName\":\"queen-drain-bound-2\"}]", WTS, l) == false; + assert merged("[{\"headRefName\":\"queen-drain\"}]", WTS, l) == false; + assert merged("[]", WTS, l) == false; + assert merged("", WTS, l) == false; + assert merged("[{\"headRefName\":\"\"}]", WTS, d) == false; +} + +// Scenario: the lanes judged. #958 fails a check and conflicts: RED first. #1696 is green and BLOCKED: +// STALE at 45 minutes, BLOCKED at 44 and with no age. +test the_lanes_are_judged { + const a : usize = next_pr(PRS, 0); + const ae : usize = pr_end(PRS, a); + const b : usize = next_pr(PRS, ae); + const be : usize = pr_end(PRS, b); + assert pr_verdict(PRS, b, be, 0, STALE_MINUTES) == V_RED; + assert pr_verdict(PRS, a, ae, 45, STALE_MINUTES) == V_STALE; + assert pr_verdict(PRS, a, ae, 44, STALE_MINUTES) == V_BLOCKED; + assert pr_verdict(PRS, a, ae, AGE_UNKNOWN, STALE_MINUTES) == V_BLOCKED; + const dirty : string = " {\n \"mergeStateStatus\": \"DIRTY\"\n }"; + assert pr_verdict(dirty, 0, dirty.len, 900, STALE_MINUTES) == V_CONFLICT; + const clean : string = " {\n \"mergeStateStatus\": \"CLEAN\"\n }"; + assert pr_verdict(clean, 0, clean.len, 3, STALE_MINUTES) == V_READY; + assert pr_verdict(clean, 0, clean.len, 46, STALE_MINUTES) == V_STALE; + const behind : string = " {\n \"mergeStateStatus\": \"BEHIND\"\n }"; + assert pr_verdict(behind, 0, behind.len, 3, STALE_MINUTES) == V_BEHIND; + const running : string = " {\n \"mergeStateStatus\": \"CLEAN\",\n \"status\": \"QUEUED\"\n }"; + assert pr_verdict(running, 0, running.len, 3, STALE_MINUTES) == V_PENDING; + const l : usize = next_worktree(WTS, 0, "/w/"); + const d : usize = next_worktree(WTS, record_end(WTS, l), "/w/"); + assert worktree_verdict(WTS, l, 10, STALE_MINUTES, false) == V_NO_PR; + assert worktree_verdict(WTS, l, 45, STALE_MINUTES, false) == V_STALE; + assert worktree_verdict(WTS, l, 900, STALE_MINUTES, true) == V_MERGED; + assert worktree_verdict(WTS, d, 900, STALE_MINUTES, true) == V_DETACHED; +} + +// Scenario: alarms, exits, the owner rule and the words. +test alarms_exits_and_words { + assert is_alarm(V_RED) and is_alarm(V_CONFLICT) and is_alarm(V_STALE); + assert is_alarm(V_READY) == false and is_alarm(V_PENDING) == false and is_alarm(V_BLOCKED) == false; + assert is_alarm(V_BEHIND) == false and is_alarm(V_NO_PR) == false and is_alarm(V_DETACHED) == false and is_alarm(V_MERGED) == false; + assert why(V_MERGED)[0] == 109; + assert exit_code(false, 0) == EXIT_UNREAD; + assert exit_code(false, 3) == EXIT_UNREAD; + assert exit_code(true, 1) == EXIT_ALARM; + assert exit_code(true, 0) == EXIT_OK; + assert needs_owner("trios") == true and needs_owner("gHashTag/t27") == false; + assert why(V_RED)[0] == 82 and why(V_CONFLICT)[0] == 67 and why(V_STALE)[0] == 83; + assert why(V_READY)[0] == 114 and why(V_NO_PR)[0] == 110 and why(V_DETACHED)[0] == 100; + assert why(V_PENDING)[0] == 112 and why(V_BLOCKED)[2] == 111 and why(V_BEHIND)[2] == 104; + assert exit_why(EXIT_OK)[5] == 48 and exit_why(EXIT_ALARM)[5] == 49 and exit_why(EXIT_UNREAD)[5] == 50; + assert span_is(ci_name(CI_RED), 0, 3, "RED") and span_is(pr_label(1), 0, 6, "branch"); +} diff --git a/tools/census/fetches.txt b/tools/census/fetches.txt index d7a9f0a3c5..2f80db8ed4 100644 --- a/tools/census/fetches.txt +++ b/tools/census/fetches.txt @@ -1,9 +1,9 @@ BOUNDED GITHUB FETCHES, AND WHETHER EACH CAN TELL A PAGE FROM A TOTAL files read 48 - lines naming either spelling 76 + lines naming either spelling 78 of those, FETCH SITES 35 - refused as not a fetch 41 (--excluded prints them) + refused as not a fetch 43 (--excluded prints them) complete by construction: --paginate 11 @@ -35,7 +35,7 @@ BOUNDED GITHUB FETCHES, AND WHETHER EACH CAN TELL A PAGE FROM A TOTAL exactly that. 2 of the 5 in that bucket are that shape. The exclusions are the other half of the reading. Taking every line that - merely NAMES `--limit` or `per_page=` reports 76 sites in a crate that has + merely NAMES `--limit` or `per_page=` reports 78 sites in a crate that has 35: prose, doc comments, a marker table and test assertions. A matcher that swallowed them would be describing its input.