diff --git a/.trinity/seals/pr_TriPrPreflight.json b/.trinity/seals/pr_TriPrPreflight.json index 258e2be404..b4b451cf8c 100644 --- a/.trinity/seals/pr_TriPrPreflight.json +++ b/.trinity/seals/pr_TriPrPreflight.json @@ -1,5 +1,5 @@ { - "built_by": "t27c-bootstrap@0.5.2+fdb6b28dd", + "built_by": "t27c-bootstrap@0.5.2+b258b9007", "closure": [ { "spec": "specs/tri/lab/receipt.t27", @@ -11,27 +11,27 @@ } ], "config": "gen=c,rust,verilog,zig;tests=zig", - "gen_hash_c": "sha256:d85f440682c707fd126ed726dc9a8c3713f33fa67528953a532a9eb40da55b81", - "gen_hash_rust": "sha256:d945336cc8dded5d2368a118984c549c629b7428fcd054089b7db46cfaa4ced2", - "gen_hash_verilog": "sha256:68f5baedb845f39d78c0aed0c6100be3574cd1d7a3bccd09b9e1d9042e589850", - "gen_hash_zig": "sha256:bf8cabe6e83216400e8702e979de7cd26cb4bfd5919a7f6fc557ca621092709a", + "gen_hash_c": "sha256:7b05ed840e722e114f7e7427e7808981f9fe5a01c2b45d5908c6dfef0394e952", + "gen_hash_rust": "sha256:d6ab06097d38589835a474ff94c2b97cccb59b0185844c936bbfd858b0a723de", + "gen_hash_verilog": "sha256:39f7e63349e6fe4ee7aa4e3fb71554cf09a3a9147f5faff0494d599239f4bb9a", + "gen_hash_zig": "sha256:6b0f5756fe610ad85c1b745922d896ce7f5d6a1f33668858c2964f6afe299465", "module": "TriPrPreflight", "ring": 12, "seal_schema": 2, - "sealed_at": "2026-10-18T05:09:07Z", + "sealed_at": "2026-10-19T18:07:06Z", "sealed_by": "t27c-bootstrap@0.5.2", - "spec_hash": "sha256:24634647a1245ac9d776735bd4323f524d71f7c52f0928ce31066a114b68f409", + "spec_hash": "sha256:ce7132cb0b49246d3d86bdde48ecad5bc41f252d721dbfa0b1912306b7a39897", "spec_path": "specs/tri/pr/preflight.t27", "tests": { "failed": 0, "failing": [], "forced": false, - "passed": 19, - "total": 19, + "passed": 24, + "total": 24, "vacuous": 0 }, "toolchain": { - "test_runner": "sha256:1982ae948e24d00c5d221ccec12dfce1eb01204b53e37b0f857f93b16d64629d", + "test_runner": "sha256:8512b80da533b79289110fca4a65f3336abd7a059ca4b72ea595d5e33ff8ad10", "zig": "0.16.0" } } \ No newline at end of file diff --git a/gen/rust/tri/pr/preflight.rs b/gen/rust/tri/pr/preflight.rs index 293d57cf52..714ce5928d 100644 --- a/gen/rust/tri/pr/preflight.rs +++ b/gen/rust/tri/pr/preflight.rs @@ -1,6 +1,6 @@ // Generated from .t27 spec // DO NOT EDIT — generated by t27c -// NOT LOWERED BY THIS BACKEND: 19 test(s), 1 invariant(s), 0 function(s) with no body. +// NOT LOWERED BY THIS BACKEND: 24 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. @@ -38,9 +38,13 @@ pub const STEP_SUITE: u8 = 15; pub const STEP_DUPES_BASE: u8 = 16; -pub const STEPS: u8 = 17; +pub const STEP_BUDGET: u8 = 17; -pub const NO_STEP: u8 = 17; +pub const STEP_DEVHOME: u8 = 18; + +pub const STEPS: u8 = 19; + +pub const NO_STEP: u8 = 19; pub const R_CHANGES: u8 = 0; @@ -62,9 +66,13 @@ pub const R_SUITE: u8 = 8; pub const R_CENSUS: u8 = 9; -pub const R_COUNT: u8 = 10; +pub const R_BUDGET: u8 = 10; + +pub const R_DEVHOME: u8 = 11; -pub const RULES: u8 = 11; +pub const R_COUNT: u8 = 12; + +pub const RULES: u8 = 13; pub const P_PASS: u8 = 0; @@ -135,7 +143,13 @@ pub fn step_cmd(k: u8) -> &'static str { if (k == STEP_SUITE) { return "\"$T27C\" suite --repo-root . --ratchet --corpus-only 2>&1"; } - 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)\"; 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 \\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"; + } + return "python3 -I tools/check_devhome_paths.py 2>&1"; } pub fn step_each(k: u8) -> bool { @@ -265,10 +279,10 @@ pub fn adds_workflow(c: &'static str) -> bool { return false; } -pub fn touches_census(c: &'static str) -> bool { +pub fn changes_under(c: &'static str, lit: &'static str) -> bool { let mut n: u64 = 0; while (n < entries(c)) { - if path_is(c, n, "tools/census/", "") { + if path_is(c, n, lit, "") { return true; } n = (n + 1); @@ -276,6 +290,10 @@ pub fn touches_census(c: &'static str) -> bool { return false; } +pub fn touches_census(c: &'static str) -> bool { + return changes_under(c, "tools/census/"); +} + pub fn digits_end(text: &'static str, at: usize, to: usize) -> usize { let mut k: usize = at; while (((k < to) && (text.as_bytes()[(k) as usize] >= 48)) && (text.as_bytes()[(k) as usize] <= 57)) { @@ -520,6 +538,43 @@ pub fn names_path(ev: &'static str, from: usize, to: usize, c: &'static str, n: return false; } +pub fn finding_path(ev: &'static str, at: usize, end: usize) -> usize { + if (opens_with(ev, at, end, "FAIL ") == false) { + return (ev.len() + 1); + } + let mut k: usize = (at + 5); + while (((k < end) && (ev.as_bytes()[(k) as usize] >= 97)) && (ev.as_bytes()[(k) as usize] <= 122)) { + k = (k + 1); + } + if (((k == (at + 5)) || (k >= end)) || (ev.as_bytes()[(k) as usize] != 32)) { + return (ev.len() + 1); + } + while ((k < end) && (ev.as_bytes()[(k) as usize] == 32)) { + k = (k + 1); + } + return k; +} + +pub fn names_home(ev: &'static str, from: usize, to: usize, c: &'static str, n: u64) -> bool { + let pf: usize = path_start(c, n); + let pl: usize = (path_end(c, n) - pf); + let mut at: usize = from; + while (at < to) { + let mut end: usize = line_end(ev, at); + if (end > to) { + end = to; + } + let k: usize = finding_path(ev, at, end); + if (((k <= ev.len()) && ((end - k) >= pl)) && same(ev, k, c, pf, (pf + pl))) { + if (((k + pl) == end) || opens_with(ev, (k + pl), end, " ")) { + return true; + } + } + at = (end + 1); + } + return false; +} + pub fn exit_verdict(x: u64) -> u8 { if (x == 0) { return P_PASS; @@ -613,6 +668,35 @@ pub fn once_verdict(c: &'static str, ev: &'static str, r: u8, quick: bool) -> u8 } return exit_verdict(exit_of(ev, STEP_CENSUS, 0)); } + if (r == R_BUDGET) { + let bf: usize = body_from(ev, STEP_BUDGET, 0); + let bt: usize = body_to(ev, STEP_BUDGET, 0); + if ((find_in(ev, bf, bt, " hand-written foreign lines added") <= ev.len()) || (find_in(ev, bf, bt, ": unreadable numstat line") <= ev.len())) { + return P_FAIL; + } + if ((exit_of(ev, STEP_BUDGET, 0) == 0) && (bf == bt)) { + return P_PASS; + } + return P_UNREAD; + } + if (r == R_DEVHOME) { + let hf: usize = body_from(ev, STEP_DEVHOME, 0); + let ht: usize = body_to(ev, STEP_DEVHOME, 0); + let x: u64 = exit_of(ev, STEP_DEVHOME, 0); + if (x == 0) { + if has_line(ev, hf, ht, "OK: ") { + return P_PASS; + } + return P_UNREAD; + } + if ((x != 1) || (has_line(ev, hf, ht, "FAIL ") == false)) { + return P_UNREAD; + } + if (changes_under(c, "tools/devhome_baseline.txt") || changes_under(c, "tools/check_devhome_paths.py")) { + return P_FAIL; + } + return P_PASS; + } return P_PASS; } @@ -641,6 +725,18 @@ pub fn entry_verdict(c: &'static str, ev: &'static str, r: u8, n: u64, quick: bo } return P_PASS; } + if (r == R_DEVHOME) { + if (exit_of(ev, STEP_DEVHOME, 0) != 1) { + return P_PASS; + } + if (path_safe(c, n) == false) { + return P_UNREAD; + } + if names_home(ev, body_from(ev, STEP_DEVHOME, 0), body_to(ev, STEP_DEVHOME, 0), c, n) { + return P_FAIL; + } + return P_PASS; + } if (is_target(c, n) == false) { return P_PASS; } @@ -801,6 +897,12 @@ pub fn evidence_step(c: &'static str, ev: &'static str, r: u8, n: u64) -> u8 { if ((r == R_CENSUS) && touches_census(c)) { return STEP_CENSUS; } + if (r == R_BUDGET) { + return STEP_BUDGET; + } + if (r == R_DEVHOME) { + return STEP_DEVHOME; + } return NO_STEP; } @@ -893,6 +995,12 @@ pub fn rule_name(r: u8) -> &'static str { if (r == R_CENSUS) { return "census"; } + if (r == R_BUDGET) { + return "budget"; + } + if (r == R_DEVHOME) { + return "devhome"; + } return "kept"; } @@ -949,6 +1057,12 @@ pub fn rule_why(r: u8, v: u8) -> &'static str { if (r == R_CENSUS) { return "a new workflow moves the census: run `tri census pin --bless` and commit tools/census/ in the same change"; } + if (r == R_BUDGET) { + return "the foreign-line budget (the merge base's own_language gate) refuses this diff: at most 40 hand-written foreign lines added per file and 80 per diff, never more added than deleted -- write the logic in .t27 and generate it"; + } + if (r == R_DEVHOME) { + return "a file this branch changes names a developer home directory (tools/check_devhome_paths.py): take the path from the environment or `git rev-parse --show-toplevel`; never re-bless to hide it"; + } return "the spec has fewer test blocks than on origin/master: deleting tests is not a fix"; } @@ -968,6 +1082,14 @@ pub const EV: &'static str = "@@ preflight 0 0 0 0\n\n@@ preflight 1 0 0 3\nA\tx pub const SUITE_OUT: &'static str = "@@ preflight 15 0 1 244\n - specs/x/new.t27 [gen]\n--- Ratchet (W628) ---\n UNEXPECTED PASSES : 3\n - specs/igla/coder/arch.t27 [parse] (fixed -- remove from the ledger)\n - specs/x/old.t27x [parse]\n - specs/x/old.t27 [parse-no-discard] (fixed)\nRATCHET: FAIL\n\n"; +pub const BUDGET_C: &'static str = "M\t.trinity/seals/t27o_T27oLedger.json\nM\tcli/tri/src/main.rs\nA\tgen/rust/tools/t27o/ledger.rs\nM\tspecs/tools/t27o/ledger.t27\n"; + +pub const BUDGET_OUT: &'static str = "@@ preflight 17 0 1 212\nall files: more hand-written foreign lines added than deleted (owner rule 2026-10-09: no hand-written code in any language but t27 may grow; port it to .t27 and generate it); no exception and no label lifts this\n\n"; + +pub const HOME_C: &'static str = "A\tdocs/new.md\nM\tspecs/x/grew.t27\nM\tspecs/x/shrank.t27\nD\tspecs/x/gone.t27\nM\tdocs/a.md\n"; + +pub const HOME_FIND: &'static str = "@@ 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"; + pub const RUN: &'static str = "{\n \"lab\": \"t27b-lab\",\n \"commit\": \"891a9087afcbc45ca68c87c07a2e4ff0ae5db00c\",\n \"finished\": \"2026-10-09T22:47:35Z\",\n \"top_blockers\": [\n {\n \"construct\": \"ExprCall(undeclared fn)\",\n \"file\": \"specs/x.t27\"\n }\n ],\n \"results\": [\n {\n \"file\": \"specs/a/b_c.t27\",\n \"reference\": \"pass\",\n \"reference_detail\": \"\",\n \"t27b\": \"pass_vacuous\",\n \"tests\": 0,\n \"invariants\": 0,\n \"asserts\": 0,\n \"blockers\": [],\n \"test_verdicts\": {},\n \"reference_disagree\": []\n },\n {\n \"file\": \"specs/queen/actors.t27\",\n \"reference\": \"pass\",\n \"t27b\": \"pass\",\n \"tests\": 37,\n \"asserts\": 2474,\n \"test_verdicts\": {\n \"file\": true,\n \"t27b\": true\n },\n \"reference_disagree\": []\n },\n {\n \"file\": \"specs/account/auth.t27\",\n \"reference\": \"blocked\",\n \"reference_detail\": \"does not compile: spec.zig:23:16: error: use of undeclared identifier 'Ok'\",\n \"t27b\": \"blocked\",\n \"tests\": 0,\n \"asserts\": null,\n \"blockers\": [\n \"ExprFieldAccess\"\n ],\n \"detail\": \"ExprFieldAccess\"\n }\n ]\n}\n"; pub fn line_end(text: &'static str, at: usize) -> usize { diff --git a/specs/tri/pr/preflight.t27 b/specs/tri/pr/preflight.t27 index d2c6c95556..e69dd3768e 100644 --- a/specs/tri/pr/preflight.t27 +++ b/specs/tri/pr/preflight.t27 @@ -21,7 +21,12 @@ ; census on 2026-10-18). A finding that does not mention a spec the branch changes does not fail it. ; the length is in the header -- an output that holds a line opening "@@ preflight " (this spec's ; own text does) cannot start a section, because a section ends where its length says. -; Fixture text is the commands' real output on master 92178c87d (2026-10-18), shortened. +; Two CI checks outside the specs are steps too (#11070). The own-language foreign-line budget (STEP_BUDGET) +; is compiled from the merge base's gen/c/policy/own_language.c, never from the branch, and fed the diff +; from the merge base, so every line it prints is the branch's own. The home-path scan (STEP_DEVHOME) walks +; the whole tree, so a finding fails the branch only when it names a file the branch changes. +; Fixture text is the commands' real output on master 92178c87d (2026-10-18), shortened; the budget and +; home-path fixtures are the two commands' real output on 2026-10-20, shortened. ; ASCII only (L3) ; phi^2 + 1/phi^2 = 3 | TRINITY @@ -50,9 +55,13 @@ pub const STEP_T27B_ROWS : u8 = 14; pub const STEP_SUITE : u8 = 15; // dupe_scan again, on a scratch checkout of origin/master: what master already prints is not the branch's. pub const STEP_DUPES_BASE : u8 = 16; -pub const STEPS : u8 = 17; +// The own-language foreign-line budget on the diff from the merge base, as .github/workflows/own-language.yml runs it. +pub const STEP_BUDGET : u8 = 17; +// The developer-home-path scan over the tree, as .github/workflows/secret-scan.yml runs it. +pub const STEP_DEVHOME : u8 = 18; +pub const STEPS : u8 = 19; // A rule whose evidence is in no single step's output (the ledgers, the test tally). -pub const NO_STEP : u8 = 17; +pub const NO_STEP : u8 = 19; // The rules, in the order they print. pub const R_CHANGES : u8 = 0; @@ -65,8 +74,10 @@ pub const R_DUPES : u8 = 6; pub const R_LEDGERS : u8 = 7; pub const R_SUITE : u8 = 8; pub const R_CENSUS : u8 = 9; -pub const R_COUNT : u8 = 10; -pub const RULES : u8 = 11; +pub const R_BUDGET : u8 = 10; +pub const R_DEVHOME : u8 = 11; +pub const R_COUNT : u8 = 12; +pub const RULES : u8 = 13; // A rule's outcome. They are numbered worst last, so the outcome of many is the largest. pub const P_PASS : u8 = 0; @@ -87,7 +98,7 @@ pub const MAX_EVIDENCE : usize = 4000; /// The steps and rules are numbered as the glue and the tests below call them, every per-target step /// runs after the one that names the targets, and the verdicts rank PASS < NOT_RUN < UNREAD < FAIL. -invariant the_steps_rules_and_verdicts_are_numbered { assert STEP_CHANGES < STEP_CHECK and STEP_LIKE < STEP_CLASSIFIED and STEP_SUITE + 1 == STEP_DUPES_BASE and STEP_DUPES_BASE == STEPS - 1 and NO_STEP == STEPS and R_COUNT == RULES - 1 and P_PASS < P_NOT_RUN and P_NOT_RUN < P_UNREAD and P_UNREAD < P_FAIL and PF_UNREAD == 2 and NO_EXIT == 256; } +invariant the_steps_rules_and_verdicts_are_numbered { assert STEP_CHANGES < STEP_CHECK and STEP_LIKE < STEP_CLASSIFIED and STEP_SUITE + 1 == STEP_DUPES_BASE and STEP_DUPES_BASE + 1 == STEP_BUDGET and STEP_BUDGET + 1 == STEP_DEVHOME and STEP_DEVHOME == STEPS - 1 and NO_STEP == STEPS and R_CENSUS + 1 == R_BUDGET and R_BUDGET + 1 == R_DEVHOME and R_COUNT == RULES - 1 and P_PASS < P_NOT_RUN and P_NOT_RUN < P_UNREAD and P_UNREAD < P_FAIL and PF_UNREAD == 2 and NO_EXIT == 256; } /// The shell command of step k. It runs at the repository root; every one ends in 2>&1, so the /// section holds what a person would have seen. @@ -108,7 +119,11 @@ 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"; } - 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)\"; 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 \\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"; } + return "python3 -I tools/check_devhome_paths.py 2>&1"; } /// Does step k run once per target (with SPEC set) rather than once? @@ -232,16 +247,21 @@ pub fn adds_workflow(c: string) -> bool { return false; } -/// Does the branch change the census ledger, tools/census/? -pub fn touches_census(c: string) -> bool { +/// Does the branch change a path that starts with lit, whatever the status? +pub fn changes_under(c: string, lit: string) -> bool { var n : u64 = 0; while (n < entries(c)) { - if (path_is(c, n, "tools/census/", "")) { return true; } + if (path_is(c, n, lit, "")) { return true; } n = n + 1; } return false; } +/// Does the branch change the census ledger, tools/census/? +pub fn touches_census(c: string) -> bool { + return changes_under(c, "tools/census/"); +} + // ---- the evidence: `@@ preflight ` sections ------------------------------ /// The offset of the first byte at or after `at` (and before `to`) that is not a digit. @@ -459,6 +479,39 @@ pub fn names_path(ev: string, from: usize, to: usize, c: string, n: u64) -> bool return false; } +/// Where the path begins on a finding line of tools/check_devhome_paths.py -- "FAIL ", a word (new, grew, +/// shrank, fixed), blanks, the path -- or ev.len + 1 when the line is none. "FAIL: ..." is an error, not a finding. +pub fn finding_path(ev: string, at: usize, end: usize) -> usize { + if (opens_with(ev, at, end, "FAIL ") == false) { return ev.len + 1; } + var k : usize = at + 5; + while (k < end and ev[k] >= 97 and ev[k] <= 122) { + k = k + 1; + } + if (k == at + 5 or k >= end or ev[k] != 32) { return ev.len + 1; } + while (k < end and ev[k] == 32) { + k = k + 1; + } + return k; +} + +/// Does a finding line in ev[from..to] name entry n's path, whole and followed by two blanks or the line's end? +/// The caller has seen the path is safe, so it is not empty. +pub fn names_home(ev: string, from: usize, to: usize, c: string, n: u64) -> bool { + const pf : usize = path_start(c, n); + const pl : usize = path_end(c, n) - pf; + var at : usize = from; + while (at < to) { + var end : usize = line_end(ev, at); + if (end > to) { end = to; } + const k : usize = finding_path(ev, at, end); + if (k <= ev.len and end - k >= pl and same(ev, k, c, pf, pf + pl)) { + if (k + pl == end or opens_with(ev, k + pl, end, " ")) { return true; } + } + at = end + 1; + } + return false; +} + // ---- the rules ---------------------------------------------------------------------------------- /// A step's exit as a outcome: 0 is green; no run, 126 (not executable) and 127 (not found) are no outcome. @@ -518,6 +571,25 @@ pub fn once_verdict(c: string, ev: string, r: u8, quick: bool) -> u8 { if (touches_census(c) == false) { return P_FAIL; } return exit_verdict(exit_of(ev, STEP_CENSUS, 0)); } + if (r == R_BUDGET) { + const bf : usize = body_from(ev, STEP_BUDGET, 0); + const bt : usize = body_to(ev, STEP_BUDGET, 0); + if (find_in(ev, bf, bt, " hand-written foreign lines added") <= ev.len or find_in(ev, bf, bt, ": unreadable numstat line") <= ev.len) { return P_FAIL; } + if (exit_of(ev, STEP_BUDGET, 0) == 0 and bf == bt) { return P_PASS; } + return P_UNREAD; + } + if (r == R_DEVHOME) { + const hf : usize = body_from(ev, STEP_DEVHOME, 0); + const ht : usize = body_to(ev, STEP_DEVHOME, 0); + const x : u64 = exit_of(ev, STEP_DEVHOME, 0); + if (x == 0) { + if (has_line(ev, hf, ht, "OK: ")) { return P_PASS; } + return P_UNREAD; + } + if (x != 1 or has_line(ev, hf, ht, "FAIL ") == false) { return P_UNREAD; } + if (changes_under(c, "tools/devhome_baseline.txt") or changes_under(c, "tools/check_devhome_paths.py")) { return P_FAIL; } + return P_PASS; + } return P_PASS; } @@ -537,6 +609,12 @@ pub fn entry_verdict(c: string, ev: string, r: u8, n: u64, quick: bool) -> u8 { if (is_target(c, n) and status(c, n) == 65 and has_row(ev, STEP_T27B_ROWS, c, n) == false) { return P_FAIL; } return P_PASS; } + if (r == R_DEVHOME) { + if (exit_of(ev, STEP_DEVHOME, 0) != 1) { return P_PASS; } + if (path_safe(c, n) == false) { return P_UNREAD; } + if (names_home(ev, body_from(ev, STEP_DEVHOME, 0), body_to(ev, STEP_DEVHOME, 0), c, n)) { return P_FAIL; } + return P_PASS; + } if (is_target(c, n) == false) { return P_PASS; } if (r == R_CHECK) { return exit_verdict(exit_of(ev, STEP_CHECK, n)); } if (r == R_TESTS) { @@ -635,6 +713,8 @@ pub fn evidence_step(c: string, ev: string, r: u8, n: u64) -> u8 { } if (r == R_SUITE) { return STEP_SUITE; } if (r == R_CENSUS and touches_census(c)) { return STEP_CENSUS; } + if (r == R_BUDGET) { return STEP_BUDGET; } + if (r == R_DEVHOME) { return STEP_DEVHOME; } return NO_STEP; } @@ -699,6 +779,8 @@ pub fn rule_name(r: u8) -> string { if (r == R_LEDGERS) { return "ledgers"; } if (r == R_SUITE) { return "suite"; } if (r == R_CENSUS) { return "census"; } + if (r == R_BUDGET) { return "budget"; } + if (r == R_DEVHOME) { return "devhome"; } return "kept"; } @@ -724,6 +806,8 @@ pub fn rule_why(r: u8, v: u8) -> string { if (r == R_LEDGERS) { return "a ledger row is wrong: a deleted spec leaves docs/reports/suite_expectations.json and t27b_expectations.json, an added spec needs a t27b_expectations.json row"; } if (r == R_SUITE) { return "the corpus ratchet names this spec: fix the spec, or drop its suite_expectations.json row if it was fixed"; } if (r == R_CENSUS) { return "a new workflow moves the census: run `tri census pin --bless` and commit tools/census/ in the same change"; } + if (r == R_BUDGET) { return "the foreign-line budget (the merge base's own_language gate) refuses this diff: at most 40 hand-written foreign lines added per file and 80 per diff, never more added than deleted -- write the logic in .t27 and generate it"; } + if (r == R_DEVHOME) { return "a file this branch changes names a developer home directory (tools/check_devhome_paths.py): take the path from the environment or `git rev-parse --show-toplevel`; never re-bless to hide it"; } return "the spec has fewer test blocks than on origin/master: deleting tests is not a fix"; } @@ -742,6 +826,7 @@ test the_steps_are_named_and_ordered { assert span_is(step_cmd(STEP_SUITE), 0, 14, "\"$T27C\" suite "); assert step_runs(STEP_SUITE, false) and step_runs(STEP_SUITE, true) == false and step_runs(STEP_T27B_ROWS, true); assert step_runs(STEPS, false) == false and step_runs(STEP_DUPES_BASE, true); + assert step_runs(STEP_BUDGET, true) and step_runs(STEP_DEVHOME, true) and step_each(STEP_BUDGET) == false and step_each(STEP_DEVHOME) == false; assert find_in(step_cmd(STEP_DUPES_BASE), 0, step_cmd(STEP_DUPES_BASE).len, "origin/master") < step_cmd(STEP_DUPES_BASE).len; var k : u8 = 0; var each : u8 = 0; @@ -764,6 +849,7 @@ test the_changes_are_read_entry_by_entry { assert is_target(C, 0) and is_target(C, 1) and is_target(C, 2) == false and is_target(C, 3) == false; assert is_gone(C, 2) and is_gone(C, 0) == false; assert adds_workflow(C) == false and touches_census(C) == false; + assert changes_under(C, "docs/") and changes_under(C, "specs/x/gone") and changes_under(C, "tools/") == false and changes_under("", "docs/") == false; const w : string = "A\t.github/workflows/new.yml\nM\ttools/census/quiet.txt\n"; assert adds_workflow(w) and touches_census(w); assert adds_workflow("M\t.github/workflows/old.yml\n") == false; @@ -941,6 +1027,88 @@ test rule_census_follows_a_new_workflow { assert rule_verdict(wc, "", R_CENSUS, false) == P_UNREAD; } +// PR #10538 added 9 hand-written lines to cli/tri/src/main.rs and deleted none. The gate compiled from its +// merge base printed the shrink-only line and exited 1; BUDGET_C is that diff's `git diff --name-status`. +const BUDGET_C : string = "M\t.trinity/seals/t27o_T27oLedger.json\nM\tcli/tri/src/main.rs\nA\tgen/rust/tools/t27o/ledger.rs\nM\tspecs/tools/t27o/ledger.t27\n"; +const BUDGET_OUT : string = "@@ preflight 17 0 1 212\nall files: more hand-written foreign lines added than deleted (owner rule 2026-10-09: no hand-written code in any language but t27 may grow; port it to .t27 and generate it); no exception and no label lifts this\n\n"; + +// Scenario: the budget fails on any line the merge base's gate prints, or on its exit, and a branch that adds no +// foreign line passes. +test rule_budget_fails_on_the_gates_own_output { + assert rule_verdict(BUDGET_C, BUDGET_OUT, R_BUDGET, false) == P_FAIL and rule_at(BUDGET_C, BUDGET_OUT, R_BUDGET, false) == entries(BUDGET_C); + assert evidence_step(BUDGET_C, BUDGET_OUT, R_BUDGET, 4) == STEP_BUDGET; + assert find_in(BUDGET_OUT, evidence_from(BUDGET_OUT, STEP_BUDGET, 0), evidence_to(BUDGET_OUT, STEP_BUDGET, 0), "than deleted") < BUDGET_OUT.len; + const c : string = "M\tcli/tri/src/main.rs\n"; + assert rule_verdict(c, "@@ preflight 17 0 1 110\ncli/tri/src/main.rs: more than 40 hand-written foreign lines added in one file (owner rule 2026-10-07, #7371)\n\n", R_BUDGET, false) == P_FAIL; + assert rule_verdict(c, "@@ preflight 17 0 1 88\nall files: more than 80 hand-written foreign lines added (owner rule 2026-10-07, #7371)\n\n", R_BUDGET, false) == P_FAIL; + assert rule_verdict(c, "@@ preflight 17 0 1 30\nx.rs: unreadable numstat line\n\n", R_BUDGET, false) == P_FAIL; + assert rule_verdict(c, "@@ preflight 17 0 0 62\nall files: more hand-written foreign lines added than deleted\n\n", R_BUDGET, false) == P_FAIL; + assert rule_verdict("M\tdocs/a.md\n", "@@ preflight 17 0 0 0\n\n", R_BUDGET, false) == P_PASS; + assert rule_verdict(BUDGET_C, "@@ preflight 17 0 0 0\n\n", R_BUDGET, true) == P_PASS; +} + +// Scenario: anything but a recognised failure or a silent exit 0 -- a compile error, a missing cc, a git that failed, +// no section -- is no outcome. +test rule_budget_fails_closed { + const c : string = "M\tcli/tri/src/main.rs\n"; + assert rule_verdict(c, "@@ preflight 17 0 1 83\n:3:57: error: call to undeclared function 'check_budget'\n1 error generated.\n\n", R_BUDGET, false) == P_UNREAD; + assert rule_verdict(c, "@@ preflight 17 0 127 26\nsh: cc: command not found\n\n", R_BUDGET, false) == P_UNREAD; + assert rule_verdict(c, "@@ preflight 17 0 128 45\nfatal: Not a valid object name origin/master\n\n", R_BUDGET, false) == P_UNREAD; + assert rule_verdict(c, "@@ preflight 17 0 0 24\nwarning: something else\n\n", R_BUDGET, false) == P_UNREAD; + assert rule_verdict(c, "@@ preflight 17 0 1 0\n\n", R_BUDGET, false) == P_UNREAD; + assert rule_verdict(c, "@@ preflight 16 0 0 0\n\n", R_BUDGET, false) == P_UNREAD; + assert rule_verdict(c, "", R_BUDGET, false) == P_UNREAD; +} + +// Scenario: the gate is the merge base's, not the branch's; a git that fails cannot leave it an empty input to pass on. +test the_budget_gate_is_compiled_from_the_merge_base { + const s : string = step_cmd(STEP_BUDGET); + assert find_in(s, 0, s.len, "git merge-base origin/master HEAD") < s.len; + assert find_in(s, 0, s.len, "git show \"${b}:gen/c/policy/own_language.c\" > \"$d/gate.c\"") < s.len; + assert find_in(s, 0, s.len, "check_budget(") < s.len and find_in(s, 0, s.len, "cc -w -I \"$d\" ") < s.len; + assert find_in(s, 0, s.len, "git diff --numstat --no-renames \"${b}...HEAD\" > \"$d/in\" && ") < s.len; + assert find_in(s, 0, s.len, "HEAD:gen/c/policy/own_language.c") == s.len + 1; + assert find_in(s, 0, s.len, "#include \"gen/c/policy") == s.len + 1; + assert find_in(s, 0, s.len, "exit $x; } 2>&1") < s.len; + assert opens_with(step_cmd(STEP_DEVHOME), 0, step_cmd(STEP_DEVHOME).len, "python3 -I tools/check_devhome_paths.py"); +} + +// 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"; + +// Scenario: a finding fails the branch only for a file it changes; master's own findings are not the branch's. +test rule_devhome_fails_for_a_file_the_branch_changes { + const ok : string = "@@ preflight 18 0 0 75\nOK: 29 file(s), 37 occurrence(s), all pinned in tools/devhome_baseline.txt\n\n"; + assert rule_verdict("M\tdocs/a.md\n", ok, R_DEVHOME, false) == P_PASS and rule_verdict(HOME_C, ok, R_DEVHOME, false) == P_PASS; + assert entry_verdict(HOME_C, HOME_FIND, R_DEVHOME, 0, false) == P_FAIL and entry_verdict(HOME_C, HOME_FIND, R_DEVHOME, 1, false) == P_FAIL; + assert entry_verdict(HOME_C, HOME_FIND, R_DEVHOME, 2, false) == P_FAIL and entry_verdict(HOME_C, HOME_FIND, R_DEVHOME, 3, false) == P_FAIL; + assert entry_verdict(HOME_C, HOME_FIND, R_DEVHOME, 4, false) == P_PASS; + assert rule_verdict(HOME_C, HOME_FIND, R_DEVHOME, false) == P_FAIL and rule_at(HOME_C, HOME_FIND, R_DEVHOME, false) == 0; + assert rule_verdict("M\tdocs/a.md\n", HOME_FIND, R_DEVHOME, false) == P_PASS; + assert rule_verdict("M\tspecs/x/grew\nM\tspecs/x/grew.t2\nM\tspecs/x/grew.t27x\n", HOME_FIND, R_DEVHOME, false) == P_PASS; + assert evidence_step(HOME_C, HOME_FIND, R_DEVHOME, 0) == STEP_DEVHOME; + assert find_in(HOME_FIND, evidence_from(HOME_FIND, STEP_DEVHOME, 0), evidence_to(HOME_FIND, STEP_DEVHOME, 0), "FAIL new") < HOME_FIND.len; + assert rule_verdict("M\ttools/devhome_baseline.txt\n", HOME_FIND, R_DEVHOME, false) == P_FAIL; + assert rule_verdict("M\ttools/check_devhome_paths.py\n", HOME_FIND, R_DEVHOME, false) == P_FAIL; + assert rule_verdict("M\ttools/devhome_baseline.txt\n", ok, R_DEVHOME, false) == P_PASS; + assert rule_verdict("M\t\"docs/a b.md\"\n", HOME_FIND, R_DEVHOME, false) == P_UNREAD; + assert rule_verdict("M\t\"docs/a b.md\"\n", ok, R_DEVHOME, false) == P_PASS; +} + +// Scenario: the scan crashed, found no baseline, or printed something this rule does not read: no outcome. +test rule_devhome_fails_closed { + const c : string = "M\tdocs/a.md\n"; + assert rule_verdict(c, "@@ preflight 18 0 2 49\nFAIL: no baseline at tools/devhome_baseline.txt.\n\n", R_DEVHOME, false) == P_UNREAD; + assert rule_verdict(c, "@@ preflight 18 0 1 26\nFAIL: grep exited 2: boom\n\n", R_DEVHOME, false) == P_UNREAD; + assert rule_verdict(c, "@@ preflight 18 0 1 52\nTraceback (most recent call last):\n File x, line 1\n\n", R_DEVHOME, false) == P_UNREAD; + assert rule_verdict(c, "@@ preflight 18 0 1 0\n\n", R_DEVHOME, false) == P_UNREAD; + assert rule_verdict(c, "@@ preflight 18 0 0 11\nall pinned\n\n", R_DEVHOME, false) == P_UNREAD; + assert rule_verdict(c, "@@ preflight 18 0 127 31\nsh: python3: command not found\n\n", R_DEVHOME, false) == P_UNREAD; + assert rule_verdict(c, "@@ preflight 17 0 0 0\n\n", R_DEVHOME, false) == P_UNREAD; + assert rule_verdict(c, "", R_DEVHOME, false) == P_UNREAD; +} + // Scenario: PR #8879 took 12 of 26 tests out of a spec; fewer test blocks than master fails. test rule_count_refuses_deleted_tests { const c : string = "M\tspecs/x/old.t27\n"; @@ -956,9 +1124,12 @@ test rule_count_refuses_deleted_tests { // Scenario: the exit is 1 for any FAIL, 2 for no outcome and no FAIL, and 0 only when all ran green. test the_exit_is_the_worst_rule { const c : string = "M\tdocs/a.md\n"; - const green : string = "@@ preflight 0 0 0 0\n\n@@ preflight 1 0 0 0\n\n@@ preflight 2 0 0 0\n\n@@ preflight 9 0 0 0\n\n@@ preflight 10 0 0 15\nRATCHET: CLEAN\n\n@@ preflight 11 0 0 26\nok: no new duplicate body\n\n@@ preflight 13 0 0 0\n\n@@ preflight 14 0 0 0\n\n"; + const green : string = "@@ preflight 0 0 0 0\n\n@@ preflight 1 0 0 0\n\n@@ preflight 2 0 0 0\n\n@@ preflight 9 0 0 0\n\n@@ preflight 10 0 0 15\nRATCHET: CLEAN\n\n@@ preflight 11 0 0 26\nok: no new duplicate body\n\n@@ preflight 13 0 0 0\n\n@@ preflight 14 0 0 0\n\n@@ preflight 17 0 0 0\n\n@@ preflight 18 0 0 14\nOK: 1 file(s)\n\n"; assert preflight_exit(c, green, true) == PF_OK; assert preflight_exit(c, green, false) == PF_UNREAD; + const over : string = "@@ preflight 0 0 0 0\n\n@@ preflight 1 0 0 0\n\n@@ preflight 2 0 0 0\n\n@@ preflight 9 0 0 0\n\n@@ preflight 10 0 0 15\nRATCHET: CLEAN\n\n@@ preflight 11 0 0 26\nok: no new duplicate body\n\n@@ preflight 13 0 0 0\n\n@@ preflight 14 0 0 0\n\n@@ preflight 17 0 1 30\nx.rs: unreadable numstat line\n\n@@ preflight 18 0 0 14\nOK: 1 file(s)\n\n"; + assert preflight_exit(c, over, true) == PF_FAIL; + assert preflight_exit(c, "@@ preflight 18 0 0 14\nOK: 1 file(s)\n\n", true) == PF_UNREAD; const behind : string = "@@ preflight 0 0 0 0\n\n@@ preflight 1 0 0 0\n\n@@ preflight 2 0 1 0\n\n"; assert preflight_exit(c, behind, true) == PF_FAIL; assert preflight_exit(c, "", true) == PF_UNREAD; @@ -987,5 +1158,7 @@ test each_rule_and_verdict_says_what_it_means { } assert span_is(name(P_FAIL), 0, 4, "FAIL") and span_is(name(P_UNREAD), 0, 6, "UNREAD"); assert span_is(rule_why(R_TYPES, P_FAIL), 0, 30, "the types ratchet is not CLEAN"); + assert span_is(rule_name(R_BUDGET), 0, 6, "budget") and rule_name(R_BUDGET).len == 6 and span_is(rule_name(R_DEVHOME), 0, 7, "devhome") and rule_name(R_DEVHOME).len == 7 and rule_name(R_COUNT).len == 4; + assert span_is(rule_why(R_BUDGET, P_FAIL), 0, 23, "the foreign-line budget") and span_is(rule_why(R_DEVHOME, P_FAIL), 0, 25, "a file this branch change"); assert span_is(preflight_exit_why(PF_UNREAD), 0, 6, "exit 2") and span_is(preflight_exit_why(PF_FAIL), 0, 6, "exit 1"); }