From a8fc90d9cfd17784d09d72af0ed19096ff550ed3 Mon Sep 17 00:00:00 2001 From: HackingGate Date: Mon, 26 Oct 2026 22:57:17 +0900 Subject: [PATCH 1/2] A repository adopts records by id under [decide], with an order for each adopted conflict `[decide] adopt` names the catalog records a repository's design questions are answered from, and `[[decide.order]] prefer/over/why` says which side it takes on each adopted pair in `conflicts_with`. `uphold check` prints both after the reconciled claims and carries them in `--json`. An unknown or deprecated id, an unordered adopted conflict, an order over a pair that does not conflict or is not adopted, and a pair ordered twice are exit 1; a malformed table, and one in an inherited file, are exit 2 at load. Closes #430. Claude-Session: https://claude.ai/code/session_01BHpLckRt4BNGCdFG9vLyEP --- docs/REFERENCE.md | 50 +++++++- docs/schema/principles.schema.json | 49 +++++++ src/catalog.rs | 4 + src/check.rs | 133 ++++++++++++++++++- src/config.rs | 82 ++++++++++++ tests/check_cli.rs | 199 +++++++++++++++++++++++++++++ tests/schema_cli.rs | 14 ++ 7 files changed, 527 insertions(+), 4 deletions(-) diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 7a1e82a..762da16 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -20,6 +20,7 @@ namespace — that is what lets a claim in - [`uphold schema` — the policy file's JSON Schema](#uphold-schema--the-policy-files-json-schema) - [The review tier](#the-review-tier) - [Controls: can a record produce a finding at all?](#controls-can-a-record-produce-a-finding-at-all) +- [Design rulings: `[decide]`](#design-rulings-decide) ## Rule shape @@ -4126,7 +4127,9 @@ suppliers. Each supplier names its `component` (`local` or `uphold`) explicitly and carries the supplying `seam` as a description. Consumers route by component and treat seam descriptions, notes and diagnostics as text for a person. `violations`, `unavailable` and `notes` are arrays of diagnostic strings. -Additional fields may be added within a schema version. +`decide` is the policy's [`[decide]`](#design-rulings-decide) as written, +`{"adopt": [...], "order": [{"prefer", "over", "why"}]}`, empty where the +policy has none. Additional fields may be added within a schema version. A missing or invalid policy or declaration, discovery failure, or other error before evaluation produces no JSON: stdout is empty, the diagnostic is on @@ -4368,3 +4371,48 @@ is one that has not answered within `--timeout` seconds (default 300); a person answering at a prompt passes a longer one. No model client lives here; the reviewer command carries the model, the key and the budget. See [DESIGN.md](DESIGN.md#a-control-is-the-probe-fixture-one-tier-up). + +## Design rulings: `[decide]` + +A repository's design questions are answered from the records it adopts, and +where two adopted records conflict, from the side it takes. Both are written in +the repository's own policy: + +```toml +# policy/principles.toml +[decide] +adopt = ["unix-composability", "defense-in-depth", "economy-of-mechanism"] + +[[decide.order]] +prefer = "defense-in-depth" +over = "economy-of-mechanism" +why = "A second control is worth the larger mechanism for anything that publishes." +``` + +`adopt` is catalog ids. Every pair of adopted records that names the other in +`conflicts_with` takes exactly one `[[decide.order]]`: `prefer` is the record +whose answer wins where the two disagree, `over` the one it wins over, and `why` +the reason, for whoever reviews the order. `conflicts_with` is symmetric, so +one order covers the pair whichever end lists it. + +`uphold check` holds the table to the catalog this binary carries and prints +the adopted list and its orders after the reconciled claims, so the review of +what a repository rules by is one command: + +```text +adopted 3 record(s) for design rulings ([decide]): + unix-composability + defense-in-depth + economy-of-mechanism + order defense-in-depth over economy-of-mechanism: A second control is ... +``` + +Exit `1`, under `design rulings refused ([decide]):`, for an adopted id the +catalog does not define or keeps only as deprecated, an adopted conflict with no +order, an order naming a record the repository does not adopt, an order over two +records that do not conflict, and a second order over a pair already ordered. +Exit `2`, at load and so from every command that reads the policy, for a table +whose shape is wrong: an unknown key, an `order` missing `prefer`, `over` or +`why` or holding a blank one, an id adopted twice, and `[decide]` in a bundled +set or an `inherit.paths` file, since which records a repository rules by is +that repository's own declaration. `uphold schema` describes the table. diff --git a/docs/schema/principles.schema.json b/docs/schema/principles.schema.json index 725fbf7..acf8fcf 100644 --- a/docs/schema/principles.schema.json +++ b/docs/schema/principles.schema.json @@ -39,6 +39,29 @@ }, "type": "object" }, + "Decide": { + "additionalProperties": false, + "description": "`[decide]` in a repository's own policy: the records its design questions\nare answered from, and which side it takes where two of them conflict.\n\nThe shape is refused here, at load. Whether each id is in the catalog and\nwhether every adopted conflict carries an order are questions about the\ncatalog this binary carries, and `uphold check` answers them at exit 1.", + "properties": { + "adopt": { + "default": [], + "description": "Catalog record ids, each at most once.", + "items": { + "type": "string" + }, + "type": "array" + }, + "order": { + "default": [], + "description": "`[[decide.order]]`: one per pair of adopted records that names the\nother in `conflicts_with`.", + "items": { + "$ref": "#/$defs/Order" + }, + "type": "array" + } + }, + "type": "object" + }, "Editor": { "description": "When an invocation that supplied no body opens an editor for one.\n\nA closed value rather than a flag list or an expression, because the\nquestion has three answers and the commands this tool stands in front of\ngive exactly those three. The answer decides two things that used to be\none: whether the shim stands in as the editor, and whether it SAYS the\neditor is the checkpoint -- which it said for `gh pr merge --squash`, where\nno editor ever opens and the forge composed the merge message unread.", "oneOf": [ @@ -175,6 +198,28 @@ }, "type": "object" }, + "Order": { + "additionalProperties": false, + "description": "One `[[decide.order]]`: where `prefer` and `over` disagree, the answer\nis `prefer`'s, for the reason `why` gives.", + "properties": { + "over": { + "type": "string" + }, + "prefer": { + "type": "string" + }, + "why": { + "description": "Read by whoever reviews the order; an order with no reason is refused.", + "type": "string" + } + }, + "required": [ + "prefer", + "over", + "why" + ], + "type": "object" + }, "OverrideCommand": { "additionalProperties": false, "description": "The `command` keys of an [`OverrideShape`].", @@ -849,6 +894,10 @@ "description": "Whether every path-baseline entry must say who excused it and why.\n\nOff by default, and the default is not neutrality -- it is what every\nexisting baseline file already is. A repository turns it on when its\nbaselines stop being one homogeneous debt with a header explaining all\nof it, and start holding entries that differ from each other. The\n`.ljust(` case is the shape: one call site should take the dependency\nand another is correct as it stands, and no pattern separates them, so\nthe entry that excuses the second has to carry the judgement or the\njudgement is nowhere.\n\nPolicy-level rather than per-rule, because \"may an exemption be\nanonymous\" is one answer a repository gives once. Per-rule it would be a\nsetting every new baseline has to remember, which is the same as not\nhaving it.", "type": "boolean" }, + "decide": { + "$ref": "#/$defs/Decide", + "description": "`[decide]`: the records this repository's design questions are\nanswered from. See [`Decide`]." + }, "foreign_hosts": { "default": [], "description": "The hosts this repository's documents cite that carry no repository the\nprivate-name guards need an answer about.\n\nA policy field rather than a rule one for the reason\n`private_owners_from` is: a rule arriving from a bundled set cannot be\nhanded a parameter, and which hosts a repository's own bibliography\nnames is a property of the repository rather than of one rule in it.\n\nHost globs, matched case-insensitively -- `\"doi.org\"`, `\"*.sr.ht\"`. What\nis NOT named here and is not GitHub is could-not-look and exits 2,\nbecause the tool cannot tell whether `git.acme.example/acme/secret` is\nprivate and silence would read as clean.\n\nExtends [`FOREIGN_HOSTS_DEFAULT`] rather than replacing it: a host that\nserves no `owner/repo` path for anybody is not a fact about this\nrepository, and the fleet had said so in 84 identical lines.", diff --git a/src/catalog.rs b/src/catalog.rs index 22d1922..e1aa5dc 100644 --- a/src/catalog.rs +++ b/src/catalog.rs @@ -205,6 +205,10 @@ pub(crate) struct Record { pub status: String, #[serde(default)] pub enforcement: Enforcement, + /// Symmetric, as `scripts/validate.py` holds it: an adopted pair in it is + /// a pair `[decide]` must order. + #[serde(default)] + pub conflicts_with: Vec, } #[derive(Debug, Clone, Default, Deserialize)] diff --git a/src/check.rs b/src/check.rs index 86b0934..816ba28 100644 --- a/src/check.rs +++ b/src/check.rs @@ -31,7 +31,7 @@ use std::path::Path; use serde::{Deserialize, Serialize}; use crate::catalog; -use crate::config::Policy; +use crate::config::{Decide, Policy}; use crate::error::{Exit, Fatal, Result, verdict}; const DECLARATION: &str = "policy/upheld.toml"; @@ -732,8 +732,14 @@ pub(crate) struct Report { violations: Vec, unavailable: Vec, notes: Vec, + /// The policy's `[decide]`, as written, so the review of what a + /// repository rules by is this one report. + decide: Decide, #[serde(skip)] misplaced_count: usize, + /// How many of `violations`, at the end, are refusals of `[decide]`. + #[serde(skip)] + decide_count: usize, #[serde(skip)] fatal: Option, } @@ -808,6 +814,7 @@ pub(crate) fn run( return report_coverage(policy, &declaration, &installed, &supply); } let mut report = evaluate(&path, &declaration, &installed, &supply)?; + decided(&mut report, &policy.decide)?; let measured = probe_revision .map(|revision| revision.and_then(|revision| crate::probe::measure(root, &revision))); for claim in &mut report.claims { @@ -956,7 +963,9 @@ fn evaluate( violations: installed.misplaced.clone(), unavailable: installed.unreadable.clone(), notes: installed.how.clone(), + decide: Decide::default(), misplaced_count: installed.misplaced.len(), + decide_count: 0, fatal: None, }; for (index, (principle, rule)) in claims.into_iter().enumerate() { @@ -1020,9 +1029,21 @@ fn report_human(report: &Report) -> Result { eprintln!("- {misplaced}"); } } - if report.violations.len() > report.misplaced_count { + let claims_end = report.violations.len().saturating_sub(report.decide_count); + if claims_end > report.misplaced_count { eprintln!("enforcement claims refused ({DECLARATION}):"); - for failure in report.violations.iter().skip(report.misplaced_count) { + for failure in report + .violations + .iter() + .take(claims_end) + .skip(report.misplaced_count) + { + eprintln!("- {failure}"); + } + } + if report.decide_count > 0 { + eprintln!("design rulings refused ([decide]):"); + for failure in report.violations.iter().skip(claims_end) { eprintln!("- {failure}"); } } @@ -1064,9 +1085,115 @@ fn report_human(report: &Report) -> Result { for note in &report.notes { println!(" note {note}"); } + if !report.decide.adopt.is_empty() { + println!( + "adopted {} record(s) for design rulings ([decide]):", + report.decide.adopt.len() + ); + for id in &report.decide.adopt { + println!(" {id}"); + } + for order in &report.decide.order { + println!( + " order {} over {}: {}", + order.prefer, order.over, order.why + ); + } + } Ok(report.verdict.exit()) } +/// Hold `[decide]` to the catalog this binary carries, into `report`. +/// +/// Every id must be a live record, every adopted pair that one names in +/// `conflicts_with` must carry exactly one order, and every order must be over +/// such a pair. An adopted conflict with no order is two answers to one +/// question, which is the thing an order exists to remove; an order over +/// records that do not conflict, or that this repository does not adopt, +/// decides nothing and reads as though it did. +fn decided(report: &mut Report, decide: &Decide) -> Result<()> { + let mut failures = Vec::new(); + let known = catalog::ids()?.len(); + let mut adopted: BTreeMap<&str, &catalog::Record> = BTreeMap::new(); + for id in &decide.adopt { + match catalog::get(id)? { + None => failures.push(format!("adopt: {}", catalog::unknown(id, known))), + Some(record) if record.deprecated() => failures.push(format!( + "adopt: {id:?} is deprecated; the catalog keeps it for redirects only" + )), + Some(record) => { + adopted.insert(id.as_str(), record); + } + } + } + let conflicts: BTreeSet<(&str, &str)> = adopted + .iter() + .flat_map(|(id, record)| { + record + .conflicts_with + .iter() + .filter(|other| adopted.contains_key(other.as_str())) + .map(move |other| pair(id, other)) + }) + .collect(); + let mut ordered: BTreeMap<(&str, &str), usize> = BTreeMap::new(); + for (index, order) in decide.order.iter().enumerate() { + let at = format!("order[{index}] ({} over {})", order.prefer, order.over); + let unadopted: Vec<&str> = [order.prefer.as_str(), order.over.as_str()] + .into_iter() + .filter(|id| !adopted.contains_key(id)) + .collect(); + if !unadopted.is_empty() { + failures.push(format!( + "{at}: orders {}, which this repository does not adopt", + unadopted.join(" and ") + )); + continue; + } + let key = pair(&order.prefer, &order.over); + if !conflicts.contains(&key) { + failures.push(format!( + "{at}: the two records do not name each other in conflicts_with, so there \ + is no conflict for an order to settle" + )); + continue; + } + if let Some(first) = ordered.insert(key, index) { + failures.push(format!( + "{at}: order[{first}] already orders this pair; one pair takes one order" + )); + } + } + for (left, right) in &conflicts { + if !ordered.contains_key(&(*left, *right)) { + failures.push(format!( + "{left} and {right} are both adopted and conflict, and no [[decide.order]] \ + says which this repository prefers. Add one with prefer, over and why" + )); + } + } + report.decide = decide.clone(); + report.decide_count = failures.len(); + report.violations.extend(failures); + if report.fatal.is_none() { + report.verdict = match verdict(report.violations.len(), report.unavailable.len()) { + Exit::Clean => Verdict::Clean, + Exit::Violations => Verdict::Violations, + Exit::Broken => Verdict::CouldNotLook, + }; + } + Ok(()) +} + +/// A conflict's two ends in one order, since `conflicts_with` is symmetric. +fn pair<'a>(left: &'a str, right: &'a str) -> (&'a str, &'a str) { + if left <= right { + (left, right) + } else { + (right, left) + } +} + /// The denominator the reconcile cannot see: rules that run and claim nothing. /// /// It reports and does not refuse. A mode that failed a build over an unclaimed diff --git a/src/config.rs b/src/config.rs index 7d569cf..8466a8d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -722,6 +722,64 @@ impl Stop { } } +/// `[decide]` in a repository's own policy: the records its design questions +/// are answered from, and which side it takes where two of them conflict. +/// +/// The shape is refused here, at load. Whether each id is in the catalog and +/// whether every adopted conflict carries an order are questions about the +/// catalog this binary carries, and `uphold check` answers them at exit 1. +#[derive(Debug, Clone, Default, Deserialize, Serialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub(crate) struct Decide { + /// Catalog record ids, each at most once. + #[serde(default)] + pub adopt: Vec, + /// `[[decide.order]]`: one per pair of adopted records that names the + /// other in `conflicts_with`. + #[serde(default)] + pub order: Vec, +} + +/// One `[[decide.order]]`: where `prefer` and `over` disagree, the answer +/// is `prefer`'s, for the reason `why` gives. +#[derive(Debug, Clone, Deserialize, Serialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub(crate) struct Order { + pub prefer: String, + pub over: String, + /// Read by whoever reviews the order; an order with no reason is refused. + pub why: String, +} + +impl Decide { + fn validate(&self) -> std::result::Result<(), String> { + let mut seen = BTreeSet::new(); + for id in &self.adopt { + if id.trim().is_empty() { + return Err("`[decide] adopt` holds a blank id".to_owned()); + } + if !seen.insert(id.as_str()) { + return Err(format!("`[decide] adopt` names {id:?} twice")); + } + } + for order in &self.order { + for (field, value) in [ + ("prefer", &order.prefer), + ("over", &order.over), + ("why", &order.why), + ] { + if value.trim().is_empty() { + return Err(format!( + "a `[[decide.order]]` has a blank `{field}`. Each order names the \ + record preferred, the one it is preferred over, and why" + )); + } + } + } + Ok(()) + } +} + /// The scanner a retired waiver may still name. uphold no longer runs it, so /// the row is read and ignored; naming any other scanner is an error, as it /// always was, because every scanner that is run carries its own suppression in @@ -994,6 +1052,10 @@ pub(crate) struct PolicyFile { /// `[stop]`: the gate an agent's turn may not end red. See [`Stop`]. #[serde(default)] pub stop: Option, + /// `[decide]`: the records this repository's design questions are + /// answered from. See [`Decide`]. + #[serde(default)] + pub decide: Option, /// Whether every path-baseline entry must say who excused it and why. /// /// Off by default, and the default is not neutrality -- it is what every @@ -1313,6 +1375,8 @@ pub(crate) struct Policy { pub supply_chain: SupplyChain, /// The repository's own `[stop]`, or the empty one. pub stop: Stop, + /// The repository's own `[decide]`, or the empty one. + pub decide: Decide, } impl Policy { @@ -1602,6 +1666,18 @@ fn refuse_inherited_override(path: &Path, file: &PolicyFile, kind: &str) -> Resu ), )); } + // Which records a repository rules by is what its owner reviews, so it is + // written where that repository's review reaches. + if file.decide.is_some() { + return Err(Fatal::at( + path, + format!( + "`[decide]` names the records one repository's design questions are \ + answered from, and {kind} is not that repository. Move it to the policy \ + of the repository it decides for" + ), + )); + } if let Some(id) = file.overrides.keys().next() { return Err(Fatal::at( path, @@ -2138,6 +2214,11 @@ pub(crate) fn load_text(root: &Path, policy_path: &Path, text: &str) -> Result

Result

PathBuf { + let root = workspace(); + write( + &root, + "policy/principles.toml", + &format!("{GUARD_POLICY}\n{decide}"), + ); + write(&root, "policy/upheld.toml", "# nothing claimed yet\n"); + write(&root, ".pre-commit-config.yaml", PRE_COMMIT); + root +} + +const ORDERED: &str = "\ +[decide] +adopt = [\"economy-of-mechanism\", \"defense-in-depth\", \"unix-composability\"] + +[[decide.order]] +prefer = \"defense-in-depth\" +over = \"economy-of-mechanism\" +why = \"A second control is worth the larger mechanism here.\" + +[[decide.order]] +prefer = \"unix-composability\" +over = \"defense-in-depth\" +why = \"Each layer is a separate command.\" +"; + +#[test] +fn an_adopted_list_with_every_conflict_ordered_is_printed_and_zero() { + let output = check(&decide_fixture(ORDERED), &[]); + assert_eq!(code(&output), 0, "{}", stderr(&output)); + let printed = stdout(&output); + assert!( + printed.contains("adopted 3 record(s) for design rulings"), + "{printed}" + ); + assert!( + printed.contains("order defense-in-depth over economy-of-mechanism: A second control"), + "{printed}" + ); +} + +#[test] +fn the_json_report_carries_the_adopted_list() { + let output = check(&decide_fixture(ORDERED), &["--json"]); + assert_eq!(code(&output), 0, "{}", stderr(&output)); + let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!( + report.pointer("/decide/adopt/2").unwrap(), + "unix-composability" + ); + assert_eq!( + report.pointer("/decide/order/0/prefer").unwrap(), + "defense-in-depth" + ); +} + +#[test] +fn an_adopted_conflict_with_no_order_is_one() { + let output = check( + &decide_fixture("[decide]\nadopt = [\"economy-of-mechanism\", \"defense-in-depth\"]\n"), + &[], + ); + assert_eq!(code(&output), 1, "{}", stdout(&output)); + assert!( + stderr(&output) + .contains("defense-in-depth and economy-of-mechanism are both adopted and conflict"), + "{}", + stderr(&output) + ); +} + +#[test] +fn an_order_over_a_pair_that_does_not_conflict_is_one() { + let output = check( + &decide_fixture( + "[decide]\nadopt = [\"economy-of-mechanism\", \"unix-composability\"]\n\n\ + [[decide.order]]\nprefer = \"unix-composability\"\nover = \"economy-of-mechanism\"\n\ + why = \"no conflict to settle\"\n", + ), + &[], + ); + assert_eq!(code(&output), 1, "{}", stdout(&output)); + assert!( + stderr(&output).contains("do not name each other in conflicts_with"), + "{}", + stderr(&output) + ); +} + +#[test] +fn an_order_naming_a_record_not_adopted_is_one() { + let output = check( + &decide_fixture( + "[decide]\nadopt = [\"economy-of-mechanism\"]\n\n\ + [[decide.order]]\nprefer = \"defense-in-depth\"\nover = \"economy-of-mechanism\"\n\ + why = \"stale\"\n", + ), + &[], + ); + assert_eq!(code(&output), 1, "{}", stdout(&output)); + assert!( + stderr(&output).contains("orders defense-in-depth, which this repository does not adopt"), + "{}", + stderr(&output) + ); +} + +#[test] +fn a_pair_ordered_twice_is_one() { + let output = check( + &decide_fixture(&format!( + "{ORDERED}\n[[decide.order]]\nprefer = \"economy-of-mechanism\"\n\ + over = \"defense-in-depth\"\nwhy = \"the other way\"\n" + )), + &[], + ); + assert_eq!(code(&output), 1, "{}", stdout(&output)); + assert!( + stderr(&output).contains("order[0] already orders this pair"), + "{}", + stderr(&output) + ); +} + +#[test] +fn an_adopted_id_the_catalog_lacks_is_one() { + let output = check( + &decide_fixture("[decide]\nadopt = [\"no-such-principle\"]\n"), + &[], + ); + assert_eq!(code(&output), 1, "{}", stdout(&output)); + assert!( + stderr(&output).contains("unknown principle id \"no-such-principle\""), + "{}", + stderr(&output) + ); +} + +#[test] +fn a_malformed_decide_table_is_two() { + for (decide, says) in [ + ( + "[decide]\nadopted = [\"economy-of-mechanism\"]\n", + "adopted", + ), + ( + "[decide]\nadopt = [\"economy-of-mechanism\", \"economy-of-mechanism\"]\n", + "twice", + ), + ( + "[decide]\nadopt = [\"economy-of-mechanism\"]\n\n[[decide.order]]\n\ + prefer = \"economy-of-mechanism\"\nover = \"defense-in-depth\"\n", + "why", + ), + ( + "[decide]\nadopt = [\"economy-of-mechanism\"]\n\n[[decide.order]]\n\ + prefer = \"economy-of-mechanism\"\nover = \"defense-in-depth\"\nwhy = \" \"\n", + "blank `why`", + ), + ] { + let output = check(&decide_fixture(decide), &[]); + assert_eq!(code(&output), 2, "{decide}: {}", stderr(&output)); + assert!( + stderr(&output).contains(says), + "{decide}: {}", + stderr(&output) + ); + } +} + +#[test] +fn a_decide_table_in_an_inherited_file_is_two() { + let root = workspace(); + write( + &root, + "policy/principles.toml", + "[inherit]\npaths = [\"policy/extra.toml\"]\n", + ); + write( + &root, + "policy/extra.toml", + "[decide]\nadopt = [\"economy-of-mechanism\"]\n", + ); + write(&root, "policy/upheld.toml", "# nothing claimed yet\n"); + write(&root, ".pre-commit-config.yaml", PRE_COMMIT); + let output = check(&root, &[]); + assert_eq!(code(&output), 2, "{}", stderr(&output)); + assert!( + stderr(&output).contains("Move it to the policy of the repository it decides for"), + "{}", + stderr(&output) + ); +} diff --git a/tests/schema_cli.rs b/tests/schema_cli.rs index 50281c5..93d6831 100644 --- a/tests/schema_cli.rs +++ b/tests/schema_cli.rs @@ -143,6 +143,12 @@ scanner = "guarddog" package = "pypi:x@1" check = "c" reason = "r" +[decide] +adopt = ["economy-of-mechanism", "defense-in-depth"] +[[decide.order]] +prefer = "defense-in-depth" +over = "economy-of-mechanism" +why = "w" "#, ); assert!(found.is_empty(), "{found:#?}"); @@ -193,6 +199,14 @@ fn a_document_the_deserializer_refuses_is_refused_by_the_schema() { "a shim with no command", "[[shim]]\nmatch = [\"pr:create\"]\n", ), + ( + "an order with no reason", + "[[decide.order]]\nprefer = \"a\"\nover = \"b\"\n", + ), + ( + "an adopted list that is not a list", + "[decide]\nadopt = \"economy-of-mechanism\"\n", + ), ] { assert!(!errors(text).is_empty(), "{what} was accepted:\n{text}"); } From f73a3752b21ac200ca35478cff9812757689a9bf Mon Sep 17 00:00:00 2001 From: HackingGate Date: Mon, 26 Oct 2026 23:22:39 +0900 Subject: [PATCH 2/2] The [decide] example orders every adopted conflict, and the fixture comment names both pairs Claude-Session: https://claude.ai/code/session_01BHpLckRt4BNGCdFG9vLyEP --- docs/REFERENCE.md | 6 ++++++ tests/check_cli.rs | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 762da16..5955371 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -4387,6 +4387,11 @@ adopt = ["unix-composability", "defense-in-depth", "economy-of-mechanism"] prefer = "defense-in-depth" over = "economy-of-mechanism" why = "A second control is worth the larger mechanism for anything that publishes." + +[[decide.order]] +prefer = "unix-composability" +over = "defense-in-depth" +why = "Each layer is a separate command that can be run and tested alone." ``` `adopt` is catalog ids. Every pair of adopted records that names the other in @@ -4405,6 +4410,7 @@ adopted 3 record(s) for design rulings ([decide]): defense-in-depth economy-of-mechanism order defense-in-depth over economy-of-mechanism: A second control is ... + order unix-composability over defense-in-depth: Each layer is a separate ... ``` Exit `1`, under `design rulings refused ([decide]):`, for an adopted id the diff --git a/tests/check_cli.rs b/tests/check_cli.rs index 4698036..6590f7b 100644 --- a/tests/check_cli.rs +++ b/tests/check_cli.rs @@ -1438,7 +1438,8 @@ fn misplaced_range_hooks_do_not_supply_partial_claim_evidence() { // ── [decide]: the records design questions are answered from ───────── /// `economy-of-mechanism` and `defense-in-depth` name each other in -/// `conflicts_with`; `unix-composability` conflicts with neither of the first. +/// `conflicts_with`, and so do `unix-composability` and `defense-in-depth`; +/// `unix-composability` and `economy-of-mechanism` do not. fn decide_fixture(decide: &str) -> PathBuf { let root = workspace(); write(