diff --git a/.github/workflows/guards-nightly.yml b/.github/workflows/guards-nightly.yml index 332a0419d2..4bd6aa037f 100644 --- a/.github/workflows/guards-nightly.yml +++ b/.github/workflows/guards-nightly.yml @@ -77,6 +77,13 @@ jobs: # a nightly on a shared 32-core host pays contention a PR job does not and # nothing is blocked while it waits. timeout-minutes: 60 + # PP-19: this job runs `--ignored` tests on the shared X64 clean-room host, + # so it joins the repo-wide `perf-intel` group (the one nightly-bench's + # `measure` and beat-speed-nightly declare) and serialises against them. + # Enforced by scripts/check_perf_concurrency_groups.sh. + concurrency: + group: perf-intel + cancel-in-progress: false env: IMAGE: localhost:5000/sovereign-ci:stable # Same shape as guard-cargo (ci.yml). On a schedule/dispatch run there is @@ -246,6 +253,45 @@ jobs: # 4. the satd class of the ratchet-semantics case table (rebuilds nothing) DR_ENV=() dr "$GITHUB_WORKSPACE" bash scripts/tests/ratchet_semantics_test.sh --class satd + # The hand-list guard (#3745 S3) and the ladder gap (#5231). LAB, not GATE: they run + # here, never on a PR, merge-queue or release path. Their case tables run with the + # apr-cli lib tests; these two steps run the #[ignore]d tree scan and ladder-gap + # requirement. Each red reaches ONE ticket with ONE owner through its own opener + # below (its label; a ticket already carrying it is commented, never duplicated). + - name: Release surfaces may not hand-list apr verbs or flags (#3745 S3) + id: lab_hand_list + if: ${{ !cancelled() }} + run: | + set -euo pipefail + exec > >(tee -a "$RUNNER_TEMP/hand-list.log") 2>&1 + cargo test -p apr-cli --lib -- --ignored --exact hand_list_guard::tests::no_release_surface_hand_lists_apr_verbs_or_flags + - name: The model ladder owes cells to every model-cell command (#5231) + id: lab_ladder_gap + if: ${{ !cancelled() }} + run: | + set -euo pipefail + exec > >(tee -a "$RUNNER_TEMP/ladder-gap.log") 2>&1 + cargo test -p apr-cli --lib -- --ignored --exact hand_list_guard::tests::the_ladder_owes_every_model_cell_command + - name: A red hand-list night opens or updates its one LAB ticket (#3745) + if: failure() && steps.lab_hand_list.outcome == 'failure' + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + bash scripts/lab_ticket.sh --label lab-hand-list --title "LAB: a release surface hand-lists apr verbs or flags (#3745)" \ + --owner rex --log "$RUNNER_TEMP/hand-list.log" --event "$GITHUB_EVENT_NAME" --run-url "$RUN_URL" + - name: A red ladder-gap night opens or updates its one LAB ticket (#5231) + if: failure() && steps.lab_ladder_gap.outcome == 'failure' + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + bash scripts/lab_ticket.sh --label lab-ladder-gap --title "LAB: the model ladder owes cells to model-cell commands (ladder-gap, #3745)" \ + --owner aprender-a7 --log "$RUNNER_TEMP/ladder-gap.log" --event "$GITHUB_EVENT_NAME" --run-url "$RUN_URL" # #5182 (GATE/LAB). The two ratchets that left the PR path. On a schedule # run HEAD *is* origin/main, so merge-base(HEAD, origin/main) is HEAD and a # shrink-only comparison against it can never fail. The comparand is diff --git a/Cargo.lock b/Cargo.lock index f52fa53dfb..8524a17ab9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -327,6 +327,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", + "toml 0.8.23", "tower 0.5.3", "unicode-normalization", "ureq 2.12.1", diff --git a/crates/apr-cli/Cargo.toml b/crates/apr-cli/Cargo.toml index 9152f8a611..43a899e392 100644 --- a/crates/apr-cli/Cargo.toml +++ b/crates/apr-cli/Cargo.toml @@ -300,6 +300,8 @@ required-features = ["inference"] aprender-build-sha = { workspace = true } # #4219: APR_GIT_SHA for --version [dev-dependencies] +# #3745 S3 — the hand-list guard reads the dogfood gate list from the root Cargo.toml +toml = { workspace = true } jsonschema = "0.28" # apr devices --json validated against contracts/schemas/apr-devices-v1.schema.json (PP-066 R-0a) assert_cmd = "2.0" predicates = "3.1" diff --git a/crates/apr-cli/src/dispatch.rs b/crates/apr-cli/src/dispatch.rs index 2565d35318..e8c555262c 100644 --- a/crates/apr-cli/src/dispatch.rs +++ b/crates/apr-cli/src/dispatch.rs @@ -806,38 +806,7 @@ fn dispatch_diagnostic_commands(cli: &Cli) -> Option> { crate::pipe::with_stdin_support(file, |p| tensors::run(p, s, f.as_deref(), j, l)) } - Commands::Diff { - file1, - file2, - weights, - values, - filter, - limit, - transpose_aware, - json, - quant_roundtrip, - threshold, - no_threshold, - } => { - let json = *json || cli.json; - crate::error::resolve_model_path(file1).and_then(|r1| { - let r2 = crate::error::resolve_model_path(file2)?; - if *quant_roundtrip { - // CRUX-B-20: per-tensor quant roundtrip error report. - return dispatch_quant_roundtrip(&r1, &r2, *threshold, *no_threshold, json); - } - diff::run( - &r1, - &r2, - *weights, - *values, - filter.as_deref(), - *limit, - *transpose_aware, - json, - ) - }) - } + Commands::Diff { .. } => return dispatch_diff_command(cli), _ => return None, }) @@ -1274,6 +1243,45 @@ fn dispatch_model_commands(cli: &Cli) -> Option> { }) } +/// `apr diff`: split out of [`dispatch_diagnostic_commands`] to keep it under the +/// complexity threshold (#5227). `None` when the command is not `diff`. +fn dispatch_diff_command(cli: &Cli) -> Option> { + let Commands::Diff { + file1, + file2, + weights, + values, + filter, + limit, + transpose_aware, + json, + quant_roundtrip, + threshold, + no_threshold, + } = cli.command.as_ref() + else { + return None; + }; + Some(crate::error::resolve_model_path(file1).and_then(|r1| { + let r2 = crate::error::resolve_model_path(file2)?; + if *quant_roundtrip { + // CRUX-B-20: per-tensor quant roundtrip error report. + dispatch_quant_roundtrip(&r1, &r2, *threshold, *no_threshold, *json || cli.json) + } else { + diff::run( + &r1, + &r2, + *weights, + *values, + filter.as_deref(), + *limit, + *transpose_aware, + *json || cli.json, + ) + } + })) +} + #[cfg(all(test, feature = "inference"))] mod batch_jsonl_logprobs_4026 { use super::run_batch_jsonl; diff --git a/crates/apr-cli/src/hand_list_guard.rs b/crates/apr-cli/src/hand_list_guard.rs new file mode 100644 index 0000000000..9fa952a9fe --- /dev/null +++ b/crates/apr-cli/src/hand_list_guard.rs @@ -0,0 +1,878 @@ +//! #3745 S3 (#3752): the hand-list guard. A release surface may not enumerate apr's verbs or flags. +//! +//! Operator 2026-09-21, verbatim: "again, the way to prevent testing leakage is tests are dervived from +//! surface and SHACL enforced...WE ARE NOT ENFORCING our interface AT ALL..it is hand coded not derived". +//! Every release cell used to come from a list someone typed (`{run, chat, serve, code}`, a `VERBS` +//! const, …), so a verb, flag or input shape nobody typed was invisible to the gate. This guard refuses +//! such a list wherever a release decision reads one. +//! +//! * **The vocabulary is not typed.** It is S1's surface, `crate::surface::emit()` -- the document +//! `apr surface --json` prints -- restricted to the release cell universe: apr's own commands that +//! take a model-role argument or generate. Every path token and alias of such a command is a verb; +//! every `--long`, `-s`, long alias and short alias of it, plus the root's `global_args`, is a flag. +//! * **RED** (S3.1 as amended on #3745 by the issue author): a literal list with >= 2 surface verbs, or +//! >= 2 flags of one verb, AND those are a strict majority of its items. Padding a hand list with one +//! non-verb does not escape (`[run, chat, serve, code, zzz]`), and neither does splitting it across +//! adjacent literals (`["run"] + ["chat", "zzz"]`, `V=(run zzz)` then `V+=(chat)`). An argv carrying +//! values, a probe list checked AGAINST the surface (2 of 5 are verbs), and a script's own +//! option-parser arms (`-q|--quiet)`) are not enumerations. +//! * **What is read:** YAML sequences and scalars, shell arrays (with `+=`), `for … in` word lists, +//! verb `case` alternations, quoted word lists (comma-separated, or >= 3 words), and bracketed +//! string-literal lists (joined by `+`) in Python heredocs and Rust. +//! * **Scan set:** the gates `[package.metadata.dogfood]` declares (read from Cargo.toml, not typed +//! here), `scripts/release/**`, the dogfood scripts, the model-ladder and release-readiness +//! contracts, and the release-evidence extractor. +//! * **No allow-list** (S3.3). The planted fixtures below live in this file, which is not in the scan +//! set; `the_scan_set_never_includes_this_guard` holds that line. +//! +//! Where it runs. Under the GATE/LAB rule a new check starts as LAB: it never runs on a PR, +//! merge-queue or release path until it is ruled a GATE. +//! * The guard's own case tables run as ordinary unit tests, with every other apr-cli lib test. They +//! prove the guard can still turn RED and stay quiet; they do not judge the tree. +//! * The tree scan, `no_release_surface_hand_lists_apr_verbs_or_flags`, is `#[ignore]`d, so +//! `workspace-test`, coverage and mutants never run it. It runs once a night on `main` in +//! `.github/workflows/guards-nightly.yml`, with `--ignored`. A red there updates #3745 (one ticket, one +//! owner). + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; + +/// The verbs and flags of the binary, read from S1's surface. +pub(crate) struct Vocab { + pub(crate) verbs: BTreeSet, + /// command key ("serve plan") -> its flags, dashed ("--gpu", "-i"), globals included + pub(crate) flags: BTreeMap>, +} + +fn arg_flags(a: &crate::surface::ArgEntry, into: &mut BTreeSet) { + if a.positional { + return; + } + into.extend( + a.long + .iter() + .chain(&a.long_aliases) + .map(|l| format!("--{l}")), + ); + into.extend( + a.short + .iter() + .chain(&a.short_aliases) + .map(|s| format!("-{s}")), + ); +} + +/// Whether a command is in the release cell universe (#3745 design 3): apr's own command, not a foreign +/// mount, that takes a model-role argument or carries the generation marker. Read from the surface. +pub(crate) fn owes_model_cells(c: &crate::surface::CommandEntry) -> bool { + !c.foreign + && (c.generates + || c.args + .iter() + .any(|a| a.role == batuta_common::cli_roles::Role::Model.as_str())) +} + +/// The vocabulary, from `surface::emit()` (which runs its own clap walk on a large-stack thread): +/// the path tokens, aliases and flags of the commands that owe model cells. Measured on `main` +/// (2026-10-12): taking every command instead let `merge tag publish` (release stages), `test coverage` +/// (dogfood rows) and cargo's own `-q|--quiet|-v` read as hand lists of apr, because `test`, `coverage` +/// and those flags exist elsewhere in apr's tree or in a foreign mount. +pub(crate) fn vocab() -> Vocab { + vocab_from(&crate::surface::emit()) +} + +pub(crate) fn vocab_from(s: &crate::surface::Surface) -> Vocab { + let mut globals = BTreeSet::new(); + for a in &s.global_args { + arg_flags(a, &mut globals); + } + let mut v = Vocab { + verbs: BTreeSet::new(), + flags: BTreeMap::new(), + }; + for c in s.commands.iter().filter(|c| owes_model_cells(c)) { + v.verbs.extend(c.path.iter().cloned()); + v.verbs.extend(c.aliases.iter().cloned()); + let mut fl = globals.clone(); + for a in &c.args { + arg_flags(a, &mut fl); + } + v.flags.insert(c.key.clone(), fl); + } + v +} + +/// Why a literal list is a hand list (an enumeration of the surface), or None. The named items must be +/// >= 2 AND a strict majority of the list's distinct items (S3.1 as amended). +pub(crate) fn judge(items: &[String], v: &Vocab) -> Option { + let distinct: BTreeSet<&str> = items.iter().map(String::as_str).collect(); + let n = distinct.len(); + let majority = |k: usize| k >= 2 && 2 * k > n; + let verbs: BTreeSet<&str> = distinct + .iter() + .copied() + .filter(|s| v.verbs.contains(*s)) + .collect(); + if majority(verbs.len()) { + return Some(format!( + "enumerates {} surface verbs {verbs:?} (of {n} items)", + verbs.len() + )); + } + v.flags + .iter() + .map(|(key, fl)| { + ( + key, + distinct + .iter() + .copied() + .filter(|s| fl.contains(*s)) + .collect::>(), + ) + }) + .filter(|(_, hit)| majority(hit.len())) + .max_by_key(|(_, hit)| hit.len()) + .map(|(key, hit)| { + format!( + "enumerates {} flags of `apr {key}` {hit:?} (of {n} items)", + hit.len() + ) + }) +} + +/// The kind of source a file is scanned as. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Kind { + Yaml, + Shell, + Rust, +} + +pub(crate) fn kind_of(p: &Path) -> Option { + match p.extension().and_then(|e| e.to_str()) { + Some("yaml" | "yml") => Some(Kind::Yaml), + Some("sh") => Some(Kind::Shell), + Some("rs") => Some(Kind::Rust), + _ => None, + } +} + +/// Words of a quoted or bare shell/YAML string: split on commas and whitespace, quotes stripped. +fn words(s: &str) -> Vec { + s.split(|c: char| c == ',' || c.is_whitespace()) + .map(|w| w.trim_matches(|c| c == '"' || c == '\'')) + .filter(|w| !w.is_empty()) + .map(str::to_string) + .collect() +} + +/// A string marked `\u{0}` is any string literal; one marked `\u{1}` is the right-hand side of a shell +/// assignment (`VERBS="run chat serve"`), the only place a space-separated word list is a list. +const ANY_STRING: char = '\u{0}'; +const ASSIGNED_STRING: char = '\u{1}'; + +/// A string read as a list. Comma-separated, it is a list anywhere ("run, chat"), judged by the majority +/// rule. Space-separated, it is a list only as a shell assignment whose EVERY word is a verb: a YAML +/// `test: "pv validate contracts/x.yaml"` is a command, `ok "probar llm test against $URL"` is a log +/// line, and "schema drift" is prose -- none of them is an enumeration. +fn string_as_list(s: &str, assigned: bool, v: &Vocab) -> Option> { + let w = words(s); + if s.contains(',') { + return (w.len() >= 2).then_some(w); + } + (assigned && w.len() >= 2 && w.iter().all(|x| v.verbs.contains(x))).then_some(w) +} + +/// Bracketed sequences of string literals, `[ "a", 'b' ]` / `( … )` / `{ … }`, in Python or Rust, one +/// item or more; literals joined by `+` are merged into one list. Only a sequence made entirely of string +/// literals counts: an argument list with an expression in it is a call, not an enumeration. +fn bracket_string_lists(text: &str) -> Vec<(usize, Vec)> { + let b = text.as_bytes(); + let mut raw: Vec<(usize, usize, usize, Vec)> = Vec::new(); // (start, end, line, items) + let mut i = 0; + while i < b.len() { + match string_seq_at(text, i) { + Some((j, items)) => { + raw.push((i, j, text[..i].matches('\n').count() + 1, items)); + i = j + 1; + } + None => i += 1, + } + } + merge_plus_joined(text, raw) +} + +fn skip_ws(b: &[u8], mut j: usize) -> usize { + while j < b.len() && b[j].is_ascii_whitespace() { + j += 1; + } + j +} + +/// The string literal opening at `j` (a quote byte): its body and the index just past its closing +/// quote. `None` if `j` is not a quote or the literal does not close on its line. +fn string_lit_at(text: &str, j: usize) -> Option<(String, usize)> { + let b = text.as_bytes(); + let q = *b.get(j).filter(|c| **c == b'"' || **c == b'\'')?; + let start = j + 1; + let end = start + b[start..].iter().position(|c| *c == q || *c == b'\n')?; + (b[end] == q).then(|| (text[start..end].to_string(), end + 1)) +} + +/// The all-string-literal bracketed sequence opening at `i`: the index of its closing bracket and its +/// items. `None` when `i` is not an opening bracket, the sequence is empty, or any item is not a literal. +fn string_seq_at(text: &str, i: usize) -> Option<(usize, Vec)> { + let b = text.as_bytes(); + let close = match b[i] { + b'[' => b']', + b'(' => b')', + b'{' => b'}', + _ => return None, + }; + let mut j = i + 1; + let mut items = Vec::new(); + loop { + j = skip_ws(b, j); + if j < b.len() && b[j] == close { + return (!items.is_empty()).then_some((j, items)); + } + let (item, next) = string_lit_at(text, j)?; + items.push(item); + j = skip_ws(b, next); + if j < b.len() && b[j] == b',' { + j += 1; + } + } +} + +/// Merge `lit + lit`: only whitespace and one `+` between one literal's end and the next's start. +fn merge_plus_joined( + text: &str, + raw: Vec<(usize, usize, usize, Vec)>, +) -> Vec<(usize, Vec)> { + let mut out: Vec<(usize, Vec)> = Vec::new(); + let mut prev_end: Option = None; + for (start, end, line, items) in raw { + let joined = prev_end.is_some_and(|pe| text[pe + 1..start].trim() == "+"); + if joined { + if let Some(last) = out.last_mut() { + last.1.extend(items); + } + } else { + out.push((line, items)); + } + prev_end = Some(end); + } + out +} + +/// Every literal list in a shell script: arrays (a name's `=( )` and `+=( )` are one list), `for … in` +/// lists, verb `case` alternations, quoted word lists, and the bracketed lists of any heredoc body (a +/// Python heredoc is where gate logic lives). A string literal is marked with a leading NUL so the +/// caller reads it with `string_as_list`. +fn shell_lists(text: &str) -> Vec<(usize, Vec)> { + let mut out = Vec::new(); + let mut arrays: BTreeMap)> = BTreeMap::new(); + for (n, raw) in text.lines().enumerate() { + let line = raw.split(" #").next().unwrap_or(raw); + let t = line.trim_start(); + if t.starts_with('#') { + continue; + } + let ln = n + 1; + shell_array(t, ln, &mut arrays); + if let Some(l) = shell_for_list(t) { + out.push((ln, l)); + } + if let Some(l) = shell_case_arm(t) { + out.push((ln, l)); + } + out.extend(shell_quoted(t).into_iter().map(|s| (ln, vec![s]))); + } + out.extend(arrays.into_values()); + out.extend(bracket_string_lists(text)); + out +} + +/// `name=( … )` starts a list; `name+=( … )` extends it. +fn shell_array(t: &str, ln: usize, arrays: &mut BTreeMap)>) { + let Some(pos) = t.find("=(") else { return }; + let (lhs, append) = match t[..pos].strip_suffix('+') { + Some(l) => (l, true), + None => (&t[..pos], false), + }; + let name = lhs + .rsplit(char::is_whitespace) + .next() + .unwrap_or(lhs) + .to_string(); + let rest = &t[pos + 2..]; + let body = words(rest.split(')').next().unwrap_or(rest)); + let slot = arrays.entry(name).or_insert((ln, Vec::new())); + if !append { + *slot = (ln, Vec::new()); + } + slot.1.extend(body); +} + +fn shell_for_list(t: &str) -> Option> { + let rest = t.strip_prefix("for ")?; + let tail = &rest[rest.find(" in ")? + 4..]; + Some(words(tail.split(';').next().unwrap_or(tail))) +} + +/// A `case` arm of VERBS is an enumeration; an arm of flags is the script's own option parser. +fn shell_case_arm(t: &str) -> Option> { + let pat = t.split(')').next()?; + let unquote = |a: &str| a.trim_matches(|c| c == '"' || c == '\'').to_string(); + // any `-x` / `+x` alternative makes it an option parser (`+*|-q|--quiet)`, cargo's own argv) + let parser = pat.split('|').any(|a| unquote(a).starts_with(['-', '+'])); + (pat.contains('|') && !pat.contains(' ') && t.contains(')') && !parser) + .then(|| pat.split('|').map(unquote).collect()) +} + +/// Every quoted string on the line, marked for `string_as_list`: the first one is `ASSIGNED_STRING` +/// when it is the right-hand side of `name=`. +fn shell_quoted(t: &str) -> Vec { + let mut out = Vec::new(); + for q in ['"', '\''] { + let parts: Vec<&str> = t.split(q).collect(); + for k in (1..parts.len()).step_by(2) { + let assigned = k == 1 + && parts[0] + .trim_end() + .strip_suffix('=') + .is_some_and(|l| l.ends_with(|c: char| c.is_ascii_alphanumeric() || c == '_')); + let mark = if assigned { + ASSIGNED_STRING + } else { + ANY_STRING + }; + out.push(format!("{mark}{}", parts[k])); + } + } + out +} + +fn yaml_lists(text: &str) -> Result)>, String> { + fn go(v: &serde_yaml::Value, path: &str, out: &mut Vec<(String, Vec)>) { + match v { + serde_yaml::Value::Sequence(s) => { + out.push(( + path.to_string(), + s.iter() + .filter_map(|x| x.as_str().map(str::to_string)) + .collect(), + )); + for (i, x) in s.iter().enumerate() { + go(x, &format!("{path}[{i}]"), out); + } + } + serde_yaml::Value::Mapping(m) => { + for (k, x) in m { + let key = k.as_str().map_or_else(|| format!("{k:?}"), str::to_string); + go( + x, + &if path.is_empty() { + key.clone() + } else { + format!("{path}.{key}") + }, + out, + ); + } + } + serde_yaml::Value::String(s) => { + out.push((path.to_string(), vec![format!("{ANY_STRING}{s}")])) + } + _ => {} + } + } + let doc: serde_yaml::Value = + serde_yaml::from_str(text).map_err(|e| format!("not YAML: {e}"))?; + let mut out = Vec::new(); + go(&doc, "", &mut out); + Ok(out) +} + +/// Every hand list in one file, as `where why`. +pub(crate) fn scan(kind: Kind, text: &str, v: &Vocab) -> Result, String> { + let lists: Vec<(String, Vec)> = match kind { + Kind::Yaml => yaml_lists(text)?, + Kind::Shell => shell_lists(text) + .into_iter() + .map(|(l, i)| (format!("line {l}"), i)) + .collect(), + Kind::Rust => bracket_string_lists(text) + .into_iter() + .map(|(l, i)| (format!("line {l}"), i)) + .collect(), + }; + let mut out = Vec::new(); + for (at, items) in lists { + let items = match items.as_slice() { + [one] if one.starts_with([ANY_STRING, ASSIGNED_STRING]) => { + match string_as_list(&one[1..], one.starts_with(ASSIGNED_STRING), v) { + Some(w) => w, + None => continue, + } + } + _ => items, + }; + if let Some(why) = judge(&items, v) { + out.push(format!("{at} {why}")); + } + } + Ok(out) +} + +/// The repository root, from this crate's manifest dir (never `git rev-parse`: it dies in the CI +/// container, aprender#3581). +pub(crate) fn repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .canonicalize() + .expect("the repo root resolves") +} + +fn walk_files(dir: &Path, out: &mut Vec) { + let Ok(rd) = std::fs::read_dir(dir) else { + return; + }; + for e in rd.flatten() { + let p = e.path(); + if p.is_dir() { + walk_files(&p, out); + } else if kind_of(&p).is_some() { + out.push(p); + } + } +} + +fn files_in(dir: &Path, keep: impl Fn(&str) -> bool) -> Vec { + let Ok(rd) = std::fs::read_dir(dir) else { + return Vec::new(); + }; + rd.flatten() + .map(|e| e.path()) + .filter(|p| p.file_name().is_some_and(|n| keep(&n.to_string_lossy()))) + .collect() +} + +/// The scan set: the surfaces a release decision reads. The gate paths come from Cargo.toml. +pub(crate) fn scan_set(root: &Path) -> Result, String> { + let manifest = + std::fs::read_to_string(root.join("Cargo.toml")).map_err(|e| format!("Cargo.toml: {e}"))?; + let doc: toml::Value = manifest + .parse() + .map_err(|e| format!("Cargo.toml is not TOML: {e}"))?; + let gates = doc + .get("package") + .and_then(|p| p.get("metadata")) + .and_then(|m| m.get("dogfood")) + .and_then(|d| d.get("gates")) + .and_then(toml::Value::as_array) + .ok_or("Cargo.toml declares no [package.metadata.dogfood] gates -- the release gates cannot be found")?; + let mut set: BTreeSet = gates + .iter() + .filter_map(toml::Value::as_str) + .map(|g| root.join(g)) + .collect(); + let mut files = Vec::new(); + walk_files(&root.join("scripts/release"), &mut files); + walk_files(&root.join(".claude/skills/apr-dogfood"), &mut files); + files.extend(files_in(&root.join("scripts"), |n| { + n.starts_with("dogfood") && n.ends_with(".sh") + })); + files.extend(files_in(&root.join("contracts"), |n| { + n.starts_with("model-capability-ladder") || n.contains("release-readiness") + })); + let mut rs = Vec::new(); + walk_files(&root.join("crates/aprender-contracts/src"), &mut rs); + files.extend(rs.into_iter().filter(|p| { + p.file_name() + .is_some_and(|n| n.to_string_lossy().contains("release_evidence")) + })); + set.extend(files); + Ok(set.into_iter().filter(|p| kind_of(p).is_some()).collect()) +} + +/// #3745, quorum q-3745 (option A): the model ladder owes cells to every command in the release cell +/// universe, read from the surface, not to a typed subset. Returns the command keys the ladder's +/// `cells.verbs` does not name. LAB only: the release path still judges the ladder as it did, and +/// widening that is a gate change of its own. +pub(crate) fn ladder_gap( + ladder_verbs: &BTreeSet, + s: &crate::surface::Surface, +) -> BTreeSet { + s.commands + .iter() + .filter(|c| owes_model_cells(c)) + .map(|c| c.key.clone()) + .filter(|k| !ladder_verbs.contains(k)) + .collect() +} + +/// The ladder contract's `ladder.cells.verbs`. +pub(crate) fn ladder_verbs(text: &str) -> Result, String> { + let doc: serde_yaml::Value = + serde_yaml::from_str(text).map_err(|e| format!("the ladder contract is not YAML: {e}"))?; + let seq = doc + .get("ladder") + .and_then(|l| l.get("cells")) + .and_then(|c| c.get("verbs")) + .and_then(serde_yaml::Value::as_sequence) + .ok_or("the ladder contract has no `ladder.cells.verbs` sequence")?; + Ok(seq + .iter() + .filter_map(|x| x.as_str().map(str::to_string)) + .collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The ladder-gap requirement can turn RED and can go GREEN: a typed subset leaves a gap, and the + /// surface's own keys close it. + #[test] + fn ladder_gap_case_table() { + let s = crate::surface::emit(); + let owed: BTreeSet = s + .commands + .iter() + .filter(|c| owes_model_cells(c)) + .map(|c| c.key.clone()) + .collect(); + assert!( + owed.len() >= 10, + "the surface owes only {} model-cell commands -- a vacuous universe proves nothing", + owed.len() + ); + assert!( + ladder_gap(&owed, &s).is_empty(), + "the full universe left a gap" + ); + let typed: BTreeSet = ["run", "chat"].iter().map(|x| (*x).to_string()).collect(); + assert!( + !ladder_gap(&typed, &s).is_empty(), + "a typed two-verb ladder left no gap" + ); + assert_eq!( + ladder_verbs("ladder:\n cells:\n verbs: [a, b]\n").expect("parses"), + ["a", "b"].iter().map(|x| (*x).to_string()).collect() + ); + assert!(ladder_verbs("ladder:\n cells: {}\n").is_err()); + } + + /// The model ladder owes a cell to every command in the release cell universe (#3745, quorum + /// q-3745 option A). A LAB check, run beside the tree scan in guards-nightly.yml (ticket #5231); the release path + /// still judges the ladder's own verb set and the anti-shrink floor as before. + #[test] + #[ignore = "LAB (#5231): the ladder-gap requirement runs in guards-nightly.yml, never on a PR path"] + fn the_ladder_owes_every_model_cell_command() { + let p = repo_root().join("contracts/model-capability-ladder-v1.yaml"); + let text = std::fs::read_to_string(&p).expect("the ladder contract is readable"); + let have = ladder_verbs(&text).expect("the ladder names its verbs"); + let gap = ladder_gap(&have, &crate::surface::emit()); + assert!( + gap.is_empty(), + "the model ladder owes no cell to {} command(s) of the release cell universe (#3745). Read from `apr surface --json`:\n {}", + gap.len(), + gap.iter().cloned().collect::>().join("\n ") + ); + } + + fn found(kind: Kind, text: &str, v: &Vocab) -> Vec { + scan(kind, text, v).expect("the fixture parses") + } + + /// The case table (S3.2). Every RED row is a list the guard must refuse; every GREEN row is a shape + /// that is not an enumeration and must stay quiet. Issue mutant 4 is the first row; the two + /// anti-bypass mutants the amendment names are the next ones. + #[test] + fn hand_list_guard_case_table() { + let v = vocab(); + assert!( + v.verbs.len() >= 10, + "the surface yielded only {} verbs -- a vacuous vocabulary proves nothing", + v.verbs.len() + ); + let reds: &[(&str, Kind, &str)] = &[ + ( + "mutant 4: a literal verb list in a gate script", + Kind::Shell, + "for v in run chat serve code; do\n echo $v\ndone\n", + ), + ( + "anti-bypass: padded with a non-verb", + Kind::Yaml, + "verbs: [run, chat, serve, code, zzz]\n", + ), + ( + "anti-bypass: split across adjacent literals (Python)", + Kind::Shell, + "python3 - <<'PY'\nV = [\"run\"] + [\"chat\", \"zzz\"]\nPY\n", + ), + ( + "anti-bypass: split across an array and its append (shell)", + Kind::Shell, + "V=(run zzz)\nV+=(chat)\n", + ), + ( + "mutant 4, comma-spelled in a string", + Kind::Shell, + "VERBS=\"run, chat, serve, code\"\n", + ), + ( + "a space-spelled verb list, assigned", + Kind::Shell, + "VERBS=\"run chat serve code\"\n", + ), + ( + "a comma-spelled verb list in a YAML scalar, padded", + Kind::Yaml, + "note: \"run, chat, serve, zzz\"\n", + ), + ("a shell array of verbs", Kind::Shell, "verbs=(run chat)\n"), + ( + "a case alternation of verbs", + Kind::Shell, + "case \"$v\" in\n run|chat) echo ok ;;\nesac\n", + ), + ( + "a Python heredoc list of verbs", + Kind::Shell, + "python3 - <<'PY'\nVERBS = [\"run\", \"serve\"]\nPY\n", + ), + ( + "a Rust const of verbs (the release_evidence.rs shape)", + Kind::Rust, + "pub const VERBS: [&str; 4] = [\"run\", \"chat\", \"serve\", \"code\"];\n", + ), + ( + "a YAML flow sequence of verbs (B1's ladder shape)", + Kind::Yaml, + "cells:\n verbs: [run, chat, serve, code]\n", + ), + ( + "a YAML block sequence of verbs", + Kind::Yaml, + "verbs:\n - run\n - serve\n", + ), + ( + "a YAML scalar spelling a verb list", + Kind::Yaml, + "verbs: \"run, chat\"\n", + ), + ( + "two flags of one verb", + Kind::Yaml, + "modes: [\"--gpu\", \"--no-gpu\"]\n", + ), + ( + "a shell array of flags of one verb", + Kind::Shell, + "MODES=(--gpu --no-gpu)\n", + ), + ( + "two flags of one verb in Rust", + Kind::Rust, + "let f = [\"--max-tokens\", \"--gpu\"];\n", + ), + ]; + for (what, kind, text) in reds { + assert!( + !found(*kind, text, &v).is_empty(), + "RED row stayed quiet: {what}\n{text}" + ); + } + let greens: &[(&str, Kind, &str)] = &[ + ( + "an invocation is not a list", + Kind::Shell, + "\"$APR\" run \"$m\" --gpu --max-tokens 16\n", + ), + ( + "an argv carries values", + Kind::Shell, + "PV_BINDARG=(--binding contracts/binding.yaml --crate-dir .)\n", + ), + ( + "a probe list checked against the surface (2 of 5 are verbs)", + Kind::Shell, + "for probe in mcp serve http api rpc; do\n :\ndone\n", + ), + ( + "a script's own option parser", + Kind::Shell, + "case \"$1\" in\n -q|--quiet) Q=1 ;;\n -h|--help) usage ;;\nesac\n", + ), + ( + "two-word prose that hits two verbs", + Kind::Yaml, + "if_fails: \"schema drift\"\n", + ), + ( + "a command string in a contract is an invocation", + Kind::Yaml, + "test: \"pv validate contracts/x.yaml\"\n", + ), + ( + "a log line naming a foreign verb chain", + Kind::Shell, + "ok \"probar llm test against $URL\"\n", + ), + ( + "an assignment that is a message, not a verb list", + Kind::Shell, + "MSG=\"run the chat gate now\"\n", + ), + ( + "one verb in a string", + Kind::Shell, + "echo \"run the gate\"\n", + ), + ( + "a comment naming verbs", + Kind::Shell, + "# run, chat, serve and code are derived now\n", + ), + ( + "a list of paths", + Kind::Yaml, + "dirs: [\"~/models\", \"~/.apr/models\"]\n", + ), + ( + "one verb among non-verbs", + Kind::Yaml, + "verbs: [run, walrus, otter]\n", + ), + ( + "a half-verb list is not a strict majority", + Kind::Yaml, + "mixed: [run, chat, walrus, otter]\n", + ), + ( + "a call with an expression is not an enumeration", + Kind::Rust, + "cmd.args([\"run\", path.as_str()]);\n", + ), + ( + "adjacent literals WITHOUT a join are two lists", + Kind::Rust, + "let a = [\"run\", \"zzz\"];\nlet b = [\"chat\", \"yyy\"];\n", + ), + // The three false REDs measured on main (2026-10-12), each a list of another vocabulary. + ( + "release stage names (release_ready.sh) are not apr verbs", + Kind::Shell, + "for st in merge tag publish; do\n :\ndone\n", + ), + ( + "dogfood row names (dogfood.sh) are not apr verbs", + Kind::Shell, + "DOGFOOD_LONG_ROWS=\"test coverage\"\n", + ), + ( + "cargo's own option parser, led by a `+toolchain` arm (lib_write_guard.sh)", + Kind::Shell, + "case $1 in\n +*|-q|--quiet|-v|-vv|--verbose|--locked|--frozen|--offline) shift ;;\nesac\n", + ), + ]; + for (what, kind, text) in greens { + let f = found(*kind, text, &v); + assert!(f.is_empty(), "GREEN row went RED: {what}: {f:?}"); + } + } + + /// The vocabulary is S1's surface restricted to the commands that owe model cells: every path token + /// of such a command is a verb, `surface` (no model, no generation) is not, and the flag axis is + /// judged per verb -- two of `run`'s own flags are RED. + #[test] + fn the_vocabulary_is_the_emitted_surface() { + let s = crate::surface::emit(); + let v = vocab_from(&s); + let owing: Vec<_> = s.commands.iter().filter(|c| owes_model_cells(c)).collect(); + assert!( + owing.len() >= 10, + "only {} commands owe model cells -- a vacuous universe proves nothing", + owing.len() + ); + for c in &owing { + for t in &c.path { + assert!( + v.verbs.contains(t), + "`{}` owes cells but `{t}` is not a verb", + c.key + ); + } + } + assert!( + !v.verbs.contains("surface"), + "`surface` takes no model and does not generate, so it is outside the cell universe" + ); + let run = v.flags.get("run").expect("the surface has a `run` command"); + let two: Vec = run + .iter() + .filter(|f| f.starts_with("--")) + .take(2) + .cloned() + .collect(); + assert_eq!(two.len(), 2, "`apr run` exposes {} long flags", two.len()); + assert!( + judge(&two, &v).is_some(), + "two of run's own flags {two:?} must be RED" + ); + } + + /// S3.3: there is no allow-list, and this guard's own fixtures are never scanned. + #[test] + fn the_scan_set_never_includes_this_guard() { + let root = repo_root(); + let set = scan_set(&root).expect("the scan set resolves"); + assert!( + set.len() >= 5, + "the scan set holds only {} files -- nothing would be judged", + set.len() + ); + let me = root.join("crates/apr-cli/src/hand_list_guard.rs"); + assert!( + !set.iter().any(|p| p == &me), + "the guard's own fixtures are in its scan set" + ); + } + + /// S3.1 on the real tree: every release surface is free of hand lists. A LAB check: it runs only in + /// guards-nightly.yml (`--ignored`), never on a PR, merge-queue or release path. + #[test] + #[ignore = "LAB (#3745 S3): the tree scan runs in guards-nightly.yml, never on a PR path"] + fn no_release_surface_hand_lists_apr_verbs_or_flags() { + let v = vocab(); + let root = repo_root(); + let set = scan_set(&root).expect("the scan set resolves"); + let mut bad = Vec::new(); + for p in &set { + let rel = p.strip_prefix(&root).unwrap_or(p).display().to_string(); + let Ok(text) = std::fs::read_to_string(p) else { + bad.push(format!("{rel}: declared but unreadable -- a gate that cannot be read is not a clean gate")); + continue; + }; + match scan( + kind_of(p).expect("the scan set holds only known kinds"), + &text, + &v, + ) { + Ok(f) => bad.extend(f.into_iter().map(|f| format!("{rel}:{f}"))), + Err(e) => bad.push(format!("{rel}: {e}")), + } + } + assert!( + bad.is_empty(), + "{} hand list(s) of apr verbs/flags on release surfaces (#3745 S3). Derive them from `apr surface --json` instead:\n {}", + bad.len(), + bad.join("\n ") + ); + } +} diff --git a/crates/apr-cli/src/help_producer_truth.rs b/crates/apr-cli/src/help_producer_truth.rs index 8d7d3df7c7..9ddb2fe53f 100644 --- a/crates/apr-cli/src/help_producer_truth.rs +++ b/crates/apr-cli/src/help_producer_truth.rs @@ -61,80 +61,104 @@ fn resolve(root: &clap::Command, quoted: &str) -> Result<(), String> { for tok in tokens { if let Some(flag) = tok.strip_prefix("--") { - awaiting_value = false; - let name = flag.split('=').next().unwrap_or(flag); - if name.is_empty() { - continue; // bare `--` - } - let matches_long = |a: &clap::Arg| { - a.get_long() == Some(name) - || a.get_all_aliases() - .is_some_and(|al| al.iter().any(|x| *x == name)) - }; - // clap propagates `global = true` args from the root to every - // subcommand, so `--json` is legal on any of them. - let Some(arg) = cmd - .get_arguments() - .find(|a| matches_long(a)) - .or_else(|| root.get_arguments().find(|a| a.is_global_set() && matches_long(a))) - else { - return Err(format!("`{path}` has no flag `--{name}`")); - }; - awaiting_value = !flag.contains('=') - && arg - .get_num_args() - .is_none_or(|r| r.takes_values()) - && arg.get_action().takes_values(); + awaiting_value = long_flag_takes_value(root, cmd, &path, flag)?; continue; } if let Some(short) = tok.strip_prefix('-') { - // Short flags are not name-checked (help text uses them rarely), - // but a short flag that takes a value consumes the next word. - let c = short.chars().next(); - awaiting_value = c.is_some_and(|c| { - cmd.get_arguments() - .chain(root.get_arguments().filter(|a| a.is_global_set())) - .any(|a| a.get_short() == Some(c) && a.get_action().takes_values()) - }) && short.len() == 1; + awaiting_value = short_flag_takes_value(root, cmd, short); continue; } - if tok.starts_with('<') || tok.starts_with('"') { - awaiting_value = false; - continue; // placeholders are not checked - } - if awaiting_value { + if tok.starts_with('<') || tok.starts_with('"') || awaiting_value { + // placeholders are not checked; a bare word after a value-taking + // flag is that flag's value awaiting_value = false; - continue; // this bare word is the previous flag's value - } - // A bare word is a subcommand while the command still has subcommands - // and has not started consuming positionals; otherwise it is a - // positional VALUE — and there are only so many of those. - let sub = if positionals_used == 0 { - cmd.get_subcommands() - .find(|s| s.get_name() == tok || s.get_all_aliases().any(|a| a == tok)) - } else { - None - }; - if let Some(sub) = sub { - cmd = sub; - path = format!("{path} {tok}"); continue; } - if positionals_used < positional_capacity(cmd) { - positionals_used += 1; - continue; - } - if cmd.get_subcommands().next().is_some() { - return Err(format!("`{path}` has no subcommand `{tok}`")); + match bare_word(cmd, &path, tok, positionals_used)? { + Some(sub) => { + cmd = sub; + path = format!("{path} {tok}"); + } + None => positionals_used += 1, } - return Err(format!( - "`{path}` takes {} positional(s); `{tok}` is one too many", - positional_capacity(cmd) - )); } Ok(()) } +/// Check a `--flag[=value]` against `cmd` (and the root's global args); returns +/// whether the NEXT word is its value. +fn long_flag_takes_value( + root: &clap::Command, + cmd: &clap::Command, + path: &str, + flag: &str, +) -> Result { + let name = flag.split('=').next().unwrap_or(flag); + if name.is_empty() { + return Ok(false); // bare `--` + } + let matches_long = |a: &clap::Arg| { + a.get_long() == Some(name) + || a.get_all_aliases() + .is_some_and(|al| al.iter().any(|x| *x == name)) + }; + // clap propagates `global = true` args from the root to every + // subcommand, so `--json` is legal on any of them. + let Some(arg) = cmd + .get_arguments() + .find(|a| matches_long(a)) + .or_else(|| root.get_arguments().find(|a| a.is_global_set() && matches_long(a))) + else { + return Err(format!("`{path}` has no flag `--{name}`")); + }; + Ok(!flag.contains('=') + && arg + .get_num_args() + .is_none_or(|r| r.takes_values()) + && arg.get_action().takes_values()) +} + +/// Short flags are not name-checked (help text uses them rarely), but a short +/// flag that takes a value consumes the next word. +fn short_flag_takes_value(root: &clap::Command, cmd: &clap::Command, short: &str) -> bool { + let c = short.chars().next(); + c.is_some_and(|c| { + cmd.get_arguments() + .chain(root.get_arguments().filter(|a| a.is_global_set())) + .any(|a| a.get_short() == Some(c) && a.get_action().takes_values()) + }) && short.len() == 1 +} + +/// A bare word is a subcommand while the command still has subcommands and has +/// not started consuming positionals (`Ok(Some(sub))`); otherwise it is a +/// positional VALUE (`Ok(None)`) — and there are only so many of those. +fn bare_word<'a>( + cmd: &'a clap::Command, + path: &str, + tok: &str, + positionals_used: usize, +) -> Result, String> { + let sub = if positionals_used == 0 { + cmd.get_subcommands() + .find(|s| s.get_name() == tok || s.get_all_aliases().any(|a| a == tok)) + } else { + None + }; + if sub.is_some() { + return Ok(sub); + } + if positionals_used < positional_capacity(cmd) { + return Ok(None); + } + if cmd.get_subcommands().next().is_some() { + return Err(format!("`{path}` has no subcommand `{tok}`")); + } + Err(format!( + "`{path}` takes {} positional(s); `{tok}` is one too many", + positional_capacity(cmd) + )) +} + /// How many bare words this command can swallow as positional values. /// A variadic positional (`num_args` with no upper bound) means unlimited. fn positional_capacity(cmd: &clap::Command) -> usize { diff --git a/crates/apr-cli/src/lib.rs b/crates/apr-cli/src/lib.rs index fecd2149b5..42c624d635 100644 --- a/crates/apr-cli/src/lib.rs +++ b/crates/apr-cli/src/lib.rs @@ -62,6 +62,10 @@ mod output; pub mod pipe; // #3745 S1: the surface the binary actually has, emitted by the binary. pub mod surface; +// #3745 S3: release surfaces may not hand-list apr's verbs or flags. Test-only; the tree +// scan is a LAB check that runs in guards-nightly.yml, never on a PR path. +#[cfg(test)] +mod hand_list_guard; pub use error::CliError; diff --git a/docs/roadmaps/entries/PMAT-3745.yaml b/docs/roadmaps/entries/PMAT-3745.yaml new file mode 100644 index 0000000000..310e45ab62 --- /dev/null +++ b/docs/roadmaps/entries/PMAT-3745.yaml @@ -0,0 +1,18 @@ +- id: PMAT-3745 + github_issue: 3745 + item_type: task + title: '#3745 S3: hand-list guard (LAB nightly) + hand-list migration' + status: planned + priority: medium + assigned_to: null + created: 2026-10-12T16:14:06Z + updated: 2026-10-12T16:14:06Z + spec: null + acceptance_criteria: [] + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + notes: null + epic: 3863 diff --git a/docs/roadmaps/entries/PMAT-5227.yaml b/docs/roadmaps/entries/PMAT-5227.yaml new file mode 100644 index 0000000000..1e6b4a6dfd --- /dev/null +++ b/docs/roadmaps/entries/PMAT-5227.yaml @@ -0,0 +1,18 @@ +- id: PMAT-5227 + github_issue: 5227 + item_type: task + title: apr-cli lib.rs include!d fns exceed complexity thresholds + status: planned + priority: medium + assigned_to: null + created: 2026-10-12T16:33:54Z + updated: 2026-10-12T16:33:54Z + spec: null + acceptance_criteria: [] + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + notes: null + epic: 3863 diff --git a/docs/roadmaps/roadmap.yaml b/docs/roadmaps/roadmap.yaml index e1d52bc8d3..1a5c2549c7 100644 --- a/docs/roadmaps/roadmap.yaml +++ b/docs/roadmaps/roadmap.yaml @@ -21550,6 +21550,24 @@ Not done, pending the ruling: moving the 31 fixture files to an obviously synthe labels: - kind:code notes: 'SCOPE, as the cop ruled: the FIX already landed as #3672 in batch-1 #3707 (a9502d992). run_prompt_and_chat hands realizar the RAW prompt and `force_chat_template`, and prepare_tokens applies the model''s template once. This row does not re-fix dispatch_run.rs. It adds only what #3672 lacked: the three-shape equality test and the dogfood check. MEASURED (done_when 1) on lambda, CPU, Qwen3.5-0.8B-Q4_K_M, --verbose `[DEBUG] formatted_prompt` + `encoded N tokens`, same text "What is 2+2? Answer with one number.". `apr 0.69.0 (52f43da71)`: `--prompt P --chat` 56 tokens, positional `P --chat` 56, `-i file --chat` 24; the two 56s carry the zero-width escape and their formatted-prompt sha (cbbfe515524c) differs from the file shape (fe8052ef3bac). `apr 0.69.0 (a9502d992)` (batch-1): 24 == 24 == 24, identical formatted-prompt sha fe8052ef3bac on all three, no zero-width. TEST (done_when 1): lib_execute_export_convert.rs test_3743_three_prompt_shapes_build_the_same_realizar_config parses the three argv spellings with clap (on a 16 MB thread), feeds each through run_prompt_and_chat exactly as dispatch_run does, and builds realizar''s config through the new `commands::run::realizar_config` (execute_with_realizar''s config building, extracted unchanged). It asserts every shape reaches realizar as the raw text with force_chat_template set, so the formatted prompt, a pure function of that config and the model, is byte-identical. RED at 0.69.0, whose shape was the CLI-side ChatML wrap. The wrap-restored mutant (run_prompt_and_chat wrapping when chat_template) turns it RED: `left: Some("<|im_start|>user\nWhat is 2+2? ...")`. THINKING TOGGLE (done_when 2): on main neither `apr run` nor realizar''s InferenceConfig has an `enable_thinking`/thinking flag yet (#3723, aprender-fd). The apr-cli side no longer wraps anything, so nothing can re-wrap or drop it. `realizar_config()` is now the single place a thinking flag must be forwarded, and fd was told the seam. This criterion cannot be closed until #3723 adds the flag, so the PR says Refs, not Closes. DOGFOOD (done_when 3): scripts/model_ladder.sh''s per-backend run cell adds --verbose and sets `escaped_special` when realizar''s `formatted_prompt=` line carries `\u{200b}` (Debug-escaped) or the raw U+200B. The rung is then not green, and scripts/check_model_ladder.sh (the [package.metadata.dogfood] gate) fails it: "templated twice (#3743)". Old receipts without the field read as not escaped. New case scripts/lib/model_ladder_cases/red-escaped-special; `check_model_ladder.sh --self-test` 19 cases, 0 bad. Mutant: judge rule removed -> "FAIL case red-escaped-special rc=0 want=1". The ladder''s exact cell and grep on qwen2.5-coder-0.5b-instruct-q4_k_m.gguf (instruct-named, so 0.69.0''s name heuristic wrapped it): 52f43da71 -> escaped_special=true (`<\u{200b}|im_start|>user` visible); a9502d992 -> false. CRUX: this repo holds no CRUX run cell that feeds `apr run` a prompt; the CRUX probes live in the nightly-ux-crux skill, and that check is named for its owner. SUITES at this commit: `cargo test -p apr-cli --lib` 7301 passed; `-p aprender-contracts --lib` 1689 passed; clippy -D warnings apr-cli clean; fmt clean; bashrs lint findings unchanged vs main (model_ladder.sh 45=45, check_model_ladder.sh 18=18).' +- id: PMAT-3745 + github_issue: 3745 + item_type: task + title: '#3745 S3: hand-list guard (LAB nightly) + hand-list migration' + status: planned + priority: medium + assigned_to: null + created: 2026-10-12T16:14:06Z + updated: 2026-10-12T16:14:06Z + spec: null + acceptance_criteria: [] + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + notes: null + epic: 3863 - id: PMAT-3747 github_issue: 3747 item_type: task @@ -25196,6 +25214,24 @@ Not done, pending the ruling: moving the 31 fixture files to an obviously synthe - kind:code notes: null epic: 4741 +- id: PMAT-5227 + github_issue: 5227 + item_type: task + title: apr-cli lib.rs include!d fns exceed complexity thresholds + status: planned + priority: medium + assigned_to: null + created: 2026-10-12T16:33:54Z + updated: 2026-10-12T16:33:54Z + spec: null + acceptance_criteria: [] + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + notes: null + epic: 3863 - id: PMAT-5282 github_issue: 5282 item_type: task diff --git a/scripts/guards_nightly_manifest.txt b/scripts/guards_nightly_manifest.txt index 2ecedda874..423ddca459 100644 --- a/scripts/guards_nightly_manifest.txt +++ b/scripts/guards_nightly_manifest.txt @@ -29,6 +29,9 @@ # and fails closed if a line here names a step nothing runs), and it is also # what keeps scripts/check_guards_are_wired.sh finding these guards wired -- # that guard greps every file under .github/workflows/, not ci.yml alone. +# +# The last two lines are the hand-list guard tree scan (#3745 S3) and the ladder-gap requirement (#5231), LAB checks. Their seconds are a +# warm local run, 2026-10-12, not a CI run; the build is shared with the steps above. 586 Test-tier decision case table (BSE-17): quick/full/reuse, drift is ENV 405 Perf gate can still discriminate (APR-PERF-GATE-001, case table) 256 model-tests falsification suites (was gated by nothing, aprender#2522) -- SATD ceiling measured on the comparand (BSE-03) @@ -37,6 +40,8 @@ 135 No test may assert about the fd 0 it inherited 130 Publish safety (binary/oversize files in published packages) 94 aprender-core builds for wasm32 +1 Release surfaces may not hand-list apr verbs or flags (#3745 S3) +1 The model ladder owes cells to every model-cell command (#5231) # #5182 (GATE/LAB): two ratchets that may not sit on a PR path. Seconds are a # local wall-clock sample (2026-10-12, not a CI run); the first nightly replaces them. 19 Per-function complexity may only fall (shrink-only) diff --git a/scripts/release/release_readiness.sh b/scripts/release/release_readiness.sh index 167d78f706..cbb3669641 100755 --- a/scripts/release/release_readiness.sh +++ b/scripts/release/release_readiness.sh @@ -126,7 +126,7 @@ PY judge() { # judge mode root version commit receipts dogfood out local mode="$1" root="$2" version="$3" commit="$4" receipts="$5" dogfood="$6" out="$7" surface="${8:-}" local scope="${9:-}" - local pv pvv rc rx sum label crux cf e + local pv pvv rc rx sum label crux cf e ncells nref local -a args pv="${RELEASE_READINESS_PV:-}" if [ -z "$pv" ]; then @@ -162,12 +162,18 @@ judge() { # judge mode root version commit receipts dogfood out ( cd "$root" && "$pv" "${args[@]}" ) > "$out" 2>&1; rc=$? label="$SHAPE for $version at ${commit:0:12}${scope}" case "$rc" in - 0) echo "ok R8 $label: Pass" + 0) # #3745: the cells pv derived from the surface and judged. A Pass over zero derived cells judged + # nothing, so it is not a pass; the count goes on the verdict line for the tag and the publish. + ncells="$(derived_cells "$out" cells)"; nref="$(derived_cells "$out" refusal_cells)" + case "$ncells" in + ""|0) echo "FAIL R8 $label: pv passed with ${ncells:-no} derived release cells, so no cell was judged (#3745)"; return 2 ;; + esac + echo "ok R8 $label: Pass, $ncells derived cells (${nref:-0} owed as a typed refusal)" # #3715 B1 (operator 2026-09-28): the ONE line autopilot cut_tag() requires before `git tag`. # Printed only for an enforced Pass, so a weakened run, a skipped step or an absent log # all leave the tag refused. out_sha256 is pv's verdict output, for the receipt. if [ "$mode" = enforce ]; then - echo "ok R8 #3715 ENFORCE PASS version=$version commit=$commit pv=${pvv:-unknown} out_sha256=$(sha256sum < "$out" | cut -c1-64)" + echo "ok R8 #3715 ENFORCE PASS version=$version commit=$commit pv=${pvv:-unknown} out_sha256=$(sha256sum < "$out" | cut -c1-64) cells=$ncells refusals=${nref:-0}" fi return 0 ;; 1) @@ -181,6 +187,12 @@ judge() { # judge mode root version commit receipts dogfood out esac } +# derived_cells out key -> prints release. from pv's shapes-gate JSON (ReleaseStats, #3745), empty when absent. +# The key sits before any nested object of the release stats, so the first match after "release" is its own. +derived_cells() { + perl -0ne 'print $1 if /"release"\s*:\s*\{[^{}]*?"\Q'"$2"'\E"\s*:\s*(\d+)/s' -- "$1" +} + # policy_scope root version -> rc 0 and RR_SCOPE (the verdict-line suffix; empty when the policy does not # cover version), rc 1 a per-release entry for a covered version, rc 2 not measured. RP_WHY says why. # Called in the current shell (not in $( )) so RR_SCOPE and RP_WHY survive. @@ -262,7 +274,9 @@ selftest() { printf '%s\n' "$*" > "${FX_ARGS:?}" case "${FX_PV_BODY:-fail}" in fail) printf '{"gate":"shapes","verdict":"Fail","extra":{"violations":3,"by_shape":["release-readiness-v1.kernel=0","release-readiness-v1=3"]}}\nreject: lint failed\n' ;; - pass) printf '{"gate":"shapes","verdict":"Pass","extra":{"violations":0,"by_shape":[]}}\n' ;; + pass) printf '{"gate":"shapes","verdict":"Pass","extra":{"violations":0,"by_shape":[],"release":{"models":2,"cells":12,"refusal_cells":3,"surface":{"commands":4}}}}\n' ;; + passnocells) printf '{"gate":"shapes","verdict":"Pass","extra":{"violations":0,"by_shape":[],"release":{"models":0,"cells":0,"refusal_cells":0}}}\n' ;; + passnorelease) printf '{"gate":"shapes","verdict":"Pass","extra":{"violations":0,"by_shape":[]}}\n' ;; junk) printf 'thread main panicked\n' ;; esac exit "${FX_PV_RC:-1}" @@ -306,6 +320,11 @@ STUB d="$tmp/r"; mk "$d"; x="$(git -C "$d" rev-parse HEAD~1)"; c="$(git -C "$d" rev-parse HEAD)" row pass_is_ok 0 "ok R8 release-readiness-v1 for 1.2.3" "$d" FX_PV_RC=0 FX_PV_BODY=pass row pass_prints_the_enforce_line 0 "ok R8 #3715 ENFORCE PASS version=1.2.3 commit=$(git -C "$d" rev-parse HEAD) pv=pv_9.9.9-stub out_sha256=" "$d" FX_PV_RC=0 FX_PV_BODY=pass + row pass_line_counts_cells_and_refusals 0 "Pass, 12 derived cells (3 owed as a typed refusal)" "$d" FX_PV_RC=0 FX_PV_BODY=pass + row enforce_line_carries_cells 0 " cells=12 refusals=3" "$d" FX_PV_RC=0 FX_PV_BODY=pass + row pass_over_zero_cells_declines 2 "pv passed with 0 derived release cells" "$d" FX_PV_RC=0 FX_PV_BODY=passnocells + row pass_without_release_stats_declines 2 "pv passed with no derived release cells" "$d" FX_PV_RC=0 FX_PV_BODY=passnorelease + row pass_over_zero_cells_has_no_enforce_line 2 "!ENFORCE PASS" "$d" FX_PV_RC=0 FX_PV_BODY=passnocells argrow pass_asks_the_shape "--gate shapes --shape release-readiness-v1 --release-version 1.2.3 --release-commit $c" argrow receipts_commit_earned_is_passed "--receipts-commit $x" argrow no_surface_is_not_invented "!--surface" diff --git a/scripts/tree_reader_tests.txt b/scripts/tree_reader_tests.txt index 740da7de6a..5315c8d0da 100644 --- a/scripts/tree_reader_tests.txt +++ b/scripts/tree_reader_tests.txt @@ -25,6 +25,7 @@ apr-cli --lib commands::serve apr-cli --lib commands::serve_plan apr-cli --lib commands::test_llm_band apr-cli --lib commands::tokenize +apr-cli --lib hand_list_guard apr-cli --test backend_refusal_case_table apr-cli --test beat_apr_sibling_cli_reach apr-cli --test capability_mirror