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
10 changes: 8 additions & 2 deletions docs/brief.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,14 @@ batch (the service's `--rally-openers`). It's declared and changes no story's st
`rally batch end ... --gate-log <path>` records the gate's own log with the end. Rally hashes the file (sha256 and
size; nothing is typed) and reads its result lines with one fixed parser, for DiscoSim's `dsm-gate`: the first
line `# dsm-gate: commit <sha> tree <sha> scenes: ... started <time> script sha256 <hex>`, `QUICK OK`, and each
scene's `=== N passed, M failed ===`. A log without that first line is refused plainly, and the service refuses a
log whose commit isn't the batch's tip: the wrong log for the wrong batch, or a gate run on a stale tip.
suite's or scene's `=== N passed, M failed ===` (or `..., K skipped ===`). Since RAL-181 that's every suite: the
QUICK phase's (`== test_sim ==`, `== test_layout (what it covers) ==`) by name as well as the scenes
(`== res://tests/<scene>.tscn ==`), about 112 a gate, where before only the three scenes were. A QUICK suite with
no count line, such as the bench (judged by its own verdict suite), is covered by `QUICK OK` and left out; without
a `QUICK OK`, it's kept as giving no result. At most 256 are recorded, and the service checks that bound too, so
a CLI from RAL-181 on needs a service from RAL-181 on to record a full log. A log without that first line is
refused plainly, and the service refuses a log whose commit isn't the batch's tip: the wrong log for the wrong
batch, or a gate run on a stale tip.

What it is worth, and what it isn't. The hash binds the log the watchman keeps to the one it named when the batch
ended, so a log changed afterwards is caught by anyone who has it. It doesn't show that the gate ran: the
Expand Down
6 changes: 6 additions & 0 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -1405,6 +1405,12 @@ principles named were durable, accurate and reactive. Found in rally 0x7 by plus
roadmap with the default driver and named it a conflict resolved, where a checkout with the attributes named
none. Done when: each merge is remerged with its first parent's `.gitattributes` (`--attr-source`), the output
says which attributes it used, and a test checks a checkout without the attributes gets the head's answer.
- [ ] **RAL-181: `batch end --gate-log` records every suite, the QUICK phase's too.** From reading the gate logs
for RAL-180 (2026-11-14): the parser counted only the three `res://tests/` scenes of about 112 suites. Done when:
each `== <suite> ==` or `== <suite> (what it covers) ==` header is recorded by name with its counts, beside the
scenes; a `, K skipped` count reads; a QUICK suite with no count line (the bench) is covered by `QUICK OK` and
left out, kept as giving no result when there's no `QUICK OK`; the bound is 256; every real `.ok` log on Murph
reads green; with tests (docs/brief.md).
- [ ] **RAL-180: The digest tells a locally gated project's merges from its gate, observed.** From the watchman
(2026-11-14): every DiscoSim landing showed as "the base's CI couldn't be checked: no repository in the
evidence", five lines a train, noise that would hide a real red. Done when: a merge whose story has no evidence
Expand Down
102 changes: 84 additions & 18 deletions src/gate_log.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,19 @@
//!
//! The format is DiscoSim's `dsm-gate` (2026-11-13 on): a first line
//! `# dsm-gate: commit <sha> tree <sha> scenes: <scenes> started <time> script sha256 <hex>`, then
//! `QUICK OK` (or `QUICK OK (...)`), and for each scene a `== res://tests/<scene>.tscn ==` header
//! followed by `=== N passed, M failed ===`. A log without the first line is refused plainly
//! rather than guessed at.
//! the QUICK phase, a suite at a time (`== test_sim ==`, or `== test_layout (what it covers) ==`),
//! then `QUICK OK` (or `QUICK OK (...)`), and for each scene a `== res://tests/<scene>.tscn ==`
//! header. Each suite or scene header is followed by `=== N passed, M failed ===`, and all of them
//! are recorded, the QUICK phase's suites by name and the scenes by theirs (RAL-181: before, only
//! the scenes were, three of about 113). A log without the first line is refused plainly rather
//! than guessed at.

use serde::{Deserialize, Serialize};

/// The longest log read, so a wrong path to a huge file isn't read whole.
pub const MAX_BYTES: u64 = 64 * 1024 * 1024;
/// The most scenes recorded.
pub const MAX_SCENES: usize = 64;
/// The most suites and scenes recorded (RAL-181: DiscoSim's gate runs about 113).
pub const MAX_SCENES: usize = 256;

/// One scene's result line, or that its header had none.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
Expand Down Expand Up @@ -75,6 +78,24 @@ fn after<'a>(line: &'a str, key: &str) -> Option<&'a str> {
rest.split_whitespace().next()
}

/// A suite or scene header's name: `== res://tests/<scene>.tscn ==` gives the scene, and
/// `== <suite> ==` or `== <suite> (what it covers) ==` the suite (RAL-181). A `=== ... ===` result
/// line isn't one.
fn suite_header(line: &str) -> Option<&str> {
if line.starts_with("===") {
return None;
}
let inner = line.strip_prefix("== ")?.strip_suffix(" ==")?.trim();
if let Some(scene) = inner
.strip_prefix("res://tests/")
.and_then(|rest| rest.strip_suffix(".tscn"))
{
return Some(scene);
}
let name = inner.split(" (").next().unwrap_or(inner).trim();
(!name.is_empty() && !name.contains(char::is_whitespace)).then_some(name)
}

