codemini-cli 0.1.19 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -26,6 +26,7 @@ It is designed for teams that want a coding assistant that feels practical, cont
26
26
  - Configurable reply language through `ui.reply_language` (`zh` / `en`)
27
27
  - Richer slash completion with priority sorting, inline descriptions, and left/right paging
28
28
  - Structured code tools for small models: `grep`, `read`, `edit`
29
+ - Tree-sitter AST tools for small models: `ast_query`, `read_ast_node`, node-scoped `edit`
29
30
  - More conservative `plan auto` acceptance checks with reviewer/tester goal checklists
30
31
  - Tone presets through `soul`, without changing plans or code behavior
31
32
  - Example soul presets include `professional`, `playful`, `anime`, `pirate`, `caveman`, and `ceo`
@@ -67,6 +68,8 @@ codemini skill list|install|enable|disable|inspect|reindex
67
68
  - Ambiguous feature requests can pause for lightweight brainstorming first, and `/brainstorm <question>` gives an explicit way to compare options before coding
68
69
  - `plan auto` now turns the original goal into an acceptance checklist, uses a lighter chain only for truly tiny tasks, and treats unmet checklist items as failure signals
69
70
  - Structured code tools reduce shell-noise for small models by preferring `grep/read -> edit`
71
+ - Tree-sitter-aware code tools support `grep/read -> ast_query/read_ast_node -> edit(ast_target)` for function/class-level edits with explicit node range limits
72
+ - Current Tree-sitter language support includes JavaScript/JSX, TypeScript/TSX, Python, Go, C, C++, Bash, Java, Rust, C#, PHP, and Ruby
70
73
 
71
74
  ### Skill Loading
72
75
 
@@ -74,16 +77,15 @@ CodeMini CLI loads skills from these locations:
74
77
 
75
78
  - Bundled repo skills: `skills/<name>/SKILL.md`
76
79
  - Installed global skills: `<base-config-dir>/skills/<name>/SKILL.md`
77
- - Project-scoped legacy skills: `.coder/skills/<name>/SKILL.md`
80
+ - Project-scoped skills: `.codemini/skills/<name>/SKILL.md`
78
81
 
79
82
  The base config directory is resolved in this order:
80
83
 
81
- - `CODEMINI_CONFIG_DIR`
82
- - `COMPANY_CODER_CONFIG_DIR`
83
- - Windows: `%APPDATA%\\codemini-cli\\`
84
- - macOS: `~/Library/Preferences/codemini-cli`
85
- - Linux/XDG: `$XDG_CONFIG_HOME/codemini-cli`
86
- - Fallback in restricted environments: `.codemini-cli/`
84
+ - `CODEMINI_GLOBAL_DIR`
85
+ - Windows: `%APPDATA%\\codemini-global\\`
86
+ - macOS: `~/Library/Preferences/codemini-global`
87
+ - Linux/XDG: `$XDG_CONFIG_HOME/codemini-global`
88
+ - Fallback in restricted environments: `.codemini-global/`
87
89
 
88
90
  ### Brainstorming
89
91
 
