merge e5f009b0-d4f9-49d7-8461-f6bd031499b2
Message
merge e5f009b0-d4f9-49d7-8461-f6bd031499b2
Diff
crates/cli/src/cmd/ci.rs+1 −1
--- a/crates/cli/src/cmd/ci.rs+++ b/crates/cli/src/cmd/ci.rs@@ -8,7 +8,7 @@ use std::process::Command;88 use std::time::Instant;99 1010 pub fn help() -> &'static str {11- "f0rge ci: script runs\nusage: f0rge ci <run|list|view|log|rerun|cancel>\nverbs: run list view log rerun cancel\ncontract: f0rge --skill\n"11+ "f0rge ci: script runs\nusage: f0rge ci <verb> [--json] [flags]\nverbs: run list view log rerun cancel\ndocs: spec/schema.md §Ci\ncontract: f0rge --skill\n"1212 }1313 1414 const VERBS: &[&str] = &["run", "list", "view", "log", "rerun", "cancel"];
crates/cli/src/cmd/issue.rs+13 −1
--- a/crates/cli/src/cmd/issue.rs+++ b/crates/cli/src/cmd/issue.rs@@ -10,7 +10,7 @@ use std::collections::BTreeMap;1010 use std::path::Path;1111 1212 pub fn help() -> &'static str {13- "f0rge issue: issues\nusage: f0rge issue [--json] <verb> [flags]\nverbs: create list view comment close reopen assign label edit\nflags: --title --body --label --assignee --milestone --state --search\ncontract: f0rge --skill\n"13+ "f0rge issue: issues\nusage: f0rge issue <verb> [--json] [flags]\nverbs: create list view comment close reopen assign label edit\nflags: --title --body --label --assignee --milestone --state --search\ndocs: spec/schema.md §Issue\ncontract: f0rge --skill\n"1414 }1515 1616 const VERBS: &[&str] =@@ -257,6 +257,18 @@ fn one_id(fs: &args::FlagSet, verb: &str) -> Result<String, CliError> {257257 // ---- verbs ----258258 259259 fn create(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {260+ if fs.has("state") {261+ return Err(usage("f0rge issue create: --state not accepted"));262+ }263+ if fs.has("search") {264+ return Err(usage("f0rge issue create: --search not accepted"));265+ }266+ if fs.has("author") {267+ return Err(usage("f0rge issue create: --author not accepted"));268+ }269+ if fs.is("remove") {270+ return Err(usage("f0rge issue create: --remove not accepted"));271+ }260272 if !fs.positional.is_empty() {261273 return Err(usage("f0rge issue create --title T"));262274 }
crates/cli/src/cmd/wiki.rs+8 −3
--- a/crates/cli/src/cmd/wiki.rs+++ b/crates/cli/src/cmd/wiki.rs@@ -8,7 +8,7 @@ use serde::Serialize;88 use std::path::{Component, Path};99 1010 pub fn help() -> &'static str {11- "f0rge wiki: memory\nusage: f0rge wiki <get|put|list> <path>\nindex derived: tree + first H1\ncontract: f0rge --skill\n"11+ "f0rge wiki: memory\nusage: f0rge wiki <verb> [--json] [flags]\nverbs: get put list\nindex derived: tree + first H1\ndocs: spec/schema.md §Wiki\ncontract: f0rge --skill\n"1212 }1313 1414 fn spec() -> Vec<args::FlagSpec> {@@ -221,8 +221,13 @@ fn list(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {221221 if idx.is_empty() {222222 return Ok(());223223 }224- let grid: Vec<Vec<String>> =225- idx.into_iter().map(|e| vec![e.path, e.title]).collect();224+ let grid: Vec<Vec<String>> = idx225+ .into_iter()226+ .map(|e| {227+ let sz = sizes.get(e.path.as_str()).copied().unwrap_or(0);228+ vec![e.path, e.title, sz.to_string()]229+ })230+ .collect();226231 output::out(&format!("{}\n", output::align(&grid)));227232 Ok(())228233 }
crates/cli/tests/issue_e2e.rs+25 −0
--- a/crates/cli/tests/issue_e2e.rs+++ b/crates/cli/tests/issue_e2e.rs@@ -263,3 +263,28 @@ fn errors_and_budgets() {263263 264264 let _ = std::fs::remove_dir_all(&dir);265265 }266+267+#[test]268+fn create_rejects_state_and_silent_flags() {269+ // #31: gh-literal, no silent accepts on create.270+ let dir = tmp("reject");271+ run_in(&dir, &["init"]);272+ for flag in ["--state", "--search", "--author"] {273+ let (_, err, code) = run_in(&dir, &["issue", "create", "--title", "T", flag, "x"]);274+ assert_eq!(code, 2, "flag {flag}");275+ assert_eq!(err.lines().count(), 1, "flag {flag}: {err:?}");276+ assert!(err.contains(flag), "flag {flag} unnamed: {err:?}");277+ }278+ let (_, err, code) = run_in(&dir, &["issue", "create", "--title", "T", "--remove"]);279+ assert_eq!(code, 2);280+ assert_eq!(err.lines().count(), 1);281+ assert!(err.contains("--remove"), "{err:?}");282+ // default state only via create; list --state still filters.283+ let (o, _, code) = run_in(&dir, &["issue", "create", "--title", "T", "--json"]);284+ assert_eq!(code, 0);285+ assert_eq!(jv(&o)["state"], "open");286+ let (o, _, code) = run_in(&dir, &["issue", "list", "--json", "--state", "open"]);287+ assert_eq!(code, 0);288+ assert_eq!(o.lines().count(), 1);289+ let _ = std::fs::remove_dir_all(&dir);290+}
crates/cli/tests/m3d_m4.rs+22 −0
--- a/crates/cli/tests/m3d_m4.rs+++ b/crates/cli/tests/m3d_m4.rs@@ -296,3 +296,25 @@ fn ci_cancel_running_run() {296296 let _ = child.wait();297297 let _ = std::fs::remove_dir_all(&repo);298298 }299+300+#[test]301+fn ci_run_prints_verdict_line() {302+ // #26: run end prints ONE "<ts> <verdict>" line to stdout.303+ let repo = setup();304+ let steps = repo.join(".forge/ci/steps");305+ exec_file(&steps.join("00_ok.sh"), "#!/bin/sh\necho out\nexit 0\n");306+ let (c, o, _) = f0rge(&repo, &["ci", "run"]);307+ assert_eq!(c, 0, "out={o}");308+ let line = o.lines().last().unwrap().trim().to_string();309+ let parts: Vec<&str> = line.split_whitespace().collect();310+ assert_eq!(parts.len(), 2, "line={line:?} out={o:?}");311+ assert_eq!(parts[1], "pass");312+ // fail still prints the line on stdout (stderr holds the error).313+ exec_file(&steps.join("00_ok.sh"), "#!/bin/sh\nexit 1\n");314+ let (c, o, e) = f0rge(&repo, &["ci", "run"]);315+ assert_eq!(c, 1, "err={e}");316+ let line = o.lines().last().unwrap().trim().to_string();317+ assert!(line.ends_with(" fail"), "line={line:?}");318+ assert_eq!(e.lines().count(), 1);319+ let _ = std::fs::remove_dir_all(&repo);320+}
crates/cli/tests/m5.rs+17 −0
--- a/crates/cli/tests/m5.rs+++ b/crates/cli/tests/m5.rs@@ -254,3 +254,20 @@ fn release_unknown_field_warns() {254254 assert!(err.lines().any(|l| l.contains("zzz")), "stderr:\n{err}");255255 let _ = std::fs::remove_dir_all(&dir);256256 }257+258+#[test]259+fn wiki_human_list_shows_size() {260+ // #29: human table gains size column; json unchanged.261+ let dir = tmp("wikisize");262+ assert_eq!(run_in(&dir, &["init"]).2, 0);263+ let body = "# T\n\nhello\n";264+ let (_, _, code) = run_in_stdin(&dir, &["wiki", "put", "docs/s.md", "-"], body.as_bytes());265+ assert_eq!(code, 0);266+ let (h, _, code) = run_in(&dir, &["wiki", "list"]);267+ assert_eq!(code, 0);268+ assert!(h.contains("docs/s.md") && h.contains("11"), "human:\n{h}");269+ let (j, _, code) = run_in(&dir, &["wiki", "list", "--json"]);270+ assert_eq!(code, 0);271+ assert_eq!(jv(&j)[0]["size"], body.len() as u64);272+ let _ = std::fs::remove_dir_all(&dir);273+}
crates/core/src/lib.rs+49 −10
--- a/crates/core/src/lib.rs+++ b/crates/core/src/lib.rs@@ -96,10 +96,25 @@ pub fn is_uuid4_lower(s: &str) -> bool {9696 true9797 }9898 99-/// RFC3339 UTC second precision: `2025-09-08T02:00:00Z`.99+/// RFC3339 UTC: `2025-09-08T02:00:00Z` with optional fractional100+/// seconds (`...00.123456789Z`, 1-9 digits, fixed width in output).101+/// Lexicographic == chronological holds for fixed-width output.100102 pub fn is_rfc3339_utc(s: &str) -> bool {101- let b = s.as_bytes();102- if b.len() != 20 {103+ let t = match s.strip_suffix('Z') {104+ Some(x) => x,105+ None => return false,106+ };107+ let (base, frac) = match t.find('.') {108+ Some(i) => (&t[..i], Some(&t[i + 1..])),109+ None => (t, None),110+ };111+ if let Some(f) = frac {112+ if f.is_empty() || f.len() > 9 || !f.bytes().all(|b| b.is_ascii_digit()) {113+ return false;114+ }115+ }116+ let b = base.as_bytes();117+ if b.len() != 19 {103118 return false;104119 }105120 for (i, c) in b.iter().enumerate() {@@ -107,7 +122,6 @@ pub fn is_rfc3339_utc(s: &str) -> bool {107122 4 | 7 => *c == b'-',108123 10 => *c == b'T',109124 13 | 16 => *c == b':',110- 19 => *c == b'Z',111125 _ => c.is_ascii_digit(),112126 };113127 if !ok {@@ -145,13 +159,38 @@ pub fn unix_to_rfc3339(secs: u64) -> String {145159 format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z")146160 }147161 148-/// Now as RFC3339 UTC seconds.162+/// Now as RFC3339 UTC with fixed-width fractional seconds163+/// (`...SS.nnnnnnnnnZ`). Monotonic: concurrent/rapid calls never tie,164+/// so `(created, id)` rank == creation order. All entities inherit165+/// via this single helper.166+static LAST_NANOS: AtomicU64 = AtomicU64::new(0);167+149168 pub fn now_rfc3339() -> String {150- let secs = SystemTime::now()151- .duration_since(UNIX_EPOCH)152- .map(|d| d.as_secs())153- .unwrap_or(0);154- unix_to_rfc3339(secs)169+ let d = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default();170+ let mut total =171+ d.as_secs().saturating_mul(1_000_000_000).saturating_add(d.subsec_nanos() as u64);172+ loop {173+ let last = LAST_NANOS.load(Ordering::Relaxed);174+ if total > last {175+ if LAST_NANOS176+ .compare_exchange_weak(last, total, Ordering::Relaxed, Ordering::Relaxed)177+ .is_ok()178+ {179+ break;180+ }181+ } else {182+ let bumped = last.saturating_add(1);183+ if LAST_NANOS184+ .compare_exchange_weak(last, bumped, Ordering::Relaxed, Ordering::Relaxed)185+ .is_ok()186+ {187+ total = bumped;188+ break;189+ }190+ }191+ }192+ let base = unix_to_rfc3339(total / 1_000_000_000);193+ format!("{}.{:09}Z", &base[..base.len() - 1], total % 1_000_000_000)155194 }156195 157196 // ---- frontmatter split + pre-parse guards (SEC-01, before yaml parse) ----
crates/core/src/tests.rs+28 −0
--- a/crates/core/src/tests.rs+++ b/crates/core/src/tests.rs@@ -369,3 +369,31 @@ fn stale_tmp_never_read_nor_listed() {369369 let r: Parsed<Issue> = fg.load_entity(&rel).unwrap();370370 assert_eq!(r.body, "second\n");371371 }372+373+#[test]374+fn created_fractional_orders_and_parses_both() {375+ // #28: new stamps carry fractional seconds; old second-precision files parse.376+ let a = now_rfc3339();377+ assert!(a.contains('.'), "fraction missing: {a}");378+ assert!(is_rfc3339_utc(&a), "{a}");379+ assert!(is_rfc3339_utc("2026-09-08T07:44:29Z"));380+ assert!(is_rfc3339_utc("2026-09-08T07:44:29.123Z"));381+ assert!(is_rfc3339_utc("2026-09-08T07:44:29.123456Z"));382+ assert!(!is_rfc3339_utc("2026-09-08T07:44:29.Z"));383+ assert!(!is_rfc3339_utc("2026-09-08T07:44:29.1234567890Z"));384+ // rapid calls never tie: lexicographic == chronological.385+ let mut prev = now_rfc3339();386+ for _ in 0..50 {387+ let cur = now_rfc3339();388+ assert!(prev < cur, "not monotonic: {prev} vs {cur}");389+ prev = cur;390+ }391+ // old file (no fraction) still validates through the entity path.392+ let id = "11111111-1111-4111-8111-111111111111";393+ let old = format!("---\nid: {id}\ntitle: t\nstate: open\ncreated: 2025-09-08T02:00:00Z\n---\nb\n");394+ let p: Parsed<Issue> = parse_entity(&old, "old.md").unwrap();395+ assert_eq!(p.value.created, "2025-09-08T02:00:00Z");396+ let new = format!("---\nid: {id}\ntitle: t\nstate: open\ncreated: {a}\n---\nb\n");397+ let q: Parsed<Issue> = parse_entity(&new, "new.md").unwrap();398+ assert_eq!(q.value.created, a);399+}