/// Reads a `dsm-gate` log's lines. `path`, `sha256` and `bytes` are the caller's, which read the
/// file; this only parses, so it can be tested without one.
pub fn parse(text: &str, path: &str, sha256: &str, bytes: u64) -> Result<GateLog, String> {
Expand Down Expand Up @@ -104,10 +125,10 @@ pub fn parse(text: &str, path: &str, sha256: &str, bytes: u64) -> Result<GateLog
let line = line.trim();
if quick.is_none() && line.starts_with("QUICK OK") {
quick = Some(line.chars().take(200).collect());
} else if let Some(scene) = line
.strip_prefix("== res://tests/")
.and_then(|rest| rest.strip_suffix(".tscn =="))
{
// A QUICK suite with no count line, such as the bench, which is judged by its own
// verdict suite, is covered by the gate's QUICK OK and left out (RAL-181).
scenes.retain(|s| s.passed.is_some());
} else if let Some(scene) = suite_header(line) {
if scenes.len() >= MAX_SCENES {
return Err(format!("the log has more than {MAX_SCENES} scenes"));
}
Expand All @@ -121,15 +142,11 @@ pub fn parse(text: &str, path: &str, sha256: &str, bytes: u64) -> Result<GateLog
.and_then(|rest| rest.strip_suffix(" ==="))
&& let Some(last) = scenes.last_mut().filter(|s| s.passed.is_none())
{
// "N passed, M failed"
let mut words = counts.split([' ', ',']).filter(|w| !w.is_empty());
if let (Some(p), Some("passed"), Some(f), Some("failed"), None) = (
words.next(),
words.next(),
words.next(),
words.next(),
words.next(),
) && let (Ok(p), Ok(f)) = (p.parse(), f.parse())
// "N passed, M failed", or with ", K skipped" after (RAL-181).
let words: Vec<&str> = counts.split([' ', ',']).filter(|w| !w.is_empty()).collect();
if let [p, "passed", f, "failed", rest @ ..] = words.as_slice()
&& (rest.is_empty() || matches!(rest, [k, "skipped"] if k.parse::<u64>().is_ok()))
&& let (Ok(p), Ok(f)) = (p.parse(), f.parse())
{
last.passed = Some(p);
last.failed = Some(f);
Expand Down Expand Up @@ -241,4 +258,53 @@ mod tests {
assert_eq!(out.sha256, hex::encode(Sha256::digest(text.as_bytes())));
assert_eq!(out.bytes, text.len() as u64);
}

/// RAL-181: the QUICK phase's suites are recorded too, by name, beside the scenes, as today's
/// logs have them; a result line or a heading with words isn't a suite.
#[test]
fn every_suite_is_recorded_the_quick_phases_too() {
let text = format!(
"# dsm-gate: commit {} tree {} scenes: x started t script sha256 0\n\
== test_sim ==\nGodot Engine v4\n=== DISCOSIM test_sim ===\n PASS 1\n\
=== 65 passed, 0 failed ===\n\
== test_layout (build catalogue, rotation, layout saves) ==\n\
=== DISCOSIM test_layout ===\n=== 232 passed, 1 failed ===\n\
== not a suite name ==\n\
== test_club_nav (the sim's walk graph) ==\n=== 25 passed, 0 failed, 0 skipped ===\n\
== bench (ms per sim tick by crowd size) ==\nBENCH guests>=0 mean=0.5\n\
QUICK OK\n\
== res://tests/test_ui_flow.tscn ==\n=== DISCOSIM test_ui_flow ===\n\
=== 267 passed, 0 failed ===\n",
"a".repeat(40),
"b".repeat(40)
);
let out = parse(&text, "p", &"f".repeat(64), 1).unwrap();
let got: Vec<(&str, Option<u64>, Option<u64>)> = out
.scenes
.iter()
.map(|s| (s.scene.as_str(), s.passed, s.failed))
.collect();
assert_eq!(
got,
[
("test_sim", Some(65), Some(0)),
("test_layout", Some(232), Some(1)),
("test_club_nav", Some(25), Some(0)),
("test_ui_flow", Some(267), Some(0)),
]
);
// The bench gives no count and is covered by QUICK OK. Without a QUICK OK, a suite with
// no result is kept, and counts as giving none.
let no_quick = text.replace("QUICK OK\n", "");
let bare = parse(&no_quick, "p", &"f".repeat(64), 1).unwrap();
assert!(
bare.scenes
.iter()
.any(|s| s.scene == "bench" && s.passed.is_none())
);
assert!(!out.scenes.iter().any(|s| s.scene == "bench"));
assert_eq!(bare.failures(), (1, 1));
assert_eq!(out.failures(), (1, 0));
assert_eq!(out.quick.as_deref(), Some("QUICK OK"));
}
}
Loading