@bigknoxy/hashpilot 4.6.3

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.
Files changed (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +777 -0
  3. package/docs/ADAPTER-CONTRACT.md +1260 -0
  4. package/docs/ARCHITECTURE.md +846 -0
  5. package/docs/CLI-QUICKREF.md +827 -0
  6. package/docs/COMPETITIVE-ANALYSIS.md +307 -0
  7. package/docs/INSTALL.md +403 -0
  8. package/docs/INTEGRATION-CLAUDE.md +126 -0
  9. package/docs/INTEGRATION-MCP.md +196 -0
  10. package/docs/INTEGRATION-OPENCODE.md +136 -0
  11. package/docs/INTEGRATION-PI.md +195 -0
  12. package/package.json +77 -0
  13. package/scripts/build-site.sh +39 -0
  14. package/scripts/doctor.sh +218 -0
  15. package/scripts/gen-cli-quickref.ts +232 -0
  16. package/scripts/install-cli.sh +60 -0
  17. package/scripts/install.sh +466 -0
  18. package/scripts/roadmap-lint.ts +200 -0
  19. package/scripts/uninstall.sh +202 -0
  20. package/src/cli-node.cjs +51 -0
  21. package/src/cli.ts +209 -0
  22. package/src/commands/ast.ts +255 -0
  23. package/src/commands/diff.ts +98 -0
  24. package/src/commands/edit.ts +93 -0
  25. package/src/commands/hash.ts +64 -0
  26. package/src/commands/intent.ts +68 -0
  27. package/src/commands/maintenance.ts +191 -0
  28. package/src/commands/mcp.ts +28 -0
  29. package/src/commands/provenance.ts +111 -0
  30. package/src/commands/read.ts +117 -0
  31. package/src/commands/route.ts +42 -0
  32. package/src/commands/shared.ts +65 -0
  33. package/src/commands/telemetry.ts +126 -0
  34. package/src/commands/verify.ts +61 -0
  35. package/src/core/ast-edit.ts +2357 -0
  36. package/src/core/batch-edit.ts +185 -0
  37. package/src/core/config.ts +189 -0
  38. package/src/core/diff-engine.ts +474 -0
  39. package/src/core/doctor.ts +303 -0
  40. package/src/core/encoding.ts +116 -0
  41. package/src/core/envelope.ts +163 -0
  42. package/src/core/exit-codes.ts +198 -0
  43. package/src/core/format.ts +339 -0
  44. package/src/core/grep.ts +180 -0
  45. package/src/core/hash-edit.ts +416 -0
  46. package/src/core/index.ts +155 -0
  47. package/src/core/intent.ts +584 -0
  48. package/src/core/locking.ts +292 -0
  49. package/src/core/module-system.ts +142 -0
  50. package/src/core/operations.ts +557 -0
  51. package/src/core/output.ts +122 -0
  52. package/src/core/path-normalize.ts +61 -0
  53. package/src/core/paths.ts +326 -0
  54. package/src/core/plan-executor.ts +437 -0
  55. package/src/core/platform.ts +132 -0
  56. package/src/core/provenance.ts +214 -0
  57. package/src/core/read.ts +111 -0
  58. package/src/core/redact.ts +98 -0
  59. package/src/core/resolve-content.ts +12 -0
  60. package/src/core/router.ts +463 -0
  61. package/src/core/snapshot.ts +346 -0
  62. package/src/core/telemetry.ts +838 -0
  63. package/src/core/utils.ts +7 -0
  64. package/src/core/verify-baseline.ts +186 -0
  65. package/src/core/verify-scope.ts +282 -0
  66. package/src/core/verify.ts +753 -0
  67. package/src/mcp/server.ts +325 -0
  68. package/templates/claude-section.md +12 -0
  69. package/templates/opencode-agent.md +106 -0
  70. package/templates/opencode-skill.md +241 -0
  71. package/templates/pi-extension.ts +288 -0
  72. package/templates/pi-skill.md +123 -0
  73. package/tsconfig.json +19 -0
@@ -0,0 +1,2357 @@
1
+ import Parser from "tree-sitter";
2
+ import TypeScript from "tree-sitter-typescript";
3
+ import Python from "tree-sitter-python";
4
+ import JavaScript from "tree-sitter-javascript";
5
+ import Go from "tree-sitter-go";
6
+ import Rust from "tree-sitter-rust";
7
+ import { escapeRegex } from "./utils";
8
+ import { detectModuleSystem } from "./module-system";
9
+ import { ErrorCode } from "./telemetry";
10
+ import { addWarning } from "./envelope";
11
+
12
+ // Language registry: maps internal language IDs to parser + metadata
13
+ interface LangEntry {
14
+ parser: Parser;
15
+ extensions: string[];
16
+ }
17
+
18
+ const SUPPORTED_LANGUAGES: Record<string, LangEntry> = {};
19
+
20
+ // Mapping from file extension to language ID (longest suffix first for correctness)
21
+ const EXTENSION_MAP: [string, string][] = [
22
+ [".d.ts", "__typescript_decl__"], // reserved, excluded from AST
23
+ [".tsx", "tsx"],
24
+ [".ts", "typescript"],
25
+ [".jsx", "javascript"],
26
+ [".js", "javascript"],
27
+ [".mjs", "javascript"],
28
+ [".cjs", "javascript"],
29
+ [".py", "python"],
30
+ [".go", "go"],
31
+ [".rs", "rust"],
32
+ ];
33
+
34
+ /** Every language with a tree-sitter binding, in `ast capabilities` order. */
35
+ export const AST_LANGUAGES = ["typescript", "tsx", "javascript", "python", "go", "rust"] as const;
36
+
37
+ /**
38
+ * Why a parser failed to initialize, keyed by language. `getParser` returns
39
+ * `null` on failure and the router then silently falls back to hash/diff, so
40
+ * without this the only symptom of a broken native build is mysteriously worse
41
+ * edits. `doctor` reads this to report the real reason (#46).
42
+ */
43
+ const PARSER_ERRORS: Record<string, string> = {};
44
+
45
+ function getParser(lang: string): Parser | null {
46
+ if (SUPPORTED_LANGUAGES[lang]) return SUPPORTED_LANGUAGES[lang].parser;
47
+ try {
48
+ const p = new Parser();
49
+ switch (lang) {
50
+ case "typescript":
51
+ p.setLanguage(TypeScript.typescript);
52
+ break;
53
+ case "tsx":
54
+ p.setLanguage(TypeScript.tsx);
55
+ break;
56
+ case "javascript":
57
+ p.setLanguage(JavaScript);
58
+ break;
59
+ case "python":
60
+ p.setLanguage(Python);
61
+ break;
62
+ case "go":
63
+ p.setLanguage(Go);
64
+ break;
65
+ case "rust":
66
+ p.setLanguage(Rust);
67
+ break;
68
+ default:
69
+ return null;
70
+ }
71
+ SUPPORTED_LANGUAGES[lang] = { parser: p, extensions: [] };
72
+ return p;
73
+ } catch (e) {
74
+ PARSER_ERRORS[lang] = e instanceof Error ? e.message : String(e);
75
+ return null;
76
+ }
77
+ }
78
+
79
+ /** One language's tree-sitter binding status, as reported by `doctor`. */
80
+ export interface ParserProbe {
81
+ lang: string;
82
+ loaded: boolean;
83
+ /** Present only when the binding failed to initialize. */
84
+ error?: string;
85
+ }
86
+
87
+ /**
88
+ * Attempt to initialize every supported language's parser and report the
89
+ * outcome. This is the only way to distinguish "HashPilot routed to diff
90
+ * because the operation is unsupported" from "HashPilot routed to diff because
91
+ * tree-sitter never loaded" (#46).
92
+ */
93
+ export function probeParsers(): ParserProbe[] {
94
+ return AST_LANGUAGES.map((lang): ParserProbe => {
95
+ const parser = getParser(lang);
96
+ if (parser) return { lang, loaded: true };
97
+ return { lang, loaded: false, error: PARSER_ERRORS[lang] || "parser returned null" };
98
+ });
99
+ }
100
+
101
+ /**
102
+ * Chunk size for the callback-form parse. Anything comfortably under the
103
+ * binding's 32KB marshalling buffer works.
104
+ */
105
+ const PARSE_CHUNK = 16 * 1024;
106
+
107
+ /**
108
+ * Parse source of any size.
109
+ *
110
+ * `parser.parse(string)` marshals the whole string through a fixed 32KB buffer
111
+ * in the node-tree-sitter binding and throws a bare `Invalid argument` at 32767
112
+ * characters. Every AST operation used that overload, so the top tier of the
113
+ * edit hierarchy was dead on exactly the large files where a structured edit
114
+ * beats a hand-written diff (#55). The callback form streams the source in
115
+ * chunks and has no such limit.
116
+ */
117
+ export function parseSource(parser: Parser, source: string) {
118
+ return parser.parse((index: number) => {
119
+ if (index >= source.length) return null;
120
+ let end = Math.min(index + PARSE_CHUNK, source.length);
121
+ // Never split a surrogate pair across chunks — half a code point would
122
+ // reach the parser as a lone surrogate and corrupt every offset after it.
123
+ const last = source.charCodeAt(end - 1);
124
+ if (end < source.length && last >= 0xd800 && last <= 0xdbff) end -= 1;
125
+ return source.slice(index, end);
126
+ });
127
+ }
128
+
129
+ /** Detect language from file path. Returns null for unsupported files. */
130
+ export function detectLanguage(filePath: string): string | null {
131
+ for (const [ext, lang] of EXTENSION_MAP) {
132
+ if (filePath.endsWith(ext)) {
133
+ // .d.ts files are excluded from AST editing (declaration files)
134
+ if (lang === "__typescript_decl__") return null;
135
+ return lang;
136
+ }
137
+ }
138
+ return null;
139
+ }
140
+
141
+ export function isLanguageSupported(filePath: string): boolean {
142
+ return detectLanguage(filePath) !== null;
143
+ }
144
+
145
+ /** Return the list of supported language IDs. */
146
+ export function supportedLanguages(): string[] {
147
+ return ["typescript", "tsx", "javascript", "python", "go", "rust"];
148
+ }
149
+
150
+ /**
151
+ * Machine-readable capability matrix for all supported AST languages.
152
+ * Each entry lists the language, associated extensions, supported operations,
153
+ * and any known limitations.
154
+ */
155
+ export function astCapabilities(): LanguageCapability[] {
156
+ return [
157
+ {
158
+ lang: "typescript",
159
+ extensions: [".ts"],
160
+ operations: ALL_AST_OPS,
161
+ limitations: [".d.ts files are excluded"],
162
+ },
163
+ {
164
+ lang: "tsx",
165
+ extensions: [".tsx"],
166
+ operations: ALL_AST_OPS,
167
+ limitations: [],
168
+ },
169
+ {
170
+ lang: "javascript",
171
+ extensions: [".js", ".jsx", ".mjs", ".cjs"],
172
+ operations: ALL_AST_OPS,
173
+ limitations: [],
174
+ },
175
+ {
176
+ lang: "python",
177
+ extensions: [".py"],
178
+ operations: ALL_AST_OPS,
179
+ limitations: [
180
+ "add-import supports `import X`, `from X import Y`, and `from X import Y, Z`; auto-merges into existing from-import for the same module",
181
+ ],
182
+ },
183
+ {
184
+ lang: "go",
185
+ extensions: [".go"],
186
+ operations: ALL_AST_OPS,
187
+ limitations: [
188
+ "add-import: with no existing imports inserts after `package` clause; with grouped `import ( ... )` block inserts inside the group",
189
+ ],
190
+ },
191
+ {
192
+ lang: "rust",
193
+ extensions: [".rs"],
194
+ operations: ALL_AST_OPS,
195
+ limitations: [
196
+ "remove-import: grouped `use X::{Y, Z}` supports surgical per-item removal; last item simplifies to `use X::Y`; no substring false positives",
197
+ ],
198
+ },
199
+ ];
200
+ }
201
+
202
+ export interface LanguageCapability {
203
+ /** Language identifier (e.g. "typescript", "go") */
204
+ lang: string;
205
+ /** File extensions associated with this language */
206
+ extensions: string[];
207
+ /** Operations fully supported */
208
+ operations: string[];
209
+ /** Any known limitations for this language */
210
+ limitations: string[];
211
+ }
212
+
213
+ const ALL_AST_OPS = [
214
+ "find-symbols",
215
+ "rename-symbol",
216
+ "replace-body",
217
+ "add-import",
218
+ "remove-import",
219
+ "insert-before",
220
+ "insert-after",
221
+ ];
222
+
223
+ // ── Per-language AST configuration ─────────────────────────────────────
224
+
225
+ interface LangConfig {
226
+ /** Node types representing named symbol declarations */
227
+ symbolKinds: string[];
228
+ /** Node types that can have a function/method body */
229
+ functionTypes: string[];
230
+ }
231
+
232
+ const LANG_CONFIGS: Record<string, LangConfig> = {
233
+ typescript: {
234
+ symbolKinds: [
235
+ "function_declaration", "method_definition", "class_declaration",
236
+ "interface_declaration", "type_alias_declaration", "variable_declarator",
237
+ ],
238
+ functionTypes: ["function_declaration", "method_definition", "arrow_function"],
239
+ },
240
+ tsx: {
241
+ symbolKinds: [
242
+ "function_declaration", "method_definition", "class_declaration",
243
+ "interface_declaration", "type_alias_declaration", "variable_declarator",
244
+ ],
245
+ functionTypes: ["function_declaration", "method_definition", "arrow_function"],
246
+ },
247
+ javascript: {
248
+ symbolKinds: [
249
+ "function_declaration", "method_definition", "class_declaration",
250
+ "variable_declarator",
251
+ ],
252
+ functionTypes: ["function_declaration", "method_definition", "arrow_function"],
253
+ },
254
+ python: {
255
+ symbolKinds: ["function_definition", "class_definition"],
256
+ functionTypes: ["function_definition"],
257
+ },
258
+ go: {
259
+ symbolKinds: ["function_declaration", "method_declaration", "type_spec", "var_spec"],
260
+ functionTypes: ["function_declaration", "method_declaration"],
261
+ },
262
+ rust: {
263
+ symbolKinds: [
264
+ "function_item", "struct_item", "enum_item", "trait_item",
265
+ "type_item", "const_item", "static_item",
266
+ ],
267
+ functionTypes: ["function_item"],
268
+ },
269
+ };
270
+
271
+ function configFor(lang: string): LangConfig | null {
272
+ return LANG_CONFIGS[lang] ?? null;
273
+ }
274
+
275
+ /** Common identifier node types recognized across all supported grammars */
276
+ const IDENTIFIER_TYPES = new Set(["identifier", "type_identifier", "property_identifier"]);
277
+
278
+ export interface ASTEditResult {
279
+ success: boolean;
280
+ path: string;
281
+ operation: string;
282
+ changes: number;
283
+ message: string;
284
+ error?: string;
285
+ newSource?: string;
286
+ symbolFound?: boolean;
287
+ /** Set when the parse-validity gate rejected the edit. Always PARSE_ERROR. */
288
+ errorCode?: string;
289
+ /**
290
+ * What the caller can do about a refusal. Surfaced as `error.recovery` in the
291
+ * JSON envelope, so a refusal an agent cannot act on is a bug, not a style
292
+ * choice.
293
+ */
294
+ recovery?: string;
295
+ /** Where the offending syntax error is, when `errorCode` is PARSE_ERROR. */
296
+ parseIssue?: ParseIssue;
297
+ }
298
+ export interface SymbolInfo {
299
+ name: string;
300
+ kind: string;
301
+ /**
302
+ * Zero-indexed tree-sitter coordinates. Kept for backward compatibility;
303
+ * prefer the 1-indexed `startLine`/`endLine`/`startColumn`/`endColumn` below,
304
+ * which match every other line number HashPilot reports — notably the `range`
305
+ * accepted by the hash tier and the lines returned by `read-hash` (#99).
306
+ */
307
+ startRow: number;
308
+ endRow: number;
309
+ startCol: number;
310
+ endCol: number;
311
+ /** 1-indexed line of the symbol's first character. */
312
+ startLine: number;
313
+ /** 1-indexed line of the symbol's last character. */
314
+ endLine: number;
315
+ /** 1-indexed column of the symbol's first character. */
316
+ startColumn: number;
317
+ /** 1-indexed column of the symbol's last character. */
318
+ endColumn: number;
319
+ }
320
+
321
+ /**
322
+ * Runaway guard for AST walks, far above any realistic nesting depth (#39).
323
+ *
324
+ * The two walks used to stop at 10 and 15 *silently*, so a symbol nested more
325
+ * deeply than that — routine in React trees or heavily generic TypeScript — was
326
+ * reported as "not found". A wrong answer indistinguishable from a right one is
327
+ * the worst failure a lookup can have, so the cap is now shared, far higher,
328
+ * and reported when it is hit. Both walks are iterative, so depth costs heap
329
+ * rather than stack.
330
+ */
331
+ export const MAX_AST_DEPTH = 200;
332
+
333
+ export interface SymbolSearch {
334
+ symbols: SymbolInfo[];
335
+ /** True when the walk stopped at MAX_AST_DEPTH with subtrees left unvisited. */
336
+ truncated: boolean;
337
+ }
338
+
339
+ /**
340
+ * Symbol search that reports whether it completed. `findSymbols` keeps the
341
+ * bare-array shape its callers expect; this is the variant that can distinguish
342
+ * "no symbols" from "stopped looking".
343
+ */
344
+ export function findSymbolsDetailed(source: string, filePath: string): SymbolSearch {
345
+ const empty = { symbols: [], truncated: false };
346
+ const lang = detectLanguage(filePath);
347
+ if (!lang) return empty;
348
+ const cfg = configFor(lang);
349
+ if (!cfg) return empty;
350
+ const parser = getParser(lang);
351
+ if (!parser) return empty;
352
+ const tree = parseSource(parser, source);
353
+ const symbols: SymbolInfo[] = [];
354
+ let truncated = false;
355
+
356
+ // Explicit work stack: a recursive walk on a pathologically deep tree
357
+ // overflows before it reaches any cap.
358
+ const stack: Array<{ node: Parser.SyntaxNode; depth: number }> = [{ node: tree.rootNode, depth: 0 }];
359
+ while (stack.length > 0) {
360
+ const { node, depth } = stack.pop()!;
361
+ if (depth > MAX_AST_DEPTH) {
362
+ truncated = true;
363
+ continue;
364
+ }
365
+ if (cfg.symbolKinds.includes(node.type)) {
366
+ const nameNode =
367
+ node.childForFieldName("name") ||
368
+ node.children.find((c) => IDENTIFIER_TYPES.has(c.type));
369
+ if (nameNode) {
370
+ // tree-sitter counts rows and columns from 0. Everything else
371
+ // HashPilot reports — `read-hash`, the hash tier's `range`, editor
372
+ // jump-to-line — counts from 1, so emit both rather than leaving each
373
+ // caller to remember which convention this one function uses (#99).
374
+ symbols.push({
375
+ name: nameNode.text,
376
+ kind: node.type,
377
+ startRow: node.startPosition.row,
378
+ endRow: node.endPosition.row,
379
+ startCol: node.startPosition.column,
380
+ endCol: node.endPosition.column,
381
+ startLine: node.startPosition.row + 1,
382
+ endLine: node.endPosition.row + 1,
383
+ startColumn: node.startPosition.column + 1,
384
+ endColumn: node.endPosition.column + 1,
385
+ });
386
+ }
387
+ }
388
+ // Push in reverse so children are visited in source order.
389
+ const kids = node.children;
390
+ for (let i = kids.length - 1; i >= 0; i--) stack.push({ node: kids[i], depth: depth + 1 });
391
+ }
392
+
393
+ if (truncated) {
394
+ addWarning({
395
+ code: "SEARCH_TRUNCATED",
396
+ message: `Symbol search stopped at depth ${MAX_AST_DEPTH} in ${filePath}; symbols nested deeper were not visited.`,
397
+ });
398
+ }
399
+ return { symbols, truncated };
400
+ }
401
+
402
+ /** Symbols in a file. Returns the bare array; see `findSymbolsDetailed` for truncation. */
403
+ export function findSymbols(source: string, filePath: string): SymbolInfo[] {
404
+ return findSymbolsDetailed(source, filePath).symbols;
405
+ }
406
+
407
+ /**
408
+ * Node types whose presence in an ancestor chain marks an identifier as an
409
+ * imported name (rather than a local use). Conservative across the six
410
+ * grammars — better to over-flag a binding than to miss one.
411
+ */
412
+ const IMPORT_CONTEXT = new Set([
413
+ // TypeScript / JavaScript
414
+ "import_statement", "import_clause", "named_import", "import",
415
+ // Python
416
+ "import_from_statement", "import_prefix", "alias", "dotted_name",
417
+ // Go
418
+ "import_declaration", "import_spec", "imported_path",
419
+ // Rust
420
+ "use_declaration", "use_as_clause", "nested_use_delimiter",
421
+ "identifier_path", "scoped_identifier",
422
+ ]);
423
+
424
+
425
+ /**
426
+ * Node types whose presence in an ancestor chain marks an identifier as a
427
+ * *parameter*. A name bound as a parameter is a fresh scope, so two parameters
428
+ * — or a parameter and a local — of the same name in one file are two bindings
429
+ * (shadowing), and a file-wide rename is unsafe. Parameter node names differ
430
+ * across the six grammars, so the list is approximate and deliberately
431
+ * conservative (better to refuse than to clobber).
432
+ */
433
+ const PARAM_CONTEXT = new Set([
434
+ // TypeScript / JavaScript
435
+ "function_parameter", "variable_pattern", "pattern",
436
+ // Python
437
+ "parameters", "typed_parameter", "default_parameter", "optional_parameter",
438
+ "required_parameter", "simple_parameter",
439
+ // Go / Rust
440
+ "parameter_declaration", "parameter_list", "function_parameter", "parameter",
441
+ "formal_parameters",
442
+ ]);
443
+
444
+ /** A place in a file where a name is *bound* (declared or imported). */
445
+ interface BindingSite {
446
+ row: number;
447
+ kind: string;
448
+ }
449
+
450
+ /**
451
+ * Collect every location that binds `oldName`: each declaration whose symbol
452
+ * name equals it (a shadow is simply a *second* declaration at an inner scope)
453
+ * plus each import that binds the name. `rename-symbol` is file-safe only when
454
+ * there is at most one such site; more than one means the name is genuinely
455
+ * multi-bound and a file-wide textual rename would clobber an unintended one.
456
+ *
457
+ * Property keys, string literals, and comments are never `identifier`/
458
+ * `type_identifier` nodes here, so they are excluded for free — the only gap
459
+ * this fills is the *binding* gap (which of several same-named symbols targeted).
460
+ */
461
+ function collectBindingSites(
462
+ tree: Parser.SyntaxTree,
463
+ oldName: string,
464
+ cfg: LangConfig
465
+ ): BindingSite[] {
466
+ const sites: BindingSite[] = [];
467
+ const seen = new Set<number>();
468
+ const push = (row: number, kind: string, idx: number) => {
469
+ if (!seen.has(idx)) {
470
+ seen.add(idx);
471
+ sites.push({ row, kind });
472
+ }
473
+ };
474
+
475
+ function isImportBinding(node: Parser.SyntaxNode): boolean {
476
+ let p = node.parent;
477
+ while (p && p.type) {
478
+ if (IMPORT_CONTEXT.has(p.type)) return true;
479
+ p = p.parent;
480
+ }
481
+ return false;
482
+ }
483
+
484
+ function isParamBinding(node: Parser.SyntaxNode): boolean {
485
+ let p = node.parent;
486
+ while (p && p.type) {
487
+ if (PARAM_CONTEXT.has(p.type)) return true;
488
+ p = p.parent;
489
+ }
490
+ return false;
491
+ }
492
+
493
+ function walk(node: Parser.SyntaxNode) {
494
+ // Declaration site: a symbol-kind node whose declared name matches.
495
+ if (cfg.symbolKinds.includes(node.type)) {
496
+ const nameNode =
497
+ node.childForFieldName("name") ||
498
+ node.children.find((c) => IDENTIFIER_TYPES.has(c.type));
499
+ if (nameNode && nameNode.text === oldName) {
500
+ push(node.startPosition.row + 1, node.type, node.startIndex);
501
+ }
502
+ }
503
+ // An identifier that is not a declaration may still bind the name as an
504
+ // import or as a parameter. Only one of those applies to a given node.
505
+ const isIdent = node.type === "identifier" || node.type === "type_identifier";
506
+ if (isIdent && node.text === oldName) {
507
+ if (isImportBinding(node)) {
508
+ push(node.startPosition.row + 1, "import", node.startIndex);
509
+ return; // an import is a binding on its own; don't re-count as a param
510
+ }
511
+ if (isParamBinding(node)) {
512
+ push(node.startPosition.row + 1, "parameter", node.startIndex);
513
+ }
514
+ }
515
+ for (const child of node.children) walk(child);
516
+ }
517
+
518
+ walk(tree.rootNode);
519
+ return sites;
520
+ }
521
+
522
+ function renameSymbolUnchecked(
523
+ source: string,
524
+ filePath: string,
525
+ oldName: string,
526
+ newName: string
527
+ ): ASTEditResult {
528
+ const lang = detectLanguage(filePath);
529
+ if (!lang) return { success: false, path: filePath, operation: "rename-symbol", changes: 0, message: "Unsupported language", error: `Language not supported for file: ${filePath}` };
530
+ const parser = getParser(lang);
531
+ if (!parser) return { success: false, path: filePath, operation: "rename-symbol", changes: 0, message: "Parser unavailable", errorCode: ErrorCode.UNSUPPORTED_LANGUAGE };
532
+
533
+ const tree = parseSource(parser, source);
534
+ let changes = 0;
535
+ const edits: { start: number; end: number; text: string }[] = [];
536
+
537
+ // #14 (B9): a file-wide rename is only safe when the name binds at most one
538
+ // symbol in this file. Detect the bindings first; if there is more than one
539
+ // (a shadow/local, a foreign import, or duplicate top-level declarations)
540
+ // refuse and name the sites. The node-type filter below already spares
541
+ // property keys, string literals, and comments.
542
+ const cfg = configFor(lang);
543
+ const bindingSites = cfg ? collectBindingSites(tree, oldName, cfg) : [];
544
+ if (bindingSites.length > 1) {
545
+ const where = bindingSites
546
+ .map((s) => ` line ${s.row} (${s.kind})`)
547
+ .join("\n");
548
+ return {
549
+ success: false,
550
+ path: filePath,
551
+ operation: "rename-symbol",
552
+ changes: 0,
553
+ message:
554
+ `Symbol '${oldName}' binds ${bindingSites.length} distinct locations in this ` +
555
+ `file (a shadow, a foreign import, or duplicate declarations); refusing a ` +
556
+ `file-wide rename that would clobber an unintended binding. Disambiguate by ` +
557
+ `scoping the rename or renaming each declaration separately:\n${where}`,
558
+ errorCode: ErrorCode.AMBIGUOUS_SYMBOL,
559
+ symbolFound: true,
560
+ };
561
+ }
562
+ function findRefs(node: Parser.SyntaxNode) {
563
+ if ((node.type === "identifier" || node.type === "type_identifier") && node.text === oldName) {
564
+ edits.push({ start: node.startIndex, end: node.endIndex, text: newName });
565
+ changes++;
566
+ }
567
+ for (const child of node.children) findRefs(child);
568
+ }
569
+ findRefs(tree.rootNode);
570
+
571
+ if (changes === 0) return { success: false, path: filePath, operation: "rename-symbol", changes: 0, message: `Symbol '${oldName}' not found`, errorCode: ErrorCode.SYMBOL_NOT_FOUND };
572
+
573
+ edits.sort((a, b) => b.start - a.start);
574
+ let newSource = source;
575
+ for (const e of edits) {
576
+ newSource = newSource.slice(0, e.start) + e.text + newSource.slice(e.end);
577
+ }
578
+ return { success: true, path: filePath, operation: "rename-symbol", changes, message: `Renamed ${changes} occurrences of '${oldName}' to '${newName}'`, newSource };
579
+ }
580
+
581
+ function replaceBodyUnchecked(
582
+ source: string,
583
+ filePath: string,
584
+ symbolName: string,
585
+ newBody: string
586
+ ): ASTEditResult {
587
+ const lang = detectLanguage(filePath);
588
+ if (!lang) return { success: false, path: filePath, operation: "replace-body", changes: 0, message: "Unsupported language", errorCode: ErrorCode.UNSUPPORTED_LANGUAGE };
589
+ const cfg = configFor(lang);
590
+ if (!cfg) return { success: false, path: filePath, operation: "replace-body", changes: 0, message: "Unsupported language", errorCode: ErrorCode.UNSUPPORTED_LANGUAGE };
591
+ const parser = getParser(lang);
592
+ if (!parser) return { success: false, path: filePath, operation: "replace-body", changes: 0, message: "Parser unavailable", errorCode: ErrorCode.UNSUPPORTED_LANGUAGE };
593
+
594
+ const tree = parseSource(parser, source);
595
+ const edits: { start: number; end: number; text: string }[] = [];
596
+ let changes = 0;
597
+
598
+ function findAndReplace(node: Parser.SyntaxNode): boolean {
599
+ if (cfg!.functionTypes.includes(node.type)) {
600
+ const nameNode = node.childForFieldName("name");
601
+ if (nameNode && nameNode.text === symbolName) {
602
+ const bodyNode = node.childForFieldName("body");
603
+ if (bodyNode) {
604
+ // Indent of the line the signature starts on — the body's own closing
605
+ // delimiter lines up with that, not with the body node's start column.
606
+ const declLineStart = source.lastIndexOf("\n", node.startIndex) + 1;
607
+ const outerIndent = source.slice(declLineStart, node.startIndex).match(/^\s*/)?.[0] ?? "";
608
+ const indent = outerIndent + " ";
609
+ const indentedBody = newBody
610
+ .split("\n")
611
+ .map((l) => (l.length ? indent + l : l))
612
+ .join("\n");
613
+
614
+ // Brace-delimited bodies keep their braces. Replacing the whole body
615
+ // node with bare statement text stripped them, producing
616
+ // `function f(): string return x;` — which does not parse. The parse
617
+ // gate now catches that, but the delimiters have to survive anyway.
618
+ const open = bodyNode.firstChild;
619
+ const close = bodyNode.lastChild;
620
+ const braced = open?.text === "{" && close?.text === "}" && open !== close;
621
+ if (braced) {
622
+ edits.push({
623
+ start: open!.endIndex,
624
+ end: close!.startIndex,
625
+ text: `\n${indentedBody}\n${outerIndent}`,
626
+ });
627
+ } else {
628
+ // Indentation-delimited (Python): the block node is the body.
629
+ edits.push({ start: bodyNode.startIndex, end: bodyNode.endIndex, text: indentedBody.trimStart() });
630
+ }
631
+ changes++;
632
+ return true;
633
+ }
634
+ }
635
+ }
636
+ for (const child of node.children) {
637
+ if (findAndReplace(child)) return true;
638
+ }
639
+ return false;
640
+ }
641
+ findAndReplace(tree.rootNode);
642
+
643
+ if (changes === 0) return { success: false, path: filePath, operation: "replace-body", changes: 0, message: `Symbol '${symbolName}' not found or has no body`, errorCode: ErrorCode.SYMBOL_NOT_FOUND };
644
+
645
+ edits.sort((a, b) => b.start - a.start);
646
+ let newSource = source;
647
+ for (const e of edits) {
648
+ newSource = newSource.slice(0, e.start) + e.text + newSource.slice(e.end);
649
+ }
650
+ return { success: true, path: filePath, operation: "replace-body", changes, message: `Replaced body of '${symbolName}'`, newSource };
651
+ }
652
+
653
+ // ── Language-specific import config ────────────────────────────────────
654
+
655
+ /**
656
+ * Optional function to determine where to insert inside a grouped import block
657
+ * (e.g., inside Go's `import ( ... )`). If provided and returns non-null,
658
+ * it takes precedence over the default append-after-last-import behavior.
659
+ */
660
+ type GroupedInsertFn = (source: string, rootNode: Parser.SyntaxNode, newImportLine: string) => string | null;
661
+
662
+ interface ImportConfig {
663
+ /** Node types that represent import/use statements */
664
+ nodeTypes: string[];
665
+ /** Template for new import text. {spec} is replaced with importSpec. */
666
+ lineTemplate: string;
667
+ /**
668
+ * Optional function to transform the user-provided importSpec before
669
+ * substituting into lineTemplate. Used for backward-compatible wrapping.
670
+ */
671
+ transformSpec?: (spec: string) => string;
672
+ /**
673
+ * Optional function to determine where to insert when no existing import
674
+ * node is found. Receives the parsed tree root. Returns a source index
675
+ * position (must be >= 0) or null to fall back to position 0.
676
+ * Default: null (inserts at position 0).
677
+ */
678
+ fallbackInsert?: (rootNode: Parser.SyntaxNode) => number | null;
679
+ /**
680
+ * Optional function to insert into an existing grouped import block
681
+ * (e.g., Go's `import ( ... )`). Returns the new source or null to
682
+ * fall through to default append-after-last-import behavior.
683
+ */
684
+ groupedInsert?: GroupedInsertFn;
685
+ }
686
+
687
+ const IMPORT_CONFIGS: Record<string, ImportConfig> = {
688
+ typescript: { nodeTypes: ["import_statement"], lineTemplate: "import {spec};\n" },
689
+ tsx: { nodeTypes: ["import_statement"], lineTemplate: "import {spec};\n" },
690
+ javascript: { nodeTypes: ["import_statement"], lineTemplate: "import {spec};\n" },
691
+ python: {
692
+ nodeTypes: ["import_statement", "import_from_statement"],
693
+ lineTemplate: "{spec}\n",
694
+ transformSpec: (s: string) =>
695
+ s.startsWith("import ") || s.startsWith("from ") ? s : "import " + s,
696
+ },
697
+ go: {
698
+ nodeTypes: ["import_declaration"],
699
+ lineTemplate: "import \"{spec}\"\n",
700
+ fallbackInsert: (root) => {
701
+ // Insert after package_clause when no imports exist
702
+ function findPkg(n: Parser.SyntaxNode): number | null {
703
+ if (n.type === "package_clause") return n.endIndex;
704
+ for (let i = 0; i < n.childCount; i++) {
705
+ const r = findPkg(n.child(i));
706
+ if (r !== null) return r;
707
+ }
708
+ return null;
709
+ }
710
+ return findPkg(root);
711
+ },
712
+ // Insert into existing grouped import block (import ( ... )) rather than creating a new line
713
+ groupedInsert: (source, root, newImportLine) => {
714
+ // Find the last grouped import_declaration (has import_spec_list child)
715
+ let grouped: Parser.SyntaxNode | null = null;
716
+ function findLastGrouped(n: Parser.SyntaxNode) {
717
+ if (n.type === "import_declaration") {
718
+ for (let i = 0; i < n.childCount; i++) {
719
+ if (n.child(i).type === "import_spec_list") {
720
+ grouped = n;
721
+ break;
722
+ }
723
+ }
724
+ }
725
+ for (let i = 0; i < n.childCount; i++) findLastGrouped(n.child(i));
726
+ }
727
+ findLastGrouped(root);
728
+ if (!grouped) return null;
729
+
730
+ // Find the import_spec_list and its closing paren
731
+ for (let i = 0; i < grouped.childCount; i++) {
732
+ if (grouped.child(i).type === "import_spec_list") {
733
+ const specList = grouped.child(i);
734
+ const closeParen = specList.child(specList.childCount - 1);
735
+ if (closeParen && closeParen.type === ")") {
736
+ // Extract just the package name from newImportLine: `import "X"` → `\t"X"\n`
737
+ const specContent = newImportLine.replace(/^import\s+/, "").replace(/;\s*$/, "\n");
738
+ const insertContent = "\t" + specContent;
739
+ const insertAt = closeParen.startIndex;
740
+ return source.slice(0, insertAt) + insertContent + source.slice(insertAt);
741
+ }
742
+ }
743
+ }
744
+ return null;
745
+ },
746
+ },
747
+ rust: { nodeTypes: ["use_declaration"], lineTemplate: "use {spec};\n" },
748
+ };
749
+
750
+ /** The clause of a JS/TS import spec, split into the pieces a merge needs. */
751
+ interface JsImportSpecParts {
752
+ module: string;
753
+ /** Named bindings as written, e.g. `writeFileSync`, `a as b`. */
754
+ named: string[];
755
+ defaultName?: string;
756
+ /** True for `import type { .. } from "m"`. Type and value imports never merge. */
757
+ isType: boolean;
758
+ }
759
+
760
+ /** The local binding a named specifier introduces: `a as b` binds `b`. */
761
+ function jsLocalName(specifierText: string): string {
762
+ const parts = specifierText.split(/\s+as\s+/);
763
+ return (parts[parts.length - 1] ?? specifierText).trim();
764
+ }
765
+
766
+ /**
767
+ * Parse a JS/TS importSpec (`{ a, b as c } from "mod"`, `def from "mod"`).
768
+ * Returns null for forms with no merge semantics (namespace imports,
769
+ * side-effect imports, anything without a `from` clause).
770
+ */
771
+ function parseJsImportSpec(spec: string): JsImportSpecParts | null {
772
+ const m = spec.trim().match(/^(.*?)\s+from\s+['"]([^'"]+)['"];?$/);
773
+ if (!m) return null;
774
+ let clause = m[1].trim();
775
+ const module = m[2];
776
+ let isType = false;
777
+ if (/^type\s/.test(clause)) {
778
+ isType = true;
779
+ clause = clause.slice(4).trim();
780
+ }
781
+ if (clause.startsWith("*")) return null;
782
+
783
+ const named: string[] = [];
784
+ const namedMatch = clause.match(/\{([^}]*)\}/);
785
+ if (namedMatch) {
786
+ for (const part of namedMatch[1].split(",")) {
787
+ const t = part.trim();
788
+ if (t) named.push(t);
789
+ }
790
+ }
791
+ const before = namedMatch ? clause.slice(0, namedMatch.index).replace(/,\s*$/, "").trim() : clause;
792
+ if (before.startsWith("*")) return null;
793
+ const defaultName = before.length > 0 ? before : undefined;
794
+ if (named.length === 0 && !defaultName) return null;
795
+ return { module, named, defaultName, isType };
796
+ }
797
+
798
+ /**
799
+ * Merge a JS/TS import into an existing statement for the same module (#103).
800
+ *
801
+ * Returns null when there is nothing to merge into, so the caller falls through
802
+ * to inserting a fresh statement. Previously every add-import inserted a new
803
+ * statement, so an agent adding one name at a time accumulated one duplicate
804
+ * `import ... from "node:fs"` per call while each call reported success.
805
+ */
806
+ function addJsImportMerged(
807
+ source: string,
808
+ tree: Parser,
809
+ filePath: string,
810
+ importSpec: string
811
+ ): ASTEditResult | null {
812
+ const parts = parseJsImportSpec(importSpec);
813
+ if (!parts) return null;
814
+
815
+ let target: Parser.SyntaxNode | null = null;
816
+ function findTarget(node: Parser.SyntaxNode) {
817
+ if (target) return;
818
+ if (node.type === "import_statement") {
819
+ // `import type { .. }` erases its bindings at compile time, so merging a
820
+ // value import into one would silently delete it from the output (#103).
821
+ const stmtIsType = node.children.some((c) => c.type === "type");
822
+ if (stmtIsType === parts!.isType) {
823
+ for (const c of node.children) {
824
+ if (c.type === "string" && unquoteLiteral(c.text) === parts!.module) {
825
+ target = node;
826
+ return;
827
+ }
828
+ }
829
+ }
830
+ }
831
+ for (const child of node.children) findTarget(child);
832
+ }
833
+ findTarget(tree.rootNode);
834
+ if (!target) return null;
835
+
836
+ const clause = findChildByType(target, "import_clause");
837
+ if (!clause) return null; // side-effect import: nothing to merge into
838
+
839
+ let namedNode: Parser.SyntaxNode | null = null;
840
+ let defaultNode: Parser.SyntaxNode | null = null;
841
+ for (const c of clause.children) {
842
+ if (c.type === "named_imports") namedNode = c;
843
+ else if (c.type === "identifier") defaultNode = c;
844
+ else if (c.type === "namespace_import") return null; // `import * as ns` has no merge form
845
+ }
846
+
847
+ const existing: string[] = [];
848
+ if (namedNode) {
849
+ for (const s of namedNode.children) {
850
+ if (s.type === "import_specifier") existing.push(s.text.trim());
851
+ }
852
+ }
853
+ const existingLocals = new Set(existing.map(jsLocalName));
854
+ const fresh = parts.named.filter((n) => !existingLocals.has(jsLocalName(n)));
855
+ const needDefault = parts.defaultName !== undefined && defaultNode === null;
856
+
857
+ if (fresh.length === 0 && !needDefault) {
858
+ return { success: false, path: filePath, operation: "add-import", changes: 0, message: `Import for '${importSpec}' already exists` };
859
+ }
860
+
861
+ const clauseParts: string[] = [];
862
+ const defaultText = defaultNode ? defaultNode.text : needDefault ? parts.defaultName! : null;
863
+ if (defaultText) clauseParts.push(defaultText);
864
+ const allNamed = [...existing, ...fresh];
865
+ if (allNamed.length > 0) clauseParts.push("{ " + allNamed.join(", ") + " }");
866
+
867
+ const newSource =
868
+ source.slice(0, clause.startIndex) + clauseParts.join(", ") + source.slice(clause.endIndex);
869
+
870
+ return {
871
+ success: true,
872
+ path: filePath,
873
+ operation: "add-import",
874
+ changes: 1,
875
+ message: `Added import: ${importSpec}`,
876
+ newSource,
877
+ };
878
+ }
879
+
880
+ /**
881
+ * A JS import spec broken into the pieces a `require` call needs (#139).
882
+ *
883
+ * `single` covers both `import d from "m"` and `import * as d from "m"`: each
884
+ * binds exactly one local name, and in CommonJS both resolve to the module
885
+ * object. For a namespace import that is exact; for a default import it is the
886
+ * usual `module.exports`-is-the-default interop convention.
887
+ */
888
+ interface CjsSpecParts {
889
+ module: string;
890
+ /** `{ a, b as c }` bindings, kept in their source form. */
891
+ named: string[];
892
+ /** The single local name bound by a default or namespace import. */
893
+ single?: string;
894
+ isType: boolean;
895
+ }
896
+
897
+ /** Local name a `{ a }` / `{ a as b }` binding introduces. */
898
+ function cjsBindingLocal(binding: string): string {
899
+ const m = binding.match(/\bas\s+([A-Za-z_$][\w$]*)\s*$/);
900
+ return m ? m[1] : binding.trim();
901
+ }
902
+
903
+ /** `a as b` → `a: b`, the CommonJS destructuring spelling of a rename. */
904
+ function cjsBindingText(binding: string): string {
905
+ const m = binding.trim().match(/^([A-Za-z_$][\w$]*)\s+as\s+([A-Za-z_$][\w$]*)$/);
906
+ return m ? `${m[1]}: ${m[2]}` : binding.trim();
907
+ }
908
+
909
+ /**
910
+ * Parse an import spec for the CommonJS emitter. Unlike `parseJsImportSpec`
911
+ * this accepts `* as ns`, because a namespace import has a direct `require`
912
+ * form even though it has no ESM merge form.
913
+ */
914
+ function parseCjsImportSpec(spec: string): CjsSpecParts | null {
915
+ const m = spec.trim().match(/^(.*?)\s+from\s+['"]([^'"]+)['"];?$/);
916
+ if (!m) return null;
917
+ let clause = m[1].trim();
918
+ const module = m[2];
919
+ let isType = false;
920
+ if (/^type\s/.test(clause)) {
921
+ isType = true;
922
+ clause = clause.slice(4).trim();
923
+ }
924
+
925
+ const named: string[] = [];
926
+ const namedMatch = clause.match(/\{([^}]*)\}/);
927
+ if (namedMatch) {
928
+ for (const part of namedMatch[1].split(",")) {
929
+ const t = part.trim();
930
+ if (t) named.push(t);
931
+ }
932
+ }
933
+ const before = namedMatch ? clause.slice(0, namedMatch.index).replace(/,\s*$/, "").trim() : clause;
934
+
935
+ let single: string | undefined;
936
+ if (before.length > 0) {
937
+ const ns = before.match(/^\*\s+as\s+([A-Za-z_$][\w$]*)$/);
938
+ if (ns) single = ns[1];
939
+ else if (/^[A-Za-z_$][\w$]*$/.test(before)) single = before;
940
+ else return null;
941
+ }
942
+
943
+ if (named.length === 0 && single === undefined) return null;
944
+ return { module, named, single, isType };
945
+ }
946
+
947
+ /** The `const ... = require("m");` line for a parsed spec. */
948
+ function cjsRequireLine(parts: CjsSpecParts): string {
949
+ const binding =
950
+ parts.single !== undefined
951
+ ? parts.single
952
+ : "{ " + parts.named.map(cjsBindingText).join(", ") + " }";
953
+ return `const ${binding} = require("${parts.module}");\n`;
954
+ }
955
+
956
+ /** A `const x = require("m")` / `const { x } = require("m")` declaration. */
957
+ interface CjsRequireDecl {
958
+ /** The whole declaration statement, including its semicolon. */
959
+ node: Parser.SyntaxNode;
960
+ module: string;
961
+ /** `object_pattern` for a destructured require, `identifier` for a whole-module one. */
962
+ pattern: Parser.SyntaxNode;
963
+ }
964
+
965
+ /** The module string of a `require("m")` call expression, or null. */
966
+ function requireCallModule(node: Parser.SyntaxNode | null): string | null {
967
+ if (!node || node.type !== "call_expression") return null;
968
+ const fn = node.child(0);
969
+ if (!fn || fn.type !== "identifier" || fn.text !== "require") return null;
970
+ const args = findChildByType(node, "arguments");
971
+ if (!args) return null;
972
+ const strings = args.children.filter((c) => c.type === "string");
973
+ if (strings.length !== 1) return null;
974
+ return unquoteLiteral(strings[0].text);
975
+ }
976
+
977
+ function collectRequireDecls(root: Parser.SyntaxNode): CjsRequireDecl[] {
978
+ const found: CjsRequireDecl[] = [];
979
+ function walk(node: Parser.SyntaxNode) {
980
+ if (node.type === "lexical_declaration" || node.type === "variable_declaration") {
981
+ const declarators = node.children.filter((c) => c.type === "variable_declarator");
982
+ // `const a = require("x"), b = require("y")` shares one statement, so
983
+ // editing it by statement would take both bindings out. Leave those alone.
984
+ if (declarators.length === 1) {
985
+ const d = declarators[0];
986
+ const pattern = d.child(0);
987
+ const value = d.childForFieldName ? d.childForFieldName("value") : null;
988
+ const module = requireCallModule(value ?? d.children[d.children.length - 1] ?? null);
989
+ if (module !== null && pattern && (pattern.type === "object_pattern" || pattern.type === "identifier")) {
990
+ found.push({ node, module, pattern });
991
+ return;
992
+ }
993
+ }
994
+ }
995
+ for (const child of node.children) walk(child);
996
+ }
997
+ walk(root);
998
+ return found;
999
+ }
1000
+
1001
+ /** Bindings inside a `const { a, b: c } = require(...)` pattern. */
1002
+ function cjsPatternEntries(pattern: Parser.SyntaxNode): { node: Parser.SyntaxNode; names: string[] }[] {
1003
+ const entries: { node: Parser.SyntaxNode; names: string[] }[] = [];
1004
+ for (const c of pattern.children) {
1005
+ if (c.type === "shorthand_property_identifier_pattern") {
1006
+ entries.push({ node: c, names: [c.text] });
1007
+ } else if (c.type === "pair_pattern") {
1008
+ const names = c.children.filter((n) => n.type !== ":").map((n) => n.text);
1009
+ entries.push({ node: c, names });
1010
+ }
1011
+ }
1012
+ return entries;
1013
+ }
1014
+
1015
+ function moduleSystemRefusal(
1016
+ filePath: string,
1017
+ operation: string,
1018
+ message: string,
1019
+ recovery: string
1020
+ ): ASTEditResult {
1021
+ return {
1022
+ success: false,
1023
+ path: filePath,
1024
+ operation,
1025
+ changes: 0,
1026
+ message,
1027
+ errorCode: ErrorCode.MODULE_SYSTEM_MISMATCH,
1028
+ recovery,
1029
+ };
1030
+ }
1031
+
1032
+ /**
1033
+ * Add an import to a CommonJS JavaScript file as a `require` declaration (#139).
1034
+ *
1035
+ * Emitting the ESM form here is the one outcome that must not happen: it parses,
1036
+ * so the validity gate passes it, and the file then fails to load at runtime.
1037
+ */
1038
+ function addCjsImport(
1039
+ source: string,
1040
+ tree: Parser,
1041
+ filePath: string,
1042
+ importSpec: string
1043
+ ): ASTEditResult {
1044
+ const parts = parseCjsImportSpec(importSpec);
1045
+ if (!parts) {
1046
+ return {
1047
+ success: false,
1048
+ path: filePath,
1049
+ operation: "add-import",
1050
+ changes: 0,
1051
+ message: `Could not read '${importSpec}' as an import clause`,
1052
+ errorCode: ErrorCode.INVALID_ARGUMENT,
1053
+ recovery:
1054
+ 'Pass a full import clause, e.g. \'{ join } from "path"\', \'path from "path"\', or \'* as path from "path"\'.',
1055
+ };
1056
+ }
1057
+ if (parts.isType) {
1058
+ return moduleSystemRefusal(
1059
+ filePath,
1060
+ "add-import",
1061
+ `'${importSpec}' is a type-only import, which has no CommonJS form`,
1062
+ "Type-only imports are erased at compile time; drop the `type` keyword, or make the edit in a TypeScript file.",
1063
+ );
1064
+ }
1065
+ if (parts.single !== undefined && parts.named.length > 0) {
1066
+ return moduleSystemRefusal(
1067
+ filePath,
1068
+ "add-import",
1069
+ `'${importSpec}' combines a default and named bindings, which has no single CommonJS declaration`,
1070
+ `Add them in two calls: '${parts.single} from "${parts.module}"' and '{ ${parts.named.join(", ")} } from "${parts.module}"'.`,
1071
+ );
1072
+ }
1073
+
1074
+ const decls = collectRequireDecls(tree.rootNode);
1075
+ const sameModule = decls.filter((d) => d.module === parts.module);
1076
+
1077
+ // Merge into an existing destructured require for the same module, mirroring
1078
+ // the ESM merge (#103) so repeated one-name-at-a-time calls do not accumulate
1079
+ // a duplicate declaration per call.
1080
+ if (parts.single === undefined) {
1081
+ const mergeTarget = sameModule.find((d) => d.pattern.type === "object_pattern");
1082
+ if (mergeTarget) {
1083
+ const existing = cjsPatternEntries(mergeTarget.pattern);
1084
+ const existingLocals = new Set(existing.map((e) => cjsBindingLocal(e.node.text)));
1085
+ const fresh = parts.named.filter((n) => !existingLocals.has(cjsBindingLocal(n)));
1086
+ if (fresh.length === 0) {
1087
+ return { success: false, path: filePath, operation: "add-import", changes: 0, message: `Import for '${importSpec}' already exists` };
1088
+ }
1089
+ const all = [...existing.map((e) => e.node.text), ...fresh.map(cjsBindingText)];
1090
+ const newSource =
1091
+ source.slice(0, mergeTarget.pattern.startIndex) +
1092
+ "{ " + all.join(", ") + " }" +
1093
+ source.slice(mergeTarget.pattern.endIndex);
1094
+ return { success: true, path: filePath, operation: "add-import", changes: 1, message: `Added import: ${importSpec}`, newSource };
1095
+ }
1096
+ } else {
1097
+ const already = sameModule.find((d) => d.pattern.type === "identifier" && d.pattern.text === parts.single);
1098
+ if (already) {
1099
+ return { success: false, path: filePath, operation: "add-import", changes: 0, message: `Import for '${importSpec}' already exists` };
1100
+ }
1101
+ }
1102
+
1103
+ const line = cjsRequireLine(parts);
1104
+
1105
+ // Anchor: after the last existing require declaration, else after any shebang
1106
+ // and leading comments, so the declaration lands with the other imports rather
1107
+ // than above the file's header.
1108
+ let insertAt: number;
1109
+ if (decls.length > 0) {
1110
+ const lastEnd = Math.max(...decls.map((d) => d.node.endIndex));
1111
+ insertAt = lastEnd;
1112
+ if (source[insertAt] === "\r") insertAt++;
1113
+ if (source[insertAt] === "\n") insertAt++;
1114
+ return {
1115
+ success: true,
1116
+ path: filePath,
1117
+ operation: "add-import",
1118
+ changes: 1,
1119
+ message: `Added import: ${importSpec}`,
1120
+ newSource: source.slice(0, insertAt) + line + source.slice(insertAt),
1121
+ };
1122
+ }
1123
+
1124
+ insertAt = 0;
1125
+ for (const child of tree.rootNode.children) {
1126
+ if (child.type === "hash_bang_line" || child.type === "comment") {
1127
+ insertAt = child.endIndex;
1128
+ continue;
1129
+ }
1130
+ break;
1131
+ }
1132
+ if (insertAt === 0) {
1133
+ return {
1134
+ success: true,
1135
+ path: filePath,
1136
+ operation: "add-import",
1137
+ changes: 1,
1138
+ message: `Added import: ${importSpec}`,
1139
+ newSource: line + source,
1140
+ };
1141
+ }
1142
+ if (source[insertAt] === "\r") insertAt++;
1143
+ if (source[insertAt] === "\n") insertAt++;
1144
+ return {
1145
+ success: true,
1146
+ path: filePath,
1147
+ operation: "add-import",
1148
+ changes: 1,
1149
+ message: `Added import: ${importSpec}`,
1150
+ newSource: source.slice(0, insertAt) + line + source.slice(insertAt),
1151
+ };
1152
+ }
1153
+
1154
+ /**
1155
+ * Remove a `require` declaration, or one binding out of a destructured one
1156
+ * (#139). Returns a failure the caller may ignore when nothing matched, so a
1157
+ * file holding both `require` and `import` still gets the ESM pass.
1158
+ */
1159
+ function removeCjsImport(
1160
+ source: string,
1161
+ tree: Parser,
1162
+ filePath: string,
1163
+ importSpec: string
1164
+ ): ASTEditResult {
1165
+ const form = parseImportSpecForm(importSpec);
1166
+ const wantModule = form.module;
1167
+ const wantName = form.name ?? (wantModule ? undefined : importSpec.trim());
1168
+
1169
+ const decls = collectRequireDecls(tree.rootNode).filter(
1170
+ (d) => wantModule === undefined || d.module === wantModule
1171
+ );
1172
+
1173
+ const edits: { start: number; end: number; replace?: string }[] = [];
1174
+ let changes = 0;
1175
+
1176
+ for (const decl of decls) {
1177
+ if (wantName === undefined) {
1178
+ edits.push({ start: decl.node.startIndex, end: decl.node.endIndex });
1179
+ changes++;
1180
+ continue;
1181
+ }
1182
+ if (decl.pattern.type === "identifier") {
1183
+ if (decl.pattern.text === wantName || (wantModule === undefined && decl.module === wantName)) {
1184
+ edits.push({ start: decl.node.startIndex, end: decl.node.endIndex });
1185
+ changes++;
1186
+ }
1187
+ continue;
1188
+ }
1189
+ const entries = cjsPatternEntries(decl.pattern);
1190
+ const matched = entries.filter((e) => e.names.includes(wantName));
1191
+ if (matched.length === 0) {
1192
+ // A bare spec may name the module rather than a binding.
1193
+ if (wantModule === undefined && decl.module === wantName) {
1194
+ edits.push({ start: decl.node.startIndex, end: decl.node.endIndex });
1195
+ changes++;
1196
+ }
1197
+ continue;
1198
+ }
1199
+ const keep = entries.filter((e) => !matched.includes(e));
1200
+ if (keep.length === 0) {
1201
+ edits.push({ start: decl.node.startIndex, end: decl.node.endIndex });
1202
+ } else {
1203
+ edits.push({
1204
+ start: decl.pattern.startIndex,
1205
+ end: decl.pattern.endIndex,
1206
+ replace: "{ " + keep.map((e) => e.node.text).join(", ") + " }",
1207
+ });
1208
+ }
1209
+ changes += matched.length;
1210
+ }
1211
+
1212
+ if (edits.length === 0) {
1213
+ return { success: false, path: filePath, operation: "remove-import", changes: 0, message: `No import for '${importSpec}' found` };
1214
+ }
1215
+
1216
+ edits.sort((a, b) => b.start - a.start);
1217
+ let newSource = source;
1218
+ for (const e of edits) {
1219
+ if (e.replace !== undefined) {
1220
+ newSource = newSource.slice(0, e.start) + e.replace + newSource.slice(e.end);
1221
+ } else {
1222
+ // Exactly one line ending, never a run of them: `add-import` inserts one
1223
+ // line, so consuming every following newline would swallow the blank line
1224
+ // separating the requires from the code and break the round-trip.
1225
+ let end = e.end;
1226
+ if (newSource[end] === "\r") end++;
1227
+ if (newSource[end] === "\n") end++;
1228
+ newSource = newSource.slice(0, e.start) + newSource.slice(end);
1229
+ }
1230
+ }
1231
+
1232
+ return { success: true, path: filePath, operation: "remove-import", changes, message: `Removed ${changes} import(s) for '${importSpec}'`, newSource };
1233
+ }
1234
+
1235
+ function addImportUnchecked(
1236
+ source: string,
1237
+ filePath: string,
1238
+ importSpec: string
1239
+ ): ASTEditResult {
1240
+ const lang = detectLanguage(filePath);
1241
+ if (!lang) return { success: false, path: filePath, operation: "add-import", changes: 0, message: "Unsupported language", errorCode: ErrorCode.UNSUPPORTED_LANGUAGE };
1242
+ const icfg = IMPORT_CONFIGS[lang];
1243
+ if (!icfg) return { success: false, path: filePath, operation: "add-import", changes: 0, message: "Unsupported language", errorCode: ErrorCode.UNSUPPORTED_LANGUAGE };
1244
+ const parser = getParser(lang);
1245
+ if (!parser) return { success: false, path: filePath, operation: "add-import", changes: 0, message: "Parser unavailable", errorCode: ErrorCode.UNSUPPORTED_LANGUAGE };
1246
+
1247
+ const tree = parseSource(parser, source);
1248
+
1249
+ // JavaScript has two module systems and only one of them takes `import`.
1250
+ // tree-sitter parses either, so the validity gate cannot tell them apart —
1251
+ // the module system has to be established before any syntax is chosen (#139).
1252
+ if (lang === "javascript") {
1253
+ const verdict = detectModuleSystem(filePath, source);
1254
+ if (verdict.system === null) {
1255
+ return moduleSystemRefusal(
1256
+ filePath,
1257
+ "add-import",
1258
+ `Cannot tell whether ${filePath} is ESM or CommonJS: ${verdict.detail}`,
1259
+ 'Rename the file to .mjs or .cjs, or set "type" in the nearest package.json, then retry.',
1260
+ );
1261
+ }
1262
+ if (verdict.system === "cjs") return addCjsImport(source, tree, filePath, importSpec);
1263
+ }
1264
+
1265
+ // JS/TS merge into an existing import of the same module, which also covers
1266
+ // the duplicate check for that module (#103).
1267
+ if (lang === "typescript" || lang === "tsx" || lang === "javascript") {
1268
+ const merged = addJsImportMerged(source, tree, filePath, importSpec);
1269
+ if (merged) return merged;
1270
+ }
1271
+
1272
+ // Dedup check: search source for existing import containing the spec text
1273
+ const dedupPattern = new RegExp(`(import|from|use).*${escapeRegex(importSpec)}`);
1274
+ if (dedupPattern.test(source)) {
1275
+ return { success: false, path: filePath, operation: "add-import", changes: 0, message: `Import for '${importSpec}' already exists` };
1276
+ }
1277
+
1278
+ let lastImportEnd = 0;
1279
+ function findLastImport(node: Parser.SyntaxNode) {
1280
+ if (icfg!.nodeTypes.includes(node.type)) lastImportEnd = Math.max(lastImportEnd, node.endIndex);
1281
+ for (const child of node.children) findLastImport(child);
1282
+ }
1283
+ findLastImport(tree.rootNode);
1284
+
1285
+ const resolvedSpec = icfg.transformSpec ? icfg.transformSpec(importSpec) : importSpec;
1286
+ const newImportLine = icfg.lineTemplate.replace("{spec}", resolvedSpec);
1287
+
1288
+ // Python from-import merging: if `from X import Y`, merge into existing statement for module X
1289
+ if (lang === "python" && importSpec.startsWith("from ")) {
1290
+ const parsed = parsePythonFromImport(importSpec, source, tree);
1291
+ if (parsed) {
1292
+ return parsed;
1293
+ }
1294
+ }
1295
+
1296
+ let newSource: string;
1297
+ if (lastImportEnd > 0) {
1298
+ // Try grouped insert first (e.g., Go import ( ... ) blocks), then fall back to appending after last import
1299
+ const groupedResult = icfg.groupedInsert?.(source, tree.rootNode, newImportLine) ?? null;
1300
+ if (groupedResult !== null) {
1301
+ newSource = groupedResult;
1302
+ } else {
1303
+ // Insert on the line right after the last import: consume exactly one
1304
+ // newline, never the blank line that separates the import block from the
1305
+ // code below it (#103).
1306
+ let insertPos = lastImportEnd;
1307
+ if (source[insertPos] === "\r") insertPos++;
1308
+ // No newline after the last import (EOF without a trailing newline): open
1309
+ // one, otherwise the two statements would be glued onto the same line.
1310
+ const prefix = source[insertPos] === "\n" ? (insertPos++, "") : "\n";
1311
+ newSource = source.slice(0, insertPos) + prefix + newImportLine + source.slice(insertPos);
1312
+ }
1313
+ } else if (icfg.fallbackInsert) {
1314
+ const pos = icfg.fallbackInsert(tree.rootNode);
1315
+ if (pos !== null && pos > 0) {
1316
+ // Insert after package_clause (or similar anchor), ensuring a blank line before code
1317
+ const restAfterPos = source.slice(pos);
1318
+ newSource = source.slice(0, pos) + "\n\n" + newImportLine + restAfterPos.replace(/^\n+/, "");
1319
+ } else {
1320
+ newSource = newImportLine + source;
1321
+ }
1322
+ } else {
1323
+ newSource = newImportLine + source;
1324
+ }
1325
+ return { success: true, path: filePath, operation: "add-import", changes: 1, message: `Added import: ${importSpec}`, newSource };
1326
+ }
1327
+
1328
+ function removeImportUnchecked(
1329
+ source: string,
1330
+ filePath: string,
1331
+ importSpec: string
1332
+ ): ASTEditResult {
1333
+ const lang = detectLanguage(filePath);
1334
+ if (!lang) {
1335
+ return { success: false, path: filePath, operation: "remove-import", changes: 0, message: "Unsupported language", errorCode: ErrorCode.UNSUPPORTED_LANGUAGE };
1336
+ }
1337
+ const parser = getParser(lang);
1338
+ if (!parser) {
1339
+ return { success: false, path: filePath, operation: "remove-import", changes: 0, message: "Parser unavailable", errorCode: ErrorCode.UNSUPPORTED_LANGUAGE };
1340
+ }
1341
+ const tree = parseSource(parser, source);
1342
+ const icfg = IMPORT_CONFIGS[lang];
1343
+
1344
+ // --- Rust grouped-use: separate code path for surgical removal ---
1345
+ if (lang === "rust") {
1346
+ return removeRustImport(source, tree, filePath, importSpec);
1347
+ }
1348
+
1349
+ // A CommonJS `require` declaration is not an `import_statement`, so the
1350
+ // generic model never sees it. Try it first for JavaScript and fall through
1351
+ // when nothing matched, which keeps mixed files working (#139).
1352
+ if (lang === "javascript") {
1353
+ const cjs = removeCjsImport(source, tree, filePath, importSpec);
1354
+ if (cjs.success) return cjs;
1355
+ }
1356
+
1357
+ // --- Other languages: binding-level surgical removal (#102) ---
1358
+ return removeImportGeneric(source, tree, filePath, importSpec, lang);
1359
+ }
1360
+
1361
+ /** One removable binding inside an import statement. */
1362
+ interface ImportItem {
1363
+ node: Parser.SyntaxNode;
1364
+ /** Every token that may legitimately select this binding. */
1365
+ names: string[];
1366
+ }
1367
+
1368
+ /** An import statement reduced to the pieces remove-import can act on. */
1369
+ interface ImportModel {
1370
+ node: Parser.SyntaxNode;
1371
+ /** Module identifiers that select the whole statement. */
1372
+ modules: string[];
1373
+ items: ImportItem[];
1374
+ /**
1375
+ * Rewrite the statement so only `keep` survives. Returns null when nothing
1376
+ * survives, which means the caller should delete the whole statement.
1377
+ */
1378
+ rewrite: (keep: ImportItem[]) => { start: number; end: number; replace: string } | null;
1379
+ }
1380
+
1381
+ /** Strip surrounding quotes from a string literal's source text. */
1382
+ function unquoteLiteral(text: string): string {
1383
+ return text.replace(/^['"`]|['"`]$/g, "");
1384
+ }
1385
+
1386
+ /**
1387
+ * Split an importSpec into an optional binding name and optional module.
1388
+ * Accepts the bare form (`statSync`, `node:fs`) and the documented full forms
1389
+ * (`{ statSync } from "node:fs"`, `statSync from "node:fs"`,
1390
+ * `from node:fs import statSync`). #102: the full form used to fail outright
1391
+ * because it was matched as a literal substring.
1392
+ */
1393
+ function parseImportSpecForm(spec: string): { name?: string; module?: string } {
1394
+ const trimmed = spec.trim();
1395
+ const jsForm = trimmed.match(/^\{?\s*([A-Za-z_$][\w$]*)\s*\}?\s+from\s+['"]([^'"]+)['"]$/);
1396
+ if (jsForm) return { name: jsForm[1], module: jsForm[2] };
1397
+ const pyForm = trimmed.match(/^from\s+([\w.]+)\s+import\s+([\w.]+)$/);
1398
+ if (pyForm) return { name: pyForm[2], module: pyForm[1] };
1399
+ const pyPlain = trimmed.match(/^import\s+([\w.]+)$/);
1400
+ if (pyPlain) return { name: pyPlain[1] };
1401
+ return {};
1402
+ }
1403
+
1404
+ /** Names that select a TS/JS import specifier: the imported name and its alias. */
1405
+ function tsSpecifierNames(spec: Parser.SyntaxNode): string[] {
1406
+ const names: string[] = [];
1407
+ for (const c of spec.children) {
1408
+ if (c.type === "identifier" || c.type === "type_identifier") names.push(c.text);
1409
+ }
1410
+ return names.length > 0 ? names : [spec.text.trim()];
1411
+ }
1412
+
1413
+ function buildTsImportModel(node: Parser.SyntaxNode): ImportModel {
1414
+ const clause = findChildByType(node, "import_clause");
1415
+ const modules: string[] = [];
1416
+ for (const c of node.children) {
1417
+ if (c.type === "string") modules.push(unquoteLiteral(c.text));
1418
+ }
1419
+
1420
+ let defaultItem: ImportItem | null = null;
1421
+ let nsItem: ImportItem | null = null;
1422
+ const named: ImportItem[] = [];
1423
+ if (clause) {
1424
+ for (const c of clause.children) {
1425
+ if (c.type === "identifier") {
1426
+ defaultItem = { node: c, names: [c.text] };
1427
+ } else if (c.type === "namespace_import") {
1428
+ const id = findLastIdentifier(c);
1429
+ nsItem = { node: c, names: id ? [id.text] : [] };
1430
+ } else if (c.type === "named_imports") {
1431
+ for (const s of c.children) {
1432
+ if (s.type === "import_specifier") named.push({ node: s, names: tsSpecifierNames(s) });
1433
+ }
1434
+ }
1435
+ }
1436
+ }
1437
+
1438
+ const items = [defaultItem, nsItem, ...named].filter((i): i is ImportItem => i !== null);
1439
+
1440
+ return {
1441
+ node,
1442
+ modules,
1443
+ items,
1444
+ rewrite(keep) {
1445
+ if (!clause) return null;
1446
+ const parts: string[] = [];
1447
+ if (defaultItem && keep.includes(defaultItem)) parts.push(defaultItem.node.text);
1448
+ if (nsItem && keep.includes(nsItem)) {
1449
+ parts.push(nsItem.node.text);
1450
+ } else {
1451
+ const keptNamed = named.filter((n) => keep.includes(n));
1452
+ if (keptNamed.length > 0) parts.push("{ " + keptNamed.map((n) => n.node.text).join(", ") + " }");
1453
+ }
1454
+ if (parts.length === 0) return null;
1455
+ return { start: clause.startIndex, end: clause.endIndex, replace: parts.join(", ") };
1456
+ },
1457
+ };
1458
+ }
1459
+
1460
+ /** Names that select a Python imported item: full dotted path, last segment, alias. */
1461
+ function pyItemNames(item: Parser.SyntaxNode): string[] {
1462
+ const names = new Set<string>();
1463
+ const add = (t: string) => {
1464
+ names.add(t);
1465
+ const last = t.split(".").pop();
1466
+ if (last) names.add(last);
1467
+ };
1468
+ if (item.type === "aliased_import") {
1469
+ for (const c of item.children) if (c.type !== "as") add(c.text);
1470
+ } else {
1471
+ add(item.text);
1472
+ }
1473
+ return [...names];
1474
+ }
1475
+
1476
+ function buildPyImportModel(node: Parser.SyntaxNode): ImportModel {
1477
+ const isFrom = node.type === "import_from_statement";
1478
+ const modules: string[] = [];
1479
+ const itemNodes: Parser.SyntaxNode[] = [];
1480
+ let seenImportKeyword = !isFrom;
1481
+
1482
+ for (const c of node.children) {
1483
+ if (c.type === "import" || c.text === "import") {
1484
+ seenImportKeyword = true;
1485
+ continue;
1486
+ }
1487
+ if (c.type === "from" || c.type === "," || c.type === "(" || c.type === ")") continue;
1488
+ if (!seenImportKeyword) {
1489
+ modules.push(c.text);
1490
+ continue;
1491
+ }
1492
+ if (c.type === "wildcard_import") continue;
1493
+ itemNodes.push(c);
1494
+ }
1495
+
1496
+ const items = itemNodes.map((n) => ({ node: n, names: pyItemNames(n) }));
1497
+
1498
+ return {
1499
+ node,
1500
+ modules,
1501
+ items,
1502
+ rewrite(keep) {
1503
+ const kept = items.filter((i) => keep.includes(i));
1504
+ if (kept.length === 0 || itemNodes.length === 0) return null;
1505
+ return {
1506
+ start: itemNodes[0].startIndex,
1507
+ end: itemNodes[itemNodes.length - 1].endIndex,
1508
+ replace: kept.map((i) => i.node.text).join(", "),
1509
+ };
1510
+ },
1511
+ };
1512
+ }
1513
+
1514
+ /** Names that select a Go import spec: full path, last path segment, alias. */
1515
+ function goSpecNames(spec: Parser.SyntaxNode): string[] {
1516
+ const names = new Set<string>();
1517
+ for (const c of spec.children) {
1518
+ if (c.type === "interpreted_string_literal" || c.type === "raw_string_literal") {
1519
+ const path = unquoteLiteral(c.text);
1520
+ names.add(path);
1521
+ const last = path.split("/").pop();
1522
+ if (last) names.add(last);
1523
+ } else if (c.type === "package_identifier" || c.type === "identifier" || c.type === "dot" || c.type === "blank_identifier") {
1524
+ names.add(c.text);
1525
+ }
1526
+ }
1527
+ if (names.size === 0) {
1528
+ const path = unquoteLiteral(spec.text);
1529
+ names.add(path);
1530
+ const last = path.split("/").pop();
1531
+ if (last) names.add(last);
1532
+ }
1533
+ return [...names];
1534
+ }
1535
+
1536
+ function buildGoImportModel(node: Parser.SyntaxNode): ImportModel {
1537
+ const specList = findChildByType(node, "import_spec_list");
1538
+ const specNodes: Parser.SyntaxNode[] = [];
1539
+ const container = specList ?? node;
1540
+ for (const c of container.children) {
1541
+ if (c.type === "import_spec") specNodes.push(c);
1542
+ else if (!specList && (c.type === "interpreted_string_literal" || c.type === "raw_string_literal")) specNodes.push(c);
1543
+ }
1544
+
1545
+ const items = specNodes.map((n) => ({ node: n, names: goSpecNames(n) }));
1546
+
1547
+ return {
1548
+ node,
1549
+ modules: [],
1550
+ items,
1551
+ rewrite(keep) {
1552
+ const kept = items.filter((i) => keep.includes(i));
1553
+ if (kept.length === 0 || specNodes.length === 0) return null;
1554
+ return {
1555
+ start: specNodes[0].startIndex,
1556
+ end: specNodes[specNodes.length - 1].endIndex,
1557
+ replace: kept.map((i) => i.node.text).join("\n\t"),
1558
+ };
1559
+ },
1560
+ };
1561
+ }
1562
+
1563
+ function buildImportModel(node: Parser.SyntaxNode, lang: string): ImportModel | null {
1564
+ if (lang === "typescript" || lang === "tsx" || lang === "javascript") {
1565
+ return node.type === "import_statement" ? buildTsImportModel(node) : null;
1566
+ }
1567
+ if (lang === "python") {
1568
+ return node.type === "import_statement" || node.type === "import_from_statement" ? buildPyImportModel(node) : null;
1569
+ }
1570
+ if (lang === "go") {
1571
+ return node.type === "import_declaration" ? buildGoImportModel(node) : null;
1572
+ }
1573
+ return null;
1574
+ }
1575
+
1576
+ /**
1577
+ * Binding-level remove-import for TS/TSX/JS/Python/Go (#102).
1578
+ *
1579
+ * The old implementation deleted any import statement whose text *contained*
1580
+ * importSpec, so removing `statSync` from
1581
+ * `import { readFileSync, writeFileSync, statSync } from "node:fs"` silently
1582
+ * deleted all three bindings, and `"fs"` matched `from "node:fs"`. Matching is
1583
+ * now against parsed binding tokens: a name that is one of several bindings is
1584
+ * removed from the clause, and the statement is deleted only when nothing
1585
+ * survives it.
1586
+ */
1587
+ function removeImportGeneric(
1588
+ source: string,
1589
+ tree: Parser,
1590
+ filePath: string,
1591
+ importSpec: string,
1592
+ lang: string
1593
+ ): ASTEditResult {
1594
+ const icfg = IMPORT_CONFIGS[lang];
1595
+ const form = parseImportSpecForm(importSpec);
1596
+ const wantModule = form.module;
1597
+ const wantName = form.name ?? (wantModule ? undefined : importSpec.trim());
1598
+
1599
+ const models: ImportModel[] = [];
1600
+ function collect(node: Parser.SyntaxNode) {
1601
+ if (icfg && icfg.nodeTypes.includes(node.type)) {
1602
+ const model = buildImportModel(node, lang);
1603
+ if (model) {
1604
+ models.push(model);
1605
+ return;
1606
+ }
1607
+ }
1608
+ for (const child of node.children) collect(child);
1609
+ }
1610
+ collect(tree.rootNode);
1611
+
1612
+ const edits: { start: number; end: number; replace?: string }[] = [];
1613
+ let changeCount = 0;
1614
+
1615
+ for (const model of models) {
1616
+ if (wantModule !== undefined && !model.modules.includes(wantModule)) continue;
1617
+
1618
+ if (wantName === undefined) {
1619
+ // Module-only spec: the whole statement goes.
1620
+ edits.push({ start: model.node.startIndex, end: model.node.endIndex });
1621
+ changeCount++;
1622
+ continue;
1623
+ }
1624
+
1625
+ const matched = model.items.filter((i) => i.names.includes(wantName));
1626
+ if (matched.length === 0) {
1627
+ // A bare spec may name the module rather than a binding.
1628
+ if (wantModule === undefined && model.modules.includes(wantName)) {
1629
+ edits.push({ start: model.node.startIndex, end: model.node.endIndex });
1630
+ changeCount++;
1631
+ }
1632
+ continue;
1633
+ }
1634
+
1635
+ const keep = model.items.filter((i) => !matched.includes(i));
1636
+ const rewrite = keep.length > 0 ? model.rewrite(keep) : null;
1637
+ if (rewrite) {
1638
+ edits.push(rewrite);
1639
+ } else {
1640
+ edits.push({ start: model.node.startIndex, end: model.node.endIndex });
1641
+ }
1642
+ changeCount += matched.length;
1643
+ }
1644
+
1645
+ if (edits.length === 0) {
1646
+ return { success: false, path: filePath, operation: "remove-import", changes: 0, message: `No import for '${importSpec}' found` };
1647
+ }
1648
+
1649
+ edits.sort((a, b) => b.start - a.start);
1650
+ let newSource = source;
1651
+ for (const e of edits) {
1652
+ if (e.replace !== undefined) {
1653
+ newSource = newSource.slice(0, e.start) + e.replace + newSource.slice(e.end);
1654
+ } else {
1655
+ let end = e.end;
1656
+ while (end < newSource.length && newSource[end] === "\n") end++;
1657
+ newSource = newSource.slice(0, e.start) + newSource.slice(end);
1658
+ }
1659
+ }
1660
+
1661
+ return { success: true, path: filePath, operation: "remove-import", changes: changeCount, message: `Removed ${changeCount} import(s) for '${importSpec}'`, newSource };
1662
+ }
1663
+
1664
+ /**
1665
+ * Rust-specific remove-import using precise AST matching for both
1666
+ * simple (use X; or use X::Y;) and grouped (use X::{A, B, C}) declarations.
1667
+ */
1668
+ function removeRustImport(source: string, tree: Parser, filePath: string, importSpec: string): ASTEditResult {
1669
+ const changes: { start: number; end: number; replace?: string }[] = [];
1670
+ let changeCount = 0;
1671
+
1672
+ function walk(node: Parser.SyntaxNode) {
1673
+ if (node.type !== "use_declaration") {
1674
+ for (let i = 0; i < node.childCount; i++) walk(node.child(i));
1675
+ return;
1676
+ }
1677
+
1678
+ // Check if this use_declaration has a grouped use_list
1679
+ const scopeList = findChildByType(node, "scoped_use_list");
1680
+ if (scopeList) {
1681
+ const useList = findChildByType(scopeList, "use_list");
1682
+ if (useList) {
1683
+ // Grouped: `use X::{A, B, C}`
1684
+ const matched = findUseListMatches(useList, importSpec);
1685
+ if (matched.length === 0) return;
1686
+
1687
+ const nonMatched = getUseListItems(useList).filter((it) => !matched.has(it));
1688
+ changeCount += matched.size;
1689
+
1690
+ if (nonMatched.length === 0) {
1691
+ // Remove entire use_declaration
1692
+ changes.push({ start: node.startIndex, end: node.endIndex });
1693
+ } else if (nonMatched.length === 1) {
1694
+ // Simplify `use X::{Y}` → `use X::Y`
1695
+ const pathBeforeBraces = source.slice(scopeList.startIndex, useList.startIndex);
1696
+ const pathStr = pathBeforeBraces.replace(/::\s*$/, "").trim();
1697
+ const replacement = `use ${pathStr}::${nonMatched[0].text};`;
1698
+ changes.push({ start: node.startIndex, end: node.endIndex, replace: replacement });
1699
+ } else {
1700
+ // Replace inner content of use_list
1701
+ const itemTexts = nonMatched.map((it) => it.text);
1702
+ const newInner = " " + itemTexts.join(", ") + " ";
1703
+ changes.push({ start: useList.startIndex + 1, end: useList.endIndex - 1, replace: newInner });
1704
+ }
1705
+ return;
1706
+ }
1707
+ }
1708
+
1709
+ // Simple use declaration: match by last path segment
1710
+ if (rustUseMatchesSimple(node, importSpec)) {
1711
+ changes.push({ start: node.startIndex, end: node.endIndex });
1712
+ changeCount++;
1713
+ }
1714
+ }
1715
+
1716
+ walk(tree.rootNode);
1717
+
1718
+ if (changes.length === 0 || changeCount === 0) {
1719
+ return { success: false, path: filePath, operation: "remove-import", changes: 0, message: `No import for '${importSpec}' found` };
1720
+ }
1721
+
1722
+ // Apply changes in reverse index order
1723
+ changes.sort((a, b) => b.start - a.start);
1724
+ let newSource = source;
1725
+ for (const c of changes) {
1726
+ if (c.replace !== undefined) {
1727
+ newSource = newSource.slice(0, c.start) + c.replace + newSource.slice(c.end);
1728
+ } else {
1729
+ let end = c.end;
1730
+ while (end < newSource.length && newSource[end] === "\n") end++;
1731
+ newSource = newSource.slice(0, c.start) + newSource.slice(end);
1732
+ }
1733
+ }
1734
+
1735
+ return { success: true, path: filePath, operation: "remove-import", changes: changeCount, message: `Removed ${changeCount} import(s) for '${importSpec}'`, newSource };
1736
+ }
1737
+
1738
+ /** Find first child with the given type */
1739
+ function findChildByType(node: Parser.SyntaxNode, type: string): Parser.SyntaxNode | null {
1740
+ for (let i = 0; i < node.childCount; i++) {
1741
+ if (node.child(i).type === type) return node.child(i);
1742
+ }
1743
+ return null;
1744
+ }
1745
+
1746
+ /** Get usable items from a use_list (excluding braces and commas) */
1747
+ function getUseListItems(useList: Parser.SyntaxNode): Parser.SyntaxNode[] {
1748
+ const items: Parser.SyntaxNode[] = [];
1749
+ for (let i = 0; i < useList.childCount; i++) {
1750
+ const c = useList.child(i);
1751
+ if (c.type !== "{" && c.type !== "}" && c.type !== ",") items.push(c);
1752
+ }
1753
+ return items;
1754
+ }
1755
+
1756
+ /** Find items in a Rust use_list that match importSpec exactly */
1757
+ function findUseListMatches(useList: Parser.SyntaxNode, importSpec: string): Set<Parser.SyntaxNode> {
1758
+ const matched = new Set<Parser.SyntaxNode>();
1759
+ for (const item of getUseListItems(useList)) {
1760
+ // Direct match: identifier, self, super, crate
1761
+ if ((item.type === "identifier" || item.type === "self" || item.type === "super" || item.type === "crate") && item.text === importSpec) {
1762
+ matched.add(item);
1763
+ }
1764
+ // Scoped identifier match by last segment: `B::C` matches "C"
1765
+ if (item.type === "scoped_identifier") {
1766
+ const last = findLastIdentifier(item);
1767
+ if (last && last.text === importSpec) matched.add(item);
1768
+ }
1769
+ }
1770
+ return matched;
1771
+ }
1772
+
1773
+ /** Check if a simple (non-grouped) Rust use_declaration matches importSpec via last path segment */
1774
+ function rustUseMatchesSimple(node: Parser.SyntaxNode, importSpec: string): boolean {
1775
+ for (let ci = 0; ci < node.childCount; ci++) {
1776
+ const child = node.child(ci);
1777
+ if (child.type === "identifier" && child.text === importSpec) return true;
1778
+ if (child.type === "scoped_identifier" && lastSegmentMatches(child, importSpec)) return true;
1779
+ if (child.type === "scoped_use_list" && lastSegmentMatches(child, importSpec)) return true;
1780
+ }
1781
+ return false;
1782
+ }
1783
+
1784
+ /** Walk a scoped path and check if the rightmost segment equals importSpec */
1785
+ function lastSegmentMatches(node: Parser.SyntaxNode, importSpec: string): boolean {
1786
+ for (let i = node.childCount - 1; i >= 0; i--) {
1787
+ const child = node.child(i);
1788
+ if (child.type === "identifier") return child.text === importSpec;
1789
+ if (child.type === "scoped_identifier") return lastSegmentMatches(child, importSpec);
1790
+ }
1791
+ return false;
1792
+ }
1793
+
1794
+ /** Find the last identifier in a scoped_identifier tree */
1795
+ function findLastIdentifier(node: Parser.SyntaxNode): Parser.SyntaxNode | null {
1796
+ for (let i = node.childCount - 1; i >= 0; i--) {
1797
+ const child = node.child(i);
1798
+ if (child.type === "identifier") return child;
1799
+ const found = findLastIdentifier(child);
1800
+ if (found) return found;
1801
+ }
1802
+ return null;
1803
+ }
1804
+
1805
+ /**
1806
+ * Statement- and declaration-level node types that may anchor an insertion (#38).
1807
+ *
1808
+ * `insert-before`/`insert-after` used to match any node carrying a `name` field.
1809
+ * In tree-sitter grammars that includes function parameters, import specifiers,
1810
+ * object properties and type parameters, so inserting relative to a name that
1811
+ * also appears as a parameter spliced a whole statement into the middle of a
1812
+ * parameter list — and reported success.
1813
+ */
1814
+ const INSERT_ANCHOR_TYPES: Record<string, Set<string>> = {
1815
+ typescript: new Set([
1816
+ "function_declaration", "generator_function_declaration", "class_declaration",
1817
+ "abstract_class_declaration", "interface_declaration", "type_alias_declaration",
1818
+ "enum_declaration", "internal_module", "module", "method_definition",
1819
+ "public_field_definition", "variable_declarator",
1820
+ ]),
1821
+ python: new Set(["function_definition", "class_definition"]),
1822
+ go: new Set([
1823
+ "function_declaration", "method_declaration", "type_spec", "const_spec", "var_spec",
1824
+ ]),
1825
+ rust: new Set([
1826
+ "function_item", "struct_item", "enum_item", "trait_item", "mod_item",
1827
+ "const_item", "static_item", "type_item", "union_item",
1828
+ ]),
1829
+ };
1830
+
1831
+ /**
1832
+ * Nodes that name a declaration but are not themselves the statement: anchoring
1833
+ * on them would insert inside `const a = 1, b = 2;` rather than around it.
1834
+ */
1835
+ const ANCHOR_PROMOTIONS: Record<string, string[]> = {
1836
+ variable_declarator: ["lexical_declaration", "variable_declaration"],
1837
+ type_spec: ["type_declaration"],
1838
+ const_spec: ["const_declaration"],
1839
+ var_spec: ["var_declaration"],
1840
+ function_definition: ["decorated_definition"],
1841
+ class_definition: ["decorated_definition"],
1842
+ };
1843
+
1844
+ function anchorTypesFor(lang: string): Set<string> {
1845
+ return INSERT_ANCHOR_TYPES[lang] ?? INSERT_ANCHOR_TYPES.typescript;
1846
+ }
1847
+
1848
+ /** Walk up from a named node to the statement that should carry the insertion. */
1849
+ function promoteAnchor(node: Parser.SyntaxNode): Parser.SyntaxNode {
1850
+ const wanted = ANCHOR_PROMOTIONS[node.type];
1851
+ if (!wanted) return node;
1852
+ let cur = node.parent;
1853
+ for (let hops = 0; cur && hops < 3; hops++, cur = cur.parent) {
1854
+ if (wanted.includes(cur.type)) return cur;
1855
+ }
1856
+ return node;
1857
+ }
1858
+
1859
+ interface AnchorLookup {
1860
+ node?: Parser.SyntaxNode;
1861
+ /** Nodes carrying the name that are not legal anchors, for the refusal message. */
1862
+ rejected: Parser.SyntaxNode[];
1863
+ /** Legal anchors; more than one means ambiguous. */
1864
+ candidates: Parser.SyntaxNode[];
1865
+ }
1866
+
1867
+ function findInsertAnchor(tree: Parser.Tree, lang: string, symbolName: string): AnchorLookup {
1868
+ const allowed = anchorTypesFor(lang);
1869
+ const candidates: Parser.SyntaxNode[] = [];
1870
+ const rejected: Parser.SyntaxNode[] = [];
1871
+
1872
+ function walk(node: Parser.SyntaxNode) {
1873
+ const nameNode = node.childForFieldName("name");
1874
+ if (nameNode && nameNode.text === symbolName) {
1875
+ if (allowed.has(node.type)) candidates.push(promoteAnchor(node));
1876
+ else rejected.push(node);
1877
+ }
1878
+ for (const child of node.children) walk(child);
1879
+ }
1880
+ walk(tree.rootNode);
1881
+
1882
+ // Promotion can map two declarators onto the same statement.
1883
+ const seen = new Set<number>();
1884
+ const unique = candidates.filter((c) => (seen.has(c.startIndex) ? false : (seen.add(c.startIndex), true)));
1885
+ return unique.length === 1
1886
+ ? { node: unique[0], rejected, candidates: unique }
1887
+ : { rejected, candidates: unique };
1888
+ }
1889
+
1890
+ /** 1-based line number of a byte offset, for refusal messages. */
1891
+ function lineOf(source: string, index: number): number {
1892
+ let line = 1;
1893
+ for (let i = 0; i < index; i++) if (source.charCodeAt(i) === 10) line++;
1894
+ return line;
1895
+ }
1896
+
1897
+ /** Leading whitespace of the line containing `index`. */
1898
+ function indentAt(source: string, index: number): string {
1899
+ const lineStart = source.lastIndexOf("\n", Math.max(0, index - 1)) + 1;
1900
+ return source.slice(lineStart).match(/^[ \t]*/)![0];
1901
+ }
1902
+
1903
+ /**
1904
+ * Re-indent inserted content to the anchor's own indentation, preserving the
1905
+ * content's internal relative structure. The previous insert-after computed an
1906
+ * indent string that was always empty, so every insertion landed at column 0.
1907
+ */
1908
+ function reindent(content: string, indent: string): string {
1909
+ const lines = content.replace(/\n+$/, "").split("\n");
1910
+ const base = lines
1911
+ .filter((l) => l.trim().length > 0)
1912
+ .reduce((min, l) => Math.min(min, l.match(/^[ \t]*/)![0].length), Infinity);
1913
+ const strip = Number.isFinite(base) ? base : 0;
1914
+ return lines.map((l) => (l.trim().length === 0 ? "" : indent + l.slice(strip))).join("\n");
1915
+ }
1916
+
1917
+ /** Build the refusal for a lookup that produced no single anchor. */
1918
+ function anchorFailure(
1919
+ source: string,
1920
+ filePath: string,
1921
+ operation: "insert-before" | "insert-after",
1922
+ symbolName: string,
1923
+ lookup: AnchorLookup
1924
+ ): ASTEditResult {
1925
+ if (lookup.candidates.length > 1) {
1926
+ const where = lookup.candidates
1927
+ .map((c) => `${c.type} at line ${lineOf(source, c.startIndex)}`)
1928
+ .join(", ");
1929
+ return {
1930
+ success: false, path: filePath, operation, changes: 0,
1931
+ message: `Symbol '${symbolName}' is ambiguous: ${where}. Narrow the target, or use the hash tier to anchor on content.`,
1932
+ errorCode: ErrorCode.AMBIGUOUS_SYMBOL,
1933
+ };
1934
+ }
1935
+ if (lookup.rejected.length > 0) {
1936
+ const found = [...new Set(lookup.rejected.map((r) => r.type))].join(", ");
1937
+ return {
1938
+ success: false, path: filePath, operation, changes: 0,
1939
+ message: `Symbol '${symbolName}' names a ${found}, not a statement or declaration; inserting there would splice code into an expression. Target a declaration, or use the hash tier.`,
1940
+ errorCode: ErrorCode.SYMBOL_NOT_FOUND,
1941
+ };
1942
+ }
1943
+ return {
1944
+ success: false, path: filePath, operation, changes: 0,
1945
+ message: `Symbol '${symbolName}' not found`,
1946
+ errorCode: ErrorCode.SYMBOL_NOT_FOUND,
1947
+ };
1948
+ }
1949
+
1950
+ function insertBeforeSymbolUnchecked(
1951
+ source: string,
1952
+ filePath: string,
1953
+ symbolName: string,
1954
+ content: string
1955
+ ): ASTEditResult {
1956
+ const lang = detectLanguage(filePath);
1957
+ if (!lang) return { success: false, path: filePath, operation: "insert-before", changes: 0, message: "Unsupported language", errorCode: ErrorCode.UNSUPPORTED_LANGUAGE };
1958
+ const parser = getParser(lang);
1959
+ if (!parser) return { success: false, path: filePath, operation: "insert-before", changes: 0, message: "Parser unavailable", errorCode: ErrorCode.UNSUPPORTED_LANGUAGE };
1960
+
1961
+ const tree = parseSource(parser, source);
1962
+ const lookup = findInsertAnchor(tree, lang, symbolName);
1963
+ if (!lookup.node) return anchorFailure(source, filePath, "insert-before", symbolName, lookup);
1964
+
1965
+ const anchor = lookup.node;
1966
+ const indent = indentAt(source, anchor.startIndex);
1967
+ // Insert at the start of the anchor's line so the statement lands on its own
1968
+ // line even when the anchor shares a line with something else.
1969
+ const insertPos = source.lastIndexOf("\n", Math.max(0, anchor.startIndex - 1)) + 1;
1970
+ const newSource = source.slice(0, insertPos) + reindent(content, indent) + "\n" + source.slice(insertPos);
1971
+ return { success: true, path: filePath, operation: "insert-before", changes: 1, message: `Inserted content before '${symbolName}'`, newSource };
1972
+ }
1973
+
1974
+ function insertAfterSymbolUnchecked(
1975
+ source: string,
1976
+ filePath: string,
1977
+ symbolName: string,
1978
+ content: string
1979
+ ): ASTEditResult {
1980
+ const lang = detectLanguage(filePath);
1981
+ if (!lang) return { success: false, path: filePath, operation: "insert-after", changes: 0, message: "Unsupported language", errorCode: ErrorCode.UNSUPPORTED_LANGUAGE };
1982
+ const parser = getParser(lang);
1983
+ if (!parser) return { success: false, path: filePath, operation: "insert-after", changes: 0, message: "Parser unavailable", errorCode: ErrorCode.UNSUPPORTED_LANGUAGE };
1984
+
1985
+ const tree = parseSource(parser, source);
1986
+ const lookup = findInsertAnchor(tree, lang, symbolName);
1987
+ if (!lookup.node) return anchorFailure(source, filePath, "insert-after", symbolName, lookup);
1988
+
1989
+ const anchor = lookup.node;
1990
+ const indent = indentAt(source, anchor.startIndex);
1991
+ // Past the rest of the anchor's final line (trailing semicolon, comment).
1992
+ const nextNewline = source.indexOf("\n", anchor.endIndex);
1993
+ const pos = nextNewline !== -1 ? nextNewline + 1 : source.length;
1994
+ const prefix = pos === source.length && !source.endsWith("\n") ? "\n" : "";
1995
+ const newSource = source.slice(0, pos) + prefix + reindent(content, indent) + "\n" + source.slice(pos);
1996
+ return { success: true, path: filePath, operation: "insert-after", changes: 1, message: `Inserted content after '${symbolName}'`, newSource };
1997
+ }
1998
+
1999
+ /**
2000
+ * Parse a `from X import Y, Z` spec and attempt to merge into an existing
2001
+ * import_from_statement for the same module X. Returns the ASTEditResult
2002
+ * if handled, or null to fall through to default add-import behavior.
2003
+ */
2004
+ function parsePythonFromImport(
2005
+ spec: string,
2006
+ source: string,
2007
+ tree: Parser
2008
+ ): ASTEditResult | null {
2009
+ // Pattern: from <module> import <names>
2010
+ const match = spec.match(/^from\s+(\S+)\s+import\s+(.+)/);
2011
+ if (!match) return null; // malformed, shouldn't happen since we checked startsWith("from ")
2012
+
2013
+ const [, targetModule, namesPart] = match;
2014
+ const newNames = namesPart.split(",").map((n) => n.trim()).filter(Boolean);
2015
+ if (newNames.length === 0) return null;
2016
+
2017
+ // Walk AST to find existing import_from_statement for the same module
2018
+ let existingNode: Parser.SyntaxNode | null = null;
2019
+ function findExisting(n: Parser.SyntaxNode) {
2020
+ if (n.type === "import_from_statement") {
2021
+ // Check if the module matches
2022
+ for (let i = 0; i < n.childCount; i++) {
2023
+ const child = n.child(i);
2024
+ if (child.type === "dotted_name" && i > 0) {
2025
+ // First dotted_name after "from" is the module
2026
+ if (child.text === targetModule) {
2027
+ existingNode = n;
2028
+ return;
2029
+ }
2030
+ }
2031
+ }
2032
+ }
2033
+ for (let i = 0; i < n.childCount; i++) findExisting(n.child(i));
2034
+ }
2035
+ findExisting(tree.rootNode);
2036
+
2037
+ if (existingNode) {
2038
+ // Merge: append new names to existing from-import
2039
+ const existingLine = source.slice(existingNode.startIndex, existingNode.endIndex);
2040
+ const existingImportMatch = existingLine.match(/^(from\s+\S+\s+import\s+)(.*)/);
2041
+ if (!existingImportMatch) return null;
2042
+
2043
+ const [, prefix, existingNamesStr] = existingImportMatch;
2044
+ const existingNames = existingNamesStr.split(",").map((n) => n.trim());
2045
+
2046
+ // Check for duplicates
2047
+ const allNew = newNames.filter((n) => !existingNames.includes(n));
2048
+ if (allNew.length === 0) {
2049
+ return { success: false, path: "", operation: "add-import", changes: 0, message: `Import for '${spec}' already exists` };
2050
+ }
2051
+
2052
+ const mergedNames = [...existingNames, ...allNew];
2053
+ const newLine = prefix + mergedNames.join(", ");
2054
+ return {
2055
+ success: true,
2056
+ path: "",
2057
+ operation: "add-import",
2058
+ changes: 1,
2059
+ message: `Added import: ${spec}`,
2060
+ newSource: source.slice(0, existingNode.startIndex) + newLine + source.slice(existingNode.endIndex),
2061
+ };
2062
+ }
2063
+
2064
+ // No existing from-import for this module — create new statement
2065
+ // Ensure no name duplicates with existing imports
2066
+ for (const name of newNames) {
2067
+ const dupRegex = new RegExp(`(?:from\\s+\\S+\\s+import|import)\\s+.*\\b${escapeRegex(name)}\\b`);
2068
+ if (dupRegex.test(source)) {
2069
+ return { success: false, path: "", operation: "add-import", changes: 0, message: `Name '${name}' already imported` };
2070
+ }
2071
+ }
2072
+
2073
+ return null; // fall through to default add-import logic
2074
+ }
2075
+
2076
+ // ── Parameter/argument insertion (for M5 intent engine) ───────────────
2077
+
2078
+ const PARAM_NODE_TYPES = new Set([
2079
+ "formal_parameters", "parameter_list", "parameters",
2080
+ ]);
2081
+
2082
+ const ARG_NODE_TYPES = new Set([
2083
+ "arguments", "argument_list",
2084
+ ]);
2085
+
2086
+ /**
2087
+ * Insert a parameter into a function/method signature.
2088
+ * Returns the modified source with the new parameter added.
2089
+ */
2090
+ function insertParameterUnchecked(
2091
+ source: string,
2092
+ filePath: string,
2093
+ symbolName: string,
2094
+ newParam: string,
2095
+ position: "last" | "first" = "last"
2096
+ ): ASTEditResult {
2097
+ const lang = detectLanguage(filePath);
2098
+ if (!lang) return { success: false, path: filePath, operation: "insert-parameter", changes: 0, message: "Unsupported language", errorCode: ErrorCode.UNSUPPORTED_LANGUAGE };
2099
+ const cfg = configFor(lang);
2100
+ if (!cfg) return { success: false, path: filePath, operation: "insert-parameter", changes: 0, message: "Unsupported language", errorCode: ErrorCode.UNSUPPORTED_LANGUAGE };
2101
+ const parser = getParser(lang);
2102
+ if (!parser) return { success: false, path: filePath, operation: "insert-parameter", changes: 0, message: "Parser unavailable", errorCode: ErrorCode.UNSUPPORTED_LANGUAGE };
2103
+
2104
+ const tree = parseSource(parser, source);
2105
+ let found = false;
2106
+ let insertPos = -1;
2107
+ let insertText = "";
2108
+
2109
+ let truncated = false;
2110
+ // Same shared cap and same iterative walk as findSymbols: a silent stop at 15
2111
+ // reported a deeply nested function as missing (#39).
2112
+ const stack: Array<{ node: Parser.SyntaxNode; depth: number }> = [{ node: tree.rootNode, depth: 0 }];
2113
+ while (stack.length > 0 && !found) {
2114
+ const { node, depth } = stack.pop()!;
2115
+ if (depth > MAX_AST_DEPTH) {
2116
+ truncated = true;
2117
+ continue;
2118
+ }
2119
+ if (cfg.functionTypes.includes(node.type)) {
2120
+ const nameNode = node.childForFieldName("name");
2121
+ if (nameNode && nameNode.text === symbolName) {
2122
+ // Find the parameters node
2123
+ const paramsNode = node.children.find((c) => PARAM_NODE_TYPES.has(c.type));
2124
+ if (paramsNode) {
2125
+ // Get existing parameter text to decide about leading comma
2126
+ const inner = source.slice(paramsNode.startIndex + 1, paramsNode.endIndex - 1).trim();
2127
+
2128
+ if (position === "first") {
2129
+ insertPos = paramsNode.startIndex + 1;
2130
+ insertText = newParam + (inner.length > 0 ? ", " : "");
2131
+ } else {
2132
+ insertPos = paramsNode.endIndex - 1;
2133
+ insertText = (inner.length > 0 ? ", " : "") + newParam;
2134
+ }
2135
+
2136
+ found = true;
2137
+ break;
2138
+ }
2139
+ }
2140
+ }
2141
+ const kids = node.children;
2142
+ for (let i = kids.length - 1; i >= 0; i--) stack.push({ node: kids[i], depth: depth + 1 });
2143
+ }
2144
+
2145
+ if (!found && truncated) {
2146
+ return {
2147
+ success: false, path: filePath, operation: "insert-parameter", changes: 0,
2148
+ message: `Search for '${symbolName}' stopped at depth ${MAX_AST_DEPTH} in ${filePath}, so the symbol may exist below the cap. Not reported as not-found.`,
2149
+ errorCode: ErrorCode.SEARCH_TRUNCATED,
2150
+ };
2151
+ }
2152
+ if (!found) return { success: false, path: filePath, operation: "insert-parameter", changes: 0, message: `Symbol '${symbolName}' not found or has no parameters`, errorCode: ErrorCode.SYMBOL_NOT_FOUND };
2153
+
2154
+ const newSource = source.slice(0, insertPos) + insertText + source.slice(insertPos);
2155
+ return { success: true, path: filePath, operation: "insert-parameter", changes: 1, message: `Inserted parameter '${newParam}' into '${symbolName}'`, newSource };
2156
+ }
2157
+
2158
+ /**
2159
+ * Insert an argument at all call sites of a named function.
2160
+ * Returns the modified source with arguments added to every call expression
2161
+ * where the function name matches.
2162
+ */
2163
+ function insertCallArgUnchecked(
2164
+ source: string,
2165
+ filePath: string,
2166
+ functionName: string,
2167
+ argValue: string
2168
+ ): ASTEditResult {
2169
+ const lang = detectLanguage(filePath);
2170
+ if (!lang) return { success: false, path: filePath, operation: "insert-call-arg", changes: 0, message: "Unsupported language", errorCode: ErrorCode.UNSUPPORTED_LANGUAGE };
2171
+ const parser = getParser(lang);
2172
+ if (!parser) return { success: false, path: filePath, operation: "insert-call-arg", changes: 0, message: "Parser unavailable", errorCode: ErrorCode.UNSUPPORTED_LANGUAGE };
2173
+
2174
+ const tree = parseSource(parser, source);
2175
+ const edits: { start: number; end: number; text: string }[] = [];
2176
+
2177
+ // Collect call_expression / call nodes where function name matches
2178
+ function findCalls(node: Parser.SyntaxNode) {
2179
+ // TypeScript/JS/Go/Rust: call_expression; Python: call
2180
+ if (node.type === "call_expression" || node.type === "call") {
2181
+ const fnNode = node.childForFieldName("function");
2182
+ if (fnNode) {
2183
+ const fnName = extractCallableName(fnNode);
2184
+ if (fnName === functionName) {
2185
+ const argsNode = node.children.find((c) => ARG_NODE_TYPES.has(c.type));
2186
+ if (argsNode) {
2187
+ const inner = source.slice(argsNode.startIndex + 1, argsNode.endIndex - 1).trim();
2188
+ const insertText = (inner.length > 0 ? ", " : "") + argValue;
2189
+ edits.push({ start: argsNode.endIndex - 1, end: argsNode.endIndex - 1, text: insertText });
2190
+ }
2191
+ }
2192
+ }
2193
+ }
2194
+ for (const child of node.children) findCalls(child);
2195
+ }
2196
+
2197
+ findCalls(tree.rootNode);
2198
+
2199
+ if (edits.length === 0) return { success: false, path: filePath, operation: "insert-call-arg", changes: 0, message: `No call sites for '${functionName}' found` };
2200
+
2201
+ // Apply edits in reverse order (to preserve indices)
2202
+ edits.sort((a, b) => b.start - a.start);
2203
+ let newSource = source;
2204
+ for (const e of edits) {
2205
+ newSource = newSource.slice(0, e.start) + e.text + newSource.slice(e.end);
2206
+ }
2207
+
2208
+ return { success: true, path: filePath, operation: "insert-call-arg", changes: edits.length, message: `Inserted argument at ${edits.length} call site(s) for '${functionName}'`, newSource };
2209
+ }
2210
+
2211
+ /**
2212
+ * Extract the callable name from a function expression node.
2213
+ * Handles: simple identifiers, member expressions (obj.method), and scoped identifiers.
2214
+ */
2215
+ function extractCallableName(node: Parser.SyntaxNode): string | null {
2216
+ if (node.type === "identifier") return node.text;
2217
+ if (node.type === "property_identifier") return node.text;
2218
+ // For member_expression (obj.method), return the property name
2219
+ if (node.type === "member_expression") {
2220
+ const prop = node.childForFieldName("property");
2221
+ if (prop) return extractCallableName(prop);
2222
+ }
2223
+ // Walk children for scoped identifiers (e.g., Rust's scoped_identifier)
2224
+ for (const child of node.children) {
2225
+ if (child.type === "identifier" || child.type === "property_identifier") {
2226
+ return child.text;
2227
+ }
2228
+ }
2229
+ return null;
2230
+ }
2231
+
2232
+ export { getParser, SUPPORTED_LANGUAGES };
2233
+
2234
+ /** Location of the first `ERROR`/`MISSING` node tree-sitter recovered from. */
2235
+ export interface ParseIssue {
2236
+ /** 1-indexed, to match every other line number the CLI prints. */
2237
+ line: number;
2238
+ column: number;
2239
+ nodeType: string;
2240
+ }
2241
+
2242
+ /**
2243
+ * tree-sitter is an error-recovering parser: handed a broken file it returns a
2244
+ * tree containing ERROR nodes rather than failing. Right for an editor,
2245
+ * dangerous here — we compute byte offsets from that tree and then write to
2246
+ * disk. Returns the first bad node, or null for a clean parse (and for files
2247
+ * with no parser at all).
2248
+ */
2249
+ export function firstParseError(source: string, filePath: string): ParseIssue | null {
2250
+ const lang = detectLanguage(filePath);
2251
+ if (!lang) return null;
2252
+ const parser = getParser(lang);
2253
+ if (!parser) return null;
2254
+ const tree = parseSource(parser, source);
2255
+ if (!tree.rootNode.hasError) return null;
2256
+
2257
+ // Walk to the deepest first offender so the reported position is the actual
2258
+ // syntax problem, not the whole file.
2259
+ let found: Parser.SyntaxNode | null = null;
2260
+ const visit = (node: Parser.SyntaxNode): boolean => {
2261
+ if (node.type === "ERROR" || node.isMissing) {
2262
+ found = node;
2263
+ return true;
2264
+ }
2265
+ for (const child of node.children) {
2266
+ if (child.hasError && visit(child)) return true;
2267
+ }
2268
+ return false;
2269
+ };
2270
+ visit(tree.rootNode);
2271
+ const node: Parser.SyntaxNode = found ?? tree.rootNode;
2272
+ return {
2273
+ line: node.startPosition.row + 1,
2274
+ column: node.startPosition.column,
2275
+ nodeType: node.isMissing ? `MISSING ${node.type}` : node.type,
2276
+ };
2277
+ }
2278
+
2279
+ let allowParseErrors = false;
2280
+
2281
+ /**
2282
+ * Escape hatch for deliberately editing a file that does not parse. Off by
2283
+ * default and never inferred — the CLI sets it only from `--allow-parse-errors`.
2284
+ */
2285
+ export function setAllowParseErrors(value: boolean): void {
2286
+ allowParseErrors = value;
2287
+ }
2288
+
2289
+ export function getAllowParseErrors(): boolean {
2290
+ return allowParseErrors;
2291
+ }
2292
+
2293
+ function parseErrorResult(filePath: string, operation: string, message: string, issue: ParseIssue): ASTEditResult {
2294
+ return {
2295
+ success: false,
2296
+ path: filePath,
2297
+ operation,
2298
+ changes: 0,
2299
+ message,
2300
+ error: message,
2301
+ errorCode: ErrorCode.PARSE_ERROR,
2302
+ parseIssue: issue,
2303
+ };
2304
+ }
2305
+
2306
+ /**
2307
+ * Wraps an AST operation in the two checks from the SWE-agent ACI paper
2308
+ * (arXiv 2405.15793), which found that rejecting edits whose result does not
2309
+ * parse materially improves agent task success:
2310
+ *
2311
+ * pre — refuse to compute offsets against a tree that already has errors.
2312
+ * post — reparse the edited source before anyone writes it. This one also
2313
+ * catches bugs in our own offset arithmetic, so it stays on even when
2314
+ * `--allow-parse-errors` waives the pre-check.
2315
+ */
2316
+ function gated<F extends (source: string, filePath: string, ...rest: never[]) => ASTEditResult>(
2317
+ fn: F,
2318
+ operation: string,
2319
+ ): F {
2320
+ return function (this: unknown, source: string, filePath: string, ...rest: never[]): ASTEditResult {
2321
+ const before = firstParseError(source, filePath);
2322
+ if (before && !allowParseErrors) {
2323
+ return parseErrorResult(
2324
+ filePath,
2325
+ operation,
2326
+ `File has a syntax error at line ${before.line}:${before.column} (${before.nodeType}); refusing to edit a tree that did not parse cleanly. Fix the file, or pass --allow-parse-errors.`,
2327
+ before,
2328
+ );
2329
+ }
2330
+
2331
+ const result = fn(source, filePath, ...rest);
2332
+ if (!result.success || result.newSource === undefined) return result;
2333
+
2334
+ // Only meaningful when the input was clean: an already-broken file is
2335
+ // expected to still be broken afterwards.
2336
+ if (before) return result;
2337
+ const after = firstParseError(result.newSource, filePath);
2338
+ if (after) {
2339
+ return parseErrorResult(
2340
+ filePath,
2341
+ operation,
2342
+ `Edit was discarded: the result does not parse (syntax error at line ${after.line}:${after.column} — ${after.nodeType}). The input parsed cleanly, so this edit would have corrupted the file.`,
2343
+ after,
2344
+ );
2345
+ }
2346
+ return result;
2347
+ } as F;
2348
+ }
2349
+
2350
+ export const renameSymbol = gated(renameSymbolUnchecked, "rename-symbol");
2351
+ export const replaceBody = gated(replaceBodyUnchecked, "replace-body");
2352
+ export const addImport = gated(addImportUnchecked, "add-import");
2353
+ export const removeImport = gated(removeImportUnchecked, "remove-import");
2354
+ export const insertBeforeSymbol = gated(insertBeforeSymbolUnchecked, "insert-before");
2355
+ export const insertAfterSymbol = gated(insertAfterSymbolUnchecked, "insert-after");
2356
+ export const insertParameter = gated(insertParameterUnchecked, "add-parameter");
2357
+ export const insertCallArg = gated(insertCallArgUnchecked, "add-call-arg");