@@ -104,9 +106,19 @@ For information on how to perform a release, please see the [Release Checklist](
104
106
 
105
107
  ### Data Layout
106
108
 
107
- - Project-scoped workspace data: `.coder/`
108
- - Global user data on Windows: `%APPDATA%\\codemini-cli\\`
109
- - Restricted-environment fallback: `.codemini-cli/`
109
+ - Project-scoped workspace data: `.codemini/`
110
+ - Global user data on Windows: `%APPDATA%\\codemini-global\\`
111
+ - Restricted-environment fallback: `.codemini-global/`
112
+
113
+ ### Project Index
114
+
115
+ CodeMini CLI now maintains a lightweight project index inside `.codemini-project/`:
116
+
117
+ - `project-map.json` for project skeleton metadata such as languages, important files, source roots, test roots, and entry candidates
118
+ - `file-index.json` for per-file symbols, imports, exports, functions, classes, and basic call hints
119
+
120
+ The index is initialized once when entering a project and refreshed incrementally after code edits, writes, and patches. Session data stays in `.codemini/`; project structure lives in `.codemini-project/`. The index is intentionally lightweight and fact-based rather than an LLM-generated project report.
121
+ At request time, CodeMini injects a short project-context summary from this index into the model prompt instead of dumping the full index.
110
122
 
111
123
  ### Positioning
112
124
 
@@ -141,6 +153,7 @@ CodeMini CLI 是一个为小模型工作流优化过的代码助手 CLI,重点
141
153
  - 支持通过 `ui.reply_language` 配置回复语言,当前支持 `zh` / `en`
142
154
  - slash 补全支持优先级排序、右侧简短说明和左右分页
143
155
  - 为小模型补了结构化代码工具:`grep`、`read`、`edit`
156
+ - 为小模型补了 Tree-sitter AST 工具:`ast_query`、`read_ast_node`,以及带 node 范围限制的 `edit`
144
157
  - `plan auto` 会基于原始目标生成验收清单,并更保守地处理 reviewer/tester 结果
145
158
  - `soul` 只影响语气,不影响计划和代码行为
146
159
  - 可用的 `soul` 示例包括 `professional`、`playful`、`anime`、`pirate`、`caveman`、`ceo`
@@ -182,6 +195,8 @@ codemini skill list|install|enable|disable|inspect|reindex
182
195
  - 对于需求仍不明确的功能请求,CLI 会先偏向轻量 brainstorm;也可以显式使用 `/brainstorm <问题>` 先比较方案再决定是否编码
183
196
  - `plan auto` 会先把原始目标展开成验收清单;只有真正很小的任务才会走轻量链路;如果 reviewer 或 tester 标记了未满足或未验证的验收项,就不会按成功处理
184
197
  - 为了减少小模型被 shell 原始输出干扰,新增了 `grep/read -> edit` 这套结构化代码工具流
198
+ - 现在也支持 `grep/read -> ast_query/read_ast_node -> edit(ast_target)` 这套 AST 工作流,适合函数级/类级精准编辑,并且能限制小模型只能修改显式选中的 node 范围
199
+ - 当前内置的 Tree-sitter 语言支持包括 JavaScript/JSX、TypeScript/TSX、Python、Go、C、C++、Bash、Java、Rust、C#、PHP、Ruby
185
200
 
186
201
  ### Skill 加载位置
187
202
 
@@ -189,16 +204,15 @@ CodeMini CLI 会从这些位置读取 skill:
189
204
 
190
205
  - 仓库内置 skill:`skills/<name>/SKILL.md`
191
206
  - 全局已安装 skill:`<base-config-dir>/skills/<name>/SKILL.md`
192
- - 项目级旧式 skill:`.coder/skills/<name>/SKILL.md`
207
+ - 项目级 skill:`.codemini/skills/<name>/SKILL.md`
193
208
 
194
209
  `base-config-dir` 的解析顺序是:
195
210
 
196
- - `CODEMINI_CONFIG_DIR`
197
- - `COMPANY_CODER_CONFIG_DIR`
198
- - Windows:`%APPDATA%\\codemini-cli\\`
199
- - macOS:`~/Library/Preferences/codemini-cli`
200
- - Linux / XDG:`$XDG_CONFIG_HOME/codemini-cli`
201
- - 受限环境回退:`.codemini-cli/`
211
+ - `CODEMINI_GLOBAL_DIR`
212
+ - Windows:`%APPDATA%\\codemini-global\\`
213
+ - macOS:`~/Library/Preferences/codemini-global`
214
+ - Linux / XDG:`$XDG_CONFIG_HOME/codemini-global`
215
+ - 受限环境回退:`.codemini-global/`
202
216
 
203
217
  ### Brainstorm 用法
204
218
 
@@ -215,6 +229,16 @@ CodeMini CLI 会从这些位置读取 skill:
215
229
 
216
230
  ### 数据目录
217
231
 
218
- - 项目工作区数据:`.coder/`
219
- - Windows 全局用户数据:`%APPDATA%\\codemini-cli\\`
220
- - 受限环境回退目录:`.codemini-cli/`
232
+ - 项目工作区数据:`.codemini/`
233
+ - Windows 全局用户数据:`%APPDATA%\\codemini-global\\`
234
+ - 受限环境回退目录:`.codemini-global/`
235
+
236
+ ### 项目索引
237
+
238
+ CodeMini CLI 现在会在 `.codemini-project/` 下维护一份轻量项目索引:
239
+
240
+ - `project-map.json`:记录项目骨架信息,例如语言、关键文件、源码目录、测试目录、入口候选
241
+ - `file-index.json`:记录文件级结构信息,例如 symbols、imports、exports、functions、classes、基础 calls
242
+
243
+ 这份索引会在进入项目时初始化一次,在 `edit`、`write`、`patch` 后做增量刷新。`.codemini/` 继续保存会话态数据,`.codemini-project/` 保存项目结构索引。它是程序维护的轻量事实索引,不是模型生成的项目总结。
244
+ 在真正请求模型时,CodeMini 会从这份索引里裁一小段项目摘要注入 prompt,而不是把整份索引直接塞进去。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codemini-cli",
3
- "version": "0.1.19",
3
+ "version": "0.2.1",
4
4
  "description": "Coding CLI optimized for small-model workflows and Windows PowerShell",
5
5
  "keywords": [
6
6
  "cli",
@@ -46,9 +46,11 @@
46
46
  "access": "public"
47
47
  },
48
48
  "dependencies": {
49
+ "@cursorless/tree-sitter-wasms": "^0.5.0",
49
50
  "ink": "^6.3.1",
50
51
  "openai": "^6.33.0",
51
- "react": "^19.0.0"
52
+ "react": "^19.0.0",
53
+ "web-tree-sitter": "^0.26.7"
52
54
  },
53
55
  "license": "MIT"
54
56
  }
