@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.
- package/LICENSE +21 -0
- package/README.md +777 -0
- package/docs/ADAPTER-CONTRACT.md +1260 -0
- package/docs/ARCHITECTURE.md +846 -0
- package/docs/CLI-QUICKREF.md +827 -0
- package/docs/COMPETITIVE-ANALYSIS.md +307 -0
- package/docs/INSTALL.md +403 -0
- package/docs/INTEGRATION-CLAUDE.md +126 -0
- package/docs/INTEGRATION-MCP.md +196 -0
- package/docs/INTEGRATION-OPENCODE.md +136 -0
- package/docs/INTEGRATION-PI.md +195 -0
- package/package.json +77 -0
- package/scripts/build-site.sh +39 -0
- package/scripts/doctor.sh +218 -0
- package/scripts/gen-cli-quickref.ts +232 -0
- package/scripts/install-cli.sh +60 -0
- package/scripts/install.sh +466 -0
- package/scripts/roadmap-lint.ts +200 -0
- package/scripts/uninstall.sh +202 -0
- package/src/cli-node.cjs +51 -0
- package/src/cli.ts +209 -0
- package/src/commands/ast.ts +255 -0
- package/src/commands/diff.ts +98 -0
- package/src/commands/edit.ts +93 -0
- package/src/commands/hash.ts +64 -0
- package/src/commands/intent.ts +68 -0
- package/src/commands/maintenance.ts +191 -0
- package/src/commands/mcp.ts +28 -0
- package/src/commands/provenance.ts +111 -0
- package/src/commands/read.ts +117 -0
- package/src/commands/route.ts +42 -0
- package/src/commands/shared.ts +65 -0
- package/src/commands/telemetry.ts +126 -0
- package/src/commands/verify.ts +61 -0
- package/src/core/ast-edit.ts +2357 -0
- package/src/core/batch-edit.ts +185 -0
- package/src/core/config.ts +189 -0
- package/src/core/diff-engine.ts +474 -0
- package/src/core/doctor.ts +303 -0
- package/src/core/encoding.ts +116 -0
- package/src/core/envelope.ts +163 -0
- package/src/core/exit-codes.ts +198 -0
- package/src/core/format.ts +339 -0
- package/src/core/grep.ts +180 -0
- package/src/core/hash-edit.ts +416 -0
- package/src/core/index.ts +155 -0
- package/src/core/intent.ts +584 -0
- package/src/core/locking.ts +292 -0
- package/src/core/module-system.ts +142 -0
- package/src/core/operations.ts +557 -0
- package/src/core/output.ts +122 -0
- package/src/core/path-normalize.ts +61 -0
- package/src/core/paths.ts +326 -0
- package/src/core/plan-executor.ts +437 -0
- package/src/core/platform.ts +132 -0
- package/src/core/provenance.ts +214 -0
- package/src/core/read.ts +111 -0
- package/src/core/redact.ts +98 -0
- package/src/core/resolve-content.ts +12 -0
- package/src/core/router.ts +463 -0
- package/src/core/snapshot.ts +346 -0
- package/src/core/telemetry.ts +838 -0
- package/src/core/utils.ts +7 -0
- package/src/core/verify-baseline.ts +186 -0
- package/src/core/verify-scope.ts +282 -0
- package/src/core/verify.ts +753 -0
- package/src/mcp/server.ts +325 -0
- package/templates/claude-section.md +12 -0
- package/templates/opencode-agent.md +106 -0
- package/templates/opencode-skill.md +241 -0
- package/templates/pi-extension.ts +288 -0
- package/templates/pi-skill.md +123 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
import { safeWrite } from "./paths";
|
|
2
|
+
import {
|
|
3
|
+
isLanguageSupported,
|
|
4
|
+
detectLanguage,
|
|
5
|
+
renameSymbol,
|
|
6
|
+
replaceBody,
|
|
7
|
+
addImport,
|
|
8
|
+
removeImport,
|
|
9
|
+
insertBeforeSymbol,
|
|
10
|
+
insertAfterSymbol,
|
|
11
|
+
findSymbols,
|
|
12
|
+
findSymbolsDetailed,
|
|
13
|
+
firstParseError,
|
|
14
|
+
} from "./ast-edit";
|
|
15
|
+
import { replaceHash } from "./hash-edit";
|
|
16
|
+
import { readMany, readHash, computeHash } from "./read";
|
|
17
|
+
import { recordEvent, ErrorCode } from "./telemetry";
|
|
18
|
+
import { buildProvenanceFields } from "./provenance";
|
|
19
|
+
import { loadConfig, policyForce, RoutePolicy } from "./config";
|
|
20
|
+
import { addWarning } from "./envelope";
|
|
21
|
+
import { acquireLock, LOCK_TIMEOUT_MS, LockAcquireError } from "./locking";
|
|
22
|
+
import { readDecoded } from "./encoding";
|
|
23
|
+
import { toPreview } from "./diff-engine";
|
|
24
|
+
import { verboseLog } from "./output";
|
|
25
|
+
|
|
26
|
+
export type EditRoute = "ast" | "hash" | "diff";
|
|
27
|
+
|
|
28
|
+
export interface RouteExplanation {
|
|
29
|
+
route: EditRoute;
|
|
30
|
+
reasons: string[];
|
|
31
|
+
policyApplied: boolean;
|
|
32
|
+
policySource?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface RouterResult {
|
|
36
|
+
route: EditRoute;
|
|
37
|
+
routeReason: string;
|
|
38
|
+
fallback?: string;
|
|
39
|
+
result: any;
|
|
40
|
+
elapsed_ms: number;
|
|
41
|
+
explanation?: RouteExplanation;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function chooseRoute(
|
|
45
|
+
filePath: string,
|
|
46
|
+
operation: string,
|
|
47
|
+
policy?: RoutePolicy
|
|
48
|
+
): { route: EditRoute; explanation: RouteExplanation } {
|
|
49
|
+
const lang = detectLanguage(filePath);
|
|
50
|
+
const reasons: string[] = [];
|
|
51
|
+
let policyApplied = false;
|
|
52
|
+
let policySource: string | undefined;
|
|
53
|
+
|
|
54
|
+
// Derive a language key from extension for policy matching (even for unsupported langs)
|
|
55
|
+
const extMatch = filePath.match(/\.([^.]+)$/);
|
|
56
|
+
const extKey = lang || (extMatch ? extMatch[1] : null);
|
|
57
|
+
|
|
58
|
+
// 1. Check policy overrides first
|
|
59
|
+
const forced = policyForce(policy, extKey, operation);
|
|
60
|
+
if (forced) {
|
|
61
|
+
const src = lang && policy?.languageOverrides?.[lang]
|
|
62
|
+
? `language override for '${lang}'`
|
|
63
|
+
: `operation override for '${operation}'`;
|
|
64
|
+
const fromConf = lang && policy?.languageOverrides?.[lang] ? "language" : "operation";
|
|
65
|
+
reasons.push(`Policy ${fromConf} forces route '${forced}'`);
|
|
66
|
+
policyApplied = true;
|
|
67
|
+
policySource = forced !== chooseRoute(filePath, operation).route ? fromConf : undefined;
|
|
68
|
+
return { route: forced, explanation: { route: forced, reasons, policyApplied, policySource } };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 2. Language + AST operation check
|
|
72
|
+
if (isLanguageSupported(filePath) && isASTOperation(operation)) {
|
|
73
|
+
reasons.push(`Language '${lang}' supports AST operations`);
|
|
74
|
+
return { route: "ast", explanation: { route: "ast", reasons, policyApplied: false } };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 3. Hash operations
|
|
78
|
+
if (isHashOperation(operation)) {
|
|
79
|
+
reasons.push(`Operation '${operation}' uses hash-based editing`);
|
|
80
|
+
return { route: "hash", explanation: { route: "hash", reasons, policyApplied: false } };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 4. Diff fallback
|
|
84
|
+
const unsupported = !isLanguageSupported(filePath)
|
|
85
|
+
? `Language '${lang || "unknown"}' not supported for AST`
|
|
86
|
+
: `Operation '${operation}' not available via AST or hash`;
|
|
87
|
+
reasons.push(unsupported);
|
|
88
|
+
reasons.push(`Falling back to diff route`);
|
|
89
|
+
return { route: "diff", explanation: { route: "diff", reasons, policyApplied: false } };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isASTOperation(op: string): boolean {
|
|
93
|
+
return [
|
|
94
|
+
"rename-symbol",
|
|
95
|
+
"replace-body",
|
|
96
|
+
"add-import",
|
|
97
|
+
"remove-import",
|
|
98
|
+
"insert-before",
|
|
99
|
+
"insert-after",
|
|
100
|
+
"find-symbols",
|
|
101
|
+
].includes(op);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function isHashOperation(op: string): boolean {
|
|
105
|
+
return ["read-hash", "replace-hash"].includes(op);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function routeEdit(params: {
|
|
109
|
+
filePath: string;
|
|
110
|
+
operation: string;
|
|
111
|
+
method?: EditRoute;
|
|
112
|
+
policy?: RoutePolicy;
|
|
113
|
+
// Hash params
|
|
114
|
+
oldHash?: string;
|
|
115
|
+
newContent?: string;
|
|
116
|
+
range?: { start: number; end: number };
|
|
117
|
+
// AST params
|
|
118
|
+
oldName?: string;
|
|
119
|
+
newName?: string;
|
|
120
|
+
symbolName?: string;
|
|
121
|
+
newBody?: string;
|
|
122
|
+
importSpec?: string;
|
|
123
|
+
content?: string;
|
|
124
|
+
// Diff params (search-and-replace fallback)
|
|
125
|
+
oldContent?: string;
|
|
126
|
+
dryRun?: boolean;
|
|
127
|
+
/**
|
|
128
|
+
* Dry runs return a unified diff instead of the whole post-edit file. Set this
|
|
129
|
+
* when you genuinely want the full text back (#98).
|
|
130
|
+
*/
|
|
131
|
+
includeSource?: boolean;
|
|
132
|
+
/**
|
|
133
|
+
* Internal: the caller already holds this file's advisory lock (`batch-edit`
|
|
134
|
+
* locks its whole file set up front in sorted order, then routes each file).
|
|
135
|
+
* The lock is not re-entrant on purpose — two genuinely concurrent writers in
|
|
136
|
+
* one process must still exclude each other — so nesting would self-deadlock
|
|
137
|
+
* until the timeout. Not exposed on the CLI.
|
|
138
|
+
*/
|
|
139
|
+
alreadyLocked?: boolean;
|
|
140
|
+
// Provenance params
|
|
141
|
+
actor?: string;
|
|
142
|
+
taskId?: string;
|
|
143
|
+
reason?: string;
|
|
144
|
+
}): Promise<RouterResult> {
|
|
145
|
+
const start = Date.now();
|
|
146
|
+
let editSource: string | undefined;
|
|
147
|
+
let editResult: string | undefined;
|
|
148
|
+
const { filePath, operation, method, policy, oldHash, newContent, range, oldName, newName, symbolName, newBody, importSpec, content: insertContent, oldContent, dryRun, includeSource, alreadyLocked, actor, taskId, reason } = params;
|
|
149
|
+
|
|
150
|
+
let route: EditRoute;
|
|
151
|
+
let explanation: RouteExplanation;
|
|
152
|
+
|
|
153
|
+
// Load config-based policy if not explicitly provided
|
|
154
|
+
const resolvedPolicy = policy || loadConfig().routePolicy;
|
|
155
|
+
|
|
156
|
+
if (method) {
|
|
157
|
+
route = method;
|
|
158
|
+
explanation = { route, reasons: [`Explicit method override: ${method}`], policyApplied: false };
|
|
159
|
+
} else {
|
|
160
|
+
const decision = chooseRoute(filePath, operation, resolvedPolicy);
|
|
161
|
+
route = decision.route;
|
|
162
|
+
explanation = decision.explanation;
|
|
163
|
+
// A downgrade to the diff route is a real degradation of edit safety, and it
|
|
164
|
+
// used to be visible only in prose inside `routeReason`. Surface it.
|
|
165
|
+
if (route === "diff" && explanation.reasons.some((r) => r.startsWith("Falling back"))) {
|
|
166
|
+
addWarning({
|
|
167
|
+
code: "ROUTE_FALLBACK",
|
|
168
|
+
message: explanation.reasons.join("; "),
|
|
169
|
+
from: "ast",
|
|
170
|
+
to: "diff",
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
let result: any;
|
|
176
|
+
let routeReason = explanation.reasons.join("; ");
|
|
177
|
+
let fallback: string | undefined;
|
|
178
|
+
|
|
179
|
+
if (route === "ast" && !isLanguageSupported(filePath)) {
|
|
180
|
+
if (method) {
|
|
181
|
+
result = { success: false, message: `Cannot force AST route: ${filePath} is not a supported language file` };
|
|
182
|
+
} else {
|
|
183
|
+
fallback = "AST unsupported for this file type";
|
|
184
|
+
// A silent downgrade looks identical to a successful AST edit, which is
|
|
185
|
+
// exactly the debugging problem the envelope's `warnings` exists for.
|
|
186
|
+
addWarning({ code: "ROUTE_FALLBACK", message: fallback, from: "ast", to: "hash" });
|
|
187
|
+
route = "hash";
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (route === "hash") {
|
|
192
|
+
if (!oldHash || newContent === undefined) {
|
|
193
|
+
route = "diff";
|
|
194
|
+
fallback = "Hash edit requires oldHash and newContent";
|
|
195
|
+
addWarning({ code: "ROUTE_FALLBACK", message: fallback, from: "hash", to: "diff" });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
routeReason = `${explanation.reasons.join("; ")}${fallback ? `; ${fallback}` : ""}`;
|
|
200
|
+
|
|
201
|
+
// `--verbose` explains the tier choice on stderr. Routing is the single most
|
|
202
|
+
// opaque decision HashPilot makes, and a silent AST->diff downgrade is exactly
|
|
203
|
+
// what an agent needs to see while debugging (#47).
|
|
204
|
+
verboseLog(() => `route: ${route} for ${operation} on ${filePath} (${routeReason})`);
|
|
205
|
+
|
|
206
|
+
// Compare-and-swap alone cannot close the single-file race: between the hash
|
|
207
|
+
// compare and `safeWrite` another writer can land, and CAS then reports success
|
|
208
|
+
// over top of an edit it never saw. Hold the same advisory lock `batch-edit`
|
|
209
|
+
// uses across the whole read → edit → compare → write window so CAS is checking
|
|
210
|
+
// a snapshot nobody else can invalidate. `batch-edit` locked its whole file set
|
|
211
|
+
// up front and passes `alreadyLocked` so it does not wait on itself (#21/B18).
|
|
212
|
+
let releaseFileLock: (() => void) | undefined;
|
|
213
|
+
if (!result && !dryRun && !alreadyLocked) {
|
|
214
|
+
try {
|
|
215
|
+
releaseFileLock = await acquireLock(filePath, { timeoutMs: LOCK_TIMEOUT_MS });
|
|
216
|
+
} catch (e: any) {
|
|
217
|
+
// A contended lock is transient, so it stays retryable — but it is NOT a
|
|
218
|
+
// stale anchor. Reporting it as one told callers to re-read the file
|
|
219
|
+
// (which changes nothing here) and inflated the stale-anchor health
|
|
220
|
+
// metric. `batch-edit` reports LOCK_TIMEOUT for the identical condition.
|
|
221
|
+
result = {
|
|
222
|
+
success: false,
|
|
223
|
+
stale: true,
|
|
224
|
+
errorCode: ErrorCode.LOCK_TIMEOUT,
|
|
225
|
+
message:
|
|
226
|
+
e instanceof LockAcquireError
|
|
227
|
+
? `Could not lock ${filePath}: ${e.message}`
|
|
228
|
+
: `Could not lock ${filePath}: ${e?.message ?? e}`,
|
|
229
|
+
recovery: "Another edit holds this file. Wait and retry.",
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
if (!result) {
|
|
236
|
+
switch (route) {
|
|
237
|
+
case "ast": {
|
|
238
|
+
let source: string;
|
|
239
|
+
let casHash: string | undefined;
|
|
240
|
+
try {
|
|
241
|
+
source = (await readDecoded(filePath)).text;
|
|
242
|
+
editSource = source;
|
|
243
|
+
casHash = computeHash(source);
|
|
244
|
+
} catch (e: any) {
|
|
245
|
+
result = { success: false, message: `Failed to read file: ${e.message}` };
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
try {
|
|
249
|
+
switch (operation) {
|
|
250
|
+
case "rename-symbol":
|
|
251
|
+
result = renameSymbol(source, filePath, oldName!, newName!);
|
|
252
|
+
break;
|
|
253
|
+
case "replace-body":
|
|
254
|
+
result = replaceBody(source, filePath, symbolName!, newBody!);
|
|
255
|
+
break;
|
|
256
|
+
case "add-import":
|
|
257
|
+
result = addImport(source, filePath, importSpec!);
|
|
258
|
+
break;
|
|
259
|
+
case "remove-import":
|
|
260
|
+
result = removeImport(source, filePath, importSpec!);
|
|
261
|
+
break;
|
|
262
|
+
case "insert-before":
|
|
263
|
+
result = insertBeforeSymbol(source, filePath, symbolName!, insertContent!);
|
|
264
|
+
break;
|
|
265
|
+
case "insert-after":
|
|
266
|
+
result = insertAfterSymbol(source, filePath, symbolName!, insertContent!);
|
|
267
|
+
break;
|
|
268
|
+
case "find-symbols":
|
|
269
|
+
{
|
|
270
|
+
const search = findSymbolsDetailed(source, filePath);
|
|
271
|
+
result = { success: true, symbols: search.symbols, truncated: search.truncated, message: "Symbols found" };
|
|
272
|
+
}
|
|
273
|
+
break;
|
|
274
|
+
default:
|
|
275
|
+
result = { success: false, message: `Unknown AST operation: ${operation}` };
|
|
276
|
+
}
|
|
277
|
+
} catch (e: any) {
|
|
278
|
+
// The tree-sitter binding throws bare errors (it used to throw
|
|
279
|
+
// `Invalid argument` for any source over 32KB — see #55). Whatever
|
|
280
|
+
// the cause, an AST failure is an edit failure, never an internal
|
|
281
|
+
// crash: this is the only thing standing between a parser bug and
|
|
282
|
+
// exit 70.
|
|
283
|
+
result = {
|
|
284
|
+
success: false,
|
|
285
|
+
errorCode: ErrorCode.PARSE_ERROR,
|
|
286
|
+
message: `AST parse failed for ${filePath}: ${e?.message ?? e}`,
|
|
287
|
+
recovery: "Retry with an explicit --old-content/--new-content pair to use the diff route.",
|
|
288
|
+
};
|
|
289
|
+
addWarning({
|
|
290
|
+
code: "ROUTE_FALLBACK",
|
|
291
|
+
message: `AST route failed to parse ${filePath}; a diff-route edit is the remaining option.`,
|
|
292
|
+
from: "ast",
|
|
293
|
+
to: "diff",
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
// Write result to file if successful — CAS guard prevents silent data
|
|
297
|
+
// loss when concurrent edits both read the same snapshot.
|
|
298
|
+
if (result.success && (result as any).newSource && !dryRun) {
|
|
299
|
+
const currentOnDisk = (await readDecoded(filePath)).text;
|
|
300
|
+
const nowHash = computeHash(currentOnDisk);
|
|
301
|
+
if (nowHash !== casHash) {
|
|
302
|
+
result = {
|
|
303
|
+
success: false,
|
|
304
|
+
stale: true,
|
|
305
|
+
errorCode: ErrorCode.STALE_ANCHOR,
|
|
306
|
+
message: `CAS failed for ${filePath}: file changed while editing`,
|
|
307
|
+
newCurrentHash: nowHash,
|
|
308
|
+
recovery: "Re-read the file and retry the edit.",
|
|
309
|
+
};
|
|
310
|
+
} else {
|
|
311
|
+
await safeWrite(filePath, (result as any).newSource);
|
|
312
|
+
editResult = (result as any).newSource;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
break;
|
|
316
|
+
}
|
|
317
|
+
case "hash":
|
|
318
|
+
editSource = (await readDecoded(filePath)).text;
|
|
319
|
+
result = await replaceHash(filePath, oldHash!, newContent!, { range, dryRun });
|
|
320
|
+
editResult = ((await readDecoded(filePath)).text);
|
|
321
|
+
break;
|
|
322
|
+
case "diff": {
|
|
323
|
+
// An empty newContent is a deletion. Only oldContent must be non-empty —
|
|
324
|
+
// there is nothing to search for otherwise (#40 falsy-parameter audit).
|
|
325
|
+
if (!oldContent || newContent === undefined) {
|
|
326
|
+
result = { success: false, message: "Diff route requires oldContent and newContent" };
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
329
|
+
let source: string;
|
|
330
|
+
let casHash: string | undefined;
|
|
331
|
+
try {
|
|
332
|
+
source = (await readDecoded(filePath)).text;
|
|
333
|
+
editSource = source;
|
|
334
|
+
casHash = computeHash(source);
|
|
335
|
+
} catch (e: any) {
|
|
336
|
+
result = { success: false, message: `Failed to read file: ${e.message}` };
|
|
337
|
+
break;
|
|
338
|
+
}
|
|
339
|
+
result = applyTextReplace(source, filePath, oldContent, newContent);
|
|
340
|
+
// Same post-edit parse check the AST and hash tiers apply: a search-and-
|
|
341
|
+
// replace that lands mid-expression must not reach disk (#13).
|
|
342
|
+
if (result.success && (result as any).newSource) {
|
|
343
|
+
const after = firstParseError((result as any).newSource, filePath);
|
|
344
|
+
if (after && !firstParseError(source, filePath)) {
|
|
345
|
+
result = {
|
|
346
|
+
success: false,
|
|
347
|
+
errorCode: ErrorCode.PARSE_ERROR,
|
|
348
|
+
message:
|
|
349
|
+
`Edit was discarded: the result does not parse (syntax error at line ${after.line}:${after.column} — ${after.nodeType}). ` +
|
|
350
|
+
`The file parsed cleanly before, so this replacement would have corrupted it.`,
|
|
351
|
+
};
|
|
352
|
+
break;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (result.success && (result as any).newSource && !dryRun) {
|
|
356
|
+
const currentOnDisk = (await readDecoded(filePath)).text;
|
|
357
|
+
const nowHash = computeHash(currentOnDisk);
|
|
358
|
+
if (nowHash !== casHash) {
|
|
359
|
+
result = {
|
|
360
|
+
success: false,
|
|
361
|
+
stale: true,
|
|
362
|
+
errorCode: ErrorCode.STALE_ANCHOR,
|
|
363
|
+
message: `CAS failed for ${filePath}: file changed while editing`,
|
|
364
|
+
newCurrentHash: nowHash,
|
|
365
|
+
recovery: "Re-read the file and retry the edit.",
|
|
366
|
+
};
|
|
367
|
+
} else {
|
|
368
|
+
await safeWrite(filePath, (result as any).newSource);
|
|
369
|
+
editResult = (result as any).newSource;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
break;
|
|
373
|
+
}
|
|
374
|
+
default:
|
|
375
|
+
result = { success: false, message: `Unknown route: ${route}` };
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
} finally {
|
|
379
|
+
releaseFileLock?.();
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const elapsed = Date.now() - start;
|
|
383
|
+
|
|
384
|
+
let errorCode: ErrorCode | undefined;
|
|
385
|
+
if (!result.success) {
|
|
386
|
+
if (result.stale) {
|
|
387
|
+
errorCode = ErrorCode.STALE_ANCHOR;
|
|
388
|
+
} else if (result.message?.includes("not found") || result.message?.includes("ENOENT")) {
|
|
389
|
+
errorCode = ErrorCode.FILE_NOT_FOUND;
|
|
390
|
+
} else if (result.message?.includes("hash")) {
|
|
391
|
+
errorCode = ErrorCode.HASH_MISMATCH;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const provenanceFields = buildProvenanceFields({
|
|
396
|
+
actor,
|
|
397
|
+
taskId,
|
|
398
|
+
reason,
|
|
399
|
+
source: editSource,
|
|
400
|
+
newSource: editResult,
|
|
401
|
+
filePath,
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
recordEvent({
|
|
405
|
+
operation,
|
|
406
|
+
route,
|
|
407
|
+
file: filePath,
|
|
408
|
+
language: detectLanguage(filePath) || undefined,
|
|
409
|
+
success: result.success ?? false,
|
|
410
|
+
fallback_reason: fallback,
|
|
411
|
+
retries: result.retries,
|
|
412
|
+
elapsed_ms: elapsed,
|
|
413
|
+
errorCode,
|
|
414
|
+
...provenanceFields,
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
// A dry run previews; it does not dump the file back at the caller (#98).
|
|
418
|
+
const payload = dryRun ? toPreview(result, editSource, filePath, includeSource) : result;
|
|
419
|
+
|
|
420
|
+
verboseLog(() => `result: ${result.success ? "ok" : "failed"} via ${route} in ${elapsed}ms`);
|
|
421
|
+
|
|
422
|
+
return { route, routeReason, fallback, result: payload, elapsed_ms: elapsed, explanation };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Search-and-replace fallback for the diff route.
|
|
427
|
+
* Detects duplicates and reports the count. If oldContent appears more than once,
|
|
428
|
+
* fails with a message listing occurrences so the caller can disambiguate.
|
|
429
|
+
*/
|
|
430
|
+
function applyTextReplace(
|
|
431
|
+
source: string,
|
|
432
|
+
filePath: string,
|
|
433
|
+
oldContent: string,
|
|
434
|
+
newContent: string
|
|
435
|
+
): { success: boolean; message: string; newSource?: string } {
|
|
436
|
+
// Count exact occurrences in the full source
|
|
437
|
+
const occurrences: number[] = [];
|
|
438
|
+
let idx = 0;
|
|
439
|
+
while ((idx = source.indexOf(oldContent, idx)) !== -1) {
|
|
440
|
+
const lineNum = source.slice(0, idx).split("\n").length;
|
|
441
|
+
occurrences.push(lineNum);
|
|
442
|
+
idx += oldContent.length;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (occurrences.length === 0) {
|
|
446
|
+
return { success: false, message: `Content not found in ${filePath}. File may have changed — re-read and retry.` };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (occurrences.length > 1) {
|
|
450
|
+
const locs = occurrences.map((l) => `line ${l}`).join(", ");
|
|
451
|
+
return {
|
|
452
|
+
success: false,
|
|
453
|
+
message: `Content appears ${occurrences.length} times (${locs}). Provide more context to disambiguate.`,
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const newSource = source.split(oldContent).join(newContent);
|
|
458
|
+
return {
|
|
459
|
+
success: true,
|
|
460
|
+
message: `Replaced content at line ${occurrences[0]}`,
|
|
461
|
+
newSource,
|
|
462
|
+
};
|
|
463
|
+
}
|