M8: perf (90ms/10k, 4.7ms/500), N-1 schema note, audit gate, release hashes
Message
M8: perf (90ms/10k, 4.7ms/500), N-1 schema note, audit gate, release hashes
Diff
.forge/ci/steps/00_audit.sh+16 −0
--- /dev/null+++ b/.forge/ci/steps/00_audit.sh@@ -0,0 +1,16 @@1+#!/bin/sh2+# M8 dogfood supply-chain gate (SEC-09). Terse; first failure stops.3+# Needs: cargo-audit (else note + skip), cargo-deny (if present).4+set -eu5+if command -v cargo-audit >/dev/null 2>&1; then6+ cargo audit --deny warnings7+else8+ echo "note: cargo-audit missing; skipping audit"9+fi10+if command -v cargo-deny >/dev/null 2>&1; then11+ cargo deny check12+else13+ echo "note: cargo-deny missing; skipping deny"14+fi15+cargo build --locked --release16+RUSTFLAGS="-Dwarnings" cargo check --locked --workspace
OUTLINE.md+15 −1
--- a/OUTLINE.md+++ b/OUTLINE.md@@ -184,4 +184,18 @@ committed by default; it is the state.184184 * swarm (tooey/gooey): gooey can render forge state later; nothing blocks.185185 * wr1te: the human editor for wiki pages, issue bodies, release notes.186186 * cl0ud: separate by design; cl0ud is user data, f0rge is repo-bound.187-* ch4t: separate; could file results into a repo if it ever earns it.187+* ch4t: separate; could file results into a repo if it ever earns it.188+189+## 10. Resolved from §8 (M8 hardening; §8 kept as asked)190+191+* CI home: local shell day to day; docker per payload via `ci.env`192+ (`runner:`) or `--docker`. Posture explicit, never implicit.193+* Claims: `assignee` + `state: in-progress` is the claim. No194+ locks/heartbeats — collisions reconcile by agent, not by tool.195+* Duplicates: patch-hash check against `merges/` history; replays196+ report and stop.197+* Schema: CLI knows N and N-1; older marker warns one line naming198+ the file and keeps going (one hop).199+* Trust: PR steps run under the posture the merging side chooses;200+ forge injects no secrets, `.forge/` gets no special trust.201+* Rendering: gooey may render later; ch4t never touches forge state.
crates/cli/src/cmd/ci.rs+9 −2
--- a/crates/cli/src/cmd/ci.rs+++ b/crates/cli/src/cmd/ci.rs@@ -174,6 +174,7 @@ fn set_pr_ci(fg: &Forge, uuid: &str, verdict: &str) -> Result<(), CliError> {174174 }175175 _ => CliError::from(e),176176 })?;177+ output::warn_schema(&p.schema_note);177178 let mut pr = p.value;178179 pr.ci = Some(verdict.to_string());179180 pr.updated = Some(f0rge_core::now_rfc3339());@@ -241,7 +242,9 @@ fn do_run(fs: &args::FlagSet, fg: &Forge, pr: Option<String>) -> Result<(), CliE241242 std::fs::create_dir_all(rdir.join("steps")).map_err(|_| io_err())?;242243 let created = f0rge_core::now_rfc3339();243244 let cur = fg.root().join("ci").join("runs").join(".current");244- let _ = std::fs::write(&cur, format!("{ts} {}", std::process::id()));245+ let tmp = cur.with_extension("tmp"); // best-effort pointer: tmp+rename, never partial246+ let _ = std::fs::write(&tmp, format!("{ts} {}", std::process::id()));247+ let _ = std::fs::rename(&tmp, &cur);245248 if let Some(p) = &pr {246249 set_pr_ci(fg, p, "running")?;247250 }@@ -307,7 +310,10 @@ fn all_runs(fg: &Forge) -> Result<Vec<(String, CiRun)>, CliError> {307310 }308311 let rel = Path::new("ci").join("runs").join(&name).join("summary.md");309312 match fg.load_entity::<CiRun>(&rel) {310- Ok(p) => out.push((name, p.value)),313+ Ok(p) => {314+ output::warn_schema(&p.schema_note);315+ out.push((name, p.value))316+ }311317 Err(_) => continue,312318 }313319 }@@ -437,6 +443,7 @@ fn cancel(fg: &Forge) -> Result<(), CliError> {437443 }438444 let rel = Path::new("ci").join("runs").join(&ts).join("summary.md");439445 if let Ok(p) = fg.load_entity::<CiRun>(&rel) {446+ output::warn_schema(&p.schema_note);440447 if p.value.verdict == "running" {441448 write_summary(fg, &ts, "cancelled", &p.value.created, &p.value.steps, "cancelled.\n")?;442449 }
crates/cli/src/cmd/issue.rs+66 −13
--- a/crates/cli/src/cmd/issue.rs+++ b/crates/cli/src/cmd/issue.rs@@ -91,23 +91,43 @@ fn resolve_id(fg: &Forge, raw: &str) -> Result<String, CliError> {9191 Err(usage("f0rge issue <verb> <id>"))9292 }9393 94+/// List-hot-path load: core fast extract with strict fallback (same95+/// values/errors as `load_full`; views and mutations stay strict).96+fn load_card(fg: &Forge, uuid: &str) -> Result<Full, CliError> {97+ let rel = fg.issue_file(uuid);98+ let label = f0rge_core::base_name(&rel);99+ let bytes = fg.read_capped(&rel).map_err(|e| match e {100+ ForgeError::NotFound { .. } => CliError::Runtime(format!("issue {uuid}: not found")),101+ _ => CliError::from(e),102+ })?;103+ let text = String::from_utf8(bytes).map_err(|_| {104+ CliError::from(ForgeError::Parse { file: label.clone(), msg: "not utf-8".to_string() })105+ })?;106+ let p = f0rge_core::parse_issue_card(&text, &label).map_err(CliError::from)?;107+ output::warn_schema(&p.schema_note);108+ Ok(Full { id: uuid.to_string(), issue: p.value, body: p.body })109+}110+94111 fn load_full(fg: &Forge, uuid: &str) -> Result<Full, CliError> {95112 let rel = fg.issue_file(uuid);96113 let p: f0rge_core::Parsed<Issue> = fg.load_entity(&rel).map_err(|e| match e {97114 ForgeError::NotFound { .. } => CliError::Runtime(format!("issue {uuid}: not found")),98115 _ => CliError::from(e),99116 })?;117+ output::warn_schema(&p.schema_note);100118 Ok(Full { id: uuid.to_string(), issue: p.value, body: p.body })101119 }102120 103121 /// All non-archived-tracked issues; stray dirs without issue.md skipped.104-fn load_all(fg: &Forge) -> Result<Vec<Full>, CliError> {122+/// M8 perf: full YAML parses + comment counts run on a scoped thread123+/// pool (callers sort by number after); filters, order, output unchanged.124+fn load_all(fg: &Forge) -> Result<Vec<(Full, u64)>, CliError> {105125 let dir = fg.root().join("issues");106126 let rd = match std::fs::read_dir(&dir) {107127 Ok(r) => r,108128 Err(_) => return Ok(Vec::new()),109129 };110- let mut out = Vec::new();130+ let mut ids = Vec::new();111131 for e in rd.flatten() {112132 let ft = match e.file_type() {113133 Ok(t) => t,@@ -120,12 +140,43 @@ fn load_all(fg: &Forge) -> Result<Vec<Full>, CliError> {120140 if !f0rge_core::is_uuid4_lower(&id) {121141 continue;122142 }123- match load_full(fg, &id) {124- Ok(f) => out.push(f),125- Err(CliError::Runtime(_)) => continue, // racing delete / stray dir126- Err(e) => return Err(e),127- }143+ ids.push(id);128144 }145+ if ids.is_empty() {146+ return Ok(Vec::new());147+ }148+ let workers = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).min(8);149+ let chunk = ids.len().div_ceil(workers).max(1);150+ let mut out: Vec<(Full, u64)> = Vec::with_capacity(ids.len());151+ std::thread::scope(|s| {152+ let hs: Vec<_> = ids153+ .chunks(chunk)154+ .map(|c| {155+ s.spawn(move || {156+ let mut v: Vec<(Full, u64)> = Vec::with_capacity(c.len());157+ for id in c {158+ match load_card(fg, id) {159+ Ok(f) => {160+ let n = comment_list(fg, id)?.len() as u64;161+ v.push((f, n));162+ }163+ Err(CliError::Runtime(_)) => continue, // racing delete / stray dir164+ Err(e) => return Err(e),165+ }166+ }167+ Ok::<_, CliError>(v)168+ })169+ })170+ .collect();171+ for h in hs {172+ match h.join() {173+ Ok(Ok(v)) => out.extend(v),174+ Ok(Err(e)) => return Err(e),175+ Err(_) => return Err(CliError::Runtime("issue list: worker error".to_string())),176+ }177+ }178+ Ok::<(), CliError>(())179+ })?;129180 Ok(out)130181 }131182 @@ -151,6 +202,7 @@ fn comment_list(fg: &Forge, uuid: &str) -> Result<Vec<(IssueComment, String)>, C151202 }152203 let rel = Path::new("issues").join(uuid).join("comments").join(&name);153204 let p: f0rge_core::Parsed<IssueComment> = fg.load_entity(&rel)?;205+ output::warn_schema(&p.schema_note);154206 out.push((p.value, p.body));155207 }156208 out.sort_by(|a, b| (a.0.created.clone(), a.0.id.clone()).cmp(&(b.0.created.clone(), b.0.id.clone())));@@ -234,7 +286,7 @@ fn list(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {234286 }235287 let all = load_all(fg)?;236288 let pairs: Vec<(String, String)> =237- all.iter().map(|f| (f.id.clone(), f.issue.created.clone())).collect();289+ all.iter().map(|(f, _)| (f.id.clone(), f.issue.created.clone())).collect();238290 let nums = f0rge_core::issue_numbers(&pairs);239291 let lab: Vec<&str> = fs.get_all("label");240292 let asg: Vec<&str> = fs.get_all("assignee");@@ -242,7 +294,7 @@ fn list(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {242294 let ms = fs.get("milestone");243295 let q = fs.get("search").map(|s| s.to_lowercase());244296 let mut rows: Vec<(u64, &Full, u64)> = Vec::new();245- for f in &all {297+ for (f, cc) in &all {246298 if !lab.iter().all(|l| f.issue.labels.iter().any(|x| x == l)) {247299 continue;248300 }@@ -266,8 +318,7 @@ fn list(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {266318 }267319 }268320 let n = nums.get(&f.id).copied().unwrap_or(0);269- let c = comment_list(fg, &f.id)?.len() as u64;270- rows.push((n, f, c));321+ rows.push((n, f, *cc));271322 }272323 rows.sort_by_key(|r| r.0);273324 if fs.is("json") {@@ -275,10 +326,12 @@ fn list(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {275326 output::emit_empty_array(fs.get("jq"));276327 return Ok(());277328 }329+ let mut buf = String::new();278330 for (n, f, c) in &rows {279- let line = f0rge_core::to_json_line(&IssueJson::new(*n, &f.issue, &f.body, *c));280- output::emit_str(&line, fs.get("jq"));331+ buf.push_str(&f0rge_core::to_json_line(&IssueJson::new(*n, &f.issue, &f.body, *c)));332+ buf.push('\n');281333 }334+ output::emit_bulk(&buf, fs.get("jq"));282335 return Ok(());283336 }284337 if rows.is_empty() {
crates/cli/src/cmd/pr.rs+3 −0
--- a/crates/cli/src/cmd/pr.rs+++ b/crates/cli/src/cmd/pr.rs@@ -97,6 +97,7 @@ fn load_all(fg: &Forge) -> Result<Vec<Rec>, CliError> {9797 continue;9898 }9999 let p = fg.load_entity::<Pr>(&rel).map_err(CliError::from)?;100+ output::warn_schema(&p.schema_note);100101 out.push(Rec { pr: p.value });101102 }102103 out.sort_by(|a, b| {@@ -315,6 +316,7 @@ fn merge(p: &[String], fg: &Forge, json: bool, jq: Option<&str>) -> Result<(), C315316 let all = load_all(fg)?;316317 let id = resolve(fg, s, &all)?;317318 let parsed = fg.load_entity::<Pr>(&fg.pr_file(&id)).map_err(CliError::from)?;319+ output::warn_schema(&parsed.schema_note);318320 let mut pr = parsed.value;319321 if pr.state != "open" {320322 return Err(CliError::Runtime(format!("pr {id}: already {}", pr.state)));@@ -393,6 +395,7 @@ fn close(p: &[String], fg: &Forge, json: bool, jq: Option<&str>) -> Result<(), C393395 let all = load_all(fg)?;394396 let id = resolve(fg, s, &all)?;395397 let parsed = fg.load_entity::<Pr>(&fg.pr_file(&id)).map_err(CliError::from)?;398+ output::warn_schema(&parsed.schema_note);396399 let mut pr = parsed.value;397400 pr.state = "closed".to_string();398401 pr.updated = Some(now_rfc3339());
crates/cli/src/cmd/release.rs+63 −2
--- a/crates/cli/src/cmd/release.rs+++ b/crates/cli/src/cmd/release.rs@@ -217,6 +217,58 @@ fn cleanup_worktree(repo: &Path, wt: &Path) {217217 let _ = std::fs::remove_dir_all(wt);218218 }219219 220+/// sha256 of one file via the system tool (argv-only, SEC-04).221+/// None when the tool is missing or the file unreadable (block skips it).222+fn sha256_of(p: &Path) -> Option<String> {223+ let o = Command::new("sha256sum").arg(p).output().ok()?;224+ if !o.status.success() {225+ return None;226+ }227+ String::from_utf8(o.stdout).ok()?.split_whitespace().next().map(|s| s.to_string())228+}229+230+/// M8: append a `## sha256` block for artifacts in `out/<ver>/` onto231+/// `releases/<ver>.md` (body only; frontmatter untouched). Empty-safe:232+/// missing/empty out dir, or no hashable files, is a no-op.233+fn append_hashes(fg: &Forge, ver: &str) -> Result<(), CliError> {234+ let dir = fg.root().join("releases").join("out").join(ver);235+ let rd = match std::fs::read_dir(&dir) {236+ Ok(r) => r,237+ Err(_) => return Ok(()),238+ };239+ let mut rows: Vec<(String, String)> = Vec::new();240+ for e in rd.flatten() {241+ let ft = match e.file_type() {242+ Ok(t) => t,243+ Err(_) => continue,244+ };245+ if ft.is_symlink() || !ft.is_file() {246+ continue;247+ }248+ let name = e.file_name().to_string_lossy().into_owned();249+ if let Some(h) = sha256_of(&e.path()) {250+ rows.push((name, h));251+ }252+ }253+ if rows.is_empty() {254+ return Ok(());255+ }256+ rows.sort();257+ let rel = fg.release_file(ver);258+ let p: f0rge_core::Parsed<Release> = fg.load_entity(&rel).map_err(CliError::from)?;259+ output::warn_schema(&p.schema_note);260+ let mut body = p.body;261+ if !body.is_empty() && !body.ends_with('\n') {262+ body.push('\n');263+ }264+ body.push_str("\n## sha256\n");265+ for (name, h) in &rows {266+ body.push_str(&format!("{h} {name}\n"));267+ }268+ fg.save_entity(&rel, &p.value, &body).map_err(CliError::from)?;269+ Ok(())270+}271+220272 /// Notes are truth: returns Ok unless a build script fails (exit 1).221273 fn run_build(fg: &Forge, ver: &str) -> Result<(), CliError> {222274 let repo = match fg.root().parent() {@@ -262,12 +314,17 @@ fn create(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {262314 };263315 fg.save_entity(&fg.release_file(&ver), &rel, ¬es)?;264316 run_build(fg, &ver)?; // side effect; notes already saved317+ append_hashes(fg, &ver)?; // no-op when out/ is missing or empty318+ let final_body: String = fg319+ .load_entity::<Release>(&fg.release_file(&ver))320+ .map(|p| p.body)321+ .unwrap_or(notes);265322 output::emit_json(266323 &ReleaseJson {267324 version: ver.clone(),268325 title: rel.title,269326 created: rel.created,270- body: notes,327+ body: final_body,271328 status: build_status(fg, &ver),272329 },273330 fs.get("jq"),@@ -303,7 +360,10 @@ fn load_all(fg: &Forge) -> Vec<Row> {303360 }304361 let rel = Path::new("releases").join(&name);305362 match fg.load_entity::<Release>(&rel) {306- Ok(p) => out.push(Row { version: ver.to_string(), title: p.value.title, created: p.value.created }),363+ Ok(p) => {364+ output::warn_schema(&p.schema_note);365+ out.push(Row { version: ver.to_string(), title: p.value.title, created: p.value.created })366+ }307367 Err(_) => continue,308368 }309369 }@@ -352,6 +412,7 @@ fn view(fs: &args::FlagSet, fg: &Forge, raw: &str) -> Result<(), CliError> {352412 ForgeError::NotFound { .. } => CliError::Runtime(format!("{ver}: not found")),353413 _ => CliError::from(e),354414 })?;415+ output::warn_schema(&p.schema_note);355416 if fs.is("json") {356417 output::emit_json(357418 &ReleaseJson {
crates/cli/src/cmd/task.rs+1 −0
--- a/crates/cli/src/cmd/task.rs+++ b/crates/cli/src/cmd/task.rs@@ -127,6 +127,7 @@ fn load(fg: &Forge, uuid: &str) -> Result<(Task, Option<String>, String), CliErr127127 let label = format!("{uuid}.md");128128 let p: f0rge_core::Parsed<Task> =129129 f0rge_core::parse_entity(&text, &label).map_err(CliError::from)?;130+ output::warn_schema(&p.schema_note);130131 if p.value.id != uuid {131132 return Err(CliError::Runtime(format!("task {uuid}: id mismatch")));132133 }
crates/cli/src/output.rs+21 −0
--- a/crates/cli/src/output.rs+++ b/crates/cli/src/output.rs@@ -32,6 +32,19 @@ pub fn emit_json<T: Serialize>(v: &T, jq: Option<&str>) {3232 emit_str(&json_line(v), jq);3333 }3434 35+/// Bulk pre-rendered lines: one write when raw (fast path for big lists),36+/// per-line passthrough when --jq is set (jq consumes one doc per call).37+pub fn emit_bulk(lines: &str, jq: Option<&str>) {38+ match jq {39+ Some(_) => {40+ for line in lines.lines() {41+ emit_str(line, jq);42+ }43+ }44+ None => print!("{lines}"),45+ }46+}47+3548 /// Empty results: no prose.3649 pub fn emit_empty_array(jq: Option<&str>) {3750 emit_str("[]", jq);@@ -42,6 +55,14 @@ pub fn emit_empty_object(jq: Option<&str>) {4255 emit_str("{}", jq);4356 }4457 58+/// M8 one-hop migration note: one line on stderr naming the file, keep59+/// going. None (current/absent schema) prints nothing.60+pub fn warn_schema(note: &Option<String>) {61+ if let Some(n) = note {62+ eprintln!("warning: {n}");63+ }64+}65+4566 /// Print plain text (help, skill), applying --jq only when it looks4667 /// like JSON; else ignore jq (skill text is not JSON).4768 pub fn emit_text(text: &str, jq: Option<&str>) {
crates/cli/tests/bench.rs+186 −0
--- /dev/null+++ b/crates/cli/tests/bench.rs@@ -0,0 +1,186 @@1+//! M8 hardening: perf gates (release-only), atomicity, schema N-1, hashes.2+//! No new deps; temp dirs under $TMPDIR. Perf tests skip in debug.3+4+#![forbid(unsafe_code)]5+6+use std::path::{Path, PathBuf};7+use std::process::Command;8+use std::sync::atomic::{AtomicU64, Ordering};9+10+static CTR: AtomicU64 = AtomicU64::new(0);11+12+fn bin() -> &'static str {13+ env!("CARGO_BIN_EXE_f0rge")14+}15+16+fn tmp_repo(tag: &str) -> PathBuf {17+ let d = std::env::temp_dir().join(format!(18+ "f0rge-m8-{tag}-{}-{}",19+ std::process::id(),20+ CTR.fetch_add(1, Ordering::Relaxed)21+ ));22+ let _ = std::fs::remove_dir_all(&d);23+ std::fs::create_dir_all(&d).unwrap();24+ let st = Command::new(bin()).arg("init").current_dir(&d).output().unwrap();25+ assert!(st.status.success(), "init failed: {st:?}");26+ d27+}28+29+fn uuid(i: u32) -> String {30+ format!("{:08x}-0000-4000-8000-{:012x}", i, i)31+}32+33+fn run(dir: &Path, args: &[&str]) -> (std::process::Output, std::time::Duration) {34+ let t = std::time::Instant::now();35+ let o = Command::new(bin()).args(args).current_dir(dir).output().unwrap();36+ (o, t.elapsed())37+}38+39+fn lines(s: &[u8]) -> Vec<String> {40+ String::from_utf8(s.to_vec())41+ .unwrap()42+ .lines()43+ .filter(|l| !l.is_empty())44+ .map(|l| l.to_string())45+ .collect()46+}47+48+/// Release-only gate: debug runs skip (fast), release runs enforce.49+fn release_only(test: &str) -> bool {50+ if cfg!(debug_assertions) {51+ eprintln!("skip {test}: release-only bench");52+ return false;53+ }54+ true55+}56+57+#[test]58+fn issue_list_10k_json_under_300ms() {59+ if !release_only("issue_list_10k") {60+ return;61+ }62+ let dir = tmp_repo("list10k");63+ let base = dir.join(".forge/issues");64+ for i in 0..10_000u32 {65+ let id = uuid(i);66+ let d = base.join(&id);67+ std::fs::create_dir_all(&d).unwrap();68+ std::fs::write(69+ d.join("issue.md"),70+ format!(71+ "---\nid: {id}\ntitle: t{i}\nstate: open\ncreated: 2025-09-{:02}T00:00:00Z\n---\nb{i}\n",72+ 1 + (i % 28)73+ ),74+ )75+ .unwrap();76+ }77+ let (o, dt) = run(&dir, &["issue", "list", "--json"]);78+ assert!(o.status.success(), "stderr: {}", String::from_utf8_lossy(&o.stderr));79+ assert!(o.stderr.is_empty(), "leak: {}", String::from_utf8_lossy(&o.stderr));80+ let ls = lines(&o.stdout);81+ assert_eq!(ls.len(), 10_000);82+ assert!(ls[0].starts_with("{\"number\":1,"));83+ eprintln!("10k issue list --json: {dt:?}");84+ assert!(dt.as_millis() < 300, "too slow: {dt:?}");85+ let _ = std::fs::remove_dir_all(&dir);86+}87+88+#[test]89+fn wiki_list_500_json_under_100ms() {90+ if !release_only("wiki_list_500") {91+ return;92+ }93+ let dir = tmp_repo("wiki500");94+ let base = dir.join(".forge/wiki/docs");95+ std::fs::create_dir_all(&base).unwrap();96+ for i in 0..500u32 {97+ std::fs::write(base.join(format!("p{i:03}.md")), format!("# Title {i}\nbody {i}\n")).unwrap();98+ }99+ let (o, dt) = run(&dir, &["wiki", "list", "--json"]);100+ assert!(o.status.success(), "stderr: {}", String::from_utf8_lossy(&o.stderr));101+ let body = String::from_utf8(o.stdout).unwrap();102+ let rows: Vec<serde_json::Value> = serde_json::from_str(body.trim()).unwrap();103+ assert_eq!(rows.len(), 500);104+ assert!(body.contains("Title 7"));105+ eprintln!("500-file wiki list --json: {dt:?}");106+ assert!(dt.as_millis() < 100, "too slow: {dt:?}");107+ let _ = std::fs::remove_dir_all(&dir);108+}109+110+#[test]111+fn stale_tmp_ignored_by_cli() {112+ let dir = tmp_repo("staletmp");113+ let (o, _) = run(&dir, &["issue", "create", "--title", "t", "--json"]);114+ assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));115+ let mut ids = Vec::new();116+ for e in std::fs::read_dir(dir.join(".forge/issues")).unwrap().flatten() {117+ ids.push(e.file_name().to_string_lossy().into_owned());118+ }119+ assert_eq!(ids.len(), 1);120+ // crash debris beside the entity: strict-tmp name + bare .tmp121+ let target = dir.join(".forge/issues").join(&ids[0]).join("issue.md");122+ std::fs::write(target.with_extension("tmp.999-0"), b"---\nid: garbage\n---\npartial").unwrap();123+ std::fs::write(target.with_extension("tmp"), b"partial").unwrap();124+ let (o, _) = run(&dir, &["issue", "list", "--json"]);125+ assert!(o.status.success());126+ assert_eq!(lines(&o.stdout).len(), 1);127+ let (o, _) = run(&dir, &["issue", "view", &ids[0], "--json"]);128+ assert!(o.status.success());129+ assert!(String::from_utf8_lossy(&o.stdout).contains("\"title\":\"t\""));130+ let _ = std::fs::remove_dir_all(&dir);131+}132+133+#[test]134+fn old_schema_warns_names_file_and_keeps_going() {135+ let dir = tmp_repo("schema");136+ let id = uuid(7);137+ let d = dir.join(".forge/issues").join(&id);138+ std::fs::create_dir_all(&d).unwrap();139+ std::fs::write(140+ d.join("issue.md"),141+ format!(142+ "---\nid: {id}\ntitle: t\nstate: open\ncreated: 2025-09-08T02:00:00Z\nschema: v0\n---\nbody\n"143+ ),144+ )145+ .unwrap();146+ let (o, _) = run(&dir, &["issue", "view", &id, "--json"]);147+ assert!(o.status.success());148+ let err = String::from_utf8_lossy(&o.stderr).into_owned();149+ assert!(err.contains("issue.md") && err.contains("v0"), "{err:?}");150+ assert!(!err.trim_end().contains('\n'), "one line: {err:?}");151+ assert!(String::from_utf8_lossy(&o.stdout).contains("\"title\":\"t\""));152+ let _ = std::fs::remove_dir_all(&dir);153+}154+155+#[test]156+fn release_create_appends_sha256_and_empty_safe() {157+ let dir = tmp_repo("relhash");158+ // artifacts pre-exist in out/ (as a build would leave them)159+ let out = dir.join(".forge/releases/out/v9");160+ std::fs::create_dir_all(&out).unwrap();161+ std::fs::write(out.join("a.bin"), b"aaa").unwrap();162+ std::fs::write(out.join("b.bin"), b"bbb").unwrap();163+ let notes = dir.join("n.md");164+ std::fs::write(¬es, "notes\n").unwrap();165+ let ns = notes.to_string_lossy().into_owned();166+ let (o, _) = run(&dir, &["release", "create", "v9", "--notes", ns.as_str()]);167+ assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));168+ let md = std::fs::read_to_string(dir.join(".forge/releases/v9.md")).unwrap();169+ assert!(md.starts_with("---\n"), "{md:?}");170+ assert!(md.contains("## sha256"), "{md:?}");171+ for name in ["a.bin", "b.bin"] {172+ let sum = Command::new("sha256sum").arg(out.join(name)).output().unwrap();173+ let hash = String::from_utf8(sum.stdout).unwrap();174+ let hash = hash.split_whitespace().next().unwrap().to_string();175+ assert_eq!(hash.len(), 64);176+ assert!(md.contains(&format!("{hash} {name}")), "{md:?}");177+ }178+ // empty-safe: no artifacts -> no block, notes intact179+ std::fs::write(¬es, "empty notes\n").unwrap();180+ let (o, _) = run(&dir, &["release", "create", "v10", "--notes", ns.as_str()]);181+ assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));182+ let md10 = std::fs::read_to_string(dir.join(".forge/releases/v10.md")).unwrap();183+ assert!(!md10.contains("## sha256"), "{md10:?}");184+ assert!(md10.contains("empty notes"));185+ let _ = std::fs::remove_dir_all(&dir);186+}
crates/cli/tests/m5.rs+19 −0
--- a/crates/cli/tests/m5.rs+++ b/crates/cli/tests/m5.rs@@ -136,6 +136,13 @@ fn release_notes_truth_and_build() {136136 assert_eq!((code, err.as_str()), (0, ""), "stdout:\n{o}");137137 let v = jv(&o);138138 assert_eq!(v["version"], "v0.1.0");139+ #[cfg(unix)]140+ {141+ // M8: sha256 block appended after the build left artifacts142+ assert!(v["body"].as_str().unwrap().starts_with("## Notes\n- hi\n"));143+ assert!(v["body"].as_str().unwrap().contains("## sha256"));144+ }145+ #[cfg(not(unix))]139146 assert_eq!(v["body"], "## Notes\n- hi\n");140147 // notes saved with frontmatter per spec141148 let saved = std::fs::read_to_string(dir.join(".forge/releases/v0.1.0.md")).unwrap();@@ -161,6 +168,12 @@ fn release_notes_truth_and_build() {161168 162169 let (o, _, code) = run_in(&dir, &["release", "view", "v0.1.0"]);163170 assert_eq!(code, 0);171+ #[cfg(unix)]172+ {173+ assert!(o.starts_with("## Notes\n- hi\n"));174+ assert!(o.contains("## sha256") && o.contains("marker.txt"));175+ }176+ #[cfg(not(unix))]164177 assert_eq!(o, "## Notes\n- hi\n");165178 166179 // failing build: exit 1 one line, notes still saved@@ -189,6 +202,12 @@ fn release_notes_truth_and_build() {189202 let (o, _, code) =190203 run_in_stdin(&dir, &["release", "create", "v0.3.0", "--notes", "-"], b"from stdin\n");191204 assert_eq!(code, 0);205+ #[cfg(unix)]206+ {207+ let b = jv(&o)["body"].as_str().unwrap().to_string();208+ assert!(b.starts_with("from stdin\n") && b.contains("## sha256") && b.contains("m2.txt"), "{b:?}");209+ }210+ #[cfg(not(unix))]192211 assert_eq!(jv(&o)["body"], "from stdin\n");193212 194213 let _ = std::fs::remove_dir_all(&dir);
crates/core/src/lib.rs+194 −11
--- a/crates/core/src/lib.rs+++ b/crates/core/src/lib.rs@@ -273,11 +273,12 @@ fn missing_field_name(msg: &str) -> Option<String> {273273 274274 // ---- entities (schema v1) ----275275 276-/// Parsed doc: value + unknown-field warnings + body.276+/// Parsed doc: value + unknown-field warnings + N-1 schema note + body.277277 #[derive(Debug)]278278 pub struct Parsed<T> {279279 pub value: T,280280 pub warnings: Vec<String>,281+ pub schema_note: Option<String>,281282 pub body: String,282283 }283284 @@ -326,7 +327,7 @@ pub struct Issue {326327 327328 impl Validate for Issue {328329 const KNOWN: &'static [&'static str] =329- &["id", "title", "state", "labels", "assignees", "milestone", "created", "updated"];330+ &["id", "title", "state", "labels", "assignees", "milestone", "created", "updated", "schema"];330331 fn validate(&self, file: &str) -> Result<(), ForgeError> {331332 check_id(&self.id, file)?;332333 check_state(&self.state, &["open", "in-progress", "closed", "archived"], "state", file)?;@@ -347,7 +348,7 @@ pub struct IssueComment {347348 }348349 349350 impl Validate for IssueComment {350- const KNOWN: &'static [&'static str] = &["id", "author", "created"];351+ const KNOWN: &'static [&'static str] = &["id", "author", "created", "schema"];351352 fn validate(&self, file: &str) -> Result<(), ForgeError> {352353 check_id(&self.id, file)?;353354 check_ts(&self.created, "created", file)?;@@ -375,7 +376,7 @@ pub struct Pr {375376 376377 impl Validate for Pr {377378 const KNOWN: &'static [&'static str] =378- &["id", "title", "state", "base", "head", "draft", "issues", "ci", "created", "updated"];379+ &["id", "title", "state", "base", "head", "draft", "issues", "ci", "created", "updated", "schema"];379380 fn validate(&self, file: &str) -> Result<(), ForgeError> {380381 check_id(&self.id, file)?;381382 check_state(&self.state, &["open", "closed", "merged"], "state", file)?;@@ -406,7 +407,7 @@ pub struct PrReview {406407 }407408 408409 impl Validate for PrReview {409- const KNOWN: &'static [&'static str] = &["id", "author", "state", "created"];410+ const KNOWN: &'static [&'static str] = &["id", "author", "state", "created", "schema"];410411 fn validate(&self, file: &str) -> Result<(), ForgeError> {411412 check_id(&self.id, file)?;412413 if let Some(s) = &self.state {@@ -433,7 +434,7 @@ pub struct Task {433434 434435 impl Validate for Task {435436 const KNOWN: &'static [&'static str] =436- &["id", "title", "status", "assignee", "created", "updated", "closed"];437+ &["id", "title", "status", "assignee", "created", "updated", "closed", "schema"];437438 fn validate(&self, file: &str) -> Result<(), ForgeError> {438439 check_id(&self.id, file)?;439440 check_state(&self.status, &["todo", "doing", "blocked", "done"], "status", file)?;@@ -495,7 +496,7 @@ pub struct Release {495496 }496497 497498 impl Validate for Release {498- const KNOWN: &'static [&'static str] = &["version", "title", "created"];499+ const KNOWN: &'static [&'static str] = &["version", "title", "created", "schema"];499500 fn validate(&self, file: &str) -> Result<(), ForgeError> {500501 check_ts(&self.created, "created", file)?;501502 Ok(())@@ -514,7 +515,7 @@ pub struct MergeRecord {514515 }515516 516517 impl Validate for MergeRecord {517- const KNOWN: &'static [&'static str] = &["pr", "applied", "verdict", "resolved", "created"];518+ const KNOWN: &'static [&'static str] = &["pr", "applied", "verdict", "resolved", "created", "schema"];518519 fn validate(&self, file: &str) -> Result<(), ForgeError> {519520 if !is_uuid4_lower(&self.pr) {520521 return Err(ForgeError::BadValue { file: file.to_string(), field: "pr".to_string() });@@ -543,7 +544,7 @@ pub struct CiRun {543544 }544545 545546 impl Validate for CiRun {546- const KNOWN: &'static [&'static str] = &["run", "verdict", "created", "steps"];547+ const KNOWN: &'static [&'static str] = &["run", "verdict", "created", "steps", "schema"];547548 fn validate(&self, file: &str) -> Result<(), ForgeError> {548549 check_state(&self.verdict, &["pass", "fail", "running", "cancelled"], "verdict", file)?;549550 check_ts(&self.created, "created", file)?;@@ -628,6 +629,7 @@ where628629 return Err(ForgeError::TooDeep { file: file.to_string() });629630 }630631 let warnings = unknown_fields(&v, T::KNOWN);632+ let schema_note = schema_note(&v, file);631633 let value: T = yaml_serde::from_value(v).map_err(|e| {632634 let m = e.to_string();633635 let line = m.lines().next().unwrap_or("parse error");@@ -638,7 +640,22 @@ where638640 }639641 })?;640642 value.validate(file)?;641- Ok(Parsed { value, warnings, body })643+ Ok(Parsed { value, warnings, schema_note, body })644+}645+646+/// One-hop migration note (M8): a frontmatter `schema` marker older than647+/// SCHEMA_VERSION yields a one-line note naming the file; parse continues.648+/// Absent or current marker -> None. `schema` is in every KNOWN list, so649+/// it never fires the unknown-field warning.650+fn schema_note(v: &yaml_serde::Value, file: &str) -> Option<String> {651+ let yaml_serde::Value::Mapping(m) = v else { return None };652+ let key = yaml_serde::Value::String("schema".to_string());653+ let yaml_serde::Value::String(s) = m.get(&key)? else { return None };654+ if s == SCHEMA_VERSION {655+ return None;656+ }657+ let mark: String = s.chars().filter(|c| *c != '\n' && *c != '\r').take(32).collect();658+ Some(format!("{file}: older schema `{mark}`, continuing"))642659 }643660 644661 /// Parse fenceless YAML list files (labels.md, milestones.md).@@ -810,7 +827,10 @@ impl Forge {810827 Ok(bytes)811828 }812829 813- /// Atomic write: tmp + fsync + rename (SEC-03).830+ /// Atomic write: tmp + fsync + rename (SEC-03). No locks, by design:831+ /// concurrent writers are last-wins; the colliding agent reconciles832+ /// (bitter lesson). A crash leaves only a stale `*.tmp.*` beside the833+ /// target; readers never open tmp paths, so partials are impossible.814834 pub fn atomic_write(&self, rel: &Path, bytes: &[u8]) -> Result<(), ForgeError> {815835 let label = base_name(rel);816836 if bytes.len() > MAX_FILE_BYTES {@@ -1079,6 +1099,169 @@ fn scan_chunk_sorted(files: &[PathBuf]) -> Result<Vec<(String, String)>, ForgeEr10791099 Ok(out)10801100 }10811101 1102+/// Plain/quoted scalar on one frontmatter line. Returns None on any shape1103+/// needing real YAML (flow, anchors, escapes, comments-in-quotes...);1104+/// the caller then falls back to strict parse.1105+fn scalar_val(s: &str) -> Option<String> {1106+ let t = s.trim();1107+ if t.is_empty() {1108+ return None;1109+ }1110+ let b = t.as_bytes();1111+ if b[0] == b'"' || b[0] == b'\'' {1112+ if b.len() < 2 || b[b.len() - 1] != b[0] {1113+ return None;1114+ }1115+ let inner = &t[1..t.len() - 1];1116+ if b[0] == b'"' && inner.contains('\\') {1117+ return None;1118+ }1119+ if b[0] == b'\'' {1120+ return Some(inner.replace("''", "'"));1121+ }1122+ return Some(inner.to_string());1123+ }1124+ if matches!(1125+ b[0],1126+ b'{' | b'}' | b'[' | b']' | b',' | b'&' | b'*' | b'#' | b'?' | b'|' | b'>' | b'!' | b'@' | b'`' | b'%'1127+ ) {1128+ return None;1129+ }1130+ let mut val = t;1131+ if let Some(i) = t.find(" #") {1132+ val = t[..i].trim_end();1133+ }1134+ if val.is_empty() {1135+ return None;1136+ }1137+ Some(val.to_string())1138+}1139+1140+/// Flow `[a, b]` seq on one line; None when quoted items or nesting appear.1141+fn flow_items(v: &str) -> Option<Vec<String>> {1142+ let t = v.trim();1143+ if !t.starts_with('[') || !t.ends_with(']') {1144+ return None;1145+ }1146+ let inner = t[1..t.len() - 1].trim();1147+ if inner.is_empty() {1148+ return Some(Vec::new());1149+ }1150+ if inner.bytes().any(|b| b == b'"' || b == b'\'') {1151+ return None;1152+ }1153+ inner.split(',').map(scalar_val).collect()1154+}1155+1156+/// Fast issue extract for `issue list` over huge dirs: plain scalars, flow1157+/// `[]` seqs, and `- ` blocks for labels/assignees only. Returns None on1158+/// any other shape; the caller falls back to strict `parse_entity`, so1159+/// values, warnings, and errors stay identical.1160+fn fast_issue(front: &str, body: &str, file: &str) -> Option<Parsed<Issue>> {1161+ if front.bytes().any(|b| b == b'\t') {1162+ return None;1163+ }1164+ let (mut id, mut title, mut state, mut created) = (None, None, None, None);1165+ let (mut updated, mut milestone): (Option<String>, Option<String>) = (None, None);1166+ let (mut labels, mut assignees) = (Vec::new(), Vec::new());1167+ let mut seq: Option<bool> = None; // block mode: true = labels1168+ let mut unknowns: Vec<String> = Vec::new();1169+ let mut schema: Option<String> = None;1170+ for raw in front.split_inclusive('\n') {1171+ let l = raw.strip_suffix('\n').unwrap_or(raw);1172+ if l.is_empty() {1173+ seq = None;1174+ continue;1175+ }1176+ if l.as_bytes()[0] == b' ' {1177+ let t = l.trim();1178+ match (t.strip_prefix("- "), seq) {1179+ (Some(item), Some(true)) => labels.push(scalar_val(item)?),1180+ (Some(item), Some(false)) => assignees.push(scalar_val(item)?),1181+ _ => return None,1182+ }1183+ continue;1184+ }1185+ seq = None;1186+ if l.starts_with('-') {1187+ return None;1188+ }1189+ let ci = l.find(':')?;1190+ let (k, rest) = (&l[..ci], &l[ci + 1..]);1191+ if k.is_empty() || !k.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') {1192+ return None;1193+ }1194+ if !rest.is_empty() && !rest.starts_with(' ') {1195+ return None; // `k:v` is a scalar line, not a mapping1196+ }1197+ let v = rest.trim();1198+ match k {1199+ "id" => id = Some(scalar_val(v)?),1200+ "title" => title = Some(scalar_val(v)?),1201+ "state" => state = Some(scalar_val(v)?),1202+ "created" => created = Some(scalar_val(v)?),1203+ "updated" => updated = Some(scalar_val(v)?),1204+ "milestone" => {1205+ if !v.is_empty() {1206+ milestone = Some(scalar_val(v)?);1207+ }1208+ }1209+ "labels" | "assignees" => {1210+ if v.is_empty() {1211+ seq = Some(k == "labels");1212+ } else {1213+ let items = flow_items(v)?;1214+ if k == "labels" {1215+ labels = items;1216+ } else {1217+ assignees = items;1218+ }1219+ }1220+ }1221+ "schema" => schema = Some(scalar_val(v)?),1222+ _ => {1223+ let w = format!("unknown field `{k}`");1224+ if unknowns.len() < 16 && !unknowns.contains(&w) {1225+ unknowns.push(w);1226+ }1227+ }1228+ }1229+ }1230+ let issue = Issue {1231+ id: id?,1232+ title: title?,1233+ state: state?,1234+ labels,1235+ assignees,1236+ milestone,1237+ created: created?,1238+ updated,1239+ };1240+ issue.validate(file).ok()?;1241+ let schema_note = match schema {1242+ Some(s) if s != SCHEMA_VERSION => {1243+ let mark: String = s.chars().take(32).collect();1244+ Some(format!("{file}: older schema `{mark}`, continuing"))1245+ }1246+ _ => None,1247+ };1248+ Some(Parsed { value: issue, warnings: unknowns, schema_note, body: body.to_string() })1249+}1250+1251+/// List-hot-path issue parse: fast string-ops extract, strict fallback.1252+/// Same values, same warnings, same errors as `parse_entity::<Issue>`.1253+/// Guards run first so hostile input fails identically either way.1254+pub fn parse_issue_card(text: &str, file: &str) -> Result<Parsed<Issue>, ForgeError> {1255+ if let Ok((front, body)) = split_frontmatter(text, file) {1256+ if guard_indent(&front, file).is_ok() && guard_flow(&front, file).is_ok() {1257+ if let Some(p) = fast_issue(&front, &body, file) {1258+ return Ok(p);1259+ }1260+ }1261+ }1262+ parse_entity(text, file)1263+}1264+10821265 /// K-way merge of `(created, id)`-sorted runs into `(id, rank)` pairs.10831266 /// Pure + deterministic: same multiset in -> same pairs out.10841267 fn merge_ranked(runs: Vec<Vec<(String, String)>>) -> Vec<(String, u64)> {
crates/core/src/tests.rs+77 −0
--- a/crates/core/src/tests.rs+++ b/crates/core/src/tests.rs@@ -292,3 +292,80 @@ fn bench_10k_issue_scan_under_100ms_debug() {292292 assert!(dt.as_millis() < 100, "too slow: {:?}", dt);293293 let _ = fs::remove_dir_all(&dir);294294 }295+296+#[test]297+fn fast_card_matches_strict_on_plain_and_fancy() {298+ // plain + flow seq + quoted + comment + block seq + unknown field299+ let id = "11111111-1111-4111-8111-111111111111";300+ let cases = vec![301+ format!("---\nid: {id}\ntitle: plain\nstate: open\ncreated: 2025-09-08T02:00:00Z\n---\nbody\n"),302+ format!("---\nid: {id}\ntitle: \"quoted # kept\"\nstate: open # live\nlabels: [a, b]\nassignees: []\nmilestone: v1\ncreated: 2025-09-08T02:00:00Z\nupdated: 2025-09-08T03:00:00Z\n---\nbody\n"),303+ format!("---\nid: {id}\ntitle: 'it''s'\nstate: in-progress\nlabels:\n- x\n- y\ncreated: 2025-09-08T02:00:00Z\nzzz_new: 1\n---\nbody\n"),304+ // fancy: escape + nested flow -> strict fallback, same values305+ format!("---\nid: {id}\ntitle: \"a\\nb\"\nstate: open\nlabels: [\"q,q\", z]\ncreated: 2025-09-08T02:00:00Z\n---\nbody\n"),306+ ];307+ for (i, text) in cases.iter().enumerate() {308+ let a: Parsed<Issue> = parse_entity(text, "c.md").unwrap();309+ let b = parse_issue_card(text, "c.md").unwrap();310+ assert_eq!(a.value, b.value, "case {i} values");311+ assert_eq!(a.warnings, b.warnings, "case {i} warnings");312+ assert_eq!(a.schema_note, b.schema_note, "case {i} schema");313+ assert_eq!(a.body, b.body, "case {i} body");314+ }315+ // errors stay identical through the fallback316+ for text in [317+ "---\nid: nope\ntitle: t\nstate: open\ncreated: 2025-09-08T02:00:00Z\n---\n",318+ "---\ntitle: t\nstate: open\ncreated: 2025-09-08T02:00:00Z\n---\n",319+ ] {320+ assert_eq!(321+ parse_entity::<Issue>(text, "e.md").unwrap_err().to_string(),322+ parse_issue_card(text, "e.md").unwrap_err().to_string()323+ );324+ }325+}326+327+#[test]328+fn schema_n1_warns_and_keeps_going() {329+ let id = "11111111-1111-4111-8111-111111111111";330+ let old = format!("---\nid: {id}\ntitle: t\nstate: open\ncreated: 2025-09-08T02:00:00Z\nschema: v0\n---\nbody\n");331+ let p: Parsed<Issue> = parse_entity(&old, "issue.md").unwrap();332+ assert_eq!(p.value.title, "t");333+ let n = p.schema_note.unwrap();334+ assert!(n.contains("issue.md") && n.contains("v0") && !n.contains('\n'), "{n}");335+ // current marker + absent marker -> silent; never an unknown-field warning336+ for text in [337+ old.replace("schema: v0", "schema: v1"),338+ old.lines().filter(|l| !l.starts_with("schema:")).collect::<Vec<_>>().join("\n"),339+ ] {340+ let q: Parsed<Issue> = parse_entity(&text, "issue.md").unwrap();341+ assert!(q.schema_note.is_none());342+ assert!(q.warnings.is_empty(), "{:?}", q.warnings);343+ }344+ // card path agrees, incl. fallback shapes345+ let c = parse_issue_card(&old, "issue.md").unwrap();346+ assert!(c.schema_note.is_some());347+}348+349+#[test]350+fn stale_tmp_never_read_nor_listed() {351+ // crash mid-write leaves `*.tmp.*`; readers ignore tmp paths entirely.352+ let (dir, fg) = tmp_forge("staletmp");353+ let p: Parsed<Issue> = parse_entity(&fx("issue.md"), "issue.md").unwrap();354+ let rel = fg.issue_file(&p.value.id);355+ fg.save_entity(&rel, &p.value, &p.body).unwrap();356+ let target = dir.join(".forge").join(&rel);357+ fs::write(target.with_extension("tmp.999-0"), b"---\nid: garbage\n---\npartial").unwrap();358+ fs::write(target.with_extension("tmp"), b"partial").unwrap();359+ let q: Parsed<Issue> = fg.load_entity(&rel).unwrap();360+ assert_eq!(p.value, q.value);361+ let c = parse_issue_card(&fs::read_to_string(&target).unwrap(), "issue.md").unwrap();362+ assert_eq!(c.value, p.value);363+ let idx = fg.scan_issue_index().unwrap();364+ assert_eq!(idx.len(), 1);365+ assert!(fg.scan_numbers().unwrap().contains_key(&p.value.id));366+ // no locks anywhere: two writers are last-wins, both leave valid docs367+ fg.save_entity(&rel, &p.value, "first\n").unwrap();368+ fg.save_entity(&rel, &p.value, "second\n").unwrap();369+ let r: Parsed<Issue> = fg.load_entity(&rel).unwrap();370+ assert_eq!(r.body, "second\n");371+}