merge e5f009b0-d4f9-49d7-8461-f6bd031499b2

99061c4gl0w

all commits

Message

merge e5f009b0-d4f9-49d7-8461-f6bd031499b2

Diff

crates/cli/src/cmd/ci.rs+1 −1
diff --git a/crates/cli/src/cmd/ci.rs b/crates/cli/src/cmd/ci.rsindex 255df16..1e2076e 100644--- 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
diff --git a/crates/cli/src/cmd/issue.rs b/crates/cli/src/cmd/issue.rsindex 8e26e84..3d0345c 100644--- 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
diff --git a/crates/cli/src/cmd/wiki.rs b/crates/cli/src/cmd/wiki.rsindex 017c0b0..61d4d57 100644--- 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
diff --git a/crates/cli/tests/issue_e2e.rs b/crates/cli/tests/issue_e2e.rsindex 1d2fe21..1f5f1ff 100644--- 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
diff --git a/crates/cli/tests/m3d_m4.rs b/crates/cli/tests/m3d_m4.rsindex b3807b1..b47fbb7 100644--- 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
diff --git a/crates/cli/tests/m5.rs b/crates/cli/tests/m5.rsindex 2d20ca8..1547fa2 100644--- 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
diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rsindex ef1be64..3de3261 100644--- 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
diff --git a/crates/core/src/tests.rs b/crates/core/src/tests.rsindex 7b6d893..12f9b4b 100644--- 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+}