From 0c00f510da06d28af5185937ff9bf77c6172b4fb Mon Sep 17 00:00:00 2001 From: Rand Lee Date: Sun, 11 Oct 2026 18:55:22 -0700 Subject: [PATCH 1/7] chore: prepare 1.7.1 release --- CHANGELOG.md | 12 ++++++++---- Cargo.lock | 18 +++++++++--------- Cargo.toml | 8 ++++---- bindings/python/pyproject.toml | 2 +- .../sc-composer-beads-python/pyproject.toml | 2 +- bindings/sc-sha-python/pyproject.toml | 2 +- 6 files changed, 24 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0fe0dbb..57a728a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [1.7.1] - 2026-10-12 + ### Added - Beads requests can opt into `bead_variable_rendering: "bd"` (or the @@ -13,6 +15,8 @@ All notable changes to this project will be documented in this file. ([beads#21](https://github.com/steveyegge/beads/issues/21)); supplied values containing literal braces remain literal under filters; and attach/graph pour leave ordinary expressions for `bd cook`. +- This release includes the strict-rendering, Beads request, and diagnostics + work tracked in #783, #784, #785, #787, #791, #792, and #618. ### Fixed @@ -43,10 +47,10 @@ All notable changes to this project will be documented in this file. ### Changed -- Every `{{ name }}` expression in a Beads template must now be declared in - frontmatter, including Beads-native `[vars]` placeholders. Undeclared - expressions are refused in every Beads operation, including attach and graph - pour; use `{% raw %}{{ name }}{% endraw %}` for literal double braces. +- Every `{{ name }}` expression must now be declared in frontmatter through + `required_variables` or `defaults`; undeclared expressions are refused. Use + `{% raw %}{{ name }}{% endraw %}` to keep literal braces. This includes + Beads-native `[vars]` placeholders in the default Beads render mode. - Strict is the only render mode (#783): no render can silently skip a variable. In `sc-compose render`/`validate`, the `sc-composer` library, the Python `sc_compose` package and `sc-compose bead`: diff --git a/Cargo.lock b/Cargo.lock index 7e0c2e0c..23a3eaf5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -712,7 +712,7 @@ checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "sc-compose" -version = "1.7.0" +version = "1.7.1" dependencies = [ "anyhow", "clap", @@ -732,7 +732,7 @@ dependencies = [ [[package]] name = "sc-compose-py" -version = "1.7.0" +version = "1.7.1" dependencies = [ "pyo3", "sc-composer", @@ -742,14 +742,14 @@ dependencies = [ [[package]] name = "sc-compose-test-support" -version = "1.7.0" +version = "1.7.1" dependencies = [ "serde_json", ] [[package]] name = "sc-composer" -version = "1.7.0" +version = "1.7.1" dependencies = [ "minijinja", "quick-xml", @@ -765,7 +765,7 @@ dependencies = [ [[package]] name = "sc-composer-beads" -version = "1.7.0" +version = "1.7.1" dependencies = [ "process-wrap", "sc-compose-test-support", @@ -778,7 +778,7 @@ dependencies = [ [[package]] name = "sc-composer-beads-python" -version = "1.7.0" +version = "1.7.1" dependencies = [ "pyo3", "sc-composer-beads", @@ -834,14 +834,14 @@ dependencies = [ [[package]] name = "sc-sha" -version = "1.7.0" +version = "1.7.1" dependencies = [ "sha2", ] [[package]] name = "sc-sha-go" -version = "1.7.0" +version = "1.7.1" dependencies = [ "sc-sha", "serde", @@ -853,7 +853,7 @@ dependencies = [ [[package]] name = "sc-sha-python" -version = "1.7.0" +version = "1.7.1" dependencies = [ "pyo3", "sc-sha", diff --git a/Cargo.toml b/Cargo.toml index efea3649..263fe15c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/sc-composer", "crates/sc-composer-beads", "crates/sc-compose" resolver = "2" [workspace.package] -version = "1.7.0" +version = "1.7.1" authors = ["Randlee"] edition = "2024" license = "MIT" @@ -24,9 +24,9 @@ serde = { version = "1", features = ["derive"] } serde_json = { version = "1", features = ["raw_value"] } serde_yaml = "0.9" quick-xml = "0.41" -sc-sha = { path = "crates/sc-sha", version = "1.7.0" } -sc-composer = { path = "crates/sc-composer", version = "1.7.0" } -sc-composer-beads = { path = "crates/sc-composer-beads", version = "1.7.0" } +sc-sha = { path = "crates/sc-sha", version = "1.7.1" } +sc-composer = { path = "crates/sc-composer", version = "1.7.1" } +sc-composer-beads = { path = "crates/sc-composer-beads", version = "1.7.1" } sc-compose-test-support = { path = "crates/test-support" } sha2 = "0.10" similar = "2" diff --git a/bindings/python/pyproject.toml b/bindings/python/pyproject.toml index a71d1191..269b8656 100644 --- a/bindings/python/pyproject.toml +++ b/bindings/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "sc-compose" -version = "1.7.0" +version = "1.7.1" description = "Python bindings for sc-compose composition APIs" readme = "README.md" requires-python = ">=3.11" diff --git a/bindings/sc-composer-beads-python/pyproject.toml b/bindings/sc-composer-beads-python/pyproject.toml index 5f62f49d..041ce9ea 100644 --- a/bindings/sc-composer-beads-python/pyproject.toml +++ b/bindings/sc-composer-beads-python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "sc-composer-beads" -version = "1.7.0" +version = "1.7.1" description = "Typed Python adapter for host-neutral Beads formula composition" requires-python = ">=3.11" license = { text = "MIT" } diff --git a/bindings/sc-sha-python/pyproject.toml b/bindings/sc-sha-python/pyproject.toml index 916f7a6e..67251a16 100644 --- a/bindings/sc-sha-python/pyproject.toml +++ b/bindings/sc-sha-python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "sc-sha" -version = "1.7.0" +version = "1.7.1" description = "Python adapter for deterministic sc-sha file and composition identities" requires-python = ">=3.11" license = { text = "MIT" } From d71c5dd18aa4f806724df2c7b8a27069cb219810 Mon Sep 17 00:00:00 2001 From: Rand Lee Date: Sun, 11 Oct 2026 18:56:20 -0700 Subject: [PATCH 2/7] docs: complete 1.7.1 fuzz fix notes --- CHANGELOG.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 57a728a0..24600d7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ All notable changes to this project will be documented in this file. pour leave ordinary expressions for `bd cook`. - This release includes the strict-rendering, Beads request, and diagnostics work tracked in #783, #784, #785, #787, #791, #792, and #618. +- `sc-composer`: new `validate_expanded_with_context` validates an + already-expanded template with its render delimiters and returns the merged + render context. ### Fixed @@ -38,9 +41,16 @@ All notable changes to this project will be documented in this file. - a name supplied in both `compose_variables` and `bead_variables` is refused with `BEADS_VARIABLE_KEY_DUPLICATE` instead of the compose value silently winning. -- `sc-composer`: new `validate_expanded_with_context` validates an - already-expanded template with its render delimiters and returns the merged - render context. +- A Beads request whose `rendered_formula` is its template is refused before + anything is written, preventing template overwrite. +- NUL and other control characters in Beads ids are rejected as invalid ids + before a `bd` call. +- Strict validation no longer discovers bogus tokens from indexed access + (`o[0].k`), literal or grouped member access (`({...}).a`, `(x).upper()`), + or locals following four closing braces. Four-brace tokens remain exact at + brace count three. +- A blocked `.graph.json` graph-plan path now reports its plan-path diagnostic + and recovery instead of misidentifying `rendered_formula`. - `sc-compose bead` now renders declared `bead_variables` in both padded `{{ key }}` and tight `{{key}}` formula placeholders before invoking `bd`, while still passing them as `--var` for Beads runtime checks (#618). From 44bcea503e87be94d8498a03a33a83f3bfd68479 Mon Sep 17 00:00:00 2001 From: Rand Lee Date: Sun, 11 Oct 2026 19:07:49 -0700 Subject: [PATCH 3/7] fix: refuse blocked bead graph plans before bd --- CHANGELOG.md | 5 +- crates/sc-compose/src/commands/bead.rs | 6 +- .../sc-compose/tests/cli/fuzz_regressions.rs | 66 ++++++++++++++++++- crates/sc-composer-beads/src/error.rs | 16 ++++- crates/sc-composer-beads/src/error_json.rs | 24 ++++++- crates/sc-composer-beads/src/execute.rs | 2 +- crates/sc-composer-beads/src/graph.rs | 59 +++++++++++------ 7 files changed, 146 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24600d7a..0d660478 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,8 +49,9 @@ All notable changes to this project will be documented in this file. (`o[0].k`), literal or grouped member access (`({...}).a`, `(x).upper()`), or locals following four closing braces. Four-brace tokens remain exact at brace count three. -- A blocked `.graph.json` graph-plan path now reports its plan-path diagnostic - and recovery instead of misidentifying `rendered_formula`. +- A blocked `.graph.json` graph-plan path is refused before `bd` runs and now + reports its plan-path diagnostic and recovery instead of misidentifying + `rendered_formula`. - `sc-compose bead` now renders declared `bead_variables` in both padded `{{ key }}` and tight `{{key}}` formula placeholders before invoking `bd`, while still passing them as `--var` for Beads runtime checks (#618). diff --git a/crates/sc-compose/src/commands/bead.rs b/crates/sc-compose/src/commands/bead.rs index f065d872..2dc7c2d7 100644 --- a/crates/sc-compose/src/commands/bead.rs +++ b/crates/sc-compose/src/commands/bead.rs @@ -85,16 +85,16 @@ pub(crate) fn run_bead(args: &BeadArgs) -> Result { } let mut diagnostics = Vec::new(); - let mut identifier_diagnostic = None; + let mut refusal_diagnostic = None; let result = execute_bead_request_with_diagnostics(&request, &mut |error| { - identifier_diagnostic = BeadDiagnostic::graph_id_invalid(error); + refusal_diagnostic = BeadDiagnostic::refused(error); if !json { diagnostics.push(serde_json::to_value(error)); } }); match result { Ok(receipt) => { - if let Some(error) = identifier_diagnostic { + if let Some(error) = refusal_diagnostic { return print_refused_receipt(RefusedBeadComposeReceipt { receipt, error }, json); } let diagnostics = diagnostics diff --git a/crates/sc-compose/tests/cli/fuzz_regressions.rs b/crates/sc-compose/tests/cli/fuzz_regressions.rs index dc60e44a..acfc986b 100644 --- a/crates/sc-compose/tests/cli/fuzz_regressions.rs +++ b/crates/sc-compose/tests/cli/fuzz_regressions.rs @@ -350,7 +350,7 @@ fn fuzz_171_13_rendered_formula_equal_to_template_is_refused_without_writes() { use std::os::unix::fs::PermissionsExt; let root = temp_root("fuzz-171-13-template-overwrite"); - let template = root.join("same.formula.toml.j2"); + let template = root.join("same.formula.toml"); let original = b"formula = \"same\"\n"; write_file(&template, std::str::from_utf8(original).unwrap()); let trace = root.join("bd.trace"); @@ -394,12 +394,74 @@ fn fuzz_171_13_rendered_formula_equal_to_template_is_refused_without_writes() { let mut expected = vec![ std::ffi::OsString::from("fake-bd"), std::ffi::OsString::from("request.json"), - std::ffi::OsString::from("same.formula.toml.j2"), + std::ffi::OsString::from("same.formula.toml"), ]; expected.sort(); assert_eq!(entries, expected, "refusal must create no output files"); } +// FUZZ-171-21: a blocked graph plan is named before `bd` is invoked. +#[cfg(unix)] +#[test] +fn fuzz_171_21_preview_attach_refuses_blocked_graph_plan_before_bd() { + use std::os::unix::fs::PermissionsExt; + + let root = temp_root("fuzz-171-21-graph-plan-path"); + let template = root.join("m.formula.toml.j2"); + write_file(&template, "formula = \"m\"\n"); + let rendered_formula = root.join("out.formula.toml"); + let graph_plan = root.join("out.formula.toml.graph.json"); + std::fs::create_dir(&graph_plan).unwrap(); + let trace = root.join("bd.trace"); + let bd = root.join("fake-bd"); + write_file( + &bd, + &format!("#!/bin/sh\nprintf called >> '{}'\n", trace.display()), + ); + std::fs::set_permissions(&bd, std::fs::Permissions::from_mode(0o755)).unwrap(); + let request = root.join("request.json"); + write_file( + &request, + &serde_json::json!({ + "schema":"sc-compose/beads/v1", "operation":"preview_attach", + "working_directory":root, "template":template, + "rendered_formula":rendered_formula, "compose_variables":{}, "bead_variables":{}, + "parent":"proj-1", "ref":"r", "bd_executable":bd + }) + .to_string(), + ); + + let output = sc_compose() + .args(["bead", "preview-attach", "--request"]) + .arg(&request) + .output() + .unwrap(); + + assert_eq!(output.status.code(), Some(2), "{output:?}"); + let diagnostic = String::from_utf8(output.stdout).unwrap(); + assert!( + diagnostic.contains("invalid graph plan output path"), + "{diagnostic}" + ); + assert!( + diagnostic.contains(graph_plan.to_str().unwrap()), + "{diagnostic}" + ); + assert!(!trace.exists(), "bd must not run before graph-plan refusal"); + + let output = sc_compose() + .args(["bead", "preview-attach", "--json", "--request"]) + .arg(&request) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(2), "{output:?}"); + let receipt = parse_stdout(&output); + assert_eq!( + receipt["payload"]["error"]["details"]["field"], "graph_plan", + "{receipt}" + ); +} + // FUZZ-171-17: control characters in graph IDs are refused before `bd` runs. #[cfg(unix)] #[test] diff --git a/crates/sc-composer-beads/src/error.rs b/crates/sc-composer-beads/src/error.rs index a2ced855..ffe6847f 100644 --- a/crates/sc-composer-beads/src/error.rs +++ b/crates/sc-composer-beads/src/error.rs @@ -180,6 +180,14 @@ pub enum BeadComposeError { /// Rule that the output path failed. rule: String, }, + /// The graph-plan output path cannot be written safely. + #[error("invalid graph plan output path `{path}`: {rule}")] + GraphPlanPathInvalid { + /// Graph plan path derived beside the rendered formula. + path: PathBuf, + /// Rule that the graph plan path failed. + rule: String, + }, /// A template path escaped the working directory. #[error("template path escapes working directory: {path}")] TemplateOutsideWorkingDirectory { @@ -606,6 +614,7 @@ impl BeadComposeError { | Self::FormulaExtensionUnsupported { .. } | Self::TemplatePathInvalid { .. } | Self::OutputPathInvalid { .. } + | Self::GraphPlanPathInvalid { .. } | Self::TemplateOutsideWorkingDirectory { .. } | Self::OutputOutsideWorkingDirectory { .. } | Self::OutputPathSymlink { .. } @@ -662,7 +671,8 @@ impl BeadComposeError { | Self::GraphEdgeMissing { .. } | Self::GraphReadFailed { .. } | Self::GraphApplyFailed { .. } - | Self::GraphApplyUnconfirmed { .. } => BeadErrorClass::Execution, + | Self::GraphApplyUnconfirmed { .. } + | Self::GraphPlanPathInvalid { .. } => BeadErrorClass::Execution, } } @@ -677,7 +687,9 @@ impl BeadComposeError { Self::FormulaPathNotFile { .. } => Self::FORMULA_NOT_FILE_CODE, Self::FormulaExtensionUnsupported { .. } => Self::FORMULA_EXTENSION_UNSUPPORTED_CODE, Self::TemplatePathInvalid { .. } => Self::TEMPLATE_PATH_INVALID_CODE, - Self::OutputPathInvalid { .. } => Self::OUTPUT_PATH_INVALID_CODE, + Self::OutputPathInvalid { .. } | Self::GraphPlanPathInvalid { .. } => { + Self::OUTPUT_PATH_INVALID_CODE + } Self::TemplateOutsideWorkingDirectory { .. } => Self::TEMPLATE_OUTSIDE_WORKING_DIR_CODE, Self::OutputOutsideWorkingDirectory { .. } => Self::OUTPUT_OUTSIDE_WORKING_DIR_CODE, Self::OutputPathSymlink { .. } => Self::OUTPUT_PATH_SYMLINK_CODE, diff --git a/crates/sc-composer-beads/src/error_json.rs b/crates/sc-composer-beads/src/error_json.rs index 0ef2d174..e68f9781 100644 --- a/crates/sc-composer-beads/src/error_json.rs +++ b/crates/sc-composer-beads/src/error_json.rs @@ -30,8 +30,10 @@ fn error_envelope(error: &BeadComposeError) -> ErrorEnvelope<'static> { json!({ "path": path.to_string_lossy(), "kind": format!("{:?}", source.kind()) }), "Check that the request path names an existing UTF-8 JSON file and that you have permission to read it.", )), - BeadComposeError::OutputPathInvalid { path, rule } => { - let plan_path = path.to_string_lossy().ends_with(".graph.json"); + BeadComposeError::OutputPathInvalid { path, rule } + | BeadComposeError::GraphPlanPathInvalid { path, rule } => { + let plan_path = matches!(error, BeadComposeError::GraphPlanPathInvalid { .. }) + || path.to_string_lossy().ends_with(".graph.json"); Some(( json!({ "field": if plan_path { "graph_plan" } else { "rendered_formula" }, "value": path.to_string_lossy(), "rule": rule }), if plan_path { @@ -114,6 +116,24 @@ fn error_envelope(error: &BeadComposeError) -> ErrorEnvelope<'static> { } impl crate::BeadDiagnostic { + /// Retain an error whose refused receipt must expose structured details. + #[must_use] + pub fn refused(error: &BeadComposeError) -> Option { + match error { + BeadComposeError::GraphIdInvalid { .. } => Self::graph_id_invalid(error), + BeadComposeError::GraphPlanPathInvalid { .. } => { + let envelope = error_envelope(error); + Some(Self { + code: envelope.code.into(), + message: envelope.message, + details: envelope.details, + recovery: envelope.recovery.map(str::to_owned), + }) + } + _ => None, + } + } + /// Retain a canonical identifier diagnostic, including its native rule. /// Returns `None` for errors outside the identifier-refusal contract. #[must_use] diff --git a/crates/sc-composer-beads/src/execute.rs b/crates/sc-composer-beads/src/execute.rs index 0e633b0c..955f020a 100644 --- a/crates/sc-composer-beads/src/execute.rs +++ b/crates/sc-composer-beads/src/execute.rs @@ -999,7 +999,7 @@ pub(crate) mod tests { ]); let mut diagnostics = Vec::new(); let receipt = super::execute_with_runner_and_diagnostics(&request, &runner, &mut |error| { - assert!(matches!(error, BeadComposeError::OutputPathInvalid { path: actual, .. } if actual == &public)); + assert!(matches!(error, BeadComposeError::GraphPlanPathInvalid { path: actual, .. } if actual == &public)); diagnostics.push(serde_json::to_value(error).unwrap()); }).unwrap(); assert_eq!(diagnostics.len(), 1); diff --git a/crates/sc-composer-beads/src/graph.rs b/crates/sc-composer-beads/src/graph.rs index 0d5962dc..aac2bce6 100644 --- a/crates/sc-composer-beads/src/graph.rs +++ b/crates/sc-composer-beads/src/graph.rs @@ -50,7 +50,10 @@ pub(crate) fn execute( stage: if attach { BeadStage::Validate } else { stage }, stages, }; - let result = run(request, &mut runtime, formula_input, stage); + // The graph plan is a separate public artifact. Check its destination + // before invoking `bd` so a blocked plan cannot be hidden by process work. + let result = validate_graph_plan_destination(&runtime) + .and_then(|_| run(request, &mut runtime, formula_input, stage)); let missing_edges = match &result { Err(BeadComposeError::GraphEdgeMissing { edges }) => edges.clone(), _ => Vec::new(), @@ -420,25 +423,7 @@ impl PendingCreate { runtime: &mut Runtime<'_>, preview: bool, ) -> Result { - let mut path = runtime - .normalized - .rendered_formula - .as_os_str() - .to_os_string(); - path.push(".graph.json"); - let path = PathBuf::from(path); - let public_path = public_path_buf(&path); - let parent = path - .parent() - .and_then(|p| fs::canonicalize(p).ok()) - .ok_or_else(|| BeadComposeError::OutputPathInvalid { - path: public_path.clone(), - rule: "output parent must exist and be accessible".into(), - })?; - if !parent.starts_with(&runtime.normalized.working_directory) { - return Err(BeadComposeError::OutputOutsideWorkingDirectory { path: public_path }); - } - crate::render::validate_output_destination(&path)?; + let (path, public_path) = validate_graph_plan_destination(runtime)?; let bytes = serde_json::to_vec(&self.payload).expect("plan JSON serializes"); let plan_input = InputSnapshot::write(&path, &bytes) .map_err(|error| crate::snapshot::output_error(&path, error))?; @@ -575,6 +560,40 @@ impl PendingCreate { } } +fn validate_graph_plan_destination( + runtime: &Runtime<'_>, +) -> Result<(PathBuf, PathBuf), BeadComposeError> { + let mut path = runtime + .normalized + .rendered_formula + .as_os_str() + .to_os_string(); + path.push(".graph.json"); + let path = PathBuf::from(path); + let public_path = public_path_buf(&path); + let parent = path + .parent() + .and_then(|parent| fs::canonicalize(parent).ok()) + .ok_or_else(|| BeadComposeError::GraphPlanPathInvalid { + path: public_path.clone(), + rule: "output parent must exist and be accessible".into(), + })?; + if !parent.starts_with(&runtime.normalized.working_directory) { + return Err(BeadComposeError::OutputOutsideWorkingDirectory { path: public_path }); + } + crate::render::validate_output_destination(&path).map_err(graph_plan_path_error)?; + Ok((path, public_path)) +} + +fn graph_plan_path_error(error: BeadComposeError) -> BeadComposeError { + match error { + BeadComposeError::OutputPathInvalid { path, rule } => { + BeadComposeError::GraphPlanPathInvalid { path, rule } + } + error => error, + } +} + #[cfg(test)] mod presentation_tests { use super::*; From c3e0515075e1e97efd5a3c16489fc9e9cc6ce178 Mon Sep 17 00:00:00 2001 From: Rand Lee Date: Sun, 11 Oct 2026 19:10:39 -0700 Subject: [PATCH 4/7] docs: complete 1.7.1 release notes --- CHANGELOG.md | 9 ++++++--- README.md | 6 +++--- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d660478..79d2d958 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,9 +49,9 @@ All notable changes to this project will be documented in this file. (`o[0].k`), literal or grouped member access (`({...}).a`, `(x).upper()`), or locals following four closing braces. Four-brace tokens remain exact at brace count three. -- A blocked `.graph.json` graph-plan path is refused before `bd` runs and now - reports its plan-path diagnostic and recovery instead of misidentifying - `rendered_formula`. +- A blocked `.graph.json` graph-plan path is refused before `bd` runs, as a + refused receipt with exit 2, and now reports its plan-path diagnostic and + recovery instead of misidentifying `rendered_formula`. - `sc-compose bead` now renders declared `bead_variables` in both padded `{{ key }}` and tight `{{key}}` formula placeholders before invoking `bd`, while still passing them as `--var` for Beads runtime checks (#618). @@ -101,6 +101,9 @@ No major version bump. `ComposePolicy.unbound_variable_policy` are removed. `Renderer::refusing_undefined()` is removed; every renderer refuses to print an undefined value. +- `sc-composer-beads`: `BeadComposeRequest` gains + `bead_variable_rendering` (`BeadVariableRendering`, default + `sc-compose`); Rust struct-literal construction must set it. - Python `sc_compose.ComposePolicy`: the `strict_undeclared_variables` and `unbound_variable_policy` arguments and attributes are removed; passing them raises `TypeError`. diff --git a/README.md b/README.md index 8d18ee35..a2dbcaf2 100644 --- a/README.md +++ b/README.md @@ -234,8 +234,8 @@ handoff requirements. | Any (Python) | PyPI | `pip install sc-compose` | | Any (Python) | PyPI | `pip install sc-composer-beads` (Beads composition) | | Any (source) | cargo | `cargo build --release -p sc-compose` | -| Rust lib | Cargo.toml | `sc-composer = "1.7.0"` | -| Rust lib (Beads) | Cargo.toml | `sc-composer-beads = "1.7.0"` | +| Rust lib | Cargo.toml | `sc-composer = "1.7.1"` | +| Rust lib (Beads) | Cargo.toml | `sc-composer-beads = "1.7.1"` | Bundled examples are guaranteed in Homebrew, Scoop, Winget, and GitHub Release installs. `cargo install` ships the binary only — set `SC_COMPOSE_DATA_DIR` for @@ -247,7 +247,7 @@ examples. | | | |---|---| -| Version | 1.7.0 | +| Version | 1.7.1 | | MSRV | Rust 1.94.1 | | Rust edition | 2024 | | Platforms | macOS, Linux, Windows | From 247ed60d5709200d3dd40d16064879168a0642ad Mon Sep 17 00:00:00 2001 From: Rand Lee Date: Sun, 11 Oct 2026 19:37:01 -0700 Subject: [PATCH 5/7] test: align fixtures with strict bead identifiers --- crates/sc-compose/src/commands/bead.rs | 5 ++++- crates/sc-compose/tests/cli/render.rs | 5 ++++- crates/sc-compose/tests/cli/validate.rs | 5 ++++- crates/sc-compose/tests/json_cli/render.rs | 9 ++++++--- crates/sc-compose/tests/json_cli/validate.rs | 12 +++++++++--- crates/sc-composer-beads/src/graph.rs | 9 +++++++-- crates/sc-composer-beads/tests/fuzz_regressions.rs | 2 +- tests/fixtures/sc-lint/bootstrap/Cargo.lock | 4 ++-- 8 files changed, 37 insertions(+), 14 deletions(-) diff --git a/crates/sc-compose/src/commands/bead.rs b/crates/sc-compose/src/commands/bead.rs index 2dc7c2d7..12c0e42c 100644 --- a/crates/sc-compose/src/commands/bead.rs +++ b/crates/sc-compose/src/commands/bead.rs @@ -348,7 +348,7 @@ mod tests { } #[test] fn missing_edge_recovery_commands_escape_controls_and_bidi_for_posix_sh() { - let from = "source'\\\u{7}\u{7f}\u{80}\u{202e}$(literal)$HOME`literal`"; + let from = "source'\\\u{202e}$(literal)$HOME`literal`"; let to = "target\u{202a}\u{202b}\u{202c}\u{202d}\u{202e}\u{2066}\u{2067}\u{2068}\u{2069}\u{200e}\u{200f}"; let receipt: BeadComposeReceipt = serde_json::from_value(json!({ "schema": "sc-compose/beads/v1", @@ -375,6 +375,9 @@ mod tests { let separators = "line\u{2028}paragraph\u{2029}end"; let escaped = super::shell_quote(separators); assert!(!escaped.contains(['\u{2028}', '\u{2029}'])); + let escaped_controls = super::shell_quote("\u{7}\u{7f}\u{80}"); + assert!(!escaped_controls.chars().any(char::is_control)); + assert!(escaped_controls.contains("\\007\\177\\302\\200")); assert!(!arguments.contains("$'"), "{arguments}"); #[cfg(unix)] { diff --git a/crates/sc-compose/tests/cli/render.rs b/crates/sc-compose/tests/cli/render.rs index 8ab99875..b563dfc3 100644 --- a/crates/sc-compose/tests/cli/render.rs +++ b/crates/sc-compose/tests/cli/render.rs @@ -528,7 +528,10 @@ fn render_append_reports_write_error_for_read_only_destination() { #[test] fn render_append_serializes_sixteen_concurrent_processes() { let root = temp_root("append-concurrent"); - write_file(&root.join("record.json.j2"), "{\"id\": {{ id }}}"); + write_file( + &root.join("record.json.j2"), + "---\nrequired_variables:\n - id\n---\n{\"id\": {{ id }}}\n", + ); let destination = root.join("records.jsonl"); let mut children = Vec::new(); for id in 0..16 { diff --git a/crates/sc-compose/tests/cli/validate.rs b/crates/sc-compose/tests/cli/validate.rs index 99bff830..28947fa6 100644 --- a/crates/sc-compose/tests/cli/validate.rs +++ b/crates/sc-compose/tests/cli/validate.rs @@ -31,7 +31,10 @@ fn validate_default_output_remains_unchanged_without_lint() { #[test] fn validate_check_render_reports_context_valid_without_emitting_body() { let root = temp_root("validate-check-render"); - write_file(&root.join("payload.json.j2"), "{\"value\": {{ value }}}"); + write_file( + &root.join("payload.json.j2"), + "---\nrequired_variables:\n - value\n---\n{\"value\": {{ value }}}\n", + ); let output = sc_compose() .args([ diff --git a/crates/sc-compose/tests/json_cli/render.rs b/crates/sc-compose/tests/json_cli/render.rs index 7b8e0005..157b1a14 100644 --- a/crates/sc-compose/tests/json_cli/render.rs +++ b/crates/sc-compose/tests/json_cli/render.rs @@ -283,7 +283,7 @@ fn render_json_cli_mode_overrides_frontmatter_mode() { let root = temp_root("render-json-cli-mode"); write_file( &root.join("payload.json.j2"), - "---\njson_escape_mode: legacy\n---\n{\"value\": {{ value }}}", + "---\njson_escape_mode: legacy\nrequired_variables:\n - value\n---\n{\"value\": {{ value }}}", ); let output = sc_compose() @@ -562,7 +562,10 @@ fn render_json_file_parser_failure_does_not_create_output() { #[test] fn render_json_check_render_reports_checked_contract() { let root = temp_root("render-json-check-render"); - write_file(&root.join("payload.json.j2"), r#"{"value": {{ value }}}"#); + write_file( + &root.join("payload.json.j2"), + "---\nrequired_variables:\n - value\n---\n{\"value\": {{ value }}}", + ); let output = sc_compose() .args([ @@ -638,7 +641,7 @@ fn render_json_custom_delimiter_report_attributes_included_defaults() { let root = temp_root("render-json-custom-context-sources"); write_file( &root.join("payload.json.j2"), - "---\ndefaults:\n root_default: from-root\n---\n{\"root\": {{{ root_default }}}, \"override\": {{{ override }}}}\n@\n", + "---\nrequired_variables:\n - override\ndefaults:\n root_default: from-root\n---\n{\"root\": {{{ root_default }}}, \"override\": {{{ override }}}}\n@\n", ); write_file( &root.join("fragment.j2"), diff --git a/crates/sc-compose/tests/json_cli/validate.rs b/crates/sc-compose/tests/json_cli/validate.rs index 4b2c16b1..76e77a50 100644 --- a/crates/sc-compose/tests/json_cli/validate.rs +++ b/crates/sc-compose/tests/json_cli/validate.rs @@ -592,7 +592,10 @@ fn validate_json_rejects_json_escape_mode_on_non_json_templates() { #[test] fn validate_json_reports_static_only_state_without_rendering() { let root = temp_root("validate-json-static-only"); - write_file(&root.join("payload.json.j2"), "{\"value\": {{ value }}}"); + write_file( + &root.join("payload.json.j2"), + "---\nrequired_variables:\n - value\n---\n{\"value\": {{ value }}}", + ); let output = sc_compose() .args([ @@ -621,7 +624,10 @@ fn validate_json_reports_static_only_state_without_rendering() { #[test] fn validate_json_check_render_reports_exact_context_and_no_body() { let root = temp_root("validate-json-check-render"); - write_file(&root.join("payload.json.j2"), "{\"value\": {{ value }}}"); + write_file( + &root.join("payload.json.j2"), + "---\nrequired_variables:\n - value\n---\n{\"value\": {{ value }}}", + ); let output = sc_compose() .args([ @@ -708,7 +714,7 @@ fn validate_json_lint_check_render_combines_diagnostics() { let root = temp_root("validate-json-lint-check-render"); write_file( &root.join("payload.json.j2"), - "{\"value\": {{ value | frontmatter_safe | yaml_safe }}}", + "---\nrequired_variables:\n - value\n---\n{\"value\": {{ value | frontmatter_safe | yaml_safe }}}", ); let output = sc_compose() diff --git a/crates/sc-composer-beads/src/graph.rs b/crates/sc-composer-beads/src/graph.rs index aac2bce6..ce13e14f 100644 --- a/crates/sc-composer-beads/src/graph.rs +++ b/crates/sc-composer-beads/src/graph.rs @@ -52,8 +52,13 @@ pub(crate) fn execute( }; // The graph plan is a separate public artifact. Check its destination // before invoking `bd` so a blocked plan cannot be hidden by process work. - let result = validate_graph_plan_destination(&runtime) - .and_then(|_| run(request, &mut runtime, formula_input, stage)); + let result = match validate_graph_plan_destination(&runtime) { + Ok(_) => run(request, &mut runtime, formula_input, stage), + Err(error) => { + runtime.stage = stage; + Err(error) + } + }; let missing_edges = match &result { Err(BeadComposeError::GraphEdgeMissing { edges }) => edges.clone(), _ => Vec::new(), diff --git a/crates/sc-composer-beads/tests/fuzz_regressions.rs b/crates/sc-composer-beads/tests/fuzz_regressions.rs index ec2f9038..6ae72a1d 100644 --- a/crates/sc-composer-beads/tests/fuzz_regressions.rs +++ b/crates/sc-composer-beads/tests/fuzz_regressions.rs @@ -564,7 +564,7 @@ const OPTION_LIKE_IDS: [&str; 3] = ["--db=/elsewhere", "--json", "-q"]; // FUZZ-040: recovery command arguments cannot execute shell syntax or emit controls. #[test] fn fuzz_040_recovery_arguments_are_shell_quoted_and_control_escaped() { - let from = "source'\\\u{7}\u{7f}\u{80}\u{202e}\u{200b}\u{00ad}$(literal)$HOME`literal`"; + let from = "source'\\\u{202e}\u{200b}\u{00ad}$(literal)$HOME`literal`"; let to = "target\u{202a}\u{202b}\u{202c}\u{202d}\u{202e}\u{2066}\u{2067}\u{2068}\u{2069}\u{200e}\u{200f}\u{feff}"; let error = sc_composer_beads::BeadComposeError::GraphEdgeMissing { edges: vec![sc_composer_beads::MissingEdge { diff --git a/tests/fixtures/sc-lint/bootstrap/Cargo.lock b/tests/fixtures/sc-lint/bootstrap/Cargo.lock index 3f26dfaa..d52d54bc 100644 --- a/tests/fixtures/sc-lint/bootstrap/Cargo.lock +++ b/tests/fixtures/sc-lint/bootstrap/Cargo.lock @@ -8,8 +8,8 @@ version = "0.1.0" [[patch.unused]] name = "sc-composer" -version = "1.7.0" +version = "1.7.1" [[patch.unused]] name = "sc-sha" -version = "1.7.0" +version = "1.7.1" From dfd40e5caee6170951125f3b81632a6b1cba70df Mon Sep 17 00:00:00 2001 From: Rand Lee Date: Sun, 11 Oct 2026 19:39:08 -0700 Subject: [PATCH 6/7] test: cover public shell quote control escaping --- CHANGELOG.md | 3 +++ crates/sc-composer-beads/tests/fuzz_regressions.rs | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79d2d958..8df265b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,9 @@ All notable changes to this project will be documented in this file. (`o[0].k`), literal or grouped member access (`({...}).a`, `(x).upper()`), or locals following four closing braces. Four-brace tokens remain exact at brace count three. +- Strict validation now discovers an expression immediately followed by a + literal `}` (for example JSON `{"v": {{ v }}}`); an undeclared name there + is refused instead of silently passing. - A blocked `.graph.json` graph-plan path is refused before `bd` runs, as a refused receipt with exit 2, and now reports its plan-path diagnostic and recovery instead of misidentifying `rendered_formula`. diff --git a/crates/sc-composer-beads/tests/fuzz_regressions.rs b/crates/sc-composer-beads/tests/fuzz_regressions.rs index 6ae72a1d..25b8eb0d 100644 --- a/crates/sc-composer-beads/tests/fuzz_regressions.rs +++ b/crates/sc-composer-beads/tests/fuzz_regressions.rs @@ -604,6 +604,12 @@ fn fuzz_040_recovery_arguments_are_shell_quoted_and_control_escaped() { assert!(escaped_separators.contains("\\342\\200\\251")); #[cfg(unix)] assert_sh_round_trip(&escaped_separators, format!("{separators}\0").as_bytes()); + let controls = "\u{7}\u{7f}\u{80}"; + let quoted_controls = sc_composer_beads::error::shell_quote(controls); + assert!(!quoted_controls.chars().any(char::is_control)); + assert!(quoted_controls.contains("\\007\\177\\302\\200")); + #[cfg(unix)] + assert_sh_round_trip("ed_controls, format!("{controls}\0").as_bytes()); assert_eq!(sc_composer_beads::error::shell_quote("a'b"), "'a'\"'\"'b'"); for value in [ "", From 1294d631309b83161bfbc58ffccac3af0a1c6c55 Mon Sep 17 00:00:00 2001 From: Rand Lee Date: Sun, 11 Oct 2026 20:06:15 -0700 Subject: [PATCH 7/7] test: declare checked JSON fixture variable --- crates/sc-composer/tests/integration.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/sc-composer/tests/integration.rs b/crates/sc-composer/tests/integration.rs index 7f6352b9..bf9699ad 100644 --- a/crates/sc-composer/tests/integration.rs +++ b/crates/sc-composer/tests/integration.rs @@ -518,7 +518,10 @@ fn compose_then_checked_output_emits_valid_json_only_through_checked_output() { #[test] fn checked_render_validates_the_complete_final_body_with_guidance_and_prompt() { let root = temp_root("checked-json-final-body"); - write_file(&root.join("payload.json.j2"), r#"{"value": {{ value }}}"#); + write_file( + &root.join("payload.json.j2"), + "---\nrequired_variables:\n - value\n---\n{\"value\": {{ value }}}", + ); let result = compose(&ComposeRequest { runtime: None,