From 90f5ebac57c8d7ad1a9c4219caae14effb0a9e36 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 09:53:30 -0400 Subject: [PATCH] =?UTF-8?q?xtask:=20parity=20=E2=80=94=20docs/parity.md=20?= =?UTF-8?q?reference=20check=20(in=20ci)=20and=20summary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cargo xtask parity verifies every cmd:/ctl: id and repository path cited in docs/parity.md exists (engine commands and controls via lightcraft-cli, UI commands from UI_COMMANDS), that row ids are unique and statuses valid, and prints the per-section summary; --write refreshes the summary table. ci runs the reference check after the tests. Parser, checker and summary are unit-tested. Co-Authored-By: Claude Opus 5.5 --- xtask/src/main.rs | 8 +- xtask/src/parity.rs | 411 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 418 insertions(+), 1 deletion(-) create mode 100644 xtask/src/parity.rs diff --git a/xtask/src/main.rs b/xtask/src/main.rs index c44460cf..fb63d8d7 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -5,6 +5,7 @@ mod assets; mod layers; +mod parity; mod stats; mod web; @@ -17,11 +18,14 @@ usage: cargo xtask commands: assets every image/icon/font/media file is attributed in assets/ATTRIBUTION.md; no Adobe assets layers enforce the crate dependency layering (plan/architecture.md ยง3) + parity [--write] + check docs/parity.md (every cmd:/ctl: id and path it cites exists) and print the + Lightroom parity summary; --write refreshes the summary table in the document wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates (+ the web app) web [--serve [port]] [--dev] build the browser app (apps/lightcraft-web) into /web/; --serve serves it on http://127.0.0.1: (default 8080) - ci fmt --check, clippy -D warnings, test, layers, assets, wasm (stops at first failure) + ci fmt --check, clippy -D warnings, test, parity refs, layers, assets, wasm (stops at first failure) corpus [--download] show where test corpora live; --download fetches PngSuite and CC0 raw samples (raw.pixls.us) into corpus/ stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`) @@ -33,6 +37,7 @@ fn main() -> ExitCode { let result = match args.first().map(String::as_str) { Some("layers") => cmd_layers(), Some("assets") => assets::run(&root()), + Some("parity") => parity::run(&root(), rest.contains(&"--write")), Some("wasm") => cmd_wasm(), Some("web") => web::run(&rest), Some("ci") => cmd_ci(), @@ -172,6 +177,7 @@ fn cmd_ci() -> Result<(), String> { run(c, "cargo test --workspace") }), ), + ("parity", Box::new(|| parity::run(&root(), false))), ("layers", Box::new(cmd_layers)), ("assets", Box::new(|| assets::run(&root()))), ("wasm", Box::new(cmd_wasm)), diff --git a/xtask/src/parity.rs b/xtask/src/parity.rs new file mode 100644 index 00000000..651f3372 --- /dev/null +++ b/xtask/src/parity.rs @@ -0,0 +1,411 @@ +//! `cargo xtask parity`: the Lightroom parity tracker (`docs/parity.md`). +//! +//! - **Reference check** (in `ci`): every `` `cmd:` `` must be a registered command (engine command specs, as +//! listed by `lightcraft-cli commands --json`, or a UI command from `UI_COMMANDS`), every `` `ctl:` `` a develop +//! control (`lightcraft-cli controls --json`), and every repository path in backticks must exist (with `:line` +//! inside the file). A trailing `*` matches an id prefix. Row ids must be unique and statuses valid. +//! - **Summary**: per section done / partial / missing / out of scope and the share of P0 and P1 rows done; +//! `--write` refreshes the table between the `parity:summary` markers in the document. + +use std::collections::{BTreeSet, HashSet}; +use std::path::Path; + +pub const DOC: &str = "docs/parity.md"; +const BEGIN: &str = ""; +const END: &str = ""; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Status { + Done, + Partial, + Missing, + OutOfScope, +} + +impl Status { + fn parse(cell: &str) -> Option { + let c = cell.trim_start(); + [("โœ…", Status::Done), ("๐ŸŸก", Status::Partial), ("โฌœ", Status::Missing), ("๐Ÿšซ", Status::OutOfScope)] + .into_iter() + .find(|(e, _)| c.starts_with(e)) + .map(|(_, s)| s) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Ref { + Cmd(String), + Ctl(String), + /// Repository-relative path, optional 1-based line. + Path(String, Option), +} + +#[derive(Clone, Debug)] +pub struct Row { + pub id: String, + pub tier: String, + pub status: Status, + pub section: String, +} + +#[derive(Debug, Default)] +pub struct Doc { + pub rows: Vec, + /// (line, reference) for every reference anywhere in the document. + pub refs: Vec<(usize, Ref)>, + pub errors: Vec, +} + +fn is_row_id(s: &str) -> bool { + let Some((prefix, rest)) = s.split_once('-') else { return false }; + matches!(prefix, "LR" | "LRC" | "MENU" | "KEY" | "KEYC") + && !rest.is_empty() + && rest.chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '-') +} + +/// Directories whose paths are checked (others, e.g. the local-only `plan/`, are ignored). +const CHECKED_DIRS: &[&str] = &["crates/", "apps/", "docs/", "xtask/", "assets/"]; + +/// A concrete id (not a placeholder such as `cmd:` in prose). +fn concrete(id: &str) -> bool { + !id.is_empty() && id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '*')) +} + +fn classify(span: &str) -> Option { + if let Some(id) = span.strip_prefix("cmd:").filter(|id| concrete(id)) { + return Some(Ref::Cmd(id.to_string())); + } + if let Some(id) = span.strip_prefix("ctl:").filter(|id| concrete(id)) { + return Some(Ref::Ctl(id.to_string())); + } + if CHECKED_DIRS.iter().any(|d| span.starts_with(d)) && !span.contains(' ') { + return Some(match span.rsplit_once(':') { + Some((p, l)) if l.parse::().is_ok() => Ref::Path(p.to_string(), l.parse().ok()), + _ => Ref::Path(span.to_string(), None), + }); + } + None +} + +/// Inline-code spans of one line (single backticks). +fn code_spans(line: &str) -> Vec<&str> { + line.split('`').skip(1).step_by(2).collect() +} + +pub fn parse(md: &str) -> Doc { + let mut doc = Doc::default(); + let mut section = String::new(); + let mut seen = HashSet::new(); + let mut in_comment = false; + for (i, line) in md.lines().enumerate() { + let n = i + 1; + let t = line.trim(); + if t.starts_with("") { + in_comment = true; + } + if in_comment { + in_comment = !t.contains("-->"); + continue; + } + if let Some(h) = t.strip_prefix("## ") { + section = h.trim().to_string(); + continue; + } + for span in code_spans(line) { + if let Some(r) = classify(span) { + doc.refs.push((n, r)); + } + } + if !t.starts_with('|') { + continue; + } + let cells: Vec<&str> = t.trim_matches('|').split('|').map(str::trim).collect(); + let Some(id) = cells.first().copied().filter(|c| is_row_id(c)) else { continue }; + if cells.len() < 6 { + doc.errors.push(format!("{DOC}:{n}: {id}: expected 6 columns (Id | Feature | Tier | Status | Evidence | Notes)")); + continue; + } + let Some(status) = Status::parse(cells[3]) else { + doc.errors.push(format!("{DOC}:{n}: {id}: status `{}` is not one of โœ… ๐ŸŸก โฌœ ๐Ÿšซ", cells[3])); + continue; + }; + if !seen.insert(id.to_string()) { + doc.errors.push(format!("{DOC}:{n}: duplicate id {id}")); + } + doc.rows.push(Row { id: id.to_string(), tier: cells[2].to_string(), status, section: section.clone() }); + } + doc +} + +/// Ids in `UI_COMMANDS` (`("view.detail", "Detail", Some("D"), "View"),` lines). +pub fn ui_command_ids(menus_rs: &str) -> Vec { + let Some(start) = menus_rs.find("pub const UI_COMMANDS") else { return Vec::new() }; + let body = &menus_rs[start..]; + let body = &body[..body.find("];").unwrap_or(body.len())]; + body.lines().filter_map(|l| l.trim().strip_prefix("(\"")).filter_map(|l| l.split_once('"').map(|(id, _)| id.to_string())).collect() +} + +fn known(set: &BTreeSet, id: &str) -> bool { + match id.strip_suffix('*') { + Some(prefix) => set.iter().any(|s| s.starts_with(prefix)), + None => set.contains(id), + } +} + +/// Reference problems (empty = OK). `file_lines` returns a repository file's line count (None = missing). +pub fn check(doc: &Doc, commands: &BTreeSet, controls: &BTreeSet, file_lines: impl Fn(&str) -> Option) -> Vec { + let mut out = doc.errors.clone(); + for (n, r) in &doc.refs { + match r { + Ref::Cmd(id) if !known(commands, id) => out.push(format!("{DOC}:{n}: unknown command `cmd:{id}`")), + Ref::Ctl(id) if !known(controls, id) => out.push(format!("{DOC}:{n}: unknown develop control `ctl:{id}`")), + Ref::Path(p, line) => match (file_lines(p), line) { + (None, _) => out.push(format!("{DOC}:{n}: missing path `{p}`")), + (Some(len), Some(l)) if *l == 0 || *l > len => out.push(format!("{DOC}:{n}: `{p}:{l}` is past the end ({len} lines)")), + _ => {} + }, + _ => {} + } + } + out +} + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct Counts { + pub done: usize, + pub partial: usize, + pub missing: usize, + pub oos: usize, + pub p0: (usize, usize), + pub p1: (usize, usize), +} + +impl Counts { + fn add(&mut self, r: &Row) { + match r.status { + Status::Done => self.done += 1, + Status::Partial => self.partial += 1, + Status::Missing => self.missing += 1, + Status::OutOfScope => self.oos += 1, + } + let done = usize::from(r.status == Status::Done); + match r.tier.as_str() { + "P0" => self.p0 = (self.p0.0 + done, self.p0.1 + 1), + "P1" => self.p1 = (self.p1.0 + done, self.p1.1 + 1), + _ => {} + } + } +} + +/// Per-section counts in document order, plus the total. +pub fn summarize(doc: &Doc) -> (Vec<(String, Counts)>, Counts) { + let mut sections: Vec<(String, Counts)> = Vec::new(); + let mut total = Counts::default(); + for r in &doc.rows { + if sections.last().is_none_or(|(s, _)| *s != r.section) { + sections.push((r.section.clone(), Counts::default())); + } + sections.last_mut().expect("pushed").1.add(r); + total.add(r); + } + (sections, total) +} + +fn pct((done, n): (usize, usize)) -> String { + match (done * 100 + n / 2).checked_div(n) { + Some(p) => format!("{done}/{n} ({p}%)"), + None => "โ€”".into(), + } +} + +pub fn summary_table(doc: &Doc) -> String { + let (sections, total) = summarize(doc); + let mut s = String::from("| Section | โœ… | ๐ŸŸก | โฌœ | ๐Ÿšซ | P0 done | P1 done |\n|---|---:|---:|---:|---:|---:|---:|\n"); + let row = + |name: &str, c: &Counts| format!("| {name} | {} | {} | {} | {} | {} | {} |\n", c.done, c.partial, c.missing, c.oos, pct(c.p0), pct(c.p1)); + for (name, c) in §ions { + s += &row(name, c); + } + s += &row("**Total**", &total); + s +} + +/// Replace the summary between the markers. +pub fn with_summary(md: &str, table: &str) -> Option { + let b = md.find(BEGIN)? + BEGIN.len(); + let e = md[b..].find(END)? + b; + Some(format!("{}\n{}{}", &md[..b], table, &md[e..])) +} + +fn registry(what: &str) -> Result, String> { + let out = crate::cargo() + .args(["run", "-q", "-p", "lightcraft-cli", "--", what, "--json"]) + .output() + .map_err(|e| format!("lightcraft-cli {what}: {e}"))?; + if !out.status.success() { + return Err(format!("lightcraft-cli {what} --json failed:\n{}", String::from_utf8_lossy(&out.stderr))); + } + let v: serde_json::Value = serde_json::from_slice(&out.stdout).map_err(|e| format!("lightcraft-cli {what}: bad JSON: {e}"))?; + Ok(v.as_array().into_iter().flatten().filter_map(|c| c["id"].as_str().map(str::to_string)).collect()) +} + +pub fn run(root: &Path, write: bool) -> Result<(), String> { + let path = root.join(DOC); + let md = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?; + let doc = parse(&md); + let menus = std::fs::read_to_string(root.join("crates/ui-egui/src/menus.rs")).map_err(|e| format!("menus.rs: {e}"))?; + let mut commands = registry("commands")?; + commands.extend(ui_command_ids(&menus)); + let controls = registry("controls")?; + let problems = check(&doc, &commands, &controls, |p| { + let full = root.join(p); + if full.is_dir() { Some(0) } else { std::fs::read_to_string(full).ok().map(|s| s.lines().count()) } + }); + + let table = summary_table(&doc); + println!("Lightroom parity ({DOC}): {} rows, {} references\n", doc.rows.len(), doc.refs.len()); + print!("{table}"); + if write { + let new = with_summary(&md, &table).ok_or_else(|| format!("{DOC}: summary markers `{BEGIN}` โ€ฆ `{END}` not found"))?; + if new != md { + std::fs::write(&path, new).map_err(|e| format!("{}: {e}", path.display()))?; + println!("\nupdated the summary in {DOC}"); + } + } else if with_summary(&md, &table).is_some_and(|new| new != md) { + println!("\nnote: the summary in {DOC} is stale โ€” run `cargo xtask parity --write`"); + } + // Ids from the local Lightroom reference that have no row yet (plan/ is local-only, so only a warning). + let catalog = root.join("plan/lightroom/03-feature-catalog.md"); + if let Ok(cat) = std::fs::read_to_string(&catalog) { + let have: HashSet<&str> = doc.rows.iter().map(|r| r.id.as_str()).collect(); + let missing: Vec<&str> = cat + .lines() + .filter_map(|l| l.trim().strip_prefix("| ")?.split(['|', ' ']).next()) + .filter(|id| id.starts_with("LR-") && !have.contains(id)) + .collect(); + if !missing.is_empty() { + println!("\nwarning: catalog ids without a row: {}", missing.join(", ")); + } + } + if problems.is_empty() { + println!("\nOK: every command, control and path referenced in {DOC} exists."); + Ok(()) + } else { + println!(); + for p in &problems { + println!(" - {p}"); + } + Err(format!("{} stale reference(s) in {DOC}", problems.len())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE: &str = "\ +# Tracker + +Prose mentioning `cmd:edit.undo`, placeholders `cmd:` / `ctl:` and `plan/lightroom/03.md` (not checked) and `rating:3`. + + +old + + + + +## A. Import (IMP) + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| LR-IMP-ONE | One | P0 | โœ… | `cmd:library.import`, `crates/engine/src/import.rs:12` | | +| LR-IMP-TWO | Two | P1 | ๐ŸŸก | `ctl:light.*` | partial | +| LR-IMP-THREE | Three | OOS | ๐Ÿšซ | | | + +| Action | Ours | Theirs | +|---|---|---| +| Pick flag | P | Z | + +## B. Library (LIB) + +| Id | Feature | Tier | Status | Evidence | Missing / notes | +|---|---|---|---|---|---| +| LR-LIB-ONE | One | P0 | โฌœ | `cmd:album.nope` | | +| LR-LIB-ONE | Dup | P1 | done | | | +| KEY-SHORT | too | few | +"; + + fn set(ids: &[&str]) -> BTreeSet { + ids.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn parses_rows_sections_and_statuses() { + let d = parse(SAMPLE); + let ids: Vec<&str> = d.rows.iter().map(|r| r.id.as_str()).collect(); + assert_eq!(ids, ["LR-IMP-ONE", "LR-IMP-TWO", "LR-IMP-THREE", "LR-LIB-ONE"]); + assert_eq!(d.rows[0].section, "A. Import (IMP)"); + assert_eq!(d.rows[3].section, "B. Library (LIB)"); + assert_eq!(d.rows[1].status, Status::Partial); + assert_eq!(d.rows[2].status, Status::OutOfScope); + assert_eq!(d.rows[1].tier, "P1"); + // the bad-status duplicate and the short row are errors; the comment row is ignored + assert_eq!(d.errors.len(), 2, "{:?}", d.errors); + assert!(d.errors.iter().any(|e| e.contains("status `done`"))); + assert!(d.errors.iter().any(|e| e.contains("KEY-SHORT"))); + } + + #[test] + fn collects_references_everywhere() { + let d = parse(SAMPLE); + let refs: Vec<&Ref> = d.refs.iter().map(|(_, r)| r).collect(); + assert!(refs.contains(&&Ref::Cmd("edit.undo".into()))); + assert!(refs.contains(&&Ref::Path("crates/engine/src/import.rs".into(), Some(12)))); + assert!(refs.contains(&&Ref::Ctl("light.*".into()))); + assert!(!refs.iter().any(|r| matches!(r, Ref::Cmd(id) if id == "nope")), "comment rows are skipped"); + assert!(!refs.iter().any(|r| matches!(r, Ref::Path(p, _) if p.starts_with("plan/")))); + assert_eq!(refs.len(), 5); + } + + #[test] + fn check_reports_unknown_ids_and_paths() { + let d = parse(SAMPLE); + let cmds = set(&["edit.undo", "library.import"]); + let ctls = set(&["light.exposure"]); + let problems = check(&d, &cmds, &ctls, |p| (p == "crates/engine/src/import.rs").then_some(100)); + let unknown: Vec<&String> = problems.iter().filter(|p| p.contains("unknown")).collect(); + assert_eq!(unknown.len(), 1, "{problems:?}"); + assert!(unknown[0].contains("cmd:album.nope")); + // a line past the end of the file is stale too + let short = check(&d, &cmds, &ctls, |_| Some(5)); + assert!(short.iter().any(|p| p.contains("import.rs:12"))); + // missing files and unknown controls + let none = check(&d, &cmds, &set(&[]), |_| None); + assert!(none.iter().any(|p| p.contains("missing path"))); + assert!(none.iter().any(|p| p.contains("ctl:light.*"))); + } + + #[test] + fn summary_counts_and_rewrites_between_markers() { + let d = parse(SAMPLE); + let (sections, total) = summarize(&d); + assert_eq!(sections.len(), 2); + assert_eq!(sections[0].1, Counts { done: 1, partial: 1, missing: 0, oos: 1, p0: (1, 1), p1: (0, 1) }); + assert_eq!(total.p0, (1, 2)); + let table = summary_table(&d); + assert!(table.contains("| A. Import (IMP) | 1 | 1 | 0 | 1 | 1/1 (100%) | 0/1 (0%) |"), "{table}"); + assert!(table.contains("| **Total** | 1 | 1 | 1 | 1 | 1/2 (50%) | 0/1 (0%) |"), "{table}"); + let new = with_summary(SAMPLE, &table).unwrap(); + assert!(!new.contains("\nold\n")); + assert!(new.contains(&format!("{BEGIN}\n{table}{END}"))); + assert_eq!(with_summary(&new, &table).unwrap(), new, "idempotent"); + } + + #[test] + fn reads_ui_command_ids() { + let src = "pub const UI_COMMANDS: &[UiCommand] = &[\n (\"view.detail\", \"Detail\", Some(\"D\"), \"View\"),\n (\"app.about\", \"About\", None, \"\"),\n];\nfn x() { (\"not.this\", 1); }"; + assert_eq!(ui_command_ids(src), ["view.detail", "app.about"]); + } +}