@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,584 @@
1
+ import { findSymbols, insertParameter, insertCallArg, getParser, parseSource, detectLanguage } from "./ast-edit";
2
+ import { glob } from "glob";
3
+ import { escapeRegex } from "./utils";
4
+ import { normalizePath, pathsEqual } from "./path-normalize";
5
+
6
+ // ── Intent types ──────────────────────────────────────────────────────
7
+
8
+ /** Thrown for an intent operation the planner cannot perform. Maps to exit code 1. */
9
+ export class UnsupportedIntentError extends Error {
10
+ readonly errorCode = "UNSUPPORTED_OPERATION";
11
+ constructor(message: string) {
12
+ super(message);
13
+ this.name = "UnsupportedIntentError";
14
+ }
15
+ }
16
+
17
+ export type IntentOperation =
18
+ | "add-parameter"
19
+ | "remove-parameter"
20
+ | "rename-exported-symbol";
21
+
22
+ export interface AddParameterIntent {
23
+ operation: "add-parameter";
24
+ symbol: string;
25
+ param: { name: string; type?: string; default?: string };
26
+ file?: string;
27
+ }
28
+
29
+ export interface RemoveParameterIntent {
30
+ operation: "remove-parameter";
31
+ symbol: string;
32
+ paramName: string;
33
+ file?: string;
34
+ }
35
+
36
+ export interface RenameExportedSymbolIntent {
37
+ operation: "rename-exported-symbol";
38
+ symbol: string;
39
+ newName: string;
40
+ file?: string;
41
+ }
42
+
43
+ export type StructuredIntent =
44
+ | AddParameterIntent
45
+ | RemoveParameterIntent
46
+ | RenameExportedSymbolIntent;
47
+
48
+ // ── Reference types ───────────────────────────────────────────────────
49
+
50
+ export interface ReferenceLocation {
51
+ file: string;
52
+ line: number;
53
+ column: number;
54
+ context: string;
55
+ }
56
+
57
+ export interface SymbolDefinition {
58
+ file: string;
59
+ name: string;
60
+ kind: string;
61
+ line: number;
62
+ column: number;
63
+ }
64
+
65
+ // ── Edit step ─────────────────────────────────────────────────────────
66
+
67
+ export interface EditStep {
68
+ order: number;
69
+ file: string;
70
+ operation: string;
71
+ description: string;
72
+ params: Record<string, any>;
73
+ }
74
+
75
+ /**
76
+ * Work the planner could not compute, surfaced to the caller instead of being
77
+ * papered over. The planner used to write a C-style TODO comment placeholder
78
+ * into the source at each of these sites — which is not even a comment in
79
+ * Python, so a "successful" plan wrote a syntax error to disk (#16).
80
+ */
81
+ export interface UnresolvedItem {
82
+ file: string;
83
+ operation: string;
84
+ /** Why the planner could not compute this edit. */
85
+ reason: string;
86
+ /** What the caller can do about it. */
87
+ resolution: string;
88
+ }
89
+
90
+ export interface EditPlan {
91
+ intent: StructuredIntent;
92
+ definition: SymbolDefinition;
93
+ references: ReferenceLocation[];
94
+ steps: EditStep[];
95
+ /** Non-empty when part of the intent could not be planned; blocks execution without `yes`. */
96
+ unresolved: UnresolvedItem[];
97
+ impactSummary: string;
98
+ /** Counts the reconciled reference resolution (#15): resolved / unresolved /
99
+ * ambiguous. `ambiguous > 0` blocks execution without `--yes`. */
100
+ reconciliation?: ReferenceReconciliation;
101
+ }
102
+
103
+ // ── Intent parsing ────────────────────────────────────────────────────
104
+
105
+ export function parseIntent(raw: string): StructuredIntent {
106
+ let obj: any;
107
+ try {
108
+ obj = JSON.parse(raw);
109
+ } catch {
110
+ throw new Error(`Invalid JSON: ${raw}`);
111
+ }
112
+
113
+ if (!obj.operation) throw new Error("Intent requires 'operation' field");
114
+ if (!obj.symbol || typeof obj.symbol !== "string") {
115
+ throw new Error("Intent requires 'symbol' field (string)");
116
+ }
117
+
118
+ switch (obj.operation) {
119
+ case "add-parameter": {
120
+ if (!obj.param || !obj.param.name) throw new Error("add-parameter requires 'param.name'");
121
+ return {
122
+ operation: "add-parameter",
123
+ symbol: obj.symbol,
124
+ param: {
125
+ name: obj.param.name,
126
+ type: obj.param.type,
127
+ default: obj.param.default,
128
+ },
129
+ file: obj.file,
130
+ };
131
+ }
132
+ case "remove-parameter": {
133
+ // Never implemented. The plan it used to generate emitted a no-op
134
+ // `remove-import` for the signature and a literal
135
+ // `/* TODO: remove arg for X */` string as the search text at every call
136
+ // site — which never matches, so the plan reported steps it could not
137
+ // perform. Refusing is strictly safer than advertising it.
138
+ throw new UnsupportedIntentError(
139
+ "remove-parameter is not implemented. Use rename-exported-symbol, or edit the signature with `ast replace-body` and each call site with `diff apply`.",
140
+ );
141
+ }
142
+ case "rename-exported-symbol": {
143
+ if (!obj.newName) throw new Error("rename-exported-symbol requires 'newName'");
144
+ return {
145
+ operation: "rename-exported-symbol",
146
+ symbol: obj.symbol,
147
+ newName: obj.newName,
148
+ file: obj.file,
149
+ };
150
+ }
151
+ default:
152
+ throw new Error(`Unknown intent operation: ${obj.operation}. Supported: add-parameter, rename-exported-symbol`);
153
+ }
154
+ }
155
+
156
+ // ── Symbol definition discovery ───────────────────────────────────────
157
+
158
+ const LANG_EXTS = ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "**/*.py", "**/*.go", "**/*.rs"];
159
+ const IGNORE_GLOBS = ["**/node_modules/**", "**/dist/**", "**/.git/**", "**/__pycache__/**", "**/target/**", "**/vendor/**"];
160
+
161
+ export async function findSymbolDefinition(
162
+ symbol: string,
163
+ projectRoot: string,
164
+ hintFile?: string
165
+ ): Promise<SymbolDefinition | null> {
166
+ // Check hint file first
167
+ if (hintFile) {
168
+ try {
169
+ const source = await Bun.file(hintFile).text();
170
+ const symbols = findSymbols(source, hintFile);
171
+ const match = symbols.find((s) => s.name === symbol);
172
+ if (match) {
173
+ return {
174
+ file: hintFile,
175
+ name: match.name,
176
+ kind: match.kind,
177
+ line: match.startLine,
178
+ column: match.startColumn,
179
+ };
180
+ }
181
+ } catch {}
182
+ }
183
+
184
+ const sourceFiles = await glob(LANG_EXTS, { cwd: projectRoot, ignore: IGNORE_GLOBS });
185
+ for (const relPath of sourceFiles) {
186
+ const absPath = `${projectRoot}/${relPath}`;
187
+ try {
188
+ const source = await Bun.file(absPath).text();
189
+ const symbols = findSymbols(source, absPath);
190
+ const match = symbols.find((s) => s.name === symbol);
191
+ if (match) {
192
+ return {
193
+ file: absPath,
194
+ name: match.name,
195
+ kind: match.kind,
196
+ line: match.startLine,
197
+ column: match.startColumn,
198
+ };
199
+ }
200
+ } catch {}
201
+ }
202
+
203
+ return null;
204
+ }
205
+
206
+ // ── Reference discovery ───────────────────────────────────────────────
207
+ //
208
+ // #15: references are resolved with tree-sitter, not regex text matching.
209
+ // The old approach (`grep -w` plus `isDefinitionLine`) matched a symbol's
210
+ // spelling inside comments, string literals, and foreign imports, and — worse —
211
+ // *dropped* legitimate call sites that happened to sit on a `const`/`function`/
212
+ // `def` line ("const x = foo(1)" was read as the symbol's own definition, so the
213
+ // call site was skipped). A syntactic walk removes all of that: a "reference" is
214
+ // a bare identifier that is neither a declaration name, a member/property access,
215
+ // nor an import binding.
216
+
217
+ /** Identifiers that, when bare, can be references a caller wants to rename/reach. */
218
+ const REF_IDENTS = new Set(["identifier", "type_identifier"]);
219
+
220
+ /**
221
+ * A node whose PARENT has one of these types is a *declaration name* — it binds
222
+ * the local symbol, it does not use it, so it is excluded from the reference set.
223
+ * Language-agnostic across the six grammars.
224
+ */
225
+ const DECL_NAME_PARENTS = new Set([
226
+ "function_declaration", "class_declaration", "interface_declaration",
227
+ "type_alias_declaration", "enum_declaration", "variable_declarator",
228
+ "lexical_declaration", "variable_declaration", "function_definition",
229
+ "function_item", "method_declaration", "method_definition", "constant_item",
230
+ "type_parameter", "enum_variant", "field_declaration", "struct_item",
231
+ ]);
232
+
233
+ /**
234
+ * A node whose PARENT has one of these types accesses the name as a *member of
235
+ * something else* (`obj.foo`, `a::b`), not as the top-level symbol, so it is
236
+ * excluded. In TS/JS such names are `property_identifier` (already outside
237
+ * `REF_IDENTS`); this set covers Python/Rust where the member is an `identifier`.
238
+ */
239
+ const MEMBER_PARENTS = new Set([
240
+ "member_expression", "property_access_expression", "selector_expression",
241
+ "attribute", "field_expression", "type_path", "scoped_identifier",
242
+ "qualified_identifier", "field_access",
243
+ ]);
244
+
245
+ /**
246
+ * Any ancestor of this type means the identifier is *binding* another origin's
247
+ * name rather than using the top-level symbol (an import/export/re-export), so it
248
+ * is excluded from references. "pair" is deliberately absent: it is the JS
249
+ * object-literal property node, not an import (#14).
250
+ */
251
+ const BINDING_CONTEXT = new Set([
252
+ /* Only the name-carrying LEAF nodes of an import/export/re-export — never the
253
+ * statement-level containers (export_statement / import_from_statement / ...),
254
+ * which wrap the WHOLE body of an exported/imported declaration and would
255
+ * otherwise mark every reference inside it as a "binding". Leaf types sit
256
+ * shallow, so an ancestor-walk over them cannot catch a reference buried in a
257
+ * function body. (Object-literal property keys are `property_identifier`,
258
+ * already excluded by REF_IDENTS — "pair" is deliberately absent, #14.)
259
+ */
260
+ // TS / JS / TSX / JSX
261
+ "import_specifier", "named_imports", "named_import", "namespace_import",
262
+ "default_import", "export_specifier", "exported_names", "export_clause",
263
+ // Python
264
+ "dotted_name", "alias", "import_prefix",
265
+ // Go
266
+ "import_spec", "imported_path",
267
+ // Rust
268
+ "use_list", "scoped_use_list", "scoped_identifier", "identifier_path",
269
+ "use_as_clause", "use_tree", "group_use_delimiter", "nested_use_delimiter",
270
+ ]);
271
+
272
+ /**
273
+ * Source extensions HashPilot has no tree-sitter grammar for, that nonetheless
274
+ * might *mention* the symbol by text. These are surfaced as `unresolved` — an
275
+ * honest "I cannot see these" — instead of being regex-guessed or silently
276
+ * ignored.
277
+ */
278
+ const UNPARSED_EXTS = [
279
+ "**/*.rb", "**/*.java", "**/*.c", "**/*.cc", "**/*.cpp", "**/*.h",
280
+ "**/*.hpp", "**/*.cs", "**/*.php", "**/*.swift", "**/*.scala", "**/*.kt",
281
+ ];
282
+
283
+ /** Counts the reconciled resolution of a symbol's references. */
284
+ export interface ReferenceReconciliation {
285
+ /** References found syntactically that the planner can act on. */
286
+ resolved: number;
287
+ /** Files that mention the symbol but HashPilot cannot parse / resolve. */
288
+ unresolved: number;
289
+ /**
290
+ * Bare references located in a file that also binds the same name more than
291
+ * once (e.g. an aliased import plus a local declaration) — the wrong module's
292
+ * symbol could be reached. True cross-module disambiguation is the LSP-tier
293
+ * successor (textDocument/references); this narrow flag is a proxy.
294
+ */
295
+ ambiguous: number;
296
+ }
297
+
298
+ interface FileResolution {
299
+ refs: ReferenceLocation[];
300
+ unresolvable: boolean;
301
+ reason?: string;
302
+ /** Number of binding sites for `symbol` in this file (used for ambiguity). */
303
+ bindings: number;
304
+ }
305
+
306
+ /**
307
+ * Resolve every bare reference to `symbol` in ONE file's source using that
308
+ * language's tree-sitter grammar, and report whether the file could not be
309
+ * resolved (no grammar / did not parse).
310
+ */
311
+ function resolveFile(source: string, absPath: string, symbol: string): FileResolution {
312
+ const lang = detectLanguage(absPath);
313
+ const parser = getParser(lang || "");
314
+ // No grammar, or the parser cannot be initialised: we cannot see this file.
315
+ if (!lang || !parser) {
316
+ return { refs: [], unresolvable: true, reason: `no parser for ${lang ?? "this language"}`, bindings: 0 };
317
+ }
318
+
319
+ let tree;
320
+ try {
321
+ tree = parseSource(parser, source);
322
+ } catch {
323
+ return { refs: [], unresolvable: true, reason: "file does not parse", bindings: 0 };
324
+ }
325
+
326
+ const lines = source.split("\n");
327
+ const refs: ReferenceLocation[] = [];
328
+ let bindings = 0;
329
+ const seenRefs = new Set<number>();
330
+
331
+ function inBindingContext(node: any): boolean {
332
+ let p = node.parent;
333
+ while (p && p.type) {
334
+ if (BINDING_CONTEXT.has(p.type)) return true;
335
+ p = p.parent;
336
+ }
337
+ return false;
338
+ }
339
+
340
+ function walk(node: any) {
341
+ if (REF_IDENTS.has(node.type) && node.text === symbol) {
342
+ const parent = node.parent;
343
+ const isDeclName =
344
+ parent && (DECL_NAME_PARENTS.has(parent.type) || MEMBER_PARENTS.has(parent.type));
345
+ const isBindingSite = isDeclName || inBindingContext(node);
346
+ if (isBindingSite) {
347
+ bindings += 1;
348
+ } else if (!seenRefs.has(node.startIndex)) {
349
+ seenRefs.add(node.startIndex);
350
+ refs.push({
351
+ file: absPath,
352
+ line: node.startPosition.row + 1,
353
+ column: node.startPosition.column + 1,
354
+ context: (lines[node.startPosition.row] || symbol).trim(),
355
+ });
356
+ }
357
+ }
358
+ for (const child of node.children) walk(child);
359
+ }
360
+
361
+ walk(tree.rootNode);
362
+ return { refs, unresolvable: false, bindings };
363
+ }
364
+
365
+ /**
366
+ * Resolve references to `symbol` across the project.
367
+ *
368
+ * Supported-language files are parsed and their bare references collected. Files
369
+ * in languages HashPilot cannot parse are reported as `unresolved` (an honest
370
+ * "I cannot see these") rather than guessed at, and any bare reference that also
371
+ * lives in a file which binds the name more than once is reported under
372
+ * `ambiguous` — a genuine cross-module clash the planner should not silently
373
+ * rename.
374
+ */
375
+ export async function resolveReferences(
376
+ symbol: string,
377
+ projectRoot: string,
378
+ _definitionFile: string
379
+ ): Promise<{
380
+ references: ReferenceLocation[];
381
+ unresolved: UnresolvedItem[];
382
+ reconciliation: ReferenceReconciliation;
383
+ }> {
384
+ const sourceFiles = await glob(LANG_EXTS, { cwd: projectRoot, ignore: IGNORE_GLOBS });
385
+ const references: ReferenceLocation[] = [];
386
+ const unresolved: UnresolvedItem[] = [];
387
+ let ambiguous = 0;
388
+
389
+ for (const rel of sourceFiles) {
390
+ const absPath = `${projectRoot}/${rel}`;
391
+ let source: string;
392
+ try {
393
+ source = await Bun.file(absPath).text();
394
+ } catch {
395
+ continue;
396
+ }
397
+ const { refs, unresolvable, reason, bindings } = resolveFile(source, absPath, symbol);
398
+ if (refs.length > 0) {
399
+ references.push(...refs);
400
+ // A bare reference that lives in a file binding the same name more than
401
+ // once is ambiguous: it may reach a different module's symbol.
402
+ if (bindings > 1) ambiguous += refs.length;
403
+ }
404
+ if (unresolvable) {
405
+ unresolved.push({
406
+ file: absPath,
407
+ operation: "resolve-references",
408
+ reason: `${rel}: ${reason ?? "file could not be resolved"}`,
409
+ resolution: `HashPilot cannot resolve references in this file; inspect and edit it manually, or add a grammar for its language.`,
410
+ });
411
+ }
412
+ }
413
+
414
+ // Languages outside LANG_EXTS that merely *mention the symbol by text* are
415
+ // surfaced as unresolved rather than silently ignored or regex-guessed.
416
+ for (const rel of await glob(UNPARSED_EXTS, { cwd: projectRoot, ignore: IGNORE_GLOBS })) {
417
+ const absPath = `${projectRoot}/${rel}`;
418
+ let source: string;
419
+ try {
420
+ source = await Bun.file(absPath).text();
421
+ } catch {
422
+ continue;
423
+ }
424
+ if (new RegExp(`\\b${escapeRegex(symbol)}\\b`).test(source)) {
425
+ unresolved.push({
426
+ file: absPath,
427
+ operation: "resolve-references",
428
+ reason: `${rel} mentions '${symbol}', but its language has no parser in HashPilot`,
429
+ resolution: `Inspect ${rel} manually; HashPilot will not guess references in a language it cannot parse.`,
430
+ });
431
+ }
432
+ }
433
+
434
+ return {
435
+ references,
436
+ unresolved,
437
+ reconciliation: {
438
+ resolved: references.length,
439
+ unresolved: unresolved.length,
440
+ ambiguous,
441
+ },
442
+ };
443
+ }
444
+
445
+ /**
446
+ * Back-compat entry point used by existing callers and tests: the rich
447
+ * `resolveReferences` result, projected down to just the reference list.
448
+ */
449
+ export async function findReferences(
450
+ symbol: string,
451
+ projectRoot: string,
452
+ _definitionFile: string
453
+ ): Promise<ReferenceLocation[]> {
454
+ const { references } = await resolveReferences(symbol, projectRoot, _definitionFile);
455
+ return references;
456
+ }
457
+
458
+ // ── Plan generation ───────────────────────────────────────────────────
459
+
460
+ export function generatePlan(
461
+ intent: StructuredIntent,
462
+ definition: SymbolDefinition,
463
+ references: ReferenceLocation[],
464
+ reconciliation?: ReferenceReconciliation
465
+ ): EditPlan {
466
+ const steps: EditStep[] = [];
467
+ const unresolved: UnresolvedItem[] = [];
468
+
469
+ switch (intent.operation) {
470
+ case "add-parameter": {
471
+ const paramParts = [intent.param.name];
472
+ if (intent.param.type) paramParts.push(intent.param.type);
473
+ const paramStr = paramParts.join(": ");
474
+ const defaultVal = intent.param.default ?? undefined;
475
+
476
+ // Step 0: Insert parameter into function signature
477
+ steps.push({
478
+ order: 0,
479
+ file: definition.file,
480
+ operation: "insert-parameter",
481
+ description: `Add parameter '${paramStr}' to function '${intent.symbol}'`,
482
+ params: {
483
+ symbolName: intent.symbol,
484
+ newParam: paramStr,
485
+ paramType: intent.param.type,
486
+ paramDefault: defaultVal,
487
+ },
488
+ });
489
+
490
+ // Steps 1..N: Insert argument at each call site file.
491
+ //
492
+ // Only possible when the caller gave a default: without one there is no
493
+ // value to pass, and inventing a placeholder means writing text that is
494
+ // wrong in every language and a syntax error in Python. Report it instead.
495
+ // Normalize before deduping: the same file reached via "src/a.ts",
496
+ // "./src/a.ts", and "/abs/proj/src/a.ts" is one file, and without this
497
+ // it produced one plan step per spelling.
498
+ const refFiles = [...new Set(references.map((r) => normalizePath(r.file)))];
499
+ if (defaultVal === undefined) {
500
+ for (const file of refFiles) {
501
+ unresolved.push({
502
+ file,
503
+ operation: "insert-call-arg",
504
+ reason: `no default given for '${intent.param.name}', so the argument to pass at each call site cannot be computed`,
505
+ resolution: `Re-run with "param": {"name": "${intent.param.name}", "default": "<value>"}, or edit the call sites in ${shortPath(file)} yourself with \`diff apply\`.`,
506
+ });
507
+ }
508
+ break;
509
+ }
510
+ refFiles.forEach((file, i) => {
511
+ steps.push({
512
+ order: i + 1,
513
+ file,
514
+ operation: "insert-call-arg",
515
+ description: `Add argument '${defaultVal}' at all call sites in ${shortPath(file)}`,
516
+ params: {
517
+ functionName: intent.symbol,
518
+ argValue: defaultVal,
519
+ },
520
+ });
521
+ });
522
+ break;
523
+ }
524
+
525
+ case "remove-parameter": {
526
+ throw new UnsupportedIntentError("remove-parameter is not implemented.");
527
+ }
528
+
529
+ case "rename-exported-symbol": {
530
+ steps.push({
531
+ order: 0,
532
+ file: definition.file,
533
+ operation: "rename-symbol",
534
+ description: `Rename '${intent.symbol}' → '${intent.newName}' in definition`,
535
+ params: { oldName: intent.symbol, newName: intent.newName },
536
+ });
537
+
538
+ // Normalized compare, so a reference spelled differently from the
539
+ // definition still filters out and does not get renamed twice.
540
+ const refFiles = [...new Set(references.map((r) => normalizePath(r.file)))]
541
+ .filter((f) => !pathsEqual(f, definition.file));
542
+ refFiles.forEach((file, i) => {
543
+ steps.push({
544
+ order: i + 1,
545
+ file,
546
+ operation: "rename-symbol",
547
+ description: `Rename in ${shortPath(file)}`,
548
+ params: { oldName: intent.symbol, newName: intent.newName },
549
+ });
550
+ });
551
+ break;
552
+ }
553
+ }
554
+
555
+ const impactedFiles = [...new Set(steps.map((s) => s.file))];
556
+
557
+ return {
558
+ intent,
559
+ definition,
560
+ references,
561
+ steps,
562
+ unresolved,
563
+ impactSummary:
564
+ `${steps.length} edits across ${impactedFiles.length} files` +
565
+ (references.length > 0 ? ` (${references.length} references found)` : "") +
566
+ (unresolved.length > 0
567
+ ? `; ${unresolved.length} unresolved in ${new Set(unresolved.map((u) => u.file)).size} files`
568
+ : "") +
569
+ (reconciliation
570
+ ? `; reconciliation: ${reconciliation.resolved} resolved, ${reconciliation.unresolved} unresolved-language, ${reconciliation.ambiguous} ambiguous`
571
+ : ""),
572
+ reconciliation,
573
+ };
574
+ }
575
+
576
+ // ── Helpers ───────────────────────────────────────────────────────────
577
+
578
+ function shortPath(file: string): string {
579
+ const idx = file.lastIndexOf("/src/");
580
+ if (idx !== -1) return file.slice(idx + 1);
581
+ const idx2 = file.lastIndexOf("/tests/");
582
+ if (idx2 !== -1) return file.slice(idx2 + 1);
583
+ return file.split("/").pop() || file;
584
+ }