From d227ee84e9c4bd98edb4614330da92b2e635814c Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 13 Oct 2026 13:00:34 +0000 Subject: [PATCH] fix(script): only a well-formed `$N` reference is replaced; `$$` is a literal `$` (#749) --- ROADMAP.md | 1 + apps/designcraft-cli/src/main.rs | 2 + crates/engine/src/script.rs | 64 +++++++++++++++++++++++++++----- crates/mcp/src/tools.rs | 3 +- docs/agents.md | 3 +- 5 files changed, 61 insertions(+), 12 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f8207..6badabc0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-13** · Command scripts look up only a well-formed `$N.path` / `$last.path` reference; other strings starting with `$`, such as a GREP replacement of `$1` and a nonbreaking space, stay as written, and `$$` writes a literal `$` (#749). No readiness percentage change. - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. diff --git a/apps/designcraft-cli/src/main.rs b/apps/designcraft-cli/src/main.rs index dd62cd91..4ae49cc0 100644 --- a/apps/designcraft-cli/src/main.rs +++ b/apps/designcraft-cli/src/main.rs @@ -55,6 +55,8 @@ References to earlier results (in string parameters): comments and blank lines are not steps. `$last` is the previous step. Paths go through result fields and array indices: "$1.story", "$3.ids.0". "${N.path}" inside a longer string, replaced by the value's text: "Frame ${0.id}". + Any other string starting with `$` stays as written ("$1-$2", "$5 off"). + "$$" a literal `$` anywhere: "$$1" passes `$1` (a GREP group), "$${0.id}" passes `${0.id}`. Text parameters: "\n" starts a new paragraph, "\u2028" is a forced line break, "\t" a tab. Most commands ignore parameters they do not know instead of rejecting them: check names diff --git a/crates/engine/src/script.rs b/crates/engine/src/script.rs index d2304e65..4a66ff87 100644 --- a/crates/engine/src/script.rs +++ b/crates/engine/src/script.rs @@ -5,7 +5,8 @@ //! becomes the JSON value at `path` in the result of step `N` (0-based): after //! `frame.create → {"id": 8, "story": 9}`, `{"story": "$0.story"}` passes `9`. Paths are dotted //! keys and array indices (`$2.ids.0`). Inside longer strings `${N.path}` is replaced by the -//! value's text (`"Frame ${0.id}"`). +//! value's text (`"Frame ${0.id}"`). Any other string starting with `$` is plain text (`"$1-$2"`, +//! `"$5 off"`), and `$$` anywhere writes a literal `$` (`"$$1"` passes `$1`, a GREP group). //! //! **Script text** ([`parse`]): a JSON array of steps, JSON lines (`{"command": …, "params": …}`), //! or compact lines `command.id {json}` / `command.id={json}` / `command.id`; blank lines and @@ -92,27 +93,44 @@ fn short(v: &Value) -> String { if s.len() > 120 { format!("{}…", &s[..s.floor_char_boundary(120)]) } else { s } } +/// Whether `r` (the text after `$`) is a whole reference: digits or `last`, then zero or more +/// `.segment` of ASCII letters, digits, `_` or `-`. +fn is_reference(r: &str) -> bool { + let mut parts = r.split('.'); + let head = parts.next().unwrap_or(""); + let head_ok = head == "last" || (!head.is_empty() && head.bytes().all(|b| b.is_ascii_digit())); + head_ok && parts.all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')) +} + /// Replace references in `params` with values from earlier `results`. pub fn resolve(params: &Value, results: &[Value]) -> Result { Ok(match params { Value::String(s) => { - if let Some(r) = s.strip_prefix('$').filter(|r| !r.is_empty() && !r.contains(['{', ' '])) { + if let Some(r) = s.strip_prefix('$').filter(|r| is_reference(r)) { return lookup(r, results); } - if !s.contains("${") { + if !s.contains("${") && !s.contains("$$") { return Ok(params.clone()); } let mut out = String::new(); let mut rest = s.as_str(); - while let Some(i) = rest.find("${") { + while let Some(i) = rest.find('$') { out.push_str(&rest[..i]); - let end = rest[i..].find('}').ok_or_else(|| format!("unclosed `${{` in {s:?}"))? + i; - let v = lookup(&rest[i + 2..end], results)?; - match v { - Value::String(t) => out.push_str(&t), - other => out.push_str(&other.to_string()), + let after = &rest[i + 1..]; + if let Some(tail) = after.strip_prefix('$') { + out.push('$'); + rest = tail; + } else if let Some(inner) = after.strip_prefix('{') { + let end = inner.find('}').ok_or_else(|| format!("unclosed `${{` in {s:?}"))?; + match lookup(&inner[..end], results)? { + Value::String(t) => out.push_str(&t), + other => out.push_str(&other.to_string()), + } + rest = &inner[end + 1..]; + } else { + out.push('$'); + rest = after; } - rest = &rest[end + 1..]; } out.push_str(rest); Value::String(out) @@ -196,6 +214,32 @@ mod tests { assert_eq!(r, json!({"story": 9, "id": 5, "label": "Frame 8 of A", "keep": "$", "list": ["A"]})); assert!(resolve(&json!({"x": "$5.id"}), &results).unwrap_err().contains("hasn't run")); assert!(resolve(&json!({"x": "$0.nope"}), &results).unwrap_err().contains("no `nope`")); + assert!(resolve(&json!({"x": "Frame ${0.id"}), &results).unwrap_err().contains("unclosed")); + } + + /// GREP replacements start with `$` too; only a well-formed reference is looked up (#749). + #[test] + fn only_well_formed_references_resolve() { + let results = vec![json!({"id": 8})]; + let p = json!({"a": "$1\u{a0}", "b": "$1-$2", "c": "$5 off", "d": "$$1", "e": "$${0.id}", "f": "$0.id", "g": "${0.id}x", "h": "$1$$", "i": "$1."}); + let r = resolve(&p, &results).unwrap(); + assert_eq!(r, json!({"a": "$1\u{a0}", "b": "$1-$2", "c": "$5 off", "d": "$1", "e": "${0.id}", "f": 8, "g": "8x", "h": "$1$", "i": "$1."})); + } + + /// Czech typesetting: a nonbreaking space after one-letter prepositions. + #[test] + fn a_script_grep_change_keeps_its_group_reference() { + let mut s = crate::Session::new(); + let steps = vec![ + Step { command: "file.new".into(), params: json!({}) }, + Step { command: "frame.create".into(), params: json!({"rect": [36, 36, 300, 300], "content": "text", "text": "a v Praze"}) }, + Step { command: "find.change".into(), params: json!({"find": r"\b([av]) ", "change": "$1\u{a0}", "grep": true, "caseSensitive": true}) }, + Step { command: "story.get".into(), params: json!({"story": "$1.story"}) }, + ]; + let r = s.run_script(&steps); + assert!(r.failed.is_none(), "{r:?}"); + assert_eq!(r.results[2]["count"], 2); + assert_eq!(r.results[3]["text"], "a\u{a0}v\u{a0}Praze"); } #[test] diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index c51bac8e..ccb6bd35 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -118,7 +118,8 @@ pub fn tool_definitions() -> Vec { and message (earlier commands stay applied; use edit.undo to revert them). Later steps can use earlier results: a \ parameter string \"$N.path\" is replaced by that value of step N's result (0-based; \"$last.path\" for the previous \ step), e.g. after frame.create → {\"id\": 8, \"story\": 9}, {\"story\": \"$0.story\"} passes 9; \"${N.path}\" \ - interpolates into longer strings. Give `commands`, or `script` text (one `command.id {json}` per line).", + interpolates into longer strings. Other strings starting with $ stay as written; \"$$\" is a literal $ \ + (\"$$1\" passes the GREP group $1). Give `commands`, or `script` text (one `command.id {json}` per line).", obj( json!({ "commands": { diff --git a/docs/agents.md b/docs/agents.md index 5115654a..b737ebb8 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -24,7 +24,8 @@ A script is one command per line (`command.id {json params}`; `#` comments), JSO `{command, params}`, in UTF-8 with or without a byte order mark. A parameter string `"$N.path"` becomes that value of step N's result: N is the 0-based step index (comments and blank lines are not steps, `$last` is the previous step) and the path goes through result fields and array indices (`"$1.story"`, `"$3.ids.0"`); `"${N.path}"` -interpolates inside longer strings. In text parameters `\n` starts a new paragraph and `\u2028` is a forced line +interpolates inside longer strings. Any other string starting with `$` stays as written (`"$1-$2"`, `"$5 off"`), and +`$$` writes a literal `$` anywhere (`"$$1"` passes `$1`, a GREP group). In text parameters `\n` starts a new paragraph and `\u2028` is a forced line break. Most commands ignore parameters they don't know instead of rejecting them, so check names with `describe`. `designcraft-cli script --help` prints this reference.