refactor: restructured chunk region API and storage layer for consistency
- Renamed ChunkRegion::Inner variant to Body across Rust and TypeScript for clearer semantics. - Renamed chunk edit tool parameter from target to sel and made op required instead of optional. - Removed region aliases (prologue, epilogue, inner) and simplified region validation to accept only head, body, tail. - Replaced db.exec() calls with db.run() for SQL execution in auth-storage, agent-storage, and history-storage. - Enhanced chunk edit documentation with comprehensive examples, indentation rules, and CRC refresh guidance. - Improved chunk statement merging logic to consolidate leaf statements into parent function bodies by default.
This commit is contained in:
@@ -162,12 +162,14 @@ impl LangClassifier for PythonClassifier {
|
||||
"except_clause" => Some(positional_candidate(node, ChunkKind::Except, source)),
|
||||
"match_statement" => Some(positional_candidate(node, ChunkKind::Match, source)),
|
||||
|
||||
// ── Variables ──
|
||||
"expression_statement" | "assignment" => {
|
||||
Some(group_candidate(node, ChunkKind::Statements, source))
|
||||
},
|
||||
|
||||
_ => None,
|
||||
// ── Variables / simple statements ──
|
||||
// Catch `expression_statement`, `assignment`, `return_statement`,
|
||||
// `raise_statement`, `pass_statement`, `break_statement`,
|
||||
// `continue_statement`, `delete_statement`, `assert_statement`,
|
||||
// `nonlocal_statement`, `global_statement`, `type_alias_statement`,
|
||||
// and any other leaf statements so they merge into the parent
|
||||
// function body instead of becoming standalone addressable chunks.
|
||||
_ => Some(group_candidate(node, ChunkKind::Statements, source)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ env_uint! {
|
||||
/// Always recurse into named (non-group) chunks when children exist,
|
||||
/// regardless of the leaf threshold. Enabled via `PI_CHUNK_ALWAYS_RECURSE=1`.
|
||||
pub static ALWAYS_RECURSE: std::sync::LazyLock<bool> = std::sync::LazyLock::new(|| {
|
||||
std::env::var("PI_CHUNK_ALWAYS_RECURSE").map_or(false, |v| !matches!(v.as_str(), "0" | "false"))
|
||||
std::env::var("PI_CHUNK_ALWAYS_RECURSE").is_ok_and(|v| !matches!(v.as_str(), "0" | "false"))
|
||||
});
|
||||
|
||||
/// Suppress `[/chunk#CRC]` closing tags in rendered output.
|
||||
|
||||
@@ -305,11 +305,15 @@ fn resolve_edit_target(
|
||||
};
|
||||
let resolved =
|
||||
resolve_chunk_with_crc(state, cleaned_selector.as_deref(), resolve_crc, warnings)?;
|
||||
let region = operation.region.or(parsed_region);
|
||||
let mut region = operation.region.or(parsed_region);
|
||||
if !batch_auto_accepted {
|
||||
validate_batch_crc(resolved.chunk, resolved.crc.as_deref(), requires_checksum)?;
|
||||
}
|
||||
let chunk = resolved.chunk.clone();
|
||||
if chunk.prologue_end_byte.is_none() || chunk.epilogue_start_byte.is_none() {
|
||||
region = None;
|
||||
}
|
||||
|
||||
Ok(ResolvedEditTarget { chunk, region })
|
||||
}
|
||||
|
||||
@@ -500,8 +504,12 @@ fn apply_insert(
|
||||
file_indent_char,
|
||||
file_indent_step,
|
||||
)?;
|
||||
let is_prepend_or_append = matches!(operation.op, ChunkEditOp::Prepend | ChunkEditOp::Append);
|
||||
let spacing = compute_insert_spacing(state, &anchor, pos, is_prepend_or_append);
|
||||
let suppress_chunk_adjacency =
|
||||
matches!(operation.op, ChunkEditOp::Prepend | ChunkEditOp::Append)
|
||||
&& !(matches!(operation.op, ChunkEditOp::Append)
|
||||
&& pos == InsertPosition::After
|
||||
&& owned_container_end_line(state, &anchor) > anchor.end_line);
|
||||
let spacing = compute_insert_spacing(state, &anchor, pos, suppress_chunk_adjacency);
|
||||
let content = operation.content.as_deref().unwrap_or_default();
|
||||
let mut replacement = normalize_inserted_content(
|
||||
content,
|
||||
@@ -528,14 +536,14 @@ fn apply_insert(
|
||||
&& anchor.children.iter().any(|child| child == "preamble")
|
||||
{
|
||||
return Err(
|
||||
"Comment-only @inner.prepend on root is not allowed when the file has a preamble \
|
||||
"Comment-only @body.prepend on root is not allowed when the file has a preamble \
|
||||
chunk. Use replace on the preamble chunk instead."
|
||||
.to_owned(),
|
||||
);
|
||||
}
|
||||
if comment_only && !anchor.children.is_empty() {
|
||||
warnings.push(
|
||||
"Comment-only @inner.prepend can merge into the following chunk's first line; it is \
|
||||
"Comment-only @body.prepend can merge into the following chunk's first line; it is \
|
||||
not a separate named chunk."
|
||||
.to_owned(),
|
||||
);
|
||||
@@ -602,7 +610,7 @@ fn validate_crc(chunk: &ChunkNode, crc: Option<&str>) -> Result<(), String> {
|
||||
format!(
|
||||
"Checksum required for {}. Re-read the chunk to get the current checksum, then include \
|
||||
it in the selector. Hint: use target \"{}\" for container replacement, or append \
|
||||
another region such as @inner.",
|
||||
another region such as @body.",
|
||||
chunk_path_opt(chunk),
|
||||
selector
|
||||
)
|
||||
@@ -693,7 +701,7 @@ fn target_indent_for_region(
|
||||
None | Some(ChunkRegion::Head | ChunkRegion::Tail) => {
|
||||
anchor.indent_char.repeat(anchor.indent as usize)
|
||||
},
|
||||
Some(ChunkRegion::Inner) => {
|
||||
Some(ChunkRegion::Body) => {
|
||||
compute_insert_indent(state, anchor, true, file_indent_char, file_indent_step)
|
||||
},
|
||||
}
|
||||
@@ -1039,7 +1047,7 @@ fn body_insertion_point(
|
||||
indent: child_indent,
|
||||
};
|
||||
}
|
||||
let (_, body_end) = chunk_region_range(anchor, ChunkRegion::Inner);
|
||||
let (_, body_end) = chunk_region_range(anchor, ChunkRegion::Body);
|
||||
return InsertionPoint { offset: body_end, indent };
|
||||
}
|
||||
|
||||
@@ -1055,7 +1063,7 @@ fn body_insertion_point(
|
||||
indent,
|
||||
};
|
||||
}
|
||||
let (body_start, _) = chunk_region_range(anchor, ChunkRegion::Inner);
|
||||
let (body_start, _) = chunk_region_range(anchor, ChunkRegion::Body);
|
||||
InsertionPoint { offset: body_start, indent }
|
||||
}
|
||||
|
||||
@@ -1078,13 +1086,13 @@ fn resolve_insertion_point(
|
||||
Ok((after_chunk_insertion_point(state, anchor), InsertPosition::After))
|
||||
},
|
||||
// Inner first-child position
|
||||
(Some(ChunkRegion::Inner), ChunkEditOp::Before | ChunkEditOp::Prepend)
|
||||
(Some(ChunkRegion::Body), ChunkEditOp::Before | ChunkEditOp::Prepend)
|
||||
| (Some(ChunkRegion::Head), ChunkEditOp::After | ChunkEditOp::Append) => Ok((
|
||||
body_insertion_point(state, anchor, false, file_indent_char, file_indent_step),
|
||||
InsertPosition::FirstChild,
|
||||
)),
|
||||
// Inner last-child position
|
||||
(Some(ChunkRegion::Inner), ChunkEditOp::After | ChunkEditOp::Append)
|
||||
(Some(ChunkRegion::Body), ChunkEditOp::After | ChunkEditOp::Append)
|
||||
| (Some(ChunkRegion::Tail), ChunkEditOp::Before | ChunkEditOp::Prepend) => Ok((
|
||||
body_insertion_point(state, anchor, true, file_indent_char, file_indent_step),
|
||||
InsertPosition::LastChild,
|
||||
@@ -1828,7 +1836,10 @@ mod tests {
|
||||
|
||||
let result = result.expect("line-number selector should auto-resolve");
|
||||
assert!(
|
||||
result.warnings.iter().any(|w| w.contains("Auto-resolved line target")),
|
||||
result
|
||||
.warnings
|
||||
.iter()
|
||||
.any(|w| w.contains("Auto-resolved line target")),
|
||||
"should warn about auto-resolution: {:?}",
|
||||
result.warnings
|
||||
);
|
||||
@@ -2143,7 +2154,7 @@ mod tests {
|
||||
|
||||
let result = apply_single_edit(&state, "test.ts", EditOperation {
|
||||
op: ChunkEditOp::Replace,
|
||||
sel: Some(format!("fn_main#{}@inner", chunk.checksum)),
|
||||
sel: Some(format!("fn_main#{}@body", chunk.checksum)),
|
||||
crc: None,
|
||||
region: None,
|
||||
content: Some("\treturn next();\n".to_owned()),
|
||||
@@ -2161,7 +2172,7 @@ mod tests {
|
||||
|
||||
let result = apply_single_edit(&state, "test.rs", EditOperation {
|
||||
op: ChunkEditOp::Replace,
|
||||
sel: Some(format!("fn_main#{}@inner", chunk.checksum)),
|
||||
sel: Some(format!("fn_main#{}@body", chunk.checksum)),
|
||||
crc: None,
|
||||
region: None,
|
||||
content: Some("\tprintln!(\"new\");\n".to_owned()),
|
||||
@@ -2179,7 +2190,7 @@ mod tests {
|
||||
|
||||
let result = apply_single_edit(&state, "test.go", EditOperation {
|
||||
op: ChunkEditOp::Replace,
|
||||
sel: Some(format!("fn_main#{}@inner", chunk.checksum)),
|
||||
sel: Some(format!("fn_main#{}@body", chunk.checksum)),
|
||||
crc: None,
|
||||
region: None,
|
||||
content: Some("\treturn\n".to_owned()),
|
||||
@@ -2197,7 +2208,7 @@ mod tests {
|
||||
|
||||
let result = apply_single_edit(&state, "test.py", EditOperation {
|
||||
op: ChunkEditOp::Replace,
|
||||
sel: Some(format!("fn_run#{}@inner", chunk.checksum)),
|
||||
sel: Some(format!("fn_run#{}@body", chunk.checksum)),
|
||||
crc: None,
|
||||
region: None,
|
||||
content: Some("\treturn 2\n".to_owned()),
|
||||
@@ -2243,7 +2254,7 @@ mod tests {
|
||||
let body_state = state_for(source, "go");
|
||||
let body_result = apply_single_edit(&body_state, "test.go", EditOperation {
|
||||
op: ChunkEditOp::Append,
|
||||
sel: Some("type_Server@inner".to_owned()),
|
||||
sel: Some("type_Server@body".to_owned()),
|
||||
crc: None,
|
||||
region: None,
|
||||
content: Some("\tPort int\n".to_owned()),
|
||||
@@ -2297,6 +2308,30 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_type_container_append_after_receiver_methods_preserves_sibling_spacing() {
|
||||
let source = "package main\n\ntype Server struct {}\n\nfunc (s *Server) Start() {}\nfunc (s \
|
||||
*Server) Stop() {}\n";
|
||||
let state = state_for(source, "go");
|
||||
|
||||
let result = apply_single_edit(&state, "test.go", EditOperation {
|
||||
op: ChunkEditOp::Append,
|
||||
sel: Some("type_Server".to_owned()),
|
||||
crc: None,
|
||||
region: None,
|
||||
content: Some("func (s *Server) Restart() {}".to_owned()),
|
||||
find: None,
|
||||
});
|
||||
|
||||
assert!(
|
||||
result
|
||||
.diff_after
|
||||
.contains("func (s *Server) Stop() {}\n\nfunc (s *Server) Restart() {}"),
|
||||
"{}",
|
||||
result.diff_after
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crc_mismatch_error_includes_fresh_chunk_context() {
|
||||
let source = "class Foo {\n bar() {\n return 1;\n }\n}\n";
|
||||
@@ -2437,7 +2472,7 @@ mod tests {
|
||||
|
||||
let result = apply_single_edit(&state, "test.ts", EditOperation {
|
||||
op: ChunkEditOp::Replace,
|
||||
sel: Some(format!("class_Server.fn_start#{}@inner", chunk.checksum)),
|
||||
sel: Some(format!("class_Server.fn_start#{}@body", chunk.checksum)),
|
||||
crc: None,
|
||||
region: None,
|
||||
content: Some("\treturn 42;\n".to_owned()),
|
||||
@@ -2462,7 +2497,7 @@ mod tests {
|
||||
|
||||
let result = apply_single_edit(&state, "test.ts", EditOperation {
|
||||
op: ChunkEditOp::Replace,
|
||||
sel: Some(format!("class_Server.fn_start#{}@inner", chunk.checksum)),
|
||||
sel: Some(format!("class_Server.fn_start#{}@body", chunk.checksum)),
|
||||
crc: None,
|
||||
region: None,
|
||||
content: Some("\treturn 42;\n".to_owned()),
|
||||
@@ -2488,7 +2523,7 @@ mod tests {
|
||||
|
||||
let result = apply_single_edit(&state, "test.ts", EditOperation {
|
||||
op: ChunkEditOp::Replace,
|
||||
sel: Some(format!("class_Server.fn_start#{}@inner", chunk.checksum)),
|
||||
sel: Some(format!("class_Server.fn_start#{}@body", chunk.checksum)),
|
||||
crc: None,
|
||||
region: None,
|
||||
content: Some("\t\tif (x) {\n\t\t\ty();\n\t\t}\n".to_owned()),
|
||||
@@ -2504,14 +2539,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn body_append_inserts_inside_class() {
|
||||
// Appending to @inner of a class should insert inside the body,
|
||||
// Appending to @body of a class should insert inside the body,
|
||||
// not after the closing brace.
|
||||
let source = "class Foo {\n bar() {\n return 1;\n }\n}\n";
|
||||
let state = state_for(source, "typescript");
|
||||
|
||||
let result = apply_single_edit(&state, "test.ts", EditOperation {
|
||||
op: ChunkEditOp::Append,
|
||||
sel: Some("class_Foo@inner".to_owned()),
|
||||
sel: Some("class_Foo@body".to_owned()),
|
||||
crc: None,
|
||||
region: None,
|
||||
content: Some("baz() {\n\treturn 2;\n}\n".to_owned()),
|
||||
@@ -2535,14 +2570,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn body_prepend_inserts_after_opening_brace() {
|
||||
// Prepending to @inner of an enum should insert after the opening brace,
|
||||
// Prepending to @body of an enum should insert after the opening brace,
|
||||
// not before doc comments.
|
||||
let source = "/** My enum. */\nenum Color {\n Red,\n Green,\n Blue,\n}\n";
|
||||
let state = state_for(source, "typescript");
|
||||
|
||||
let result = apply_single_edit(&state, "test.ts", EditOperation {
|
||||
op: ChunkEditOp::Prepend,
|
||||
sel: Some("enum_Color@inner".to_owned()),
|
||||
sel: Some("enum_Color@body".to_owned()),
|
||||
crc: None,
|
||||
region: None,
|
||||
content: Some("White,\n".to_owned()),
|
||||
@@ -2620,7 +2655,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn python_body_append_preserves_indentation() {
|
||||
let source = "class Server:\n def __init__(self):\n self.x = 1\n\n def start(self):\n pass\n";
|
||||
let source = "class Server:\n def __init__(self):\n self.x = 1\n\n def \
|
||||
start(self):\n pass\n";
|
||||
let state = state_for(source, "python");
|
||||
|
||||
let result = apply_single_edit(&state, "test.py", EditOperation {
|
||||
@@ -2635,9 +2671,166 @@ mod tests {
|
||||
// The appended method should be at 4-space indent (class member level),
|
||||
// with its body at 8-space indent.
|
||||
assert!(
|
||||
result.diff_after.contains(" def stop(self):\n pass"),
|
||||
result
|
||||
.diff_after
|
||||
.contains(" def stop(self):\n pass"),
|
||||
"appended method should have correct Python indentation: {}",
|
||||
result.diff_after
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_region_on_leaf_without_delimiters_falls_back_to_full_chunk() {
|
||||
let source = "enum LogLevel {\n Debug,\n Info,\n Warn,\n Fatal,\n}\n";
|
||||
let state = state_for(source, "rust");
|
||||
let chunk = state
|
||||
.inner()
|
||||
.chunk("enum_LogLevel.variant_Info")
|
||||
.expect("variant_Info should exist");
|
||||
assert!(chunk.prologue_end_byte.is_none(), "leaf variant should not have prologue_end_byte");
|
||||
|
||||
for region_name in ["body", "head", "tail"] {
|
||||
let sel = format!("enum_LogLevel.variant_Info#{}@{}", chunk.checksum, region_name);
|
||||
let result = apply_edits(&state, &EditParams {
|
||||
operations: vec![EditOperation {
|
||||
op: ChunkEditOp::Replace,
|
||||
sel: Some(sel),
|
||||
crc: None,
|
||||
region: None,
|
||||
content: Some("Error,".to_owned()),
|
||||
find: None,
|
||||
}],
|
||||
default_selector: None,
|
||||
default_crc: None,
|
||||
anchor_style: None,
|
||||
cwd: ".".to_owned(),
|
||||
file_path: "test.rs".to_owned(),
|
||||
})
|
||||
.expect("leaf region should fall back to full chunk");
|
||||
|
||||
assert!(
|
||||
result.diff_after.contains("Debug,\n Error,\n Warn,"),
|
||||
"@{region_name} should replace the full leaf chunk, got: {}",
|
||||
result.diff_after
|
||||
);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn rust_impl_method_head_replace_no_body_duplication() {
|
||||
let source = concat!(
|
||||
"struct Server {
|
||||
",
|
||||
" running: bool,
|
||||
",
|
||||
"}
|
||||
",
|
||||
"
|
||||
",
|
||||
"impl Server {
|
||||
",
|
||||
" /// Starts the server.
|
||||
",
|
||||
" pub fn start(&mut self) {
|
||||
",
|
||||
" self.running = true;
|
||||
",
|
||||
" println!(\"started\");
|
||||
",
|
||||
" }
|
||||
",
|
||||
"}
|
||||
",
|
||||
);
|
||||
let state = state_for(source, "rust");
|
||||
let chunk = state
|
||||
.inner()
|
||||
.chunk("impl_Server.fn_start")
|
||||
.expect("impl_Server.fn_start should exist");
|
||||
assert!(
|
||||
chunk.prologue_end_byte.is_some(),
|
||||
"fn_start should have prologue_end_byte, got: start_byte={}, end_byte={}, prologue_end_byte={:?}, epilogue_start_byte={:?}",
|
||||
chunk.start_byte, chunk.end_byte, chunk.prologue_end_byte, chunk.epilogue_start_byte,
|
||||
);
|
||||
|
||||
let result = apply_single_edit(&state, "test.rs", EditOperation {
|
||||
op: ChunkEditOp::Replace,
|
||||
sel: Some(format!("impl_Server.fn_start#{}@head", chunk.checksum)),
|
||||
crc: None,
|
||||
region: None,
|
||||
content: Some(" /// Initializes and starts the server.
|
||||
pub fn start(&mut self) {".to_owned()),
|
||||
find: None,
|
||||
});
|
||||
|
||||
let body_count = result.diff_after.matches("self.running = true;").count();
|
||||
assert_eq!(
|
||||
body_count, 1,
|
||||
"body should appear exactly once after @head replace, got {} occurrences in:
|
||||
{}",
|
||||
body_count, result.diff_after
|
||||
);
|
||||
assert!(
|
||||
result.diff_after.contains("/// Initializes and starts the server."),
|
||||
"new doc comment should be in output:
|
||||
{}", result.diff_after
|
||||
);
|
||||
assert!(
|
||||
!result.diff_after.contains("/// Starts the server."),
|
||||
"old doc comment should be removed:
|
||||
{}", result.diff_after
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typescript_class_method_head_replace_no_body_duplication() {
|
||||
let source = concat!(
|
||||
"class Server {
|
||||
",
|
||||
" /** Starts the server. */
|
||||
",
|
||||
" start() {
|
||||
",
|
||||
" this.running = true;
|
||||
",
|
||||
" console.log(\"started\");
|
||||
",
|
||||
" }
|
||||
",
|
||||
"}
|
||||
",
|
||||
);
|
||||
let state = state_for(source, "typescript");
|
||||
let chunk = state
|
||||
.inner()
|
||||
.chunk("class_Server.fn_start")
|
||||
.expect("class_Server.fn_start should exist");
|
||||
assert!(
|
||||
chunk.prologue_end_byte.is_some(),
|
||||
"fn_start should have prologue_end_byte"
|
||||
);
|
||||
|
||||
let result = apply_single_edit(&state, "test.ts", EditOperation {
|
||||
op: ChunkEditOp::Replace,
|
||||
sel: Some(format!("class_Server.fn_start#{}@head", chunk.checksum)),
|
||||
crc: None,
|
||||
region: None,
|
||||
content: Some(" /** Initializes the server. */
|
||||
start() {".to_owned()),
|
||||
find: None,
|
||||
});
|
||||
|
||||
let body_count = result.diff_after.matches("this.running = true;").count();
|
||||
assert_eq!(
|
||||
body_count, 1,
|
||||
"body should appear exactly once after @head replace, got {} occurrences in:
|
||||
{}",
|
||||
body_count, result.diff_after
|
||||
);
|
||||
assert!(
|
||||
result.diff_after.contains("/** Initializes the server. */"),
|
||||
"new doc comment should be in output:
|
||||
{}", result.diff_after
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -201,8 +201,7 @@ pub fn reindent_inserted_block(
|
||||
target_indent: &str,
|
||||
file_indent_step: Option<usize>,
|
||||
) -> String {
|
||||
let lines = content.split('\n').collect::<Vec<_>>();
|
||||
if lines.is_empty() {
|
||||
if content.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ mod ast_vue;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
|
||||
use ast_grep_core::tree_sitter::LanguageExt;
|
||||
use napi::{Error, Result};
|
||||
use napi_derive::napi;
|
||||
@@ -1727,7 +1728,7 @@ impl Config {
|
||||
.expect("state should parse");
|
||||
let result = state
|
||||
.render_read(ReadRenderParams {
|
||||
read_path: "sample.ts:fn_run@inner".to_string(),
|
||||
read_path: "sample.ts:fn_run@body".to_string(),
|
||||
display_path: "sample.ts".to_string(),
|
||||
language_tag: Some("ts".to_string()),
|
||||
omit_checksum: false,
|
||||
@@ -2204,4 +2205,65 @@ end
|
||||
tool.end_line,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn python_property_no_orphan_return_chunk() {
|
||||
// Build a function large enough to trigger recursion (> LEAF_THRESHOLD)
|
||||
// with multiple control-flow children that narrow scope enough to trigger
|
||||
// recursion, plus a `return` statement that must NOT become a standalone
|
||||
// chunk.
|
||||
let mut body = String::new();
|
||||
body.push_str("class Server:\n");
|
||||
body.push_str(" @property\n");
|
||||
body.push_str(" def address(self) -> str:\n");
|
||||
body.push_str(" if self._host:\n");
|
||||
for i in 0..20 {
|
||||
let _ = writeln!(body, " x{i} = {i}");
|
||||
}
|
||||
body.push_str(" if self._port:\n");
|
||||
for i in 0..20 {
|
||||
let _ = writeln!(body, " y{i} = {i}");
|
||||
}
|
||||
body.push_str(" return f\"{{self._host}}:{{self._port}}\"\n");
|
||||
let source = body;
|
||||
let tree = build_chunk_tree(source.as_str(), "python").expect("tree should build");
|
||||
|
||||
// Dump all chunk paths for debugging
|
||||
for chunk in &tree.chunks {
|
||||
eprintln!(
|
||||
"chunk: path={:?} kind={:?} leaf={} lines={}-{}",
|
||||
chunk.path, chunk.kind, chunk.leaf, chunk.start_line, chunk.end_line
|
||||
);
|
||||
}
|
||||
|
||||
// The function body should recurse (it's large enough) but the return
|
||||
// statement should NOT become a standalone addressable chunk.
|
||||
let fn_chunk = tree
|
||||
.chunks
|
||||
.iter()
|
||||
.find(|c| c.path == "class_Server.fn_address")
|
||||
.expect("fn_address should exist");
|
||||
|
||||
let orphan_ret = tree.chunks.iter().find(|c| c.path.contains("ret"));
|
||||
assert!(
|
||||
orphan_ret.is_none(),
|
||||
"return statement inside property method should not be a separate chunk, found: {:?}",
|
||||
orphan_ret.map(|c| (&c.path, &c.kind))
|
||||
);
|
||||
|
||||
// Verify the function actually recursed (has children)
|
||||
assert!(
|
||||
!fn_chunk.leaf,
|
||||
"fn_address should recurse into children for this test to be meaningful"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
|
||||
#[test]
|
||||
|
||||
|
||||
#[test]
|
||||
|
||||
}
|
||||
|
||||
@@ -14,18 +14,15 @@ pub struct ResolvedChunk<'a> {
|
||||
|
||||
fn parse_region_name(value: &str) -> Option<ChunkRegion> {
|
||||
match value.trim() {
|
||||
"head" | "prologue" => Some(ChunkRegion::Head),
|
||||
"inner" | "body" => Some(ChunkRegion::Inner),
|
||||
"tail" | "epilogue" => Some(ChunkRegion::Tail),
|
||||
"head" => Some(ChunkRegion::Head),
|
||||
"body" => Some(ChunkRegion::Body),
|
||||
"tail" => Some(ChunkRegion::Tail),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_known_region_name(value: &str) -> bool {
|
||||
matches!(
|
||||
value.trim(),
|
||||
"outer" | "container" | "head" | "prologue" | "inner" | "body" | "tail" | "epilogue"
|
||||
)
|
||||
matches!(value.trim(), "head" | "body" | "tail")
|
||||
}
|
||||
|
||||
/// Split a trailing `@region` suffix from a selector. Returns the selector
|
||||
@@ -69,7 +66,7 @@ pub fn split_selector_crc_and_region(
|
||||
(prefix, parsed_region)
|
||||
} else if let Some((_, suffix)) = raw.rsplit_once('@') {
|
||||
return Err(format!(
|
||||
"Unknown chunk region \"{}\". Valid regions: head, inner, tail (or omit for the full \
|
||||
"Unknown chunk region \"{}\". Valid regions: head, body, tail (or omit for the full \
|
||||
chunk).",
|
||||
suffix.trim()
|
||||
));
|
||||
@@ -201,7 +198,7 @@ pub fn chunk_region_range(chunk: &ChunkNode, region: ChunkRegion) -> (usize, usi
|
||||
let epi_start = chunk.epilogue_start_byte.map_or(end, |b| b as usize);
|
||||
match region {
|
||||
ChunkRegion::Head => (start, pro_end),
|
||||
ChunkRegion::Inner => (pro_end, epi_start),
|
||||
ChunkRegion::Body => (pro_end, epi_start),
|
||||
ChunkRegion::Tail => (epi_start, end),
|
||||
}
|
||||
}
|
||||
@@ -226,20 +223,12 @@ fn resolve_chunk_selector_impl<'a>(
|
||||
};
|
||||
|
||||
if is_line_number_selector(cleaned) {
|
||||
if let Some(line) = parse_line_number(cleaned) {
|
||||
if let Some(chunk_path) =
|
||||
crate::chunk::line_to_chunk_path(state.tree(), line)
|
||||
{
|
||||
warnings.push(format!(
|
||||
"Auto-resolved line target \"{cleaned}\" to chunk \"{chunk_path}\"."
|
||||
));
|
||||
return resolve_chunk_selector_impl(
|
||||
state,
|
||||
Some(&chunk_path),
|
||||
crc,
|
||||
warnings,
|
||||
);
|
||||
}
|
||||
if let Some(line) = parse_line_number(cleaned)
|
||||
&& let Some(chunk_path) = crate::chunk::line_to_chunk_path(state.tree(), line)
|
||||
{
|
||||
warnings
|
||||
.push(format!("Auto-resolved line target \"{cleaned}\" to chunk \"{chunk_path}\"."));
|
||||
return resolve_chunk_selector_impl(state, Some(&chunk_path), crc, warnings);
|
||||
}
|
||||
return Err(format!(
|
||||
"Line target \"{cleaned}\" does not fall inside any chunk. Use chunk paths like \
|
||||
|
||||
@@ -90,8 +90,8 @@ pub enum ChunkReadStatus {
|
||||
pub enum ChunkRegion {
|
||||
#[napi(value = "head")]
|
||||
Head,
|
||||
#[napi(value = "inner")]
|
||||
Inner,
|
||||
#[napi(value = "body")]
|
||||
Body,
|
||||
#[napi(value = "tail")]
|
||||
Tail,
|
||||
}
|
||||
@@ -100,7 +100,7 @@ impl ChunkRegion {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Head => "head",
|
||||
Self::Inner => "inner",
|
||||
Self::Body => "body",
|
||||
Self::Tail => "tail",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2205,7 +2205,7 @@ export class AuthCredentialStore {
|
||||
}
|
||||
|
||||
#initializeSchema(): void {
|
||||
this.#db.exec(`
|
||||
this.#db.run(`
|
||||
PRAGMA journal_mode=WAL;
|
||||
PRAGMA synchronous=NORMAL;
|
||||
PRAGMA busy_timeout=5000;
|
||||
@@ -2276,7 +2276,7 @@ export class AuthCredentialStore {
|
||||
}
|
||||
|
||||
#createAuthCredentialsTable(): void {
|
||||
this.#db.exec(`
|
||||
this.#db.run(`
|
||||
CREATE TABLE IF NOT EXISTS auth_credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
provider TEXT NOT NULL,
|
||||
@@ -2292,7 +2292,7 @@ export class AuthCredentialStore {
|
||||
}
|
||||
|
||||
#createAuthCredentialIndexes(): void {
|
||||
this.#db.exec(`
|
||||
this.#db.run(`
|
||||
CREATE INDEX IF NOT EXISTS idx_auth_provider ON auth_credentials(provider);
|
||||
CREATE INDEX IF NOT EXISTS idx_auth_provider_identity ON auth_credentials(provider, identity_key) WHERE identity_key IS NOT NULL;
|
||||
`);
|
||||
@@ -2315,8 +2315,8 @@ export class AuthCredentialStore {
|
||||
const v0Cols = this.#db.prepare("PRAGMA table_info(auth_credentials)").all() as Array<{ name?: string }>;
|
||||
const hasDisabled = v0Cols.some(col => col.name === "disabled");
|
||||
|
||||
this.#db.exec("ALTER TABLE auth_credentials RENAME TO auth_credentials_v0");
|
||||
this.#db.exec(`
|
||||
this.#db.run("ALTER TABLE auth_credentials RENAME TO auth_credentials_v0");
|
||||
this.#db.run(`
|
||||
CREATE TABLE auth_credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
provider TEXT NOT NULL,
|
||||
@@ -2327,7 +2327,7 @@ export class AuthCredentialStore {
|
||||
updated_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH})
|
||||
);
|
||||
`);
|
||||
this.#db.exec(`
|
||||
this.#db.run(`
|
||||
INSERT INTO auth_credentials (id, provider, credential_type, data, disabled_cause, created_at, updated_at)
|
||||
SELECT
|
||||
id,
|
||||
@@ -2339,16 +2339,16 @@ export class AuthCredentialStore {
|
||||
updated_at
|
||||
FROM auth_credentials_v0
|
||||
`);
|
||||
this.#db.exec("DROP TABLE auth_credentials_v0");
|
||||
this.#db.run("DROP TABLE auth_credentials_v0");
|
||||
});
|
||||
migrate();
|
||||
}
|
||||
|
||||
#migrateAuthSchemaV1OrV2ToV3(): void {
|
||||
const migrate = this.#db.transaction(() => {
|
||||
this.#db.exec("ALTER TABLE auth_credentials RENAME TO auth_credentials_legacy");
|
||||
this.#db.run("ALTER TABLE auth_credentials RENAME TO auth_credentials_legacy");
|
||||
this.#createAuthCredentialsTable();
|
||||
this.#db.exec(`
|
||||
this.#db.run(`
|
||||
INSERT INTO auth_credentials (id, provider, credential_type, data, disabled_cause, identity_key, created_at, updated_at)
|
||||
SELECT
|
||||
id,
|
||||
@@ -2361,16 +2361,16 @@ export class AuthCredentialStore {
|
||||
updated_at
|
||||
FROM auth_credentials_legacy
|
||||
`);
|
||||
this.#db.exec("DROP TABLE auth_credentials_legacy");
|
||||
this.#db.run("DROP TABLE auth_credentials_legacy");
|
||||
});
|
||||
migrate();
|
||||
}
|
||||
|
||||
#migrateAuthSchemaV3ToV4(): void {
|
||||
const migrate = this.#db.transaction(() => {
|
||||
this.#db.exec("ALTER TABLE auth_credentials RENAME TO auth_credentials_v3");
|
||||
this.#db.run("ALTER TABLE auth_credentials RENAME TO auth_credentials_v3");
|
||||
this.#createAuthCredentialsTable();
|
||||
this.#db.exec(`
|
||||
this.#db.run(`
|
||||
INSERT INTO auth_credentials (id, provider, credential_type, data, disabled_cause, identity_key, created_at, updated_at)
|
||||
SELECT
|
||||
id,
|
||||
@@ -2383,7 +2383,7 @@ export class AuthCredentialStore {
|
||||
updated_at
|
||||
FROM auth_credentials_v3
|
||||
`);
|
||||
this.#db.exec("DROP TABLE auth_credentials_v3");
|
||||
this.#db.run("DROP TABLE auth_credentials_v3");
|
||||
});
|
||||
migrate();
|
||||
}
|
||||
|
||||
@@ -556,12 +556,12 @@ describe("Tool argument coercion", () => {
|
||||
id: "call-heal-esc-1",
|
||||
name: "heal-esc-1",
|
||||
arguments: {
|
||||
edits: '[{"target": "fn_foo#ABCD@inner", "content": "return 1;\\n"}\\n]',
|
||||
edits: '[{"target": "fn_foo#ABCD@body", "content": "return 1;\\n"}\\n]',
|
||||
},
|
||||
};
|
||||
|
||||
const result = validateToolArguments(tool, toolCall);
|
||||
expect(result.edits).toEqual([{ target: "fn_foo#ABCD@inner", content: "return 1;\n" }]);
|
||||
expect(result.edits).toEqual([{ target: "fn_foo#ABCD@body", content: "return 1;\n" }]);
|
||||
});
|
||||
|
||||
it("heals stringified array with trailing junk after balanced container", () => {
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
|
||||
### Added
|
||||
|
||||
- Auto QA tool (`report_tool_issue`) for automated tracking of unexpected tool behavior; enabled via `PI_AUTO_QA=1` environment variable or `pi.autoqa` setting
|
||||
- `pi.autoqa` setting to enable automated tool issue reporting for all agents
|
||||
- Auto QA tool (`report_tool_issue`) for automated tracking of unexpected tool behavior; enabled via `PI_AUTO_QA=1` environment variable or `dev.autoqa` setting
|
||||
- `dev.autoqa` setting to enable automated tool issue reporting for all agents
|
||||
- System prompt guidance when `report_tool_issue` tool is available, encouraging agents to report tool behavior discrepancies
|
||||
- LSP server discovery at startup via `discoverStartupLspServers()` to detect configured language servers without blocking initialization
|
||||
- LSP startup event channel (`lsp:startup`) for asynchronous server warmup notifications with completion or failure status
|
||||
@@ -72,6 +72,18 @@
|
||||
|
||||
### Changed
|
||||
|
||||
- Chunk edit tool schema: renamed `target` parameter to `sel` for consistency with read tool terminology
|
||||
- Chunk edit tool: `op` parameter is now required (previously optional with `replace` default)
|
||||
- Chunk edit documentation: updated all region references from `@inner` to `@body` for clearer semantics
|
||||
- Chunk edit documentation: expanded with comprehensive real-world examples showing full read output, operation effects, and indentation rules
|
||||
- Chunk edit documentation: simplified indentation guidance to write content at indent-level 0 with automatic re-indentation by the tool
|
||||
- Chunk edit documentation: clarified that `@region` only works on container chunks, not leaf chunks
|
||||
- Chunk edit documentation: emphasized that CRCs change after every edit and must be refreshed from latest responses
|
||||
- Read chunk tool documentation: updated selector examples to use `@body` instead of `@inner`
|
||||
- Chunk edit region terminology updated: `@inner` renamed to `@body` for clearer semantics in container chunks
|
||||
- Chunk edit documentation restructured with comprehensive examples showing full read output, operation effects, and indentation rules
|
||||
- Chunk edit indentation guidance simplified: content should be written at indent-level 0 and the tool automatically applies correct base indentation
|
||||
- Chunk edit examples expanded with realistic TypeScript code samples demonstrating replace, insert, prepend, append, and delete operations
|
||||
- Python tool description now dynamically reflects prelude documentation availability instead of static text
|
||||
- Python tool now automatically warms the environment on first execution if prelude helpers are unavailable, ensuring documentation is loaded before use
|
||||
- Tool creation now auto-injects `report_tool_issue` when auto QA is enabled, regardless of requested tool list
|
||||
@@ -102,6 +114,8 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- Chunk edit parameter validation: corrected detection of chunk edit operations to check for `sel` field instead of `target`
|
||||
- Chunk edit streaming previews: updated to reference `sel` parameter instead of `target`
|
||||
- Python prelude introspection now respects execution timeout and signal options, preventing hangs during environment warmup
|
||||
- Welcome banner LSP server status now updates in real-time when background startup warmup completes, eliminating stale connecting status displays
|
||||
- Welcome banner LSP startup rows now re-render when background warmup finishes, use the pending status symbol while servers are still connecting, and no longer add a redundant `LSP ready` status line on successful startup
|
||||
|
||||
@@ -120,10 +120,6 @@ function fileLanguageTag(filePath: string, language?: string): string | undefine
|
||||
return ext.length > 0 ? ext : undefined;
|
||||
}
|
||||
|
||||
function resolveChunkTarget(target: string): ParsedChunkTarget {
|
||||
return { selector: target };
|
||||
}
|
||||
|
||||
async function resolveChunkSourceContext(session: ToolSession, path: string): Promise<ChunkSourceContext> {
|
||||
const resolvedPath = resolvePlanPath(session, path);
|
||||
const sourceFile = Bun.file(resolvedPath);
|
||||
@@ -314,11 +310,11 @@ export function missingChunkReadTarget(selector: string): ChunkReadTarget {
|
||||
const CHUNK_OP_VALUES = ["replace", "after", "before", "prepend", "append"] as const;
|
||||
|
||||
export const chunkToolEditSchema = Type.Object({
|
||||
target: Type.String({
|
||||
op: StringEnum(CHUNK_OP_VALUES),
|
||||
sel: Type.String({
|
||||
description:
|
||||
"Chunk selector. Format: 'path@region' for insertions, 'path#CRC@region' for replace. Omit @region to target the full chunk. Valid regions: head, inner, tail.",
|
||||
"Chunk selector. Format: 'path@region' for insertions, 'path#CRC@region' for replace. Omit @region to target the full chunk. Valid regions: head, body, tail.",
|
||||
}),
|
||||
op: Type.Optional(StringEnum(CHUNK_OP_VALUES)),
|
||||
content: Type.String({
|
||||
description: "New content. Use \\t for indentation. Do NOT include the chunk's base padding.",
|
||||
}),
|
||||
@@ -355,31 +351,12 @@ export function isChunkParams(params: unknown): params is ChunkParams {
|
||||
params.edits.length > 0 &&
|
||||
typeof params.edits[0] === "object" &&
|
||||
params.edits[0] !== null &&
|
||||
"target" in params.edits[0]
|
||||
"sel" in params.edits[0]
|
||||
);
|
||||
}
|
||||
|
||||
function parseChunkTarget(target: string): ParsedChunkTarget {
|
||||
return resolveChunkTarget(target);
|
||||
}
|
||||
|
||||
function normalizeChunkEditOperation(edit: ChunkToolEdit): ChunkEditOperation {
|
||||
const { selector } = parseChunkTarget(edit.target);
|
||||
const op = edit.op ?? "replace";
|
||||
const content = edit.content;
|
||||
return {
|
||||
op,
|
||||
sel: selector,
|
||||
content,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeChunkEditOperations(edits: ChunkToolEdit[]): ChunkEditOperation[] {
|
||||
const operations: ChunkEditOperation[] = [];
|
||||
for (const edit of edits) {
|
||||
operations.push(normalizeChunkEditOperation(edit));
|
||||
}
|
||||
return operations;
|
||||
return edits as ChunkEditOperation[];
|
||||
}
|
||||
|
||||
async function writeChunkResult(params: {
|
||||
|
||||
@@ -182,7 +182,7 @@ function formatStreamingDiff(diff: string, rawPath: string, uiTheme: Theme, labe
|
||||
}
|
||||
|
||||
function isChunkStreamingEdit(edit: Partial<HashlineToolEdit | ChunkToolEdit>): edit is Partial<ChunkToolEdit> {
|
||||
return "target" in edit;
|
||||
return "sel" in edit;
|
||||
}
|
||||
|
||||
function getStreamingEditContent(content: unknown): string {
|
||||
@@ -226,7 +226,7 @@ function formatChunkStreamingEdit(edit: Partial<ChunkToolEdit>): FormattedStream
|
||||
}
|
||||
|
||||
const contentLines = getStreamingEditContent(edit.content);
|
||||
const target = edit.target ?? "?";
|
||||
const target = edit.sel ?? "?";
|
||||
const op = edit.op ?? "replace";
|
||||
|
||||
switch (op) {
|
||||
|
||||
@@ -1,66 +1,206 @@
|
||||
Edits files via syntax-aware chunks. Run `read(path="file.ts")` first. The edit target is a chunk selector, optionally qualified with a region.
|
||||
Edits files via syntax-aware chunks. Run `read(path="file.ts")` first. The edit selector is a chunk path, optionally qualified with a region.
|
||||
|
||||
<rules>
|
||||
- **MUST** `read` first. Never invent chunk paths or CRCs. Copy them from the latest `read` output (opening lines start with `+++`) or edit response.
|
||||
- `target` format:
|
||||
- `sel` format:
|
||||
- insertions: `chunk` or `chunk@region`
|
||||
- replacements: `chunk#CRC` or `chunk#CRC@region`
|
||||
- Without a `@region` it defaults to the entire chunk. Valid regions: `head`, `inner`, `tail`.
|
||||
- Without a `@region` it defaults to the entire chunk. Valid regions: `head`, `body`, `tail`.
|
||||
- If the exact chunk path is unclear, run `read(path="file", sel="?")` and copy a selector from that listing.
|
||||
- Use `\t` for indentation in `content`. Do **NOT** include the chunk's base indentation. Only indent relative to the chunk's opening level.
|
||||
- Use `\t` for indentation in `content`. Write content at indent-level 0 — the tool re-indents it to match the chunk's position in the file. For example, to replace `@body` of a method, write the body starting at column 0:
|
||||
```
|
||||
content: "if (x) {\n\treturn true;\n}"
|
||||
```
|
||||
The tool adds the correct base indent automatically. Never manually pad with the chunk's own indentation.
|
||||
- `@region` only works on container chunks (classes, functions, impl blocks, sections). Do **not** use `@head`/`@body`/`@tail` on leaf chunks (enum variants, fields, single statements) — use the whole chunk instead.
|
||||
- `replace` requires the current CRC. Insertions do not.
|
||||
- Successful edits return refreshed chunk anchors. Use the latest selectors/CRCs for follow-up edits.
|
||||
- **CRCs change after every edit.** Always use the selectors/CRCs from the most recent `read` or edit response. Never reuse a CRC from a previous edit.
|
||||
</rules>
|
||||
|
||||
<regions>
|
||||
- `@head` — attached trivia, header/signature, and opening delimiter.
|
||||
- `@inner` — the editable interior only.
|
||||
- `@body` — the editable interior only.
|
||||
- `@tail` — the closing delimiter or trailing owned trailer.
|
||||
|
||||
For leaf chunks (fields, variants, single-line items), `@inner` falls back to the full chunk.
|
||||
For leaf chunks (fields, variants, single-line items), `@body` falls back to the full chunk.
|
||||
|
||||
**Important:** `append`/`prepend` without a `@region` inserts *outside* the chunk. To add children *inside* a class, struct, enum, or function body, use `@inner`:
|
||||
- `class_Foo@inner` + `append` → adds inside the class before `}`
|
||||
- `class_Foo@inner` + `prepend` → adds inside the class after `{`
|
||||
**Important:** `append`/`prepend` without a `@region` inserts _outside_ the chunk. To add children _inside_ a class, struct, enum, or function body, use `@body`:
|
||||
- `class_Foo@body` + `append` → adds inside the class before `}`
|
||||
- `class_Foo@body` + `prepend` → adds inside the class after `{`
|
||||
- `class_Foo` + `append` → adds after the entire class (after `}`)
|
||||
</regions>
|
||||
</regions>
|
||||
|
||||
<ops>
|
||||
|op|target form|effect|
|
||||
|op|sel|effect|
|
||||
|---|---|---|
|
||||
|`replace`|`chunk#CRC` or `chunk#CRC@region`|rewrite the addressed region|
|
||||
|`before`|`chunk` or `chunk@region`|insert before the region span|
|
||||
|`after`|`chunk` or `chunk@region`|insert after the region span|
|
||||
|`prepend`|`chunk` or `chunk@region`|insert at the start inside the region|
|
||||
|`append`|`chunk` or `chunk@region`|insert at the end inside the region|
|
||||
|`replace`|`chunk#CRC(@region)?`|rewrite the addressed region|
|
||||
|`before`|`chunk(@region)?`|insert before the region span|
|
||||
|`after`|`chunk(@region)?`|insert after the region span|
|
||||
|`prepend`|`chunk(@region)?`|insert at the start inside the region|
|
||||
|`append`|`chunk(@region)?`|insert at the end inside the region|
|
||||
</ops>
|
||||
|
||||
<examples>
|
||||
- Replace only a function body without touching the closing brace:
|
||||
- `target: "fn_main#ABCD@inner"`
|
||||
- `op: "replace"`
|
||||
- `content: "\treturn compute();\n"`
|
||||
- Insert a new top-level function after another top-level function:
|
||||
- `target: "fn_prev"`
|
||||
- `op: "after"`
|
||||
- `content: "function next(): void {\n\twork();\n}\n"`
|
||||
- Add a struct field:
|
||||
- `target: "type_Server@inner"`
|
||||
- `op: "append"`
|
||||
- `content: "\tport int\n"`
|
||||
- Add a Go receiver method owned by the type, not a struct field:
|
||||
- `target: "type_Server"`
|
||||
- `op: "append"`
|
||||
- `content: "func (s *Server) Stop() error {\n\treturn nil\n}\n"`
|
||||
- Edit a doc comment or header block:
|
||||
- `target: "fn_foo#WXYZ@head"`
|
||||
- `op: "replace"`
|
||||
- `content: "/**\n * Updated docs.\n */\nfunction foo() {"`
|
||||
Given this `read` output for `example.ts`:
|
||||
~~~
|
||||
| example.ts·34L·ts·#QBMH
|
||||
|
|
||||
| +++interface_Config#BWTR
|
||||
1| interface Config {
|
||||
| +++interface_Config.field_host#TTMN
|
||||
2| host: string;
|
||||
| +++interface_Config.field_port#QSMH
|
||||
3| port: number;
|
||||
| +++interface_Config.field_debug#JPRR
|
||||
4| debug: boolean;
|
||||
5| }
|
||||
|
|
||||
| +++class_Counter#HZHY
|
||||
7| class Counter {
|
||||
| +++class_Counter.field_value#QJBY
|
||||
8| value: number = 0;
|
||||
9|
|
||||
| +++class_Counter.fn_increment#NQWY
|
||||
10| increment(): void {
|
||||
11| this.value += 1;
|
||||
12| }
|
||||
13|
|
||||
| +++class_Counter.fn_decrement#PMBP
|
||||
14| decrement(): void {
|
||||
15| this.value -= 1;
|
||||
16| }
|
||||
17|
|
||||
| +++class_Counter.fn_toString#ZQZP
|
||||
18| toString(): string {
|
||||
19| return `Counter(${this.value})`;
|
||||
20| }
|
||||
21| }
|
||||
|
|
||||
| +++enum_Status#HYQJ
|
||||
23| enum Status {
|
||||
| +++enum_Status.variant_Active#PQNS
|
||||
24| Active = "ACTIVE",
|
||||
| +++enum_Status.variant_Paused#HHNM
|
||||
25| Paused = "PAUSED",
|
||||
| +++enum_Status.variant_Stopped#NHTY
|
||||
26| Stopped = "STOPPED",
|
||||
27| }
|
||||
|
|
||||
| +++fn_createCounter#PQQY
|
||||
29| function createCounter(initial: number): Counter {
|
||||
30| const counter = new Counter();
|
||||
31| counter.value = initial;
|
||||
32| return counter;
|
||||
33| }
|
||||
~~~
|
||||
|
||||
**Replace a whole chunk** (rename a function):
|
||||
~~~json
|
||||
{ "sel": "fn_createCounter#PQQY", "op": "replace", "content": "function makeCounter(start: number): Counter {\n\tconst c = new Counter();\n\tc.value = start;\n\treturn c;\n}\n" }
|
||||
~~~
|
||||
Result — the entire chunk is rewritten:
|
||||
~~~
|
||||
function makeCounter(start: number): Counter {
|
||||
const c = new Counter();
|
||||
c.value = start;
|
||||
return c;
|
||||
}
|
||||
~~~
|
||||
|
||||
**Replace a method body** (`@body`):
|
||||
~~~json
|
||||
{ "sel": "class_Counter.fn_increment#NQWY@body", "op": "replace", "content": "this.value += 1;\nconsole.log('incremented to', this.value);\n" }
|
||||
~~~
|
||||
Result — only the body changes, signature and braces are kept:
|
||||
~~~
|
||||
increment(): void {
|
||||
this.value += 1;
|
||||
console.log('incremented to', this.value);
|
||||
}
|
||||
~~~
|
||||
|
||||
**Replace a function header** (`@head` — signature and doc comment):
|
||||
~~~json
|
||||
{ "sel": "fn_createCounter#PQQY@head", "op": "replace", "content": "/** Creates a counter with the given start value. */\nfunction createCounter(initial: number, label?: string): Counter {\n" }
|
||||
~~~
|
||||
Result — adds a doc comment and updates the signature, body untouched:
|
||||
~~~
|
||||
/** Creates a counter with the given start value. */
|
||||
function createCounter(initial: number, label?: string): Counter {
|
||||
const counter = new Counter();
|
||||
counter.value = initial;
|
||||
return counter;
|
||||
}
|
||||
~~~
|
||||
|
||||
**Insert before a chunk** (`before`):
|
||||
~~~json
|
||||
{ "sel": "fn_createCounter", "op": "before", "content": "/** Factory function below. */\n" }
|
||||
~~~
|
||||
Result — a comment is inserted before the function:
|
||||
~~~
|
||||
/** Factory function below. */
|
||||
|
||||
function createCounter(initial: number): Counter {
|
||||
~~~
|
||||
|
||||
**Insert after a chunk** (`after`):
|
||||
~~~json
|
||||
{ "sel": "enum_Status", "op": "after", "content": "\nfunction isActive(s: Status): boolean {\n\treturn s === Status.Active;\n}\n" }
|
||||
~~~
|
||||
Result — a new function appears after the enum:
|
||||
~~~
|
||||
enum Status {
|
||||
Active = "ACTIVE",
|
||||
Paused = "PAUSED",
|
||||
Stopped = "STOPPED",
|
||||
}
|
||||
|
||||
function isActive(s: Status): boolean {
|
||||
return s === Status.Active;
|
||||
}
|
||||
|
||||
function createCounter(initial: number): Counter {
|
||||
~~~
|
||||
|
||||
**Prepend inside a container** (`@body` + `prepend`):
|
||||
~~~json
|
||||
{ "sel": "class_Counter@body", "op": "prepend", "content": "label: string = 'default';\n\n" }
|
||||
~~~
|
||||
Result — a new field is added at the top of the class body, before existing members:
|
||||
~~~
|
||||
class Counter {
|
||||
label: string = 'default';
|
||||
|
||||
value: number = 0;
|
||||
~~~
|
||||
|
||||
**Append inside a container** (`@body` + `append`):
|
||||
~~~json
|
||||
{ "sel": "class_Counter@body", "op": "append", "content": "\nreset(): void {\n\tthis.value = 0;\n}\n" }
|
||||
~~~
|
||||
Result — a new method is added at the end of the class body, before the closing `}`:
|
||||
~~~
|
||||
toString(): string {
|
||||
return `Counter(${this.value})`;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.value = 0;
|
||||
}
|
||||
}
|
||||
~~~
|
||||
|
||||
**Delete a chunk** (`replace` with empty content):
|
||||
~~~json
|
||||
{ "sel": "class_Counter.fn_toString#ZQZP", "op": "replace", "content": "" }
|
||||
~~~
|
||||
Result — the method is removed from the class.
|
||||
- Indentation rules (important):
|
||||
- Use `\t` for each indent level. The tool converts tabs to the file's actual style (2-space, 4-space, etc.).
|
||||
- Do NOT include the chunk's base indentation — only indent relative to the region's opening level.
|
||||
- For `@inner` of a function: `\t` = one level inside the body. Write `"\treturn x;\n"`, not `"\t\t\treturn x;\n"`.
|
||||
- For `@head`: `\t` = one level at the chunk's own depth. A class member's head uses `"\t/** doc */\n\tstart(): void {"`.
|
||||
- For `@body` of a function: write at column 0, e.g. `"return x;\n"`. The tool adds the correct base indent.
|
||||
- For `@head`: write at the chunk's own depth. A class member's head uses `"/** doc */\nstart(): void {"`.
|
||||
- For a top-level item: start at zero indent. Write `"function foo() {\n\treturn 1;\n}\n"`.
|
||||
- The tool strips common leading indentation from your content as a safety net, so accidental over-indentation is corrected.
|
||||
</examples>
|
||||
|
||||
@@ -2,7 +2,7 @@ Reads files using syntax-aware chunks.
|
||||
|
||||
<instruction>
|
||||
- `path` — file path or URL; may include `:selector` suffix
|
||||
- `sel` — optional selector: `class_Foo`, `class_Foo.fn_bar#ABCD@inner`, `?`, `L50`, `L50-L120`, or `raw`
|
||||
- `sel` — optional selector: `class_Foo`, `class_Foo.fn_bar#ABCD@body`, `?`, `L50`, `L50-L120`, or `raw`
|
||||
- `timeout` — seconds, for URLs only
|
||||
|
||||
Each opening anchor `+++ full.chunk.path#CCCC` in the default output identifies a chunk (with matching closers like `--- /full.chunk.path#CCCC`). Use `full.chunk.path#CCCC` as-is to read truncated chunks.
|
||||
|
||||
@@ -73,7 +73,7 @@ export class AgentStorage {
|
||||
* AuthCredentialStore handles auth_credentials and cache tables.
|
||||
*/
|
||||
#initializeSchema(): void {
|
||||
this.#db.exec(`
|
||||
this.#db.run(`
|
||||
PRAGMA journal_mode=WAL;
|
||||
PRAGMA synchronous=NORMAL;
|
||||
PRAGMA busy_timeout=5000;
|
||||
@@ -92,7 +92,7 @@ CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY);
|
||||
const hasValue = settingsInfo.some(column => column.name === "value");
|
||||
|
||||
if (!hasSettingsTable) {
|
||||
this.#db.exec(`
|
||||
this.#db.run(`
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
@@ -117,8 +117,8 @@ CREATE TABLE settings (
|
||||
}
|
||||
|
||||
const migrate = this.#db.transaction((settings: Record<string, unknown> | null) => {
|
||||
this.#db.exec("DROP TABLE settings");
|
||||
this.#db.exec(`
|
||||
this.#db.run("DROP TABLE settings");
|
||||
this.#db.run(`
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
@@ -169,34 +169,34 @@ CREATE TABLE settings (
|
||||
|
||||
#migrateSchemaV4ToV5(): void {
|
||||
const migrate = this.#db.transaction(() => {
|
||||
this.#db.exec("ALTER TABLE settings RENAME TO settings_legacy");
|
||||
this.#db.exec(`
|
||||
this.#db.run("ALTER TABLE settings RENAME TO settings_legacy");
|
||||
this.#db.run(`
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH})
|
||||
);
|
||||
`);
|
||||
this.#db.exec(`
|
||||
this.#db.run(`
|
||||
INSERT INTO settings (key, value, updated_at)
|
||||
SELECT key, value, updated_at
|
||||
FROM settings_legacy
|
||||
`);
|
||||
this.#db.exec("DROP TABLE settings_legacy");
|
||||
this.#db.run("DROP TABLE settings_legacy");
|
||||
|
||||
this.#db.exec("ALTER TABLE model_usage RENAME TO model_usage_legacy");
|
||||
this.#db.exec(`
|
||||
this.#db.run("ALTER TABLE model_usage RENAME TO model_usage_legacy");
|
||||
this.#db.run(`
|
||||
CREATE TABLE model_usage (
|
||||
model_key TEXT PRIMARY KEY,
|
||||
last_used_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH})
|
||||
);
|
||||
`);
|
||||
this.#db.exec(`
|
||||
this.#db.run(`
|
||||
INSERT INTO model_usage (model_key, last_used_at)
|
||||
SELECT model_key, last_used_at
|
||||
FROM model_usage_legacy
|
||||
`);
|
||||
this.#db.exec("DROP TABLE model_usage_legacy");
|
||||
this.#db.run("DROP TABLE model_usage_legacy");
|
||||
});
|
||||
migrate();
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export class HistoryStorage {
|
||||
|
||||
const hasFts = this.#db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='history_fts'").get();
|
||||
|
||||
this.#db.exec(`
|
||||
this.#db.run(`
|
||||
PRAGMA journal_mode=WAL;
|
||||
PRAGMA synchronous=NORMAL;
|
||||
PRAGMA busy_timeout=5000;
|
||||
@@ -150,11 +150,11 @@ CREATE TRIGGER IF NOT EXISTS history_ai AFTER INSERT ON history BEGIN
|
||||
|
||||
#migrateHistorySchema(): void {
|
||||
const migrate = this.#db.transaction(() => {
|
||||
this.#db.exec("ALTER TABLE history RENAME TO history_legacy");
|
||||
this.#db.exec("DROP INDEX IF EXISTS idx_history_created_at");
|
||||
this.#db.exec("DROP TRIGGER IF EXISTS history_ai");
|
||||
this.#db.exec("DROP TABLE IF EXISTS history_fts");
|
||||
this.#db.exec(`
|
||||
this.#db.run("ALTER TABLE history RENAME TO history_legacy");
|
||||
this.#db.run("DROP INDEX IF EXISTS idx_history_created_at");
|
||||
this.#db.run("DROP TRIGGER IF EXISTS history_ai");
|
||||
this.#db.run("DROP TABLE IF EXISTS history_fts");
|
||||
this.#db.run(`
|
||||
CREATE TABLE history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
prompt TEXT NOT NULL,
|
||||
|
||||
@@ -97,11 +97,7 @@ function getChecksum(source: string, chunkPath: string, language = "typescript")
|
||||
return chunk.checksum;
|
||||
}
|
||||
|
||||
function targetWithChecksum(
|
||||
chunkPath: string,
|
||||
checksum: string,
|
||||
region?: "head" | "inner" | "tail",
|
||||
): string {
|
||||
function targetWithChecksum(chunkPath: string, checksum: string, region?: "head" | "body" | "tail"): string {
|
||||
return `${chunkPath}#${checksum}${region ? `@${region}` : ""}`;
|
||||
}
|
||||
|
||||
@@ -109,13 +105,13 @@ function currentTarget(
|
||||
source: string,
|
||||
chunkPath: string,
|
||||
language = "typescript",
|
||||
region?: "head" | "inner" | "tail",
|
||||
region?: "head" | "body" | "tail",
|
||||
): string {
|
||||
return targetWithChecksum(chunkPath, getChecksum(source, chunkPath, language), region);
|
||||
}
|
||||
|
||||
function bodyTarget(chunkPath: string): string {
|
||||
return `${chunkPath}@inner`;
|
||||
return `${chunkPath}@body`;
|
||||
}
|
||||
|
||||
describe("applyChunkEdits", () => {
|
||||
@@ -1084,9 +1080,9 @@ describe("prepend warnings", () => {
|
||||
language: "go",
|
||||
cwd: "/",
|
||||
filePath: "main.go",
|
||||
operations: [{ op: "prepend", sel: "@inner", content: "// AUTO-GENERATED\n" }],
|
||||
operations: [{ op: "prepend", sel: "@body", content: "// AUTO-GENERATED\n" }],
|
||||
});
|
||||
expect(result.warnings.some(w => w.includes("Comment-only @inner.prepend"))).toBe(true);
|
||||
expect(result.warnings.some(w => w.includes("Comment-only @body.prepend"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1147,9 +1143,9 @@ describe("prepend preamble guard", () => {
|
||||
language: "javascript",
|
||||
cwd: "/",
|
||||
filePath: "index.js",
|
||||
operations: [{ op: "prepend", sel: "@inner", content: "// AUTO-GENERATED\n" }],
|
||||
operations: [{ op: "prepend", sel: "@body", content: "// AUTO-GENERATED\n" }],
|
||||
}),
|
||||
).toThrow(/Comment-only @inner.prepend on root is not allowed when the file has a preamble/);
|
||||
).toThrow(/Comment-only @body.prepend on root is not allowed when the file has a preamble/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -218,7 +218,8 @@ describe("chunk mode tools", () => {
|
||||
path: filePath,
|
||||
edits: [
|
||||
{
|
||||
target: `class_Server.fn_handleError#${checksum}`,
|
||||
sel: `class_Server.fn_handleError#${checksum}`,
|
||||
op: "replace",
|
||||
content: ` private handleError(err: Error): string {
|
||||
return \`normalized:\${err.message}\`;
|
||||
}
|
||||
@@ -262,11 +263,13 @@ describe("chunk mode tools", () => {
|
||||
path: filePath,
|
||||
edits: [
|
||||
{
|
||||
target: `${chunkPath}#${checksum}`,
|
||||
sel: `${chunkPath}#${checksum}`,
|
||||
op: "replace",
|
||||
content: buildHandleErrorMethod({ returnLine: " return err.message.toUpperCase() + total;" }),
|
||||
},
|
||||
{
|
||||
target: `${chunkPath}#${checksum2}`,
|
||||
sel: `${chunkPath}#${checksum2}`,
|
||||
op: "replace",
|
||||
content: buildHandleErrorMethod({
|
||||
totalInitLine: " let total = 1;",
|
||||
returnLine: " return err.message.toUpperCase() + total;",
|
||||
@@ -310,7 +313,7 @@ describe("chunk mode tools", () => {
|
||||
path: filePath,
|
||||
edits: [
|
||||
{
|
||||
target: "class_Server@inner",
|
||||
sel: "class_Server@body",
|
||||
op: "append",
|
||||
content: 'status(): string {\n return "ok";\n}\n',
|
||||
},
|
||||
@@ -332,7 +335,7 @@ describe("chunk mode tools", () => {
|
||||
|
||||
await editTool.execute("chunk-edit-empty-replace-delete", {
|
||||
path: filePath,
|
||||
edits: [{ target: `fn_main#${checksum}`, content: "" }],
|
||||
edits: [{ sel: `fn_main#${checksum}`, op: "replace", content: "" }],
|
||||
});
|
||||
|
||||
const updatedSource = await Bun.file(filePath).text();
|
||||
@@ -366,8 +369,8 @@ describe("chunk mode tools", () => {
|
||||
editTool.execute("chunk-edit-batch-rollback", {
|
||||
path: filePath,
|
||||
edits: [
|
||||
{ target: "class_Server", op: "append", content: ' status(): string {\n return "ok";\n }' },
|
||||
{ target: "class_Server.fn_handleError#ZZZZ", content: "" },
|
||||
{ sel: "class_Server", op: "append", content: ' status(): string {\n return "ok";\n }' },
|
||||
{ sel: "class_Server.fn_handleError#ZZZZ", op: "replace", content: "" },
|
||||
],
|
||||
}),
|
||||
).rejects.toThrow(/No changes were saved/);
|
||||
@@ -388,7 +391,8 @@ describe("chunk mode tools", () => {
|
||||
path: filePath,
|
||||
edits: [
|
||||
{
|
||||
target: `class_Server.fn_handleError#${checksum}`,
|
||||
sel: `class_Server.fn_handleError#${checksum}`,
|
||||
op: "replace",
|
||||
content: " private handleError(err: Error): string {\n if (err) {\n",
|
||||
},
|
||||
],
|
||||
@@ -410,11 +414,13 @@ describe("chunk mode tools", () => {
|
||||
path: filePath,
|
||||
edits: [
|
||||
{
|
||||
target: `class_Server.fn_handleError#${checksum}`,
|
||||
sel: `class_Server.fn_handleError#${checksum}`,
|
||||
op: "replace",
|
||||
content: " private handleError(err: Error): string {\n return err.message;\n }",
|
||||
},
|
||||
{
|
||||
target: `class_Server.fn_handleError#${checksum}`,
|
||||
sel: `class_Server.fn_handleError#${checksum}`,
|
||||
op: "replace",
|
||||
content: " private handleError(err: Error): string {\n return err.message.toUpperCase();\n }",
|
||||
},
|
||||
],
|
||||
@@ -436,7 +442,8 @@ describe("chunk mode tools", () => {
|
||||
path: filePath,
|
||||
edits: [
|
||||
{
|
||||
target: "class_Server.fn_handleError",
|
||||
sel: "class_Server.fn_handleError",
|
||||
op: "replace",
|
||||
content: buildHandleErrorMethod({ totalInitLine: " let total = 1;" }),
|
||||
},
|
||||
],
|
||||
@@ -456,7 +463,13 @@ describe("chunk mode tools", () => {
|
||||
// Use bare "main" instead of "fn_main"
|
||||
const _result = await editTool.execute("chunk-edit-prefix-resolve", {
|
||||
path: filePath,
|
||||
edits: [{ target: `main#${checksum}`, content: 'function main(): void {\n console.log("started");\n}\n' }],
|
||||
edits: [
|
||||
{
|
||||
sel: `main#${checksum}`,
|
||||
op: "replace",
|
||||
content: 'function main(): void {\n console.log("started");\n}\n',
|
||||
},
|
||||
],
|
||||
});
|
||||
const updatedSource = await Bun.file(filePath).text();
|
||||
expect(updatedSource).toContain('console.log("started")');
|
||||
@@ -502,7 +515,8 @@ describe("chunk mode tools", () => {
|
||||
path: filePath,
|
||||
edits: [
|
||||
{
|
||||
target: `${building.path}#${building.checksum}`,
|
||||
sel: `${building.path}#${building.checksum}`,
|
||||
op: "replace",
|
||||
content: "## Building and Testing\n\nUse `just verify` instead. It wraps cmake and ctest.\n",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Changed `ChunkRegion.Inner` enum value to `ChunkRegion.Body` to align with region semantics
|
||||
- Changed `ChunkRegion` enum values from `Container`, `Prologue`, `Body`, `Epilogue` to `Head`, `Inner`, `Tail` with updated semantics for region targeting
|
||||
- Replaced `ChunkEditOp` enum values — `AppendChild`, `PrependChild`, `AppendSibling`, `PrependSibling`, and `ReplaceBody` are now `Before`, `After`, `Prepend`, and `Append` with updated semantics for region-scoped operations
|
||||
- Removed `ReplaceBody` operation — use `Replace` with `region: ChunkRegion.Body` to replace only chunk body content
|
||||
|
||||
Vendored
+1
-1
@@ -444,7 +444,7 @@ export interface ChunkReadTarget {
|
||||
|
||||
export declare enum ChunkRegion {
|
||||
Head = 'head',
|
||||
Inner = 'inner',
|
||||
Body = 'body',
|
||||
Tail = 'tail'
|
||||
}
|
||||
|
||||
|
||||
@@ -259,7 +259,7 @@ exports.ChunkReadStatus = {
|
||||
};
|
||||
exports.ChunkRegion = {
|
||||
Head: 'head',
|
||||
Inner: 'inner',
|
||||
Body: 'body',
|
||||
Tail: 'tail',
|
||||
};
|
||||
exports.Ellipsis = {
|
||||
|
||||
Reference in New Issue
Block a user