Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions .trinity/seals/pr_TriPrPreflight.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"built_by": "t27c-bootstrap@0.5.2+b258b9007",
"built_by": "t27c-bootstrap@0.5.2+cf2d61eb1",
"closure": [
{
"spec": "specs/tri/lab/receipt.t27",
Expand All @@ -11,23 +11,23 @@
}
],
"config": "gen=c,rust,verilog,zig;tests=zig",
"gen_hash_c": "sha256:7b05ed840e722e114f7e7427e7808981f9fe5a01c2b45d5908c6dfef0394e952",
"gen_hash_rust": "sha256:d6ab06097d38589835a474ff94c2b97cccb59b0185844c936bbfd858b0a723de",
"gen_hash_verilog": "sha256:39f7e63349e6fe4ee7aa4e3fb71554cf09a3a9147f5faff0494d599239f4bb9a",
"gen_hash_zig": "sha256:6b0f5756fe610ad85c1b745922d896ce7f5d6a1f33668858c2964f6afe299465",
"gen_hash_c": "sha256:d6bb2e6658d4a30f087023af7ad08f5b7d4dff64e94deec05271f4bea855336d",
"gen_hash_rust": "sha256:9ae5246d4f986044240f97454eaf9967c685e9abdef001aad188993e7c9d2c8f",
"gen_hash_verilog": "sha256:1f3c5f2e2a3e752f779928e6dca2aa9b5d6f039af926aa7a6b01407acb10cc25",
"gen_hash_zig": "sha256:4c5b9c291c73cd30bf88628f0efe8cd0065df7ab687c2414e5bdc9e6229217d9",
"module": "TriPrPreflight",
"ring": 12,
"seal_schema": 2,
"sealed_at": "2026-10-19T18:07:06Z",
"sealed_at": "2026-10-20T01:43:21Z",
"sealed_by": "t27c-bootstrap@0.5.2",
"spec_hash": "sha256:ce7132cb0b49246d3d86bdde48ecad5bc41f252d721dbfa0b1912306b7a39897",
"spec_hash": "sha256:f9b7b84c125b47c1b5285bb98ab5532987bdef785319c0a3c60bbf7e87f5b6f6",
"spec_path": "specs/tri/pr/preflight.t27",
"tests": {
"failed": 0,
"failing": [],
"forced": false,
"passed": 24,
"total": 24,
"passed": 25,
"total": 25,
"vacuous": 0
},
"toolchain": {
Expand Down
52 changes: 0 additions & 52 deletions cli/tri/src/inflight.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,34 +36,6 @@ use anyhow::{Context, Result};
use clap::Args;
use std::process::Command;

/// What `git merge` actually said. The exit code is the only channel that
/// separates these; both leave unmerged paths behind.
#[derive(Debug, PartialEq, Eq)]
pub enum Merge {
/// Merged cleanly.
Done,
/// Genuine conflict: the merge started and stopped in the middle.
Conflicted,
/// Refused to start. A merge was already in flight, or the tree was dirty.
/// Nothing about this run's merge is knowable from the working tree.
Refused,
/// Some other exit code. Reported as itself, never folded into a neighbour.
Other(i32),
}

/// `git merge` answers with more than success and failure.
///
/// 128 is refusal, not conflict, and the distinction is the whole point: after
/// a refusal there is no merge of yours to resolve.
pub fn merge_outcome(rc: i32) -> Merge {
match rc {
0 => Merge::Done,
1 => Merge::Conflicted,
128 => Merge::Refused,
n => Merge::Other(n),
}
}

/// How many parents `git log -1 --format=%p` reported.
///
/// The root commit prints an empty line, which is zero parents and not a
Expand Down Expand Up @@ -185,30 +157,6 @@ pub fn run(a: &Merging) -> Result<()> {
}
}

#[cfg(test)]
mod outcome_tests {
use super::*;

#[test]
fn refusal_is_not_conflict() {
assert_eq!(merge_outcome(1), Merge::Conflicted);
assert_eq!(merge_outcome(128), Merge::Refused);
assert_ne!(merge_outcome(1), merge_outcome(128));
}

#[test]
fn success_is_its_own_answer() {
assert_eq!(merge_outcome(0), Merge::Done);
}

#[test]
fn an_unknown_code_stays_unknown() {
// Never folded into the nearest known neighbour.
assert_eq!(merge_outcome(2), Merge::Other(2));
assert_eq!(merge_outcome(129), Merge::Other(129));
}
}

