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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
/target
.DS_Store
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion conformance-check/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ description = "GASP conformance checker — verifies an agent repo against Part
license = "MIT"

[dependencies]
yoagent-state = "0.4"
yoagent-state = "0.5"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
Expand Down
78 changes: 78 additions & 0 deletions conformance-check/build.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
//! Records which `yoagent-state` the checker folds with, so the report can say so.
//!
//! The checker answers "can a conformant runtime fold and restore this store?"
//! by folding the store itself. That answer is only meaningful if the fold it
//! uses matches the fold runtimes use — and when it drifted, the skew was
//! discoverable only by reading two lockfiles.
//!
//! A certificate that does not say what produced it cannot be audited.
//!
//! Reads the workspace lockfile rather than guessing: cargo exposes no env var
//! for a dependency's resolved version, and the requirement in `Cargo.toml`
//! ("0.5") is not what was actually built.

use std::path::PathBuf;

fn main() {
// Both layouts, in this order. `cargo package` ships the lockfile at the
// *package* root, beside this build script — so looking only at the
// workspace parent worked in development and printed "unknown" for every
// installed build, which is the one place the line has to work. Cargo also
// regenerates the workspace lock before build scripts run, so the failure
// was unreachable in-tree.
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let lock = [manifest.join("Cargo.lock"), manifest.join("../Cargo.lock")]
.into_iter()
.find(|p| p.exists());

let version = lock
.as_ref()
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|body| resolved_version(&body, "yoagent-state"))
// Not a build failure: a missing or restructured lockfile should not
// stop the checker from running, only from naming its fold version.
.unwrap_or_else(|| "unknown".to_string());

println!("cargo:rustc-env=GASP_STATE_VERSION={version}");
if let Some(p) = lock {
println!("cargo:rerun-if-changed={}", p.display());
}
}

/// Every `version = "x"` following a `name = "<crate>"` in a Cargo.lock.
///
/// All of them, not the first. Cargo sorts by name then version ascending, so
/// taking the first reports the *lowest* when two versions resolve — and this
/// line exists precisely because 0.4 and 0.5 fold differently, so naming the
/// wrong one is worse than naming none. Ambiguity is reported as such.
fn resolved_version(lock: &str, crate_name: &str) -> Option<String> {
let needle = format!("name = \"{crate_name}\"");
let mut found: Vec<String> = Vec::new();
let lines: Vec<&str> = lock.lines().collect();
let mut in_package = false;
for (i, line) in lines.iter().enumerate() {
let trimmed = line.trim();
// Only `[[package]]` entries describe what was resolved. Cargo also
// emits `[[patch.unused]]` in the same shape, so collecting every
// matching name reported `ambiguous(...)` when a single version had
// resolved — reachable with `[patch.crates-io] yoagent-state = { path
// = ... }`, which is the obvious way to test a local fix.
if trimmed.starts_with("[[") {
in_package = trimmed == "[[package]]";
}
if !in_package || trimmed != needle {
continue;
}
if let Some(v) = lines
.get(i + 1)
.and_then(|l| l.trim().strip_prefix("version = "))
{
found.push(v.trim_matches('"').to_string());
}
}
match found.len() {
0 => None,
1 => found.pop(),
_ => Some(format!("ambiguous({})", found.join(","))),
}
}
81 changes: 62 additions & 19 deletions conformance-check/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use sha2::{Digest, Sha256};
use std::collections::{BTreeSet, HashMap, HashSet};
use std::path::Path;
use std::process::Command;
use yoagent_state::{replay, Event, Graph, Pack, StateOp};
use yoagent_state::{replay, replay_with_diagnostics, Event, Graph, Pack, StateOp};

pub const EVENTS_PATH: &str = "state/events.jsonl";