package/src/cli.js CHANGED
@@ -4,7 +4,7 @@ import { handleConfig } from './commands/config.js';
4
4
  import { handleDoctor } from './commands/doctor.js';
5
5
  import { handleSkill } from './commands/skill.js';
6
6
 
7
- const VERSION = '0.1.18';
7
+ const VERSION = '0.2.1';
8
8
 
9
9
  function printHelp() {
10
10
  console.log(`codemini ${VERSION}
@@ -99,6 +99,7 @@ export async function handleChat(args) {
99
99
  model: parsed.model || config.model.name,
100
100
  language: config.ui?.language || 'zh',
101
101
  shellName: config.shell?.default || 'powershell',
102
+ safeMode: config.policy?.safe_mode !== false,
102
103
  version: pkg.version
103
104
  })
104
105
  );
@@ -262,7 +262,7 @@ export async function runAgentLoop({
262
262
  }
263
263
 
264
264
  if (!approved) {
265
- if (onEvent) onEvent({ type: 'tool:blocked', name: displayName, id: call.id });
265
+ if (onEvent) onEvent({ type: 'tool:blocked', name: displayName, id: call.id, arguments: args });
266
266
  const blockedMessage = {
267
267
  role: 'tool',
268
268
  tool_call_id: call.id,
@@ -274,6 +274,7 @@ export async function runAgentLoop({
274
274
  type: 'tool:result',
275
275
  name: displayName,
276
276
  id: call.id,
277
+ arguments: args,
277
278
  content: blockedMessage.content,
278
279
  blocked: true
279
280
  });
@@ -281,7 +282,7 @@ export async function runAgentLoop({
281
282
  continue;
282
283
  }
283
284
 
284
- if (onEvent) onEvent({ type: 'tool:start', name: displayName, id: call.id });
285
+ if (onEvent) onEvent({ type: 'tool:start', name: displayName, id: call.id, arguments: args });
285
286
  const handler = toolHandlers[toolName];
286
287
  if (!handler) {
287
288
  throw new Error(`Unknown tool: ${call.name}`);
@@ -297,6 +298,7 @@ export async function runAgentLoop({
297
298
  type: 'tool:error',
298
299
  name: displayName,
299
300
  id: call.id,
301
+ arguments: args,
300
302
  durationMs,
301
303
  summary: trimInline(message, 120)
302
304
  });
@@ -312,6 +314,7 @@ export async function runAgentLoop({
312
314
  type: 'tool:result',
313
315
  name: displayName,
314
316
  id: call.id,
317
+ arguments: args,
315
318
  content: toolMessage.content,
316
319
  error: true
317
320
  });
@@ -324,6 +327,7 @@ export async function runAgentLoop({
324
327
  type: 'tool:end',
325
328
  name: displayName,
326
329
  id: call.id,
330
+ arguments: args,
327
331
  durationMs,
328
332
  summary: summarizeToolResult(toolResult)
329
333
  });
@@ -339,6 +343,7 @@ export async function runAgentLoop({
339
343
  type: 'tool:result',
340
344
  name: displayName,
341
345
  id: call.id,
346
+ arguments: args,
342
347
  content: toolMessage.content
343
348
  });
344
349
  }
@@ -0,0 +1,310 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import { createRequire } from 'node:module';
5
+ import { Parser, Language, Query } from 'web-tree-sitter';
6
+
7
+ const require = createRequire(import.meta.url);
8
+
9
+ const IDENTIFIER_NODE_TYPES = new Set([
10
+ 'identifier',
11
+ 'property_identifier',
12
+ 'type_identifier',
13
+ 'word',
14
+ 'field_identifier',
15
+ 'name'
16
+ ]);
17
+ const WRAPPER_NODE_TYPES = new Set([
18
+ 'declarator',
19
+ 'qualified_identifier',
20
+ 'template_function',
21
+ 'template_type'
22
+ ]);
23
+ const LANGUAGE_ALIASES = {
24
+ javascript: 'js',
25
+ js: 'js',
26
+ jsx: 'js',
27
+ typescript: 'ts',
28
+ ts: 'ts',
29
+ tsx: 'tsx',
30
+ python: 'python',
31
+ py: 'python',
32
+ go: 'go',
33
+ c: 'c',
34
+ cpp: 'cpp',
35
+ 'c++': 'cpp',
36
+ bash: 'bash',
37
+ sh: 'bash',
38
+ shell: 'bash',
39
+ java: 'java',
40
+ rust: 'rust',
41
+ rs: 'rust',
42
+ csharp: 'csharp',
43
+ 'c#': 'csharp',
44
+ cs: 'csharp',
45
+ php: 'php',
46
+ ruby: 'ruby',
47
+ rb: 'ruby'
48
+ };
49
+ const EXTENSION_LANGUAGE_MAP = {
50
+ '.js': 'js',
51
+ '.jsx': 'js',
52
+ '.mjs': 'js',
53
+ '.cjs': 'js',
54
+ '.ts': 'ts',
55
+ '.tsx': 'tsx',
56
+ '.py': 'python',
57
+ '.go': 'go',
58
+ '.c': 'c',
59
+ '.h': 'c',
60
+ '.cpp': 'cpp',
61
+ '.cc': 'cpp',
62
+ '.cxx': 'cpp',
63
+ '.hpp': 'cpp',
64
+ '.hh': 'cpp',
65
+ '.java': 'java',
66
+ '.rs': 'rust',
67
+ '.cs': 'csharp',
68
+ '.php': 'php',
69
+ '.rb': 'ruby',
70
+ '.sh': 'bash',
71
+ '.bash': 'bash'
72
+ };
73
+ const LANGUAGE_WASM_PATHS = {
74
+ js: require.resolve('@cursorless/tree-sitter-wasms/out/tree-sitter-javascript.wasm'),
75
+ ts: require.resolve('@cursorless/tree-sitter-wasms/out/tree-sitter-typescript.wasm'),
76
+ tsx: require.resolve('@cursorless/tree-sitter-wasms/out/tree-sitter-tsx.wasm'),
77
+ python: require.resolve('@cursorless/tree-sitter-wasms/out/tree-sitter-python.wasm'),
78
+ go: require.resolve('@cursorless/tree-sitter-wasms/out/tree-sitter-go.wasm'),
79
+ c: require.resolve('@cursorless/tree-sitter-wasms/out/tree-sitter-c.wasm'),
80
+ cpp: require.resolve('@cursorless/tree-sitter-wasms/out/tree-sitter-cpp.wasm'),
81
+ bash: require.resolve('@cursorless/tree-sitter-wasms/out/tree-sitter-bash.wasm'),
82
+ java: require.resolve('@cursorless/tree-sitter-wasms/out/tree-sitter-java.wasm'),
83
+ rust: require.resolve('@cursorless/tree-sitter-wasms/out/tree-sitter-rust.wasm'),
84
+ csharp: require.resolve('@cursorless/tree-sitter-wasms/out/tree-sitter-c_sharp.wasm'),
85
+ php: require.resolve('@cursorless/tree-sitter-wasms/out/tree-sitter-php.wasm'),
86
+ ruby: require.resolve('@cursorless/tree-sitter-wasms/out/tree-sitter-ruby.wasm')
87
+ };
88
+ const TREE_SITTER_WASM_PATH = require.resolve('web-tree-sitter/web-tree-sitter.wasm');
89
+
90
+ const parserInitPromise = Parser.init({
91
+ locateFile(scriptName) {
92
+ return scriptName === 'web-tree-sitter.wasm' ? TREE_SITTER_WASM_PATH : scriptName;
93
+ }
94
+ });
95
+ const languageCache = new Map();
96
+
97
+ function sha256(input) {
98
+ return `sha256:${crypto.createHash('sha256').update(String(input || '')).digest('hex')}`;
99
+ }
100
+
101
+ function clipText(text, maxLen = 220) {
102
+ const normalized = String(text || '').replace(/\s+/g, ' ').trim();
103
+ if (normalized.length <= maxLen) return normalized;
104
+ return `${normalized.slice(0, maxLen - 3)}...`;
105
+ }
106
+
107
+ function pointFromTarget(line, column) {
108
+ return {
109
+ row: Math.max(0, Number(line || 1) - 1),
110
+ column: Math.max(0, Number(column || 1) - 1)
111
+ };
112
+ }
113
+
114
+ function pointsEqual(left, right) {
115
+ return left.row === right.row && left.column === right.column;
116
+ }
117
+
118
+ function summarizeNode(node) {
119
+ if (!node) return '';
120
+ const text = clipText(node.text, 96);
121
+ return `${node.type}${text ? `: ${text}` : ''}`;
122
+ }
123
+
124
+ function selectEditableNode(node) {
125
+ let current = node;
126
+ while (current?.parent) {
127
+ if (IDENTIFIER_NODE_TYPES.has(current.type)) {
128
+ current = current.parent;
129
+ continue;
130
+ }
131
+ if (current.type.endsWith('_declarator') || WRAPPER_NODE_TYPES.has(current.type)) {
132
+ current = current.parent;
133
+ continue;
134
+ }
135
+ break;
136
+ }
137
+ return current;
138
+ }
139
+
140
+ function astTargetForNode(relativePath, language, node) {
141
+ return {
142
+ path: relativePath,
143
+ language,
144
+ node_type: node.type,
145
+ start_line: node.startPosition.row + 1,
146
+ start_column: node.startPosition.column + 1,
147
+ end_line: node.endPosition.row + 1,
148
+ end_column: node.endPosition.column + 1,
149
+ range_hash: sha256(node.text)
150
+ };
151
+ }
152
+
153
+ function inferLanguage(filePath, explicitLanguage = '') {
154
+ const alias = LANGUAGE_ALIASES[String(explicitLanguage || '').trim().toLowerCase()];
155
+ if (alias) return alias;
156
+ const ext = path.extname(String(filePath || '')).toLowerCase();
157
+ const inferred = EXTENSION_LANGUAGE_MAP[ext];
158
+ if (!inferred) {
159
+ throw new Error(`No Tree-sitter language configured for file: ${filePath}`);
160
+ }
161
+ return inferred;
162
+ }
163
+
164
+ async function loadLanguage(language) {
165
+ await parserInitPromise;
166
+ if (languageCache.has(language)) return languageCache.get(language);
167
+ const wasmPath = LANGUAGE_WASM_PATHS[language];
168
+ if (!wasmPath) throw new Error(`Unsupported Tree-sitter language: ${language}`);
169
+ const loaded = await Language.load(wasmPath);
170
+ languageCache.set(language, loaded);
171
+ return loaded;
172
+ }
173
+
174
+ async function parseContent(content, language) {
175
+ const loadedLanguage = await loadLanguage(language);
176
+ const parser = new Parser();
177
+ parser.setLanguage(loadedLanguage);
178
+ const tree = parser.parse(content);
179
+ return { parser, tree, loadedLanguage };
180
+ }
181
+
182
+ async function parseFile(root, relativePath, explicitLanguage = '') {
183
+ const target = path.resolve(root, relativePath);
184
+ const content = await fs.readFile(target, 'utf8');
185
+ const language = inferLanguage(relativePath, explicitLanguage);
186
+ const parsed = await parseContent(content, language);
187
+ return {
188
+ ...parsed,
189
+ path: relativePath,
190
+ absolutePath: target,
191
+ content,
192
+ language
193
+ };
194
+ }
195
+
196
+ function exactNodeForTarget(rootNode, target) {
197
+ const start = pointFromTarget(target.start_line, target.start_column);
198
+ const end = pointFromTarget(target.end_line, target.end_column);
199
+ let current = rootNode.namedDescendantForPosition(start, end) || rootNode.descendantForPosition(start, end);
200
+ while (current) {
201
+ if (pointsEqual(current.startPosition, start) && pointsEqual(current.endPosition, end)) {
202
+ return current;
203
+ }
204
+ current = current.parent;
205
+ }
206
+ return null;
207
+ }
208
+
209
+ export async function queryAst(root, args) {
210
+ const relativePath = String(args?.path || '').trim();
211
+ const querySource = String(args?.query || '').trim();
212
+ if (!relativePath || !querySource) {
213
+ throw new Error('ast_query requires path and query');
214
+ }
215
+ const captureName = String(args?.capture_name || '').trim();
216
+ const maxResults = Math.max(1, Math.min(100, Number(args?.max_results || 12)));
217
+ const parsed = await parseFile(root, relativePath, args?.language);
218
+ const query = new Query(parsed.loadedLanguage, querySource);
219
+ const captures = query.captures(parsed.tree.rootNode);
220
+ const matches = [];
221
+
222
+ for (const capture of captures) {
223
+ if (captureName && capture.name !== captureName) continue;
224
+ const targetNode = selectEditableNode(capture.node);
225
+ matches.push({
226
+ capture: capture.name,
227
+ node_type: targetNode.type,
228
+ start_line: targetNode.startPosition.row + 1,
229
+ start_column: targetNode.startPosition.column + 1,
230
+ end_line: targetNode.endPosition.row + 1,
231
+ end_column: targetNode.endPosition.column + 1,
232
+ text: clipText(targetNode.text),
233
+ ast_target: astTargetForNode(relativePath, parsed.language, targetNode)
234
+ });
235
+ if (matches.length >= maxResults) break;
236
+ }
237
+
238
+ query.delete();
239
+ parsed.tree.delete();
240
+ parsed.parser.delete();
241
+
242
+ return {
243
+ path: relativePath,
244
+ language: parsed.language,
245
+ query: querySource,
246
+ capture_name: captureName || undefined,
247
+ matches,
248
+ truncated: captures.length > matches.length
249
+ };
250
+ }
251
+
252
+ export async function readAstNode(root, args) {
253
+ const relativePath = String(args?.path || args?.ast_target?.path || '').trim();
254
+ const astTarget = args?.ast_target;
255
+ if (!relativePath || !astTarget) throw new Error('read_ast_node requires path and ast_target');
256
+ const parsed = await parseFile(root, relativePath, astTarget.language || args?.language);
257
+ const node = exactNodeForTarget(parsed.tree.rootNode, astTarget);
258
+ if (!node) {
259
+ throw new Error('AST target no longer matches the current file');
260
+ }
261
+
262
+ const result = {
263
+ path: relativePath,
264
+ language: parsed.language,
265
+ node: {
266
+ node_type: node.type,
267
+ start_line: node.startPosition.row + 1,
268
+ start_column: node.startPosition.column + 1,
269
+ end_line: node.endPosition.row + 1,
270
+ end_column: node.endPosition.column + 1
271
+ },
272
+ content: node.text,
273
+ parent_summary: summarizeNode(node.parent),
274
+ child_summaries: node.namedChildren.slice(0, 8).map((child) => summarizeNode(child))
275
+ };
276
+
277
+ parsed.tree.delete();
278
+ parsed.parser.delete();
279
+ return result;
280
+ }
281
+
282
+ export async function resolveAstTarget(root, relativePath, astTarget) {
283
+ if (!astTarget || typeof astTarget !== 'object') {
284
+ throw new Error('ast_target is required for AST-scoped edit');
285
+ }
286
+ if (String(astTarget.path || '').trim() !== String(relativePath || '').trim()) {
287
+ throw new Error('ast_target path does not match edit file');
288
+ }
289
+
290
+ const parsed = await parseFile(root, relativePath, astTarget.language);
291
+ const node = exactNodeForTarget(parsed.tree.rootNode, astTarget);
292
+ if (!node) {
293
+ parsed.tree.delete();
294
+ parsed.parser.delete();
295
+ throw new Error('AST target no longer matches the current file');
296
+ }
297
+
298
+ const currentHash = sha256(node.text);
299
+ if (String(astTarget.range_hash || '') !== currentHash) {
300
+ parsed.tree.delete();
301
+ parsed.parser.delete();
302
+ throw new Error('ast_target range_hash mismatch; the selected node changed and is now stale');
303
+ }
304
+
305
+ return {
306
+ ...parsed,
307
+ node,
308
+ current_hash: currentHash
309
+ };
310
+ }