#[cfg(test)]
mod parent_tests {
use super::*;
Expand Down
40 changes: 40 additions & 0 deletions cli/tri/src/prcheck.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ use clap::Subcommand;
use std::collections::{BTreeMap, BTreeSet};
use std::process::Command;

#[path = "../../../gen/rust/tri/pr/preflight.rs"] #[allow(dead_code, unused_parens, unused_mut, unused_variables, non_snake_case, unused_assignments, clippy::all)]
mod pf; // t27c gen-rust of specs/tri/pr/preflight.t27 (#10190, #11125): every rule of `tri pr preflight`

#[derive(Subcommand)]
pub enum PrCmd {
/// Classify every failing check and say plainly whether it is safe to merge.
Expand Down Expand Up @@ -119,10 +122,17 @@ pub enum PrCmd {
#[arg(long)]
why: bool,
},
/// Run the rules of specs/tri/pr/preflight.t27 on this branch against origin/master, before `gh pr create`. Exit 0 every rule passed, 1 a rule FAILed, 2 evidence could not be read. T27C names the t27c program (default t27c on PATH).
Preflight {
/// Leave out the corpus ratchet, which takes minutes.
#[arg(long)]
quick: bool,
},
}

pub fn run(cmd: &PrCmd) -> Result<()> {
match cmd {
PrCmd::Preflight { quick } => std::process::exit(preflight(*quick)?),
PrCmd::Ready {
number,
repo,
Expand Down Expand Up @@ -153,6 +163,36 @@ pub fn run(cmd: &PrCmd) -> Result<()> {
}
}

/// `tri pr preflight` (#11126): every rule is specs/tri/pr/preflight.t27. This runs each command the spec names (`sh -c`; SPEC, T27C and TRI in the environment), frames the run as one evidence section, and prints the spec's verdict; the exit is the spec's.
fn preflight(quick: bool) -> Result<i32> {
let (t27c, tri) = (std::env::var("T27C").unwrap_or("t27c".into()), std::env::current_exe()?);
let (mut ev, mut c) = (String::new(), "");
if let Some(o) = Command::new("git").args(["rev-parse", "--show-toplevel"]).output().ok().filter(|o| o.status.success()) { std::env::set_current_dir(String::from_utf8_lossy(&o.stdout).trim())?; }
for k in (0..pf::STEPS).filter(|&k| pf::step_runs(k, quick)) {
let each = pf::step_each(k);
for n in (0..if each { pf::entries(c) } else { 1 }).filter(|&n| !each || pf::is_target(c, n)) {
let spec = if each { &c[pf::path_start(c, n)..pf::path_end(c, n)] } else { "" };
let (x, out) = match Command::new("sh").args(["-c", pf::step_cmd(k)]).env("SPEC", spec).env("T27C", &t27c).env("TRI", &tri).output() {
Ok(o) => (o.status.code().unwrap_or(255), format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr))),
Err(e) => (127, e.to_string()),
};
ev += &format!("@@ preflight {k} {n} {x} {}\n{out}\n", out.len());
eprintln!("preflight step {k} entry {n}: exit {x}");
if k == pf::STEP_CHANGES && x == 0 { c = out.leak(); }
}
}
let ev: &'static str = ev.leak();
for r in 0..pf::RULES {
let (v, n) = (pf::rule_verdict(c, ev, r, quick), pf::rule_at(c, ev, r, quick));
println!("{:<8}{}{}", pf::rule_name(r), pf::name(v), if v == pf::P_PASS { String::new() } else { format!(" {}", pf::rule_why(r, v)) });
let k = pf::evidence_step(c, ev, r, n);
if v != pf::P_PASS && k < pf::STEPS { println!("{}", String::from_utf8_lossy(&ev.as_bytes()[pf::evidence_from(ev, k, n)..pf::evidence_to(ev, k, n)])); }
}
let x = pf::preflight_exit(c, ev, quick);
println!("{}", pf::preflight_exit_why(x));
Ok(x as i32)
}

/// Check that what the pull request introduced is present in the default
/// branch, file by file. Status is not content: a merged pull request whose
/// stack-mate was auto-closed leaves a list that reads as success.
Expand Down
23 changes: 13 additions & 10 deletions docs/now/2026-10-18-pr-preflight.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ its author could have seen.
## What

- `specs/tri/pr/preflight.t27` owns every rule, every verdict (PASS, FAIL, UNREAD,
NOT RUN) and every message. 19 tests, 0 vacuous, 1 comptime invariant.
NOT RUN) and every message. 25 tests, 0 vacuous, 1 comptime invariant.
- The evidence is data: each command the spec names runs once, per changed spec
where the rule is per-spec. Its output is framed as
`@@ preflight <step> <entry> <exit> <len>`, and the spec reads the frames. A
Expand All @@ -20,16 +20,18 @@ its author could have seen.
2 evidence could not be read. `--quick` leaves out the corpus ratchet. The
publisher gate runs it in the bee's worktree, with origin/master merged in,
and skips a branch that did not exit 0.
- **The glue is parked, not in this change.** It is 38 lines in
`cli/tri/src/prcheck.rs` and 30 in `tools/queen/publish.py`. It was built and
replayed on the lab, but neither file is in `tools/policy/foreign-exceptions.txt`
on master, and `check_budget` refuses any net growth of hand-written foreign
lines (owner rule 2026-10-09). So lefthook refuses the commit and no label
lifts it. Landing the glue needs the owner: either the exceptions plus an
offsetting deletion, or a t27-native way to run commands.
- **The glue landed in #11126.** `preflight` in `cli/tri/src/prcheck.rs` is 40 lines
(a `#[path]` mount of `gen/rust/tri/pr/preflight.rs`, the `Preflight` subcommand and the
loop). It loops to the spec's STEPS and RULES, so a step added to the spec runs without a
glue change. It was parked until then because `check_budget` refuses any net growth of
hand-written foreign lines (owner rule 2026-10-09) and no label lifts it; it landed
net-zero, paid by deleting the 52 lines of `inflight.rs` that `cargo check -p tri` listed
as never used (`Merge`, `merge_outcome` and their three unit tests). The Queen's side,
about 30 lines in `tools/queen/publish.py`, is not part of that change.