Expand Down Expand Up @@ -205,14 +205,40 @@ pub fn check_envelope(lines: &[String]) -> CheckReport {
/// `state/events.jsonl` (including their newlines).
pub fn check_replay(repo: &Path, events: &[Event], raw_log: &str) -> CheckReport {
let mut report = CheckReport::new(2, "replay");
if let Err(e) = replay(events) {
report.failures.push(format!("fold failed: {e}"));
return report;
// Fold exactly as a conformant runtime does, not more strictly.
//
// This check asks "can a conformant runtime fold and restore this store?".
// Since yoagent-state 0.5.1 the answer for a log containing an op that
// references a missing node is *yes* — the op is skipped and the fold
// completes. Failing here would report non-conformance for a store every
// runtime can restore, which inverts what the badge means.
//
// But the skip is not swallowed: an op the graph cannot represent is worth
// a reader's attention even when it is survivable, and a certificate that
// hides it is not auditable. It lands in `notes`, which does not fail the
// check.
match replay_with_diagnostics(events) {
Err(e) => {
report.failures.push(format!("fold failed: {e}"));
return report;
}
Ok((_graph, skipped)) => {
for s in &skipped {
// The reason already names the node, so do not repeat it.
report.notes.push(format!(
"{} at batch index {} skipped — {}. The log is readable but malformed; a \
conformant runtime folds past this, and so does this check",
s.op, s.index, s.reason,
));
}
}
}

let snapshots = repo.join("snapshots");
if !snapshots.is_dir() {
report.notes.push("no snapshots/ — skipped seed check".into());
report
.notes
.push("no snapshots/ — skipped seed check".into());
return report;
}
let entries = match std::fs::read_dir(&snapshots) {
Expand Down Expand Up @@ -252,14 +278,16 @@ pub fn check_replay(repo: &Path, events: &[Event], raw_log: &str) -> CheckReport
let value: Value = match serde_json::from_str(&raw) {
Ok(v) => v,
Err(e) => {
report.failures.push(format!("snapshot {name}: not JSON: {e}"));
report
.failures
.push(format!("snapshot {name}: not JSON: {e}"));
continue;
}
};
let (Some(graph_v), Some(integrity)) = (value.get("graph"), value.get("integrity")) else {
report
.failures
.push(format!("snapshot {name}: missing `graph` or `integrity` record"));
report.failures.push(format!(
"snapshot {name}: missing `graph` or `integrity` record"
));
continue;
};
let Some(line_count) = integrity.get("line_count").and_then(Value::as_u64) else {
Expand All @@ -269,9 +297,9 @@ pub fn check_replay(repo: &Path, events: &[Event], raw_log: &str) -> CheckReport
continue;
};
let Some(expected_sha) = integrity.get("sha256").and_then(Value::as_str) else {
report
.failures
.push(format!("snapshot {name}: integrity record missing `sha256`"));
report.failures.push(format!(
"snapshot {name}: integrity record missing `sha256`"
));
continue;
};

Expand Down Expand Up @@ -361,7 +389,11 @@ fn load_packs(repo: &Path) -> (Vec<Pack>, Vec<String>) {
if path.extension().is_none_or(|x| x != "json") {
continue;
}
let name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
let name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
match std::fs::read_to_string(&path) {
Ok(raw) => match serde_json::from_str::<Pack>(&raw) {
Ok(pack) => packs.push(pack),
Expand Down Expand Up @@ -468,7 +500,14 @@ pub fn check_append_only(repo: &Path) -> CheckReport {
for path in &paths {
let shas = match git(
repo,
&["rev-list", "--reverse", "--full-history", "HEAD", "--", path],
&[
"rev-list",
"--reverse",
"--full-history",
"HEAD",
"--",
path,
],
) {
Ok(shas) => shas,
Err(e) => {
Expand Down Expand Up @@ -602,9 +641,8 @@ pub fn check_restore(repo: &Path, events: &[Event], fixture_facts: bool) -> Chec
}

let node = |id: &str| graph.get_node(&yoagent_state::NodeId::new(id));
let prop = |id: &str, key: &str| -> Option<Value> {
node(id).and_then(|n| n.props.get(key)).cloned()
};
let prop =
|id: &str, key: &str| -> Option<Value> { node(id).and_then(|n| n.props.get(key)).cloned() };
let edge = |from: &str, rel: &str, to: &str| -> bool {
graph
.outgoing(&yoagent_state::NodeId::new(from), Some(rel))
Expand All @@ -621,7 +659,9 @@ pub fn check_restore(repo: &Path, events: &[Event], fixture_facts: bool) -> Chec
.push("fixture: patch_9 --advances--> goal_retry missing".into());
}
if prop("patch_9", "status") != Some(Value::from("Promoted")) {
report.failures.push("fixture: patch_9.status != Promoted".into());
report
.failures
.push("fixture: patch_9.status != Promoted".into());
}
if prop("patch_9", "references_commit") != Some(Value::from("abc1234")) {
report
Expand Down Expand Up @@ -677,7 +717,10 @@ pub fn check_pairing(events: &[Event]) -> CheckReport {
.push(format!("line {n}: {} payload has no `id`", event.kind));
continue;
};
let claimants = ops_for.get(event.id.as_str()).map(Vec::as_slice).unwrap_or(&[]);
let claimants = ops_for
.get(event.id.as_str())
.map(Vec::as_slice)
.unwrap_or(&[]);
if claimants.is_empty() {
report.failures.push(format!(
"line {n}: {} `{entity_id}` has no paired state.ops_applied",
Expand Down
10 changes: 10 additions & 0 deletions conformance-check/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,16 @@ fn main() -> ExitCode {
return ExitCode::from(2);
};

// Name the fold. The checker certifies a store by folding it, so the
// verdict is only meaningful against a stated fold version — and when the
// checker's `yoagent-state` drifted from the runtimes', the skew was
// discoverable only by reading two lockfiles.
println!(
"conformance-check {} — folding with yoagent-state {}",
env!("CARGO_PKG_VERSION"),
env!("GASP_STATE_VERSION"),
);

let mut failed = false;
for report in conformance_check::run_all(&repo, fixture_facts) {
let mark = if report.passed() { "PASS" } else { "FAIL" };
Expand Down
Loading
Loading