From 4b543faa600dd8c12fa75b65d1a4c98a522f300f Mon Sep 17 00:00:00 2001 From: Yuanhao Li Date: Fri, 28 Aug 2026 11:00:29 +0200 Subject: [PATCH 1/2] fix: track the runtime's yoagent-state, and say which fold produced a verdict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The checker pinned yoagent-state 0.4 while the runtimes it certifies are on 0.5. It answers "can a conformant runtime fold and restore this store?" by folding the store itself, so a version skew makes the answer wrong in either direction — certifying a store no runtime can read, or failing one every runtime can. That was latent while graph.rs happened to be byte-identical across the two versions. yoagent-state 0.5.1 changed the fold, so it is now active. Three parts. The bump the issue asks for: 0.4 -> 0.5, resolving to 0.5.1. Check 2 now folds the way a runtime does. Since 0.5.1 an op naming a missing node is skipped rather than aborting the fold, so a store containing one IS restorable, and failing it would report non-conformance for a store every runtime can open. That is also the verdict a user cannot act on: an append-only log cannot have the op removed, and inserting a fix before it rewrites published history — permanently failing check 4, which is the property the format exists to guarantee. The skip is reported, not swallowed. It lands in `notes`, which does not fail the check. Masking corruption is the failure mode this must not trade for, and a certificate that hides what it skipped cannot be audited. And the report now names the fold version, which is the issue's second suggestion and worth more than the bump: the skew was previously discoverable only by reading two lockfiles. A build script reads the resolved version from the workspace lock, because cargo exposes no env var for a dependency's resolved version and the requirement in Cargo.toml ("0.5") is not what was built. A missing lockfile degrades to "unknown" rather than failing the build. Verified end to end against yologdev/yoyo-gasp at 8,882 events with the one dangling UpdateNode that motivated yoagent#168: 7/7, with the skip named in the certificate and check 4 still green because nothing was rewritten. Previously checks 2 and 6 failed. Regression test included and mutation-verified: reverting check 2 to the strict fold fails it with "fold failed: node not found". 23 passed, clippy clean under -Dwarnings. Fixes #2 --- Cargo.lock | 4 +- conformance-check/Cargo.toml | 2 +- conformance-check/build.rs | 44 +++++++ conformance-check/src/lib.rs | 81 +++++++++--- conformance-check/src/main.rs | 10 ++ conformance-check/tests/fixture.rs | 193 +++++++++++++++++++++++++---- 6 files changed, 288 insertions(+), 46 deletions(-) create mode 100644 conformance-check/build.rs diff --git a/Cargo.lock b/Cargo.lock index 06eefa3..cc536a8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -603,9 +603,9 @@ dependencies = [ [[package]] name = "yoagent-state" -version = "0.4.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7745b432c1d947c2e90b44c14b96d5809168fb8ab5e92e9a6a687d423c3266e" +checksum = "7384e9ee74b278cbbbf4fa6d5870133c97df202e74a4e89bb9bef54a85ab229e" dependencies = [ "async-trait", "chrono", diff --git a/conformance-check/Cargo.toml b/conformance-check/Cargo.toml index 7e251ed..14d3491 100644 --- a/conformance-check/Cargo.toml +++ b/conformance-check/Cargo.toml @@ -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" diff --git a/conformance-check/build.rs b/conformance-check/build.rs new file mode 100644 index 0000000..aa27f3b --- /dev/null +++ b/conformance-check/build.rs @@ -0,0 +1,44 @@ +//! 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() { + let lock = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .map(|p| p.join("Cargo.lock")); + + 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()); + } +} + +/// The `version = "x"` line following `name = ""` in a Cargo.lock. +fn resolved_version(lock: &str, crate_name: &str) -> Option { + let needle = format!("name = \"{crate_name}\""); + let mut lines = lock.lines().skip_while(|l| l.trim() != needle); + lines.next()?; + lines + .next() + .and_then(|l| l.trim().strip_prefix("version = ")) + .map(|v| v.trim_matches('"').to_string()) +} diff --git a/conformance-check/src/lib.rs b/conformance-check/src/lib.rs index b75b3dd..5cecc67 100644 --- a/conformance-check/src/lib.rs +++ b/conformance-check/src/lib.rs @@ -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"; @@ -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) { @@ -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 { @@ -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; }; @@ -361,7 +389,11 @@ fn load_packs(repo: &Path) -> (Vec, Vec) { 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::(&raw) { Ok(pack) => packs.push(pack), @@ -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) => { @@ -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 { - node(id).and_then(|n| n.props.get(key)).cloned() - }; + let prop = + |id: &str, key: &str| -> Option { 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)) @@ -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 @@ -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", diff --git a/conformance-check/src/main.rs b/conformance-check/src/main.rs index 1bc5523..f5b4f40 100644 --- a/conformance-check/src/main.rs +++ b/conformance-check/src/main.rs @@ -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" }; diff --git a/conformance-check/tests/fixture.rs b/conformance-check/tests/fixture.rs index 9500538..36c7bbb 100644 --- a/conformance-check/tests/fixture.rs +++ b/conformance-check/tests/fixture.rs @@ -80,7 +80,10 @@ fn envelope_rejects_unknown_field() { let mut lines = fixture_lines(); lines[0] = lines[0].replace("\"causation_id\"", "\"extra\":1,\"causation_id\""); let report = check_envelope(&lines); - assert!(fails_with(&report, "unknown top-level field `extra`"), "{report:?}"); + assert!( + fails_with(&report, "unknown top-level field `extra`"), + "{report:?}" + ); } #[test] @@ -104,7 +107,11 @@ fn envelope_accepts_reordered_keys_and_nested_extras() { fn write_snapshot(repo: &Path, name: &str, snapshot: &Value) { let dir = repo.join("snapshots").join(name); std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("graph.json"), serde_json::to_vec(snapshot).unwrap()).unwrap(); + std::fs::write( + dir.join("graph.json"), + serde_json::to_vec(snapshot).unwrap(), + ) + .unwrap(); } fn valid_snapshot(repo: &Path, line_count: usize) -> Value { @@ -138,42 +145,64 @@ fn snapshot_valid_passes_and_corruptions_fail() { // hash mismatch let mut bad = valid_snapshot(&repo, 5); - bad["integrity"]["sha256"] = json!("0000000000000000000000000000000000000000000000000000000000000000"); + bad["integrity"]["sha256"] = + json!("0000000000000000000000000000000000000000000000000000000000000000"); write_snapshot(&repo, "event_10", &bad); - assert!(fails_with(&check_replay(&repo, &events, &raw), "integrity hash mismatch")); + assert!(fails_with( + &check_replay(&repo, &events, &raw), + "integrity hash mismatch" + )); // line_count out of range let mut bad = valid_snapshot(&repo, 5); bad["integrity"]["line_count"] = json!(0); write_snapshot(&repo, "event_10", &bad); - assert!(fails_with(&check_replay(&repo, &events, &raw), "out of range")); + assert!(fails_with( + &check_replay(&repo, &events, &raw), + "out of range" + )); bad["integrity"]["line_count"] = json!(99); write_snapshot(&repo, "event_10", &bad); - assert!(fails_with(&check_replay(&repo, &events, &raw), "out of range")); + assert!(fails_with( + &check_replay(&repo, &events, &raw), + "out of range" + )); // missing integrity fields are reported as what they are let mut bad = valid_snapshot(&repo, 5); bad["integrity"].as_object_mut().unwrap().remove("sha256"); write_snapshot(&repo, "event_10", &bad); - assert!(fails_with(&check_replay(&repo, &events, &raw), "missing `sha256`")); + assert!(fails_with( + &check_replay(&repo, &events, &raw), + "missing `sha256`" + )); // wrong terminal event id let mut bad = valid_snapshot(&repo, 5); bad["integrity"]["event_id"] = json!("event_99"); write_snapshot(&repo, "event_10", &bad); - assert!(fails_with(&check_replay(&repo, &events, &raw), "not the last event")); + assert!(fails_with( + &check_replay(&repo, &events, &raw), + "not the last event" + )); // tampered graph diverges from the prefix fold let mut bad = valid_snapshot(&repo, 5); bad["graph"]["version"] = json!(999); write_snapshot(&repo, "event_10", &bad); - assert!(fails_with(&check_replay(&repo, &events, &raw), "differs from folding")); + assert!(fails_with( + &check_replay(&repo, &events, &raw), + "differs from folding" + )); // a snapshot dir without graph.json is noted, not silently skipped std::fs::remove_file(repo.join("snapshots/event_10/graph.json")).unwrap(); let report = check_replay(&repo, &events, &raw); assert!(report.passed()); - assert!(report.notes.iter().any(|n| n.contains("not verified")), "{report:?}"); + assert!( + report.notes.iter().any(|n| n.contains("not verified")), + "{report:?}" + ); } // ---- check 3: vocabulary + packs ---- @@ -201,7 +230,10 @@ fn pack_admits_custom_kind_and_malformed_pack_fails() { let events = parse_events(&lines).unwrap(); // without a pack: undeclared - assert!(fails_with(&check_vocabulary(&repo, &events), "undeclared kind")); + assert!(fails_with( + &check_vocabulary(&repo, &events), + "undeclared kind" + )); // with a full pack declaring it: admitted std::fs::write( @@ -331,9 +363,15 @@ fn append_only_fails_on_non_git_directory() { fn causation_rejects_dangling_reference() { let mut lines = fixture_lines(); let i = line_of(&lines, |v| v["id"] == "event_10"); - lines[i] = lines[i].replace("\"causation_id\":\"event_09\"", "\"causation_id\":\"event_99\""); + lines[i] = lines[i].replace( + "\"causation_id\":\"event_09\"", + "\"causation_id\":\"event_99\"", + ); let events = parse_events(&lines).unwrap(); - assert!(fails_with(&check_causation(&events), "does not reference an earlier event")); + assert!(fails_with( + &check_causation(&events), + "does not reference an earlier event" + )); } #[test] @@ -367,7 +405,10 @@ fn causation_rejects_duplicate_and_self_referencing_ids() { r#"{"id":"event_x","schema_version":1,"ts_ms":1,"actor":{"kind":"agent","id":"a"},"kind":"goal.created","payload":{"id":"g"},"causation_id":"event_x","correlation_id":null}"#.to_string(), ]; let events = parse_events(&lines).unwrap(); - assert!(fails_with(&check_causation(&events), "does not reference an earlier event")); + assert!(fails_with( + &check_causation(&events), + "does not reference an earlier event" + )); } // ---- check 6: restore ---- @@ -382,7 +423,10 @@ fn restore_asserts_fixture_facts() { ); let events = parse_events(&lines).unwrap(); let report = check_restore(&fixture_dir(), &events, true); - assert!(fails_with(&report, "patch_9.status != Promoted"), "{report:?}"); + assert!( + fails_with(&report, "patch_9.status != Promoted"), + "{report:?}" + ); } // ---- check 7: pairing ---- @@ -393,7 +437,10 @@ fn pairing_rejects_missing_ops_event() { let i = line_of(&lines, |v| v["id"] == "event_02"); lines.remove(i); let events = parse_events(&lines).unwrap(); - assert!(fails_with(&check_pairing(&events), "no paired state.ops_applied")); + assert!(fails_with( + &check_pairing(&events), + "no paired state.ops_applied" + )); } #[test] @@ -421,7 +468,10 @@ fn pairing_rejects_wrong_created_kind() { lines[i] = lines[i].replace("\"kind\":\"goal\"", "\"kind\":\"task\""); let events = parse_events(&lines).unwrap(); // `task` is baseline vocabulary, so ONLY pairing catches this - assert!(fails_with(&check_pairing(&events), "created as kind `task`")); + assert!(fails_with( + &check_pairing(&events), + "created as kind `task`" + )); } #[test] @@ -431,7 +481,10 @@ fn pairing_rejects_ops_chained_to_ops() { r#"{"id":"event_x","schema_version":1,"ts_ms":1739000001000,"actor":{"kind":"agent","id":"evolve"},"kind":"state.ops_applied","payload":[{"MarkStale":{"id":"goal_retry","reason":"x"}}],"causation_id":"event_02","correlation_id":null}"#.to_string(), ); let events = parse_events(&lines).unwrap(); - assert!(fails_with(&check_pairing(&events), "chained to another ops event")); + assert!(fails_with( + &check_pairing(&events), + "chained to another ops event" + )); } // ---- run_all + CLI ---- @@ -452,8 +505,17 @@ fn cli_exit_codes() { let dir = fixture_in_temp_git(); let repo = dir.path().join("repo"); - let ok = Command::new(bin).arg(&repo).arg("--fixture").output().unwrap(); - assert_eq!(ok.status.code(), Some(0), "{}", String::from_utf8_lossy(&ok.stdout)); + let ok = Command::new(bin) + .arg(&repo) + .arg("--fixture") + .output() + .unwrap(); + assert_eq!( + ok.status.code(), + Some(0), + "{}", + String::from_utf8_lossy(&ok.stdout) + ); // non-conformant repo -> 1 let raw = std::fs::read_to_string(repo.join("state/events.jsonl")).unwrap(); @@ -468,12 +530,24 @@ fn cli_exit_codes() { // usage errors -> 2 assert_eq!(Command::new(bin).output().unwrap().status.code(), Some(2)); assert_eq!( - Command::new(bin).arg(&repo).arg("--fixtrue").output().unwrap().status.code(), + Command::new(bin) + .arg(&repo) + .arg("--fixtrue") + .output() + .unwrap() + .status + .code(), Some(2), "a typo'd flag must be a usage error, not a silently skipped assertion" ); assert_eq!( - Command::new(bin).arg(&repo).arg(&repo).output().unwrap().status.code(), + Command::new(bin) + .arg(&repo) + .arg(&repo) + .output() + .unwrap() + .status + .code(), Some(2), "multiple repo paths must be a usage error, not last-wins" ); @@ -506,7 +580,12 @@ async fn gitventstore_emitted_repo_passes_all_checks() { .await .unwrap(); let patch_id = state - .propose_patch(StatePatch::new(PatchId::new("patch_it"), "t", "s", actor.clone())) + .propose_patch(StatePatch::new( + PatchId::new("patch_it"), + "t", + "s", + actor.clone(), + )) .await .unwrap(); state @@ -546,7 +625,12 @@ async fn gitventstore_emitted_repo_passes_all_checks() { .await .unwrap(); store - .commit_run(&RunId::new("run_it"), &GoalId::new("goal_it"), "promoted", &[]) + .commit_run( + &RunId::new("run_it"), + &GoalId::new("goal_it"), + "promoted", + &[], + ) .unwrap() .expect("boundary commit"); @@ -560,3 +644,64 @@ async fn gitventstore_emitted_repo_passes_all_checks() { ); } } + +/// A store with a dangling op is **conformant**, and the certificate says so. +/// +/// The checker answers "can a conformant runtime fold and restore this store?". +/// Since yoagent-state 0.5.1 the answer for an op naming a missing node is +/// yes — the op is skipped and the fold completes (yologdev/yoagent#168). +/// +/// Failing here would report non-conformance for a store every runtime can +/// restore, which inverts what the badge means. It is also the only outcome a +/// user could not act on: an append-only log cannot have the op removed, and +/// inserting a fix before it rewrites published history — permanently failing +/// check 4, the property the format exists to guarantee. +/// +/// Regression for the real case: `yologdev/yoyo-gasp` at 8,882 events with one +/// dangling `UpdateNode`, which failed checks 2 and 6 before this. +#[test] +fn a_dangling_op_is_readable_and_reported_not_failed() { + let dir = fixture_in_temp_git(); + let repo = dir.path().join("repo"); + let log = repo.join("state/events.jsonl"); + + // Append an ops event updating a node nobody created — exactly the shape a + // multi-process writer produces when the creating process ran an older + // build. + // Derive the envelope from a real event rather than inventing one, so this + // tests the fold rather than the parser's required fields. + let mut raw = std::fs::read_to_string(&log).unwrap(); + let template: Value = log_lines(&raw) + .iter() + .map(|l| serde_json::from_str::(l).unwrap()) + .find(|v| v["kind"] == "state.ops_applied") + .expect("fixture has an ops event to model"); + let mut dangling = template.clone(); + dangling["id"] = json!("event_dangling_probe"); + dangling["ts_ms"] = json!(template["ts_ms"].as_i64().unwrap_or(0) + 1); + dangling["payload"] = + json!([{"UpdateNode": {"id": "node_never_created", "props": {"status": "closed"}}}]); + raw.push_str(&serde_json::to_string(&dangling).unwrap()); + raw.push('\n'); + std::fs::write(&log, &raw).unwrap(); + + let raw = read_log_raw(&repo).unwrap(); + let events = parse_events(&log_lines(&raw)).expect("log parses"); + let report = check_replay(&repo, &events, &raw); + + assert!( + report.passed(), + "a store a runtime can restore must certify as conformant; failures: {:?}", + report.failures + ); + assert!( + report + .notes + .iter() + .any(|n| n.contains("UpdateNode") && n.contains("skipped")), + "the skip must appear in the certificate — a report that hides it cannot be \ + audited, and masking corruption is the failure mode this must not become. \ + notes: {:?}", + report.notes + ); +} From c862c2d918955f6075e9f2e47011097056657c04 Mon Sep 17 00:00:00 2001 From: Yuanhao Li Date: Fri, 28 Aug 2026 12:03:13 +0200 Subject: [PATCH 2/2] fix: build.rs lockfile parsing, and ignore .DS_Store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects in the version line, all found by review and each verified. Packaged builds printed 'unknown'. cargo package ships Cargo.lock at the package root, beside build.rs, but the script looked one level up — so it worked in the workspace and failed in every installed build, the one place the line has to work. Cargo also regenerates the workspace lock before build scripts run, making the failure unreachable in-tree. Verified against an extracted .crate: now reports 0.5.1. A two-version lockfile reported the lowest. Cargo sorts by name then version ascending, so first-match named 0.4.0 while linking 0.5.1 — the exact skew this line exists to expose, asserted backwards. Now reports ambiguous(...) rather than confidently naming the wrong one. Collecting every match then over-collected: cargo emits [[patch.unused]] in the same shape, so a local [patch.crates-io] override — the obvious way to test a fix — made a single resolved version report as ambiguous. Now scoped to [[package]] sections. Verified by appending a real patch.unused entry. Note yoagent-state 0.5.2 adds a VERSION const, which reports what was linked rather than what a lockfile resolved and makes this whole script unnecessary. Switch once it publishes. Also ignores .DS_Store — one was sitting untracked in fixture/, the canonical artifact every runtime must restore. 23 passed. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 1 + conformance-check/build.rs | 54 +++++++++++++++++++++++++++++++------- 2 files changed, 45 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index ea8c4bf..0592392 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +.DS_Store diff --git a/conformance-check/build.rs b/conformance-check/build.rs index aa27f3b..733ef37 100644 --- a/conformance-check/build.rs +++ b/conformance-check/build.rs @@ -14,9 +14,16 @@ use std::path::PathBuf; fn main() { - let lock = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .map(|p| p.join("Cargo.lock")); + // 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() @@ -32,13 +39,40 @@ fn main() { } } -/// The `version = "x"` line following `name = ""` in a Cargo.lock. +/// Every `version = "x"` following a `name = ""` 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 { let needle = format!("name = \"{crate_name}\""); - let mut lines = lock.lines().skip_while(|l| l.trim() != needle); - lines.next()?; - lines - .next() - .and_then(|l| l.trim().strip_prefix("version = ")) - .map(|v| v.trim_matches('"').to_string()) + let mut found: Vec = 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(","))), + } }