merge 0141853c-faac-4353-b2ea-3b037354b4da
Message
merge 0141853c-faac-4353-b2ea-3b037354b4da
Diff
.forge/issues/125bcf04-f0c9-4a69-a373-d12fa0ff6280/issue.md+12 −0
--- /dev/null+++ b/.forge/issues/125bcf04-f0c9-4a69-a373-d12fa0ff6280/issue.md@@ -0,0 +1,12 @@1+---2+id: 125bcf04-f0c9-4a69-a373-d12fa0ff62803+title: unknown-field warnings dropped in issue/label/milestone/task4+state: open5+labels:6+- dogfood7+assignees: []8+milestone: null9+created: 2026-09-08T03:57:13Z10+updated: null11+---12+Parsed.warnings never printed; only schema_note via warn_schema. label/milestone discard parse_list warnings as _w. Contract: unknown field = warning. Fix: emit warnings to stderr in these four cmds.
.forge/issues/238a6e74-bed3-4d39-a6b5-a1b6c7345ab8/comments/bdcc8721-0ab8-4478-b66c-31a19e58a6c7.md+6 −0
--- /dev/null+++ b/.forge/issues/238a6e74-bed3-4d39-a6b5-a1b6c7345ab8/comments/bdcc8721-0ab8-4478-b66c-31a19e58a6c7.md@@ -0,0 +1,6 @@1+---2+id: bdcc8721-0ab8-4478-b66c-31a19e58a6c73+author: dog-issue4+created: 2026-09-08T04:03:03Z5+---6+fixed on dog-issue; quoting guard added to label/milestone/task esc()
.forge/issues/238a6e74-bed3-4d39-a6b5-a1b6c7345ab8/issue.md+14 −0
--- /dev/null+++ b/.forge/issues/238a6e74-bed3-4d39-a6b5-a1b6c7345ab8/issue.md@@ -0,0 +1,14 @@1+---2+id: 238a6e74-bed3-4d39-a6b5-a1b6c7345ab83+title: label/milestone/task render writes bare scalars that re-parse as int/float/bool4+state: open5+labels:6+- dogfood7+- pri8+assignees:9+- dog-issue10+milestone: null11+created: 2026-09-08T03:57:13Z12+updated: 2026-09-08T04:03:03Z13+---14+label create --name 123 --color true writes unquoted; reload fails: invalid type integer/float. Same esc()/plain() in label.rs milestone.rs task.rs. Live .forge/labels.md has color 5319E7 bare -> label list broken. Fix: quote YAML-risky scalars on write.
.forge/issues/4918ac8b-3700-4f8d-8b62-620b98891c5c/issue.md+12 −0
--- /dev/null+++ b/.forge/issues/4918ac8b-3700-4f8d-8b62-620b98891c5c/issue.md@@ -0,0 +1,12 @@1+---2+id: 4918ac8b-3700-4f8d-8b62-620b98891c5c3+title: default issue list shows archived; spec says skip archived by default4+state: open5+labels:6+- dogfood7+assignees: []8+milestone: null9+created: 2026-09-08T03:57:13Z10+updated: null11+---12+list with no --state shows state archived rows. Fix: default filter excludes archived; explicit --state archived still shows them.
.forge/issues/53365978-18fe-4149-85f1-652057347741/issue.md+12 −0
--- /dev/null+++ b/.forge/issues/53365978-18fe-4149-85f1-652057347741/issue.md@@ -0,0 +1,12 @@1+---2+id: 53365978-18fe-4149-85f1-6520573477413+title: empty --label/--assignee stored as empty string4+state: open5+labels:6+- dogfood7+assignees: []8+milestone: null9+created: 2026-09-08T03:57:13Z10+updated: null11+---12+issue create --label '' stores labels ['','bug']; empty strings should be dropped (create/assign/label paths). Same for assignee.
.forge/issues/7e4d18f4-b7d2-4644-9152-9458af2a654f/issue.md+12 −0
--- /dev/null+++ b/.forge/issues/7e4d18f4-b7d2-4644-9152-9458af2a654f/issue.md@@ -0,0 +1,12 @@1+---2+id: 7e4d18f4-b7d2-4644-9152-9458af2a654f3+title: issue list --state usage text says open|closed but accepts four states4+state: open5+labels:6+- dogfood7+assignees: []8+milestone: null9+created: 2026-09-08T03:57:13Z10+updated: null11+---12+Code accepts open/closed/in-progress/archived; usage string says open|closed. Fix text.
.forge/labels.md+8 −2
--- a/.forge/labels.md+++ b/.forge/labels.md@@ -1,3 +1,9 @@11 - name: dogfood2- color: 5319E73- description: "dogfood sweep"2+ color: "5319E7"3+ description: "dogfood sweep (quoted color)"4+- name: bug5+ color: FF00006+ description: defects7+- name: pri8+ color: FFCC009+ description: "priority work"
.forge/milestones.md+5 −0
--- a/.forge/milestones.md+++ b/.forge/milestones.md@@ -1,2 +1,7 @@11 - name: dogfood-m122 state: open3+ description: "dogfood sprint M1"4+- name: sprint15+ state: open6+ due: 2026-10-017+ description: "dog-issue domain sprint"
.forge/tasks/0763f7d0-ec4f-4f9b-8a5e-efdde775d327.md+8 −0
--- /dev/null+++ b/.forge/tasks/0763f7d0-ec4f-4f9b-8a5e-efdde775d327.md@@ -0,0 +1,8 @@1+---2+id: 0763f7d0-ec4f-4f9b-8a5e-efdde775d3273+title: "dog-issue: bug-hunt edge cases"4+status: done5+created: 2026-09-08T03:54:08Z6+updated: 2026-09-08T04:04:13Z7+closed: 2026-09-08T04:04:13Z8+---
.forge/tasks/4d3bcb17-c471-42d4-8b02-17b3f89e08d9.md+8 −0
--- /dev/null+++ b/.forge/tasks/4d3bcb17-c471-42d4-8b02-17b3f89e08d9.md@@ -0,0 +1,8 @@1+---2+id: 4d3bcb17-c471-42d4-8b02-17b3f89e08d93+title: "scale: 10k list + 500 wiki budget probe"4+status: done5+created: 2026-09-08T03:54:08Z6+updated: 2026-09-08T04:03:22Z7+closed: 2026-09-08T04:03:22Z8+---
.forge/tasks/583763a8-fdb3-4215-92dc-a3d319a5c91c.md+8 −0
--- /dev/null+++ b/.forge/tasks/583763a8-fdb3-4215-92dc-a3d319a5c91c.md@@ -0,0 +1,8 @@1+---2+id: 583763a8-fdb3-4215-92dc-a3d319a5c91c3+title: "clinic: verify issue cmds against stale data"4+status: done5+created: 2026-09-08T03:54:08Z6+updated: 2026-09-08T04:03:22Z7+closed: 2026-09-08T04:03:22Z8+---
.forge/tasks/838373ef-6553-4e21-9df7-b6ee97d35ff4.md+8 −0
--- /dev/null+++ b/.forge/tasks/838373ef-6553-4e21-9df7-b6ee97d35ff4.md@@ -0,0 +1,8 @@1+---2+id: 838373ef-6553-4e21-9df7-b6ee97d35ff43+title: "dog-issue: sweep issue/label/milestone/task cmds"4+status: done5+created: 2026-09-08T03:54:07Z6+updated: 2026-09-08T04:04:13Z7+closed: 2026-09-08T04:04:13Z8+---
.forge/tasks/aa476ab6-3caa-43c1-b487-932d9594948a.md+7 −0
--- /dev/null+++ b/.forge/tasks/aa476ab6-3caa-43c1-b487-932d9594948a.md@@ -0,0 +1,7 @@1+---2+id: aa476ab6-3caa-43c1-b487-932d9594948a3+title: "dog-issue: fix + tests + PR"4+status: doing5+created: 2026-09-08T03:54:08Z6+updated: 2026-09-08T03:58:55Z7+---
.forge/tasks/af34c7c6-2738-40e7-85b1-c19a3e3d17a9.md+8 −0
--- /dev/null+++ b/.forge/tasks/af34c7c6-2738-40e7-85b1-c19a3e3d17a9.md@@ -0,0 +1,8 @@1+---2+id: af34c7c6-2738-40e7-85b1-c19a3e3d17a93+title: "dog-issue: labels + milestone shape verify"4+status: done5+created: 2026-09-08T03:54:08Z6+updated: 2026-09-08T04:04:13Z7+closed: 2026-09-08T04:04:13Z8+---
crates/cli/src/cmd/issue.rs+41 −8
--- a/crates/cli/src/cmd/issue.rs+++ b/crates/cli/src/cmd/issue.rs@@ -91,6 +91,14 @@ fn resolve_id(fg: &Forge, raw: &str) -> Result<String, CliError> {9191 Err(usage("f0rge issue <verb> <id>"))9292 }9393 94+/// Unknown-field warnings + schema note, one line each on stderr.95+fn warn(file: &str, w: &[String], note: &Option<String>) {96+ for x in w {97+ eprintln!("warning: {file}: {x}");98+ }99+ output::warn_schema(note);100+}101+94102 /// List-hot-path load: core fast extract with strict fallback (same95103 /// values/errors as `load_full`; views and mutations stay strict).96104 fn load_card(fg: &Forge, uuid: &str) -> Result<Full, CliError> {@@ -104,17 +112,18 @@ fn load_card(fg: &Forge, uuid: &str) -> Result<Full, CliError> {104112 CliError::from(ForgeError::Parse { file: label.clone(), msg: "not utf-8".to_string() })105113 })?;106114 let p = f0rge_core::parse_issue_card(&text, &label).map_err(CliError::from)?;107- output::warn_schema(&p.schema_note);115+ warn(&label, &p.warnings, &p.schema_note);108116 Ok(Full { id: uuid.to_string(), issue: p.value, body: p.body })109117 }110118 111119 fn load_full(fg: &Forge, uuid: &str) -> Result<Full, CliError> {112120 let rel = fg.issue_file(uuid);121+ let label = f0rge_core::base_name(&rel);113122 let p: f0rge_core::Parsed<Issue> = fg.load_entity(&rel).map_err(|e| match e {114123 ForgeError::NotFound { .. } => CliError::Runtime(format!("issue {uuid}: not found")),115124 _ => CliError::from(e),116125 })?;117- output::warn_schema(&p.schema_note);126+ warn(&label, &p.warnings, &p.schema_note);118127 Ok(Full { id: uuid.to_string(), issue: p.value, body: p.body })119128 }120129 @@ -202,7 +211,7 @@ fn comment_list(fg: &Forge, uuid: &str) -> Result<Vec<(IssueComment, String)>, C202211 }203212 let rel = Path::new("issues").join(uuid).join("comments").join(&name);204213 let p: f0rge_core::Parsed<IssueComment> = fg.load_entity(&rel)?;205- output::warn_schema(&p.schema_note);214+ warn(&name, &p.warnings, &p.schema_note);206215 out.push((p.value, p.body));207216 }208217 out.sort_by(|a, b| (a.0.created.clone(), a.0.id.clone()).cmp(&(b.0.created.clone(), b.0.id.clone())));@@ -255,8 +264,20 @@ fn create(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {255264 return Err(usage("f0rge issue create --title T"));256265 }257266 let body = norm_body(fs.get("body").unwrap_or(""));258- let labels = dedup(fs.get_all("label").into_iter().map(|s| s.to_string()).collect());259- let assignees = dedup(fs.get_all("assignee").into_iter().map(|s| s.to_string()).collect());267+ let labels = dedup(268+ fs.get_all("label")269+ .into_iter()270+ .filter(|s| !clean(s).is_empty())271+ .map(|s| s.to_string())272+ .collect(),273+ );274+ let assignees = dedup(275+ fs.get_all("assignee")276+ .into_iter()277+ .filter(|s| !clean(s).is_empty())278+ .map(|s| s.to_string())279+ .collect(),280+ );260281 let milestone = fs.get("milestone").map(|s| s.to_string());261282 let id = uuid::Uuid::new_v4().to_string();262283 let issue = Issue {@@ -281,7 +302,7 @@ fn list(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {281302 }282303 if let Some(s) = fs.get("state") {283304 if !["open", "closed", "in-progress", "archived"].contains(&s) {284- return Err(usage("f0rge issue list --state open|closed"));305+ return Err(usage("f0rge issue list --state open|closed|in-progress|archived"));285306 }286307 }287308 let all = load_all(fg)?;@@ -305,6 +326,8 @@ fn list(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {305326 if f.issue.state != s {306327 continue;307328 }329+ } else if f.issue.state == "archived" {330+ continue; // indexes skip archived by default308331 }309332 match (ms, &f.issue.milestone) {310333 (Some(m), Some(x)) if x == m => {}@@ -418,7 +441,12 @@ fn set_state(fs: &args::FlagSet, fg: &Forge, verb: &str, state: &str) -> Result<418441 419442 fn assign(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {420443 let uuid = resolve_id(fg, &one_id(fs, "assign")?)?;421- let given: Vec<String> = fs.get_all("assignee").into_iter().map(|s| s.to_string()).collect();444+ let given: Vec<String> = fs445+ .get_all("assignee")446+ .into_iter()447+ .filter(|s| !clean(s).is_empty())448+ .map(|s| s.to_string())449+ .collect();422450 if given.is_empty() {423451 return Err(usage("f0rge issue assign <id> --assignee A"));424452 }@@ -441,7 +469,12 @@ fn assign(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {441469 442470 fn label(fs: &args::FlagSet, fg: &Forge) -> Result<(), CliError> {443471 let uuid = resolve_id(fg, &one_id(fs, "label")?)?;444- let given: Vec<String> = fs.get_all("label").into_iter().map(|s| s.to_string()).collect();472+ let given: Vec<String> = fs473+ .get_all("label")474+ .into_iter()475+ .filter(|s| !clean(s).is_empty())476+ .map(|s| s.to_string())477+ .collect();445478 if given.is_empty() {446479 return Err(usage("f0rge issue label <id> --label L"));447480 }
crates/cli/src/cmd/label.rs+39 −2
--- a/crates/cli/src/cmd/label.rs+++ b/crates/cli/src/cmd/label.rs@@ -39,13 +39,50 @@ fn load(fg: &Forge) -> Result<Vec<Label>, CliError> {3939 if text.trim().is_empty() || text.trim() == "[]" {4040 return Ok(Vec::new());4141 }42- let (items, _w) =42+ let (items, w) =4343 f0rge_core::parse_list::<Label>(&text, "labels.md").map_err(CliError::from)?;44+ for x in &w {45+ eprintln!("warning: labels.md: {x}");46+ }4447 Ok(items)4548 }4649 50+/// True when a plain scalar would re-parse as bool/null/number instead51+/// of a string (e.g. `123`, `5319E7`, `true`). Quoting is always safe.52+fn risky(s: &str) -> bool {53+ match s.to_ascii_lowercase().as_str() {54+ "true" | "false" | "yes" | "no" | "on" | "off" | "null" | "~" | "nan" | "inf"55+ | ".nan" | ".inf" | "+.nan" | "+.inf" | "-.nan" | "-.inf" => return true,56+ _ => {}57+ }58+ if !s.bytes().any(|b| b.is_ascii_digit()) {59+ return false;60+ }61+ if s.parse::<f64>().is_ok() {62+ return true;63+ }64+ let t = s.strip_prefix('+').or_else(|| s.strip_prefix('-')).unwrap_or(s);65+ if t.starts_with("0x")66+ || t.starts_with("0X")67+ || t.starts_with("0o")68+ || t.starts_with("0O")69+ || t.starts_with("0b")70+ || t.starts_with("0B")71+ {72+ return true;73+ }74+ let u: String = t.chars().filter(|c| *c != '_').collect();75+ if !u.is_empty() && u.bytes().all(|b| b.is_ascii_digit()) {76+ return true;77+ }78+ let f: String = s.chars().filter(|c| *c != '_').collect();79+ f.parse::<f64>().is_ok()80+}81+4782 fn plain(s: &str) -> bool {48- !s.is_empty() && s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.' || b == b'/')83+ !s.is_empty()84+ && !risky(s)85+ && s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.' || b == b'/')4986 }5087 5188 fn esc(s: &str) -> String {
crates/cli/src/cmd/milestone.rs+39 −2
--- a/crates/cli/src/cmd/milestone.rs+++ b/crates/cli/src/cmd/milestone.rs@@ -39,13 +39,50 @@ fn load(fg: &Forge) -> Result<Vec<Milestone>, CliError> {3939 if text.trim().is_empty() || text.trim() == "[]" {4040 return Ok(Vec::new());4141 }42- let (items, _w) =42+ let (items, w) =4343 f0rge_core::parse_list::<Milestone>(&text, "milestones.md").map_err(CliError::from)?;44+ for x in &w {45+ eprintln!("warning: milestones.md: {x}");46+ }4447 Ok(items)4548 }4649 50+/// True when a plain scalar would re-parse as bool/null/number instead51+/// of a string (e.g. `123`, `true`). Quoting is always safe.52+fn risky(s: &str) -> bool {53+ match s.to_ascii_lowercase().as_str() {54+ "true" | "false" | "yes" | "no" | "on" | "off" | "null" | "~" | "nan" | "inf"55+ | ".nan" | ".inf" | "+.nan" | "+.inf" | "-.nan" | "-.inf" => return true,56+ _ => {}57+ }58+ if !s.bytes().any(|b| b.is_ascii_digit()) {59+ return false;60+ }61+ if s.parse::<f64>().is_ok() {62+ return true;63+ }64+ let t = s.strip_prefix('+').or_else(|| s.strip_prefix('-')).unwrap_or(s);65+ if t.starts_with("0x")66+ || t.starts_with("0X")67+ || t.starts_with("0o")68+ || t.starts_with("0O")69+ || t.starts_with("0b")70+ || t.starts_with("0B")71+ {72+ return true;73+ }74+ let u: String = t.chars().filter(|c| *c != '_').collect();75+ if !u.is_empty() && u.bytes().all(|b| b.is_ascii_digit()) {76+ return true;77+ }78+ let f: String = s.chars().filter(|c| *c != '_').collect();79+ f.parse::<f64>().is_ok()80+}81+4782 fn plain(s: &str) -> bool {48- !s.is_empty() && s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.' || b == b'/')83+ !s.is_empty()84+ && !risky(s)85+ && s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.' || b == b'/')4986 }5087 5188 fn esc(s: &str) -> String {
crates/cli/src/cmd/task.rs+36 −0
--- a/crates/cli/src/cmd/task.rs+++ b/crates/cli/src/cmd/task.rs@@ -23,8 +23,41 @@ fn clean(s: &str) -> String {2323 s.chars().filter(|c| *c != '\n' && *c != '\r').take(80).collect::<String>().trim().to_string()2424 }2525 26+/// True when a plain scalar would re-parse as bool/null/number instead27+/// of a string (e.g. a task titled `123`). Quoting is always safe.28+fn risky(s: &str) -> bool {29+ match s.to_ascii_lowercase().as_str() {30+ "true" | "false" | "yes" | "no" | "on" | "off" | "null" | "~" | "nan" | "inf"31+ | ".nan" | ".inf" | "+.nan" | "+.inf" | "-.nan" | "-.inf" => return true,32+ _ => {}33+ }34+ if !s.bytes().any(|b| b.is_ascii_digit()) {35+ return false;36+ }37+ if s.parse::<f64>().is_ok() {38+ return true;39+ }40+ let t = s.strip_prefix('+').or_else(|| s.strip_prefix('-')).unwrap_or(s);41+ if t.starts_with("0x")42+ || t.starts_with("0X")43+ || t.starts_with("0o")44+ || t.starts_with("0O")45+ || t.starts_with("0b")46+ || t.starts_with("0B")47+ {48+ return true;49+ }50+ let u: String = t.chars().filter(|c| *c != '_').collect();51+ if !u.is_empty() && u.bytes().all(|b| b.is_ascii_digit()) {52+ return true;53+ }54+ let f: String = s.chars().filter(|c| *c != '_').collect();55+ f.parse::<f64>().is_ok()56+}57+2658 fn plain(s: &str) -> bool {2759 !s.is_empty()60+ && !risky(s)2861 && s.bytes().all(|b| {2962 b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.' || b == b'/'3063 })@@ -127,6 +160,9 @@ fn load(fg: &Forge, uuid: &str) -> Result<(Task, Option<String>, String), CliErr127160 let label = format!("{uuid}.md");128161 let p: f0rge_core::Parsed<Task> =129162 f0rge_core::parse_entity(&text, &label).map_err(CliError::from)?;163+ for w in &p.warnings {164+ eprintln!("warning: {label}: {w}");165+ }130166 output::warn_schema(&p.schema_note);131167 if p.value.id != uuid {132168 return Err(CliError::Runtime(format!("task {uuid}: id mismatch")));
crates/cli/tests/dog_issue.rs+149 −0
--- /dev/null+++ b/crates/cli/tests/dog_issue.rs@@ -0,0 +1,149 @@1+//! dog-issue acceptance: quoting round-trip, empty-label drop,2+//! unknown-field warnings, archived default-hide on temp repos.3+4+use std::path::{Path, PathBuf};5+use std::process::Command;6+7+fn bin() -> Command {8+ Command::new(env!("CARGO_BIN_EXE_f0rge"))9+}10+11+fn run_in(dir: &Path, args: &[&str]) -> (String, String, i32) {12+ let out = bin().current_dir(dir).args(args).output().expect("run f0rge");13+ (14+ String::from_utf8_lossy(&out.stdout).into_owned(),15+ String::from_utf8_lossy(&out.stderr).into_owned(),16+ out.status.code().unwrap_or(-1),17+ )18+}19+20+fn tmp(tag: &str) -> PathBuf {21+ let d = std::env::temp_dir()22+ .join(format!("f0rge-dogissue-{tag}-{}-{}", std::process::id(), line!()));23+ let _ = std::fs::remove_dir_all(&d);24+ std::fs::create_dir_all(&d).unwrap();25+ d26+}27+28+fn setup(tag: &str) -> PathBuf {29+ let dir = tmp(tag);30+ let (_, _, code) = run_in(&dir, &["init"]);31+ assert_eq!(code, 0);32+ dir33+}34+35+fn jv(s: &str) -> serde_json::Value {36+ serde_json::from_str(s.trim()).expect("json line")37+}38+39+#[test]40+fn label_quote_roundtrip() {41+ let dir = setup("quote");42+ let (_, err, code) = run_in(43+ &dir,44+ &["label", "create", "--name", "123", "--color", "5319E7", "--description", "true"],45+ );46+ assert_eq!(code, 0, "{err}");47+ let raw = std::fs::read_to_string(dir.join(".forge/labels.md")).unwrap();48+ assert!(raw.contains("\"123\""), "name quoted:\n{raw}");49+ assert!(raw.contains("\"5319E7\""), "color quoted:\n{raw}");50+ assert!(raw.contains("\"true\""), "desc quoted:\n{raw}");51+ let (o, err, code) = run_in(&dir, &["label", "list", "--json"]);52+ assert_eq!(code, 0, "{err}");53+ let v = jv(&o);54+ assert_eq!(v["name"], "123");55+ assert_eq!(v["color"], "5319E7");56+ assert_eq!(v["description"], "true");57+ // plain names stay bare for humans58+ let (_, _, code) = run_in(&dir, &["label", "create", "--name", "bug"]);59+ assert_eq!(code, 0);60+ let raw = std::fs::read_to_string(dir.join(".forge/labels.md")).unwrap();61+ assert!(raw.contains("- name: bug\n"), "plain stays bare:\n{raw}");62+ let _ = std::fs::remove_dir_all(&dir);63+}64+65+#[test]66+fn milestone_task_quote_roundtrip() {67+ let dir = setup("mtquote");68+ let (_, err, code) = run_in(&dir, &["milestone", "create", "--name", "007", "--due", "2026-10-01"]);69+ assert_eq!(code, 0, "{err}");70+ let (o, err, code) = run_in(&dir, &["milestone", "list", "--json"]);71+ assert_eq!(code, 0, "{err}");72+ assert_eq!(jv(&o)["name"], "007");73+ let (_, err, code) = run_in(&dir, &["task", "create", "--title", "123", "--json"]);74+ assert_eq!(code, 0, "{err}");75+ let (o, _, code) = run_in(&dir, &["task", "list", "--json"]);76+ assert_eq!(code, 0);77+ assert_eq!(jv(&o)["title"], "123");78+ let _ = std::fs::remove_dir_all(&dir);79+}80+81+#[test]82+fn issue_empty_labels_dropped() {83+ let dir = setup("empty");84+ let (o, _, code) = run_in(85+ &dir,86+ &["issue", "create", "--title", "T", "--label", "", "--label", "bug", "--assignee", ""],87+ );88+ assert_eq!(code, 0);89+ let v = jv(&o);90+ assert_eq!(v["labels"], serde_json::json!(["bug"]));91+ assert_eq!(v["assignees"], serde_json::json!([]));92+ // empty-only verbs are usage errors, one line93+ let n = v["number"].to_string();94+ let (_, err, code) = run_in(&dir, &["issue", "label", &n, "--label", ""]);95+ assert_eq!(code, 2);96+ assert_eq!(err.lines().count(), 1);97+ let (_, err, code) = run_in(&dir, &["issue", "assign", &n, "--assignee", ""]);98+ assert_eq!(code, 2);99+ assert_eq!(err.lines().count(), 1);100+ let _ = std::fs::remove_dir_all(&dir);101+}102+103+#[test]104+fn unknown_field_warns() {105+ let dir = setup("warn");106+ let (o, _, code) = run_in(&dir, &["issue", "create", "--title", "T", "--json"]);107+ assert_eq!(code, 0);108+ let uuid = {109+ let issues = dir.join(".forge/issues");110+ let e = std::fs::read_dir(&issues).unwrap().flatten().next().unwrap();111+ e.file_name().to_string_lossy().into_owned()112+ };113+ let _ = o;114+ let f = dir.join(".forge/issues").join(&uuid).join("issue.md");115+ let t = std::fs::read_to_string(&f).unwrap().replace("state: open", "state: open\nxtra: 1");116+ std::fs::write(&f, t).unwrap();117+ let (_, err, code) = run_in(&dir, &["issue", "view", &uuid]);118+ assert_eq!(code, 0);119+ assert!(err.contains("unknown field"), "stderr:\n{err}");120+ std::fs::write(dir.join(".forge/labels.md"), "- name: x\n bogus: 1\n").unwrap();121+ let (_, err, code) = run_in(&dir, &["label", "list"]);122+ assert_eq!(code, 0);123+ assert!(err.contains("unknown field"), "stderr:\n{err}");124+ let _ = std::fs::remove_dir_all(&dir);125+}126+127+#[test]128+fn archived_hidden_by_default() {129+ let dir = setup("arch");130+ let (o, _, code) = run_in(&dir, &["issue", "create", "--title", "A", "--json"]);131+ assert_eq!(code, 0);132+ let n = jv(&o)["number"].to_string();133+ let (_, _, code) = run_in(&dir, &["issue", "close", &n]);134+ assert_eq!(code, 0);135+ // flip to archived on disk (no archive verb in scope)136+ let issues = dir.join(".forge/issues");137+ for e in std::fs::read_dir(&issues).unwrap().flatten() {138+ let f = issues.join(e.file_name()).join("issue.md");139+ let t = std::fs::read_to_string(&f).unwrap().replace("state: closed", "state: archived");140+ std::fs::write(&f, t).unwrap();141+ }142+ let (o, _, code) = run_in(&dir, &["issue", "list", "--json"]);143+ assert_eq!(code, 0);144+ assert_eq!(o, "[]\n", "default hides archived, got:\n{o}");145+ let (o, _, code) = run_in(&dir, &["issue", "list", "--json", "--state", "archived"]);146+ assert_eq!(code, 0);147+ assert_eq!(o.lines().count(), 1);148+ let _ = std::fs::remove_dir_all(&dir);149+}