Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 28 additions & 7 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -13,6 +15,11 @@ 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.
- `sc-composer`: new `validate_expanded_with_context` validates an
already-expanded template with its render delimiters and returns the merged
render context.

### Fixed

Expand All @@ -34,19 +41,30 @@ 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.
- 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`.
- `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).

### 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`:
Expand Down Expand Up @@ -86,6 +104,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`.
Expand Down
18 changes: 9 additions & 9 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 4 additions & 4 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion bindings/python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion bindings/sc-composer-beads-python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
2 changes: 1 addition & 1 deletion bindings/sc-sha-python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
11 changes: 7 additions & 4 deletions crates/sc-compose/src/commands/bead.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,16 +85,16 @@ pub(crate) fn run_bead(args: &BeadArgs) -> Result<i32, CommandError> {
}

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
Expand Down Expand Up @@ -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",
Expand All @@ -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)]
{
Expand Down
66 changes: 64 additions & 2 deletions crates/sc-compose/tests/cli/fuzz_regressions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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]
Expand Down
5 changes: 4 additions & 1 deletion crates/sc-compose/tests/cli/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 4 additions & 1 deletion crates/sc-compose/tests/cli/validate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down
9 changes: 6 additions & 3 deletions crates/sc-compose/tests/json_cli/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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([
Expand Down Expand Up @@ -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@<fragment.j2>\n",
"---\nrequired_variables:\n - override\ndefaults:\n root_default: from-root\n---\n{\"root\": {{{ root_default }}}, \"override\": {{{ override }}}}\n@<fragment.j2>\n",
);
write_file(
&root.join("fragment.j2"),
Expand Down
Loading
Loading