The rules are changes, base, check, tests, seal, types, dupes, ledgers, suite,
census and kept. Only a branch's own defects fail it:
census, budget (the own-language foreign-line budget, #11125), devhome (the
developer-home-path scan, #11125) and kept. Only a branch's own defects fail it:

- dupes compares `dupe_scan.py` on the branch with the same scan on a clean
origin/master worktree. A finding master already prints is not the branch's.
Expand All @@ -49,7 +51,8 @@ census and kept. Only a branch's own defects fail it:

## Open

- Land the glue (see above); until then nothing calls this spec.
- `tools/queen/publish.py` does not call `tri pr preflight` yet; the publisher gate that
skips a branch that did not exit 0 is still to land, net-zero as well.
- t27b's JIT runs on arm64 only and the lab is x86_64, so this PR's own
`t27b_expectations.json` row is left to the tool after an arm64 run measures it.
- cli-tri (Rust build) and "documented t27c subcommands" are outside the spec rules.
4 changes: 2 additions & 2 deletions gen/rust/tri/pr/preflight.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Generated from .t27 spec
// DO NOT EDIT — generated by t27c
// NOT LOWERED BY THIS BACKEND: 24 test(s), 1 invariant(s), 0 function(s) with no body.
// NOT LOWERED BY THIS BACKEND: 25 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.

Expand Down Expand Up @@ -144,7 +144,7 @@ pub fn step_cmd(k: u8) -> &'static str {
return "\"$T27C\" suite --repo-root . --ratchet --corpus-only 2>&1";
}
if (k == STEP_DUPES_BASE) {
return "d=\"$(mktemp -d)\"; git worktree add -q --detach \"$d\" origin/master 2>&1 && (cd \"$d\" && python3 tools/dupe_scan.py 2>&1); x=$?; git worktree remove --force \"$d\" 2>&1; exit $x";
return "d=\"$(mktemp -d \"${TMPDIR:-/tmp}/dupes.XXXXXX\")\" || exit 3; git worktree add -q --detach \"$d\" origin/master 2>&1 && (cd \"$d\" && python3 tools/dupe_scan.py 2>&1); x=$?; git worktree remove --force \"$d\" 2>&1; exit $x";
}
if (k == STEP_BUDGET) {
return "{ b=\"$(git merge-base origin/master HEAD)\" && d=\"$(mktemp -d \"${TMPDIR:-/tmp}/budget.XXXXXX\")\" && git show \"${b}:gen/c/policy/own_language.c\" > \"$d/gate.c\" && printf '#include \"gate.c\"\\n#include <stdio.h>\\nint main(void){static char b[1<<22],o[1<<22];unsigned n=check_budget((uint8_t*)b,fread(b,1,sizeof b,stdin),(uint8_t*)o,sizeof o-1);fputs(o,stdout);return n!=0;}\\n' | cc -w -I \"$d\" -x c -o \"$d/budget\" - && git diff --numstat --no-renames \"${b}...HEAD\" > \"$d/in\" && \"$d/budget\" < \"$d/in\"; x=$?; rm -rf \"$d\"; exit $x; } 2>&1";
Expand Down
12 changes: 11 additions & 1 deletion specs/tri/pr/preflight.t27
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ pub fn step_cmd(k: u8) -> string {
if (k == STEP_SUITE_ROWS) { return "cat docs/reports/suite_expectations.json 2>&1"; }
if (k == STEP_T27B_ROWS) { return "cat docs/reports/t27b_expectations.json 2>&1"; }
if (k == STEP_SUITE) { return "\"$T27C\" suite --repo-root . --ratchet --corpus-only 2>&1"; }
if (k == STEP_DUPES_BASE) { return "d=\"$(mktemp -d)\"; git worktree add -q --detach \"$d\" origin/master 2>&1 && (cd \"$d\" && python3 tools/dupe_scan.py 2>&1); x=$?; git worktree remove --force \"$d\" 2>&1; exit $x"; }
if (k == STEP_DUPES_BASE) { return "d=\"$(mktemp -d \"${TMPDIR:-/tmp}/dupes.XXXXXX\")\" || exit 3; git worktree add -q --detach \"$d\" origin/master 2>&1 && (cd \"$d\" && python3 tools/dupe_scan.py 2>&1); x=$?; git worktree remove --force \"$d\" 2>&1; exit $x"; }
// The gate is the merge base's own gen/c/policy/own_language.c, as the CI step reads it from BASE; the numstat goes
// to a file first, so a git that fails leaves the gate no input to pass on.
if (k == STEP_BUDGET) { return "{ b=\"$(git merge-base origin/master HEAD)\" && d=\"$(mktemp -d \"${TMPDIR:-/tmp}/budget.XXXXXX\")\" && git show \"${b}:gen/c/policy/own_language.c\" > \"$d/gate.c\" && printf '#include \"gate.c\"\\n#include <stdio.h>\\nint main(void){static char b[1<<22],o[1<<22];unsigned n=check_budget((uint8_t*)b,fread(b,1,sizeof b,stdin),(uint8_t*)o,sizeof o-1);fputs(o,stdout);return n!=0;}\\n' | cc -w -I \"$d\" -x c -o \"$d/budget\" - && git diff --numstat --no-renames \"${b}...HEAD\" > \"$d/in\" && \"$d/budget\" < \"$d/in\"; x=$?; rm -rf \"$d\"; exit $x; } 2>&1"; }
Expand Down Expand Up @@ -1073,6 +1073,16 @@ test the_budget_gate_is_compiled_from_the_merge_base {
assert opens_with(step_cmd(STEP_DEVHOME), 0, step_cmd(STEP_DEVHOME).len, "python3 -I tools/check_devhome_paths.py");
}

// Scenario: the scratch checkout of origin/master is made under TMPDIR, as the budget's scratch dir is: macOS mktemp -d with no
// template ignores TMPDIR, so inside a sandbox it fails and git was handed an empty path (BUG, exit 134). A mktemp that fails ends the
// step with 3, never with the 1 that dupe_scan's own findings exit.
test the_dupes_base_scratch_is_made_under_tmpdir {
const s : string = step_cmd(STEP_DUPES_BASE);
assert find_in(s, 0, s.len, "mktemp -d \"${TMPDIR:-/tmp}/dupes.XXXXXX\"") < s.len;
assert find_in(s, 0, s.len, " || exit 3; git worktree add ") < s.len;
assert find_in(s, 0, s.len, "mktemp -d)") == s.len + 1;
}

// The home-path scan's four finding shapes (tools/check_devhome_paths.py), for a tree that holds one of each.
const HOME_C : string = "A\tdocs/new.md\nM\tspecs/x/grew.t27\nM\tspecs/x/shrank.t27\nD\tspecs/x/gone.t27\nM\tdocs/a.md\n";
const HOME_FIND : string = "@@ preflight 18 0 1 371\nFAIL new docs/new.md (1 occurrence(s))\nFAIL grew specs/x/grew.t27 1 -> 2\nFAIL shrank specs/x/shrank.t27 2 -> 1 -- re-bless to pin the lower count\nFAIL fixed specs/x/gone.t27 was 3 -- remove it from the baseline\n\n Use `git rev-parse --show-toplevel`, or an environment variable the\n caller sets. Re-bless with: python3 tools/check_devhome_paths.py --bless\n\n";
Expand Down
Loading