pi-mono-all 1.0.0
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/CHANGELOG.md +13 -0
- package/LICENCE.md +7 -0
- package/node_modules/pi-common/package.json +22 -0
- package/node_modules/pi-common/src/auth-config.ts +290 -0
- package/node_modules/pi-common/src/auth.ts +63 -0
- package/node_modules/pi-common/src/cache.ts +60 -0
- package/node_modules/pi-common/src/errors.ts +47 -0
- package/node_modules/pi-common/src/http-client.ts +118 -0
- package/node_modules/pi-common/src/index.ts +7 -0
- package/node_modules/pi-common/src/rate-limiter.ts +32 -0
- package/node_modules/pi-common/src/tool-result.ts +27 -0
- package/node_modules/pi-mono-ask-user-question/CHANGELOG.md +185 -0
- package/node_modules/pi-mono-ask-user-question/README.md +226 -0
- package/node_modules/pi-mono-ask-user-question/index.ts +923 -0
- package/node_modules/pi-mono-ask-user-question/package.json +29 -0
- package/node_modules/pi-mono-auto-fix/CHANGELOG.md +59 -0
- package/node_modules/pi-mono-auto-fix/README.md +77 -0
- package/node_modules/pi-mono-auto-fix/index.ts +488 -0
- package/node_modules/pi-mono-auto-fix/package.json +23 -0
- package/node_modules/pi-mono-btw/CHANGELOG.md +180 -0
- package/node_modules/pi-mono-btw/README.md +24 -0
- package/node_modules/pi-mono-btw/index.ts +499 -0
- package/node_modules/pi-mono-btw/package.json +29 -0
- package/node_modules/pi-mono-clear/CHANGELOG.md +180 -0
- package/node_modules/pi-mono-clear/README.md +40 -0
- package/node_modules/pi-mono-clear/index.ts +45 -0
- package/node_modules/pi-mono-clear/package.json +29 -0
- package/node_modules/pi-mono-context/CHANGELOG.md +12 -0
- package/node_modules/pi-mono-context/README.md +74 -0
- package/node_modules/pi-mono-context/index.ts +641 -0
- package/node_modules/pi-mono-context/package.json +29 -0
- package/node_modules/pi-mono-context-guard/CHANGELOG.md +195 -0
- package/node_modules/pi-mono-context-guard/README.md +81 -0
- package/node_modules/pi-mono-context-guard/index.ts +212 -0
- package/node_modules/pi-mono-context-guard/package.json +23 -0
- package/node_modules/pi-mono-figma/CHANGELOG.md +59 -0
- package/node_modules/pi-mono-figma/README.md +236 -0
- package/node_modules/pi-mono-figma/__tests__/code-connect.test.ts +32 -0
- package/node_modules/pi-mono-figma/__tests__/figma-assets.test.ts +38 -0
- package/node_modules/pi-mono-figma/__tests__/figma-component-hints.test.ts +23 -0
- package/node_modules/pi-mono-figma/__tests__/figma-implementation-layout.test.ts +47 -0
- package/node_modules/pi-mono-figma/__tests__/figma-search.test.ts +51 -0
- package/node_modules/pi-mono-figma/__tests__/figma-summarizer.test.ts +65 -0
- package/node_modules/pi-mono-figma/__tests__/fixtures/complex-auto-layout.json +115 -0
- package/node_modules/pi-mono-figma/__tests__/fixtures/component-instance.json +50 -0
- package/node_modules/pi-mono-figma/__tests__/fixtures/hidden-and-vectors.json +28 -0
- package/node_modules/pi-mono-figma/__tests__/fixtures/variables-and-styles.json +40 -0
- package/node_modules/pi-mono-figma/docs/live-selection-bridge.md +16 -0
- package/node_modules/pi-mono-figma/index.ts +6 -0
- package/node_modules/pi-mono-figma/package.json +33 -0
- package/node_modules/pi-mono-figma/skills/figma/SKILL.md +143 -0
- package/node_modules/pi-mono-figma/src/code-connect.ts +110 -0
- package/node_modules/pi-mono-figma/src/figma-assets.ts +146 -0
- package/node_modules/pi-mono-figma/src/figma-cache.ts +6 -0
- package/node_modules/pi-mono-figma/src/figma-client.ts +471 -0
- package/node_modules/pi-mono-figma/src/figma-component-hints.ts +87 -0
- package/node_modules/pi-mono-figma/src/figma-implementation.ts +264 -0
- package/node_modules/pi-mono-figma/src/figma-schemas.ts +139 -0
- package/node_modules/pi-mono-figma/src/figma-search.ts +195 -0
- package/node_modules/pi-mono-figma/src/figma-summarizer.ts +673 -0
- package/node_modules/pi-mono-figma/src/figma-tokens.ts +57 -0
- package/node_modules/pi-mono-figma/src/figma-tools.ts +352 -0
- package/node_modules/pi-mono-linear/CHANGELOG.md +44 -0
- package/node_modules/pi-mono-linear/README.md +159 -0
- package/node_modules/pi-mono-linear/index.ts +6 -0
- package/node_modules/pi-mono-linear/package.json +30 -0
- package/node_modules/pi-mono-linear/skills/linear/SKILL.md +107 -0
- package/node_modules/pi-mono-linear/src/linear-client.ts +339 -0
- package/node_modules/pi-mono-linear/src/linear-queries.ts +101 -0
- package/node_modules/pi-mono-linear/src/linear-schemas.ts +90 -0
- package/node_modules/pi-mono-linear/src/linear-tools.ts +362 -0
- package/node_modules/pi-mono-loop/CHANGELOG.md +163 -0
- package/node_modules/pi-mono-loop/README.md +54 -0
- package/node_modules/pi-mono-loop/index.ts +291 -0
- package/node_modules/pi-mono-loop/package.json +26 -0
- package/node_modules/pi-mono-multi-edit/CHANGELOG.md +232 -0
- package/node_modules/pi-mono-multi-edit/README.md +244 -0
- package/node_modules/pi-mono-multi-edit/__tests__/classic.test.ts +277 -0
- package/node_modules/pi-mono-multi-edit/__tests__/diff.test.ts +77 -0
- package/node_modules/pi-mono-multi-edit/__tests__/patch.test.ts +287 -0
- package/node_modules/pi-mono-multi-edit/benchmark-edits.ts +966 -0
- package/node_modules/pi-mono-multi-edit/classic.ts +435 -0
- package/node_modules/pi-mono-multi-edit/diff.ts +143 -0
- package/node_modules/pi-mono-multi-edit/index.ts +266 -0
- package/node_modules/pi-mono-multi-edit/package.json +37 -0
- package/node_modules/pi-mono-multi-edit/patch.ts +463 -0
- package/node_modules/pi-mono-multi-edit/types.ts +53 -0
- package/node_modules/pi-mono-multi-edit/workspace.ts +85 -0
- package/node_modules/pi-mono-review/CHANGELOG.md +190 -0
- package/node_modules/pi-mono-review/README.md +30 -0
- package/node_modules/pi-mono-review/common.ts +930 -0
- package/node_modules/pi-mono-review/index.ts +8 -0
- package/node_modules/pi-mono-review/package.json +29 -0
- package/node_modules/pi-mono-review/review-tui.ts +194 -0
- package/node_modules/pi-mono-review/review.ts +119 -0
- package/node_modules/pi-mono-review/reviewer.ts +339 -0
- package/node_modules/pi-mono-sentinel/CHANGELOG.md +158 -0
- package/node_modules/pi-mono-sentinel/README.md +87 -0
- package/node_modules/pi-mono-sentinel/__tests__/output-scanner.test.ts +109 -0
- package/node_modules/pi-mono-sentinel/__tests__/permissions.test.ts +202 -0
- package/node_modules/pi-mono-sentinel/__tests__/whitelist.test.ts +59 -0
- package/node_modules/pi-mono-sentinel/guards/execution-tracker.ts +281 -0
- package/node_modules/pi-mono-sentinel/guards/output-scanner.ts +232 -0
- package/node_modules/pi-mono-sentinel/guards/permission-gate.ts +170 -0
- package/node_modules/pi-mono-sentinel/index.ts +43 -0
- package/node_modules/pi-mono-sentinel/package.json +26 -0
- package/node_modules/pi-mono-sentinel/patterns/permissions.ts +175 -0
- package/node_modules/pi-mono-sentinel/patterns/read-targets.ts +104 -0
- package/node_modules/pi-mono-sentinel/patterns/secrets.ts +143 -0
- package/node_modules/pi-mono-sentinel/session.ts +95 -0
- package/node_modules/pi-mono-sentinel/specs/2026/04/sentinel/001-permission-gate.md +145 -0
- package/node_modules/pi-mono-sentinel/types.ts +39 -0
- package/node_modules/pi-mono-sentinel/whitelist.ts +86 -0
- package/node_modules/pi-mono-simplify/CHANGELOG.md +163 -0
- package/node_modules/pi-mono-simplify/README.md +56 -0
- package/node_modules/pi-mono-simplify/index.ts +78 -0
- package/node_modules/pi-mono-simplify/package.json +29 -0
- package/node_modules/pi-mono-status-line/CHANGELOG.md +180 -0
- package/node_modules/pi-mono-status-line/README.md +96 -0
- package/node_modules/pi-mono-status-line/basic.ts +89 -0
- package/node_modules/pi-mono-status-line/expert.ts +689 -0
- package/node_modules/pi-mono-status-line/index.ts +54 -0
- package/node_modules/pi-mono-status-line/package.json +29 -0
- package/node_modules/pi-mono-team-mode/CHANGELOG.md +278 -0
- package/node_modules/pi-mono-team-mode/README.md +246 -0
- package/node_modules/pi-mono-team-mode/__tests__/agent-manager-transient.test.ts +75 -0
- package/node_modules/pi-mono-team-mode/__tests__/delegation-manager.test.ts +118 -0
- package/node_modules/pi-mono-team-mode/__tests__/formatters.test.ts +104 -0
- package/node_modules/pi-mono-team-mode/__tests__/model-config.test.ts +272 -0
- package/node_modules/pi-mono-team-mode/__tests__/notification-box.test.ts +34 -0
- package/node_modules/pi-mono-team-mode/__tests__/parallel-utils.test.ts +32 -0
- package/node_modules/pi-mono-team-mode/__tests__/pi-stream-parser.test.ts +64 -0
- package/node_modules/pi-mono-team-mode/__tests__/prompts.test.ts +106 -0
- package/node_modules/pi-mono-team-mode/__tests__/store.test.ts +164 -0
- package/node_modules/pi-mono-team-mode/__tests__/tasks.test.ts +267 -0
- package/node_modules/pi-mono-team-mode/__tests__/teammate-specs.test.ts +114 -0
- package/node_modules/pi-mono-team-mode/__tests__/widget.test.ts +41 -0
- package/node_modules/pi-mono-team-mode/__tests__/worktree.test.ts +78 -0
- package/node_modules/pi-mono-team-mode/core/chain-utils.ts +90 -0
- package/node_modules/pi-mono-team-mode/core/fs-utils.ts +44 -0
- package/node_modules/pi-mono-team-mode/core/model-config.ts +432 -0
- package/node_modules/pi-mono-team-mode/core/parallel-utils.ts +48 -0
- package/node_modules/pi-mono-team-mode/core/prompts.ts +158 -0
- package/node_modules/pi-mono-team-mode/core/store.ts +156 -0
- package/node_modules/pi-mono-team-mode/core/tasks.ts +99 -0
- package/node_modules/pi-mono-team-mode/core/teammate-specs.ts +124 -0
- package/node_modules/pi-mono-team-mode/core/types.ts +160 -0
- package/node_modules/pi-mono-team-mode/index.ts +825 -0
- package/node_modules/pi-mono-team-mode/managers/agent-manager.ts +654 -0
- package/node_modules/pi-mono-team-mode/managers/delegation-manager.ts +211 -0
- package/node_modules/pi-mono-team-mode/managers/task-manager.ts +238 -0
- package/node_modules/pi-mono-team-mode/managers/team-manager.ts +59 -0
- package/node_modules/pi-mono-team-mode/package.json +33 -0
- package/node_modules/pi-mono-team-mode/runtime/pi-stream-parser.ts +194 -0
- package/node_modules/pi-mono-team-mode/runtime/subprocess.ts +183 -0
- package/node_modules/pi-mono-team-mode/runtime/transient-session.ts +196 -0
- package/node_modules/pi-mono-team-mode/runtime/worktree.ts +90 -0
- package/node_modules/pi-mono-team-mode/ui/formatters.ts +149 -0
- package/node_modules/pi-mono-team-mode/ui/notification-box.ts +55 -0
- package/node_modules/pi-mono-team-mode/ui/widget.ts +94 -0
- package/package.json +76 -0
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classic edit engine — (path, oldText, newText) triples applied against a
|
|
3
|
+
* Workspace, with positional same-file ordering, curly-quote fallback, and
|
|
4
|
+
* atomic multi-file rollback.
|
|
5
|
+
*
|
|
6
|
+
* The core loop groups edits by their absolute path so all hits against a
|
|
7
|
+
* file happen in one read/mutate/write cycle. Within a group, entries are
|
|
8
|
+
* sorted by the position of their `oldText` in the original content, so a
|
|
9
|
+
* model that lists edits bottom-up still applies them top-down.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { isAbsolute, resolve as resolvePath } from "path";
|
|
13
|
+
|
|
14
|
+
import { generateDiffString } from "./diff.ts";
|
|
15
|
+
import type { EditItem, EditResult, Workspace } from "./types.ts";
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Text matching
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
const normalizeCurlyQuotes = (s: string): string =>
|
|
22
|
+
s
|
|
23
|
+
.replace(/[\u2018\u2019\u201A\u201B]/g, "'")
|
|
24
|
+
.replace(/[\u201C\u201D\u201E\u201F]/g, '"');
|
|
25
|
+
|
|
26
|
+
const trimTrailingPerLine = (s: string): string =>
|
|
27
|
+
s
|
|
28
|
+
.split("\n")
|
|
29
|
+
.map((l) => l.trimEnd())
|
|
30
|
+
.join("\n");
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Ordered list of passes `findActualString` tries when matching `oldText`
|
|
34
|
+
* inside file content. Each pass applies a normalizer to *both* `oldText`
|
|
35
|
+
* and `content`; the first one that locates the transformed string wins.
|
|
36
|
+
*
|
|
37
|
+
* The array is the extension point: add a new pass here to gain tolerance
|
|
38
|
+
* for a new class of model/file mismatch (e.g. dash variants, NBSP).
|
|
39
|
+
*/
|
|
40
|
+
const MATCH_PASSES: readonly ((s: string) => string)[] = [
|
|
41
|
+
(s) => s, // exact
|
|
42
|
+
normalizeCurlyQuotes, // curly → straight quotes
|
|
43
|
+
trimTrailingPerLine, // trailing-whitespace tolerance per line
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Locate `oldText` inside `content` starting at `offset`. Falls back through
|
|
48
|
+
* `MATCH_PASSES` when the exact search fails — most commonly when the model
|
|
49
|
+
* wrote curly quotes but the file has straight ASCII.
|
|
50
|
+
*
|
|
51
|
+
* Returns `{ pos, actualOldText }` on match, `undefined` otherwise. Callers
|
|
52
|
+
* must use `actualOldText.length` (not the original oldText length) when
|
|
53
|
+
* splicing, since the matched region may differ from the requested text
|
|
54
|
+
* after normalization.
|
|
55
|
+
*/
|
|
56
|
+
export function findActualString(
|
|
57
|
+
content: string,
|
|
58
|
+
oldText: string,
|
|
59
|
+
offset: number,
|
|
60
|
+
): { pos: number; actualOldText: string } | undefined {
|
|
61
|
+
// Fast path: exact match with no normalization.
|
|
62
|
+
const exact = content.indexOf(oldText, offset);
|
|
63
|
+
if (exact !== -1) return { pos: exact, actualOldText: oldText };
|
|
64
|
+
|
|
65
|
+
// Slower passes: normalize both sides and map the position back to the
|
|
66
|
+
// original content. We search in the normalized content but return the
|
|
67
|
+
// position and length in the *original* so the caller can splice correctly.
|
|
68
|
+
const triedOld = new Set<string>([oldText]);
|
|
69
|
+
const triedContent = new Set<string>([content]);
|
|
70
|
+
|
|
71
|
+
for (let i = 1; i < MATCH_PASSES.length; i++) {
|
|
72
|
+
const transform = MATCH_PASSES[i];
|
|
73
|
+
const normOld = transform(oldText);
|
|
74
|
+
const normContent = transform(content);
|
|
75
|
+
|
|
76
|
+
if (triedOld.has(normOld) && triedContent.has(normContent)) continue;
|
|
77
|
+
triedOld.add(normOld);
|
|
78
|
+
triedContent.add(normContent);
|
|
79
|
+
|
|
80
|
+
const pos = normContent.indexOf(normOld, offset);
|
|
81
|
+
if (pos !== -1) {
|
|
82
|
+
// Map back: the character at `pos` in normalised content corresponds
|
|
83
|
+
// to the same index in the original (our normalizers preserve length
|
|
84
|
+
// for all passes except trimTrailingPerLine). For trimEnd we need the
|
|
85
|
+
// actual substring from original content that matches.
|
|
86
|
+
const actualOld = content.slice(pos, pos + normOld.length);
|
|
87
|
+
// Verify the mapped slice actually normalizes to the same thing.
|
|
88
|
+
if (transform(actualOld) === normOld) {
|
|
89
|
+
return { pos, actualOldText: actualOld };
|
|
90
|
+
}
|
|
91
|
+
// If the lengths shifted (trimEnd can shrink lines), fall back to a
|
|
92
|
+
// line-aligned search: find the lines in the original content.
|
|
93
|
+
const match = findByNormalizedLines(content, oldText, offset, transform);
|
|
94
|
+
if (match) return match;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Line-by-line normalized search. Used when a normalizer changes string
|
|
102
|
+
* length (e.g. trimEnd) so character offsets between original and normalized
|
|
103
|
+
* content no longer align 1:1.
|
|
104
|
+
*/
|
|
105
|
+
function findByNormalizedLines(
|
|
106
|
+
content: string,
|
|
107
|
+
oldText: string,
|
|
108
|
+
offset: number,
|
|
109
|
+
normalize: (s: string) => string,
|
|
110
|
+
): { pos: number; actualOldText: string } | undefined {
|
|
111
|
+
const contentLines = content.split("\n");
|
|
112
|
+
const oldLines = oldText.split("\n");
|
|
113
|
+
if (oldLines.length === 0) return undefined;
|
|
114
|
+
|
|
115
|
+
const normOldLines = oldLines.map((l) => normalize(l));
|
|
116
|
+
|
|
117
|
+
// Character offset → line index.
|
|
118
|
+
let charCount = 0;
|
|
119
|
+
let startLine = 0;
|
|
120
|
+
for (let i = 0; i < contentLines.length; i++) {
|
|
121
|
+
if (charCount + contentLines[i].length >= offset) {
|
|
122
|
+
startLine = i;
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
charCount += contentLines[i].length + 1; // +1 for \n
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
for (let i = startLine; i <= contentLines.length - oldLines.length; i++) {
|
|
129
|
+
let match = true;
|
|
130
|
+
for (let j = 0; j < normOldLines.length; j++) {
|
|
131
|
+
if (normalize(contentLines[i + j]) !== normOldLines[j]) {
|
|
132
|
+
match = false;
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (match) {
|
|
137
|
+
// Compute character position and actual substring from original.
|
|
138
|
+
let pos = 0;
|
|
139
|
+
for (let k = 0; k < i; k++) pos += contentLines[k].length + 1;
|
|
140
|
+
const endLine = i + oldLines.length - 1;
|
|
141
|
+
let endPos = 0;
|
|
142
|
+
for (let k = 0; k <= endLine; k++) endPos += contentLines[k].length + 1;
|
|
143
|
+
endPos--; // don't include the final \n after last matched line
|
|
144
|
+
// If oldText ended with \n, include it.
|
|
145
|
+
if (oldText.endsWith("\n") && endPos + 1 <= content.length) endPos++;
|
|
146
|
+
const actualOldText = content.slice(pos, endPos);
|
|
147
|
+
return { pos, actualOldText };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// Grouping helpers
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
interface IndexedEdit {
|
|
159
|
+
index: number;
|
|
160
|
+
edit: EditItem;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function toAbsolute(path: string, cwd: string): string {
|
|
164
|
+
return isAbsolute(path) ? resolvePath(path) : resolvePath(cwd, path);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Bucket a flat edit list by its resolved absolute path. The returned Map
|
|
169
|
+
* preserves insertion order, which is the order files are processed in the
|
|
170
|
+
* apply loop — making the first-seen file also the first to be mutated on
|
|
171
|
+
* disk.
|
|
172
|
+
*/
|
|
173
|
+
function groupEditsByPath(
|
|
174
|
+
edits: EditItem[],
|
|
175
|
+
cwd: string,
|
|
176
|
+
): Map<string, IndexedEdit[]> {
|
|
177
|
+
const groups = new Map<string, IndexedEdit[]>();
|
|
178
|
+
for (let i = 0; i < edits.length; i++) {
|
|
179
|
+
const abs = toAbsolute(edits[i].path, cwd);
|
|
180
|
+
const bucket = groups.get(abs);
|
|
181
|
+
if (bucket) {
|
|
182
|
+
bucket.push({ index: i, edit: edits[i] });
|
|
183
|
+
} else {
|
|
184
|
+
groups.set(abs, [{ index: i, edit: edits[i] }]);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return groups;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Sort same-file edits by the position of their `oldText` inside the
|
|
192
|
+
* original content. Edits whose oldText can't be located slide to the end
|
|
193
|
+
* and surface the error through the regular apply loop.
|
|
194
|
+
*/
|
|
195
|
+
function sortGroupByPosition(
|
|
196
|
+
group: IndexedEdit[],
|
|
197
|
+
originalContent: string,
|
|
198
|
+
): void {
|
|
199
|
+
if (group.length < 2) return;
|
|
200
|
+
const positions = new Map<IndexedEdit, number>();
|
|
201
|
+
for (const entry of group) {
|
|
202
|
+
const match = findActualString(originalContent, entry.edit.oldText, 0);
|
|
203
|
+
positions.set(
|
|
204
|
+
entry,
|
|
205
|
+
match === undefined ? Number.MAX_SAFE_INTEGER : match.pos,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
group.sort((a, b) => positions.get(a)! - positions.get(b)!);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
// Core apply loop
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
interface ApplyOptions {
|
|
216
|
+
collectDiff?: boolean;
|
|
217
|
+
rollbackOnError?: boolean;
|
|
218
|
+
/** When true, failed edits are recorded but the batch continues with the
|
|
219
|
+
* remaining edits instead of aborting the entire group. */
|
|
220
|
+
continueOnError?: boolean;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Apply a list of classic edits sequentially through a Workspace.
|
|
225
|
+
*
|
|
226
|
+
* Within each file the applier advances a `searchOffset` cursor after every
|
|
227
|
+
* replacement so duplicate oldText snippets are disambiguated positionally.
|
|
228
|
+
* Same-file edits are reordered by the position of their oldText in the
|
|
229
|
+
* original content so the cursor always moves forward.
|
|
230
|
+
*
|
|
231
|
+
* When `rollbackOnError` is set, any file already written in this batch is
|
|
232
|
+
* restored to its pre-edit snapshot if a later file fails — producing an
|
|
233
|
+
* atomic multi-file edit on the real filesystem.
|
|
234
|
+
*/
|
|
235
|
+
export async function applyClassicEdits(
|
|
236
|
+
edits: EditItem[],
|
|
237
|
+
workspace: Workspace,
|
|
238
|
+
cwd: string,
|
|
239
|
+
signal?: AbortSignal,
|
|
240
|
+
options: ApplyOptions = {},
|
|
241
|
+
): Promise<EditResult[]> {
|
|
242
|
+
const {
|
|
243
|
+
collectDiff = false,
|
|
244
|
+
rollbackOnError = false,
|
|
245
|
+
continueOnError = false,
|
|
246
|
+
} = options;
|
|
247
|
+
|
|
248
|
+
const fileGroups = groupEditsByPath(edits, cwd);
|
|
249
|
+
const results: EditResult[] = new Array(edits.length);
|
|
250
|
+
|
|
251
|
+
// Fail fast on any unwritable target so we don't partially mutate the FS.
|
|
252
|
+
await Promise.all(
|
|
253
|
+
Array.from(fileGroups.keys(), (absPath) =>
|
|
254
|
+
workspace.checkWriteAccess(absPath),
|
|
255
|
+
),
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
// Pre-edit snapshots keyed by absolute path — populated as each file is
|
|
259
|
+
// successfully written, consumed on failure for rollback.
|
|
260
|
+
const snapshots = new Map<string, string>();
|
|
261
|
+
|
|
262
|
+
try {
|
|
263
|
+
for (const [absPath, group] of fileGroups) {
|
|
264
|
+
throwIfAborted(signal);
|
|
265
|
+
|
|
266
|
+
const originalContent = await workspace.readText(absPath);
|
|
267
|
+
sortGroupByPosition(group, originalContent);
|
|
268
|
+
|
|
269
|
+
let updatedContent: string;
|
|
270
|
+
try {
|
|
271
|
+
updatedContent = applyGroupToContent(
|
|
272
|
+
group,
|
|
273
|
+
originalContent,
|
|
274
|
+
results,
|
|
275
|
+
edits.length,
|
|
276
|
+
signal,
|
|
277
|
+
continueOnError,
|
|
278
|
+
);
|
|
279
|
+
} catch (err) {
|
|
280
|
+
if (continueOnError) continue;
|
|
281
|
+
throw err;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (updatedContent === originalContent) continue;
|
|
285
|
+
|
|
286
|
+
snapshots.set(absPath, originalContent);
|
|
287
|
+
await workspace.writeText(absPath, updatedContent);
|
|
288
|
+
|
|
289
|
+
if (collectDiff) {
|
|
290
|
+
const { diff, firstChangedLine } = generateDiffString(
|
|
291
|
+
originalContent,
|
|
292
|
+
updatedContent,
|
|
293
|
+
);
|
|
294
|
+
const firstSuccessIdx = group.find(
|
|
295
|
+
(e) => results[e.index]?.success,
|
|
296
|
+
)?.index;
|
|
297
|
+
if (firstSuccessIdx !== undefined) {
|
|
298
|
+
results[firstSuccessIdx].diff = diff;
|
|
299
|
+
results[firstSuccessIdx].firstChangedLine = firstChangedLine;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
} catch (err) {
|
|
304
|
+
if (rollbackOnError) {
|
|
305
|
+
await rollbackSnapshots(snapshots, workspace);
|
|
306
|
+
}
|
|
307
|
+
throw err;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return results;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Apply every edit in a same-file group against `originalContent`, writing
|
|
315
|
+
* per-edit outcomes into the shared `results` slot array. Returns the final
|
|
316
|
+
* mutated content for the file, or throws with a formatted error if a hunk
|
|
317
|
+
* can't be located.
|
|
318
|
+
*/
|
|
319
|
+
function applyGroupToContent(
|
|
320
|
+
group: IndexedEdit[],
|
|
321
|
+
originalContent: string,
|
|
322
|
+
results: EditResult[],
|
|
323
|
+
totalEdits: number,
|
|
324
|
+
signal: AbortSignal | undefined,
|
|
325
|
+
continueOnError = false,
|
|
326
|
+
): string {
|
|
327
|
+
let content = originalContent;
|
|
328
|
+
let searchOffset = 0;
|
|
329
|
+
|
|
330
|
+
// Track which oldText→newText pairs already landed in this file so we
|
|
331
|
+
// can skip a redundant duplicate gracefully instead of failing the batch.
|
|
332
|
+
const appliedPairs = new Set<string>();
|
|
333
|
+
const pairKey = (edit: EditItem) => `${edit.oldText}\0${edit.newText}`;
|
|
334
|
+
|
|
335
|
+
for (const { index, edit } of group) {
|
|
336
|
+
throwIfAborted(signal);
|
|
337
|
+
|
|
338
|
+
const match = findActualString(content, edit.oldText, searchOffset);
|
|
339
|
+
|
|
340
|
+
if (match === undefined) {
|
|
341
|
+
if (appliedPairs.has(pairKey(edit))) {
|
|
342
|
+
results[index] = {
|
|
343
|
+
path: edit.path,
|
|
344
|
+
success: true,
|
|
345
|
+
message: `Skipped redundant edit in ${edit.path} (already replaced all occurrences).`,
|
|
346
|
+
};
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
results[index] = {
|
|
351
|
+
path: edit.path,
|
|
352
|
+
success: false,
|
|
353
|
+
message: `Could not find the exact text in ${edit.path}. The old text must match exactly including all whitespace and newlines.`,
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
if (continueOnError) continue;
|
|
357
|
+
|
|
358
|
+
markRemainingSkipped(group, index, results);
|
|
359
|
+
throw new Error(formatResults(results.filter(Boolean), totalEdits));
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const { pos, actualOldText } = match;
|
|
363
|
+
content =
|
|
364
|
+
content.slice(0, pos) +
|
|
365
|
+
edit.newText +
|
|
366
|
+
content.slice(pos + actualOldText.length);
|
|
367
|
+
searchOffset = pos + edit.newText.length;
|
|
368
|
+
appliedPairs.add(pairKey(edit));
|
|
369
|
+
|
|
370
|
+
results[index] = {
|
|
371
|
+
path: edit.path,
|
|
372
|
+
success: true,
|
|
373
|
+
message: `Edited ${edit.path}.`,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return content;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function markRemainingSkipped(
|
|
381
|
+
group: IndexedEdit[],
|
|
382
|
+
failedIndex: number,
|
|
383
|
+
results: EditResult[],
|
|
384
|
+
): void {
|
|
385
|
+
const failedPos = group.findIndex((e) => e.index === failedIndex);
|
|
386
|
+
for (let i = failedPos + 1; i < group.length; i++) {
|
|
387
|
+
const pending = group[i];
|
|
388
|
+
results[pending.index] = {
|
|
389
|
+
path: pending.edit.path,
|
|
390
|
+
success: false,
|
|
391
|
+
message: `Skipped (earlier edit in ${pending.edit.path} failed).`,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function throwIfAborted(signal: AbortSignal | undefined): void {
|
|
397
|
+
if (signal?.aborted) {
|
|
398
|
+
throw new Error("Operation aborted");
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async function rollbackSnapshots(
|
|
403
|
+
snapshots: Map<string, string>,
|
|
404
|
+
workspace: Workspace,
|
|
405
|
+
): Promise<void> {
|
|
406
|
+
// Best-effort restore — surface the original failure regardless of per-file
|
|
407
|
+
// rollback failures.
|
|
408
|
+
await Promise.all(
|
|
409
|
+
Array.from(snapshots, ([absPath, original]) =>
|
|
410
|
+
workspace.writeText(absPath, original).catch(() => {}),
|
|
411
|
+
),
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export function formatResults(
|
|
416
|
+
results: EditResult[],
|
|
417
|
+
totalEdits: number,
|
|
418
|
+
): string {
|
|
419
|
+
const lines: string[] = [];
|
|
420
|
+
|
|
421
|
+
for (let i = 0; i < results.length; i++) {
|
|
422
|
+
const r = results[i];
|
|
423
|
+
const status = r.success ? "✓" : "✗";
|
|
424
|
+
lines.push(
|
|
425
|
+
`${status} Edit ${i + 1}/${totalEdits} (${r.path}): ${r.message}`,
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const remaining = totalEdits - results.length;
|
|
430
|
+
if (remaining > 0) {
|
|
431
|
+
lines.push(`⊘ ${remaining} remaining edit(s) skipped due to error.`);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return lines.join("\n");
|
|
435
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two-pass diff renderer on top of `diff.diffLines`.
|
|
3
|
+
*
|
|
4
|
+
* Pass 1 — `expand`: flatten the `diffLines` change parts into a typed
|
|
5
|
+
* `Entry[]` stream where every entry carries its own 1-indexed old/new line
|
|
6
|
+
* numbers. No rendering decisions happen here.
|
|
7
|
+
*
|
|
8
|
+
* Pass 2 — `render`: walk the entries, buffer each unchanged run, and decide
|
|
9
|
+
* per run whether to show it whole, collapse it with a `...` marker, or
|
|
10
|
+
* discard it (when the run sits outside any change's context window).
|
|
11
|
+
*
|
|
12
|
+
* Output contract (stable, tested in __tests__/diff.test.ts):
|
|
13
|
+
* - Added lines: `+NN text`
|
|
14
|
+
* - Removed lines: `-NN text`
|
|
15
|
+
* - Context lines: ` NN text`
|
|
16
|
+
* - Collapsed run: ` __ ...` (blank gutter + ellipsis)
|
|
17
|
+
*
|
|
18
|
+
* Line numbers are right-padded to the width of the longest 1-indexed line
|
|
19
|
+
* number across both files so the gutter stays aligned.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import * as Diff from "diff";
|
|
23
|
+
|
|
24
|
+
type ContextEntry = { kind: "context"; oldLine: number; newLine: number; text: string };
|
|
25
|
+
type AddedEntry = { kind: "added"; newLine: number; text: string };
|
|
26
|
+
type RemovedEntry = { kind: "removed"; oldLine: number; text: string };
|
|
27
|
+
type Entry = ContextEntry | AddedEntry | RemovedEntry;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Walk `diff.diffLines` parts and yield a flat entry list plus the 1-indexed
|
|
31
|
+
* new-file line number of the first change. `firstChangedLine` is captured
|
|
32
|
+
* eagerly during expansion so callers don't have to re-scan the entries.
|
|
33
|
+
*/
|
|
34
|
+
function expand(parts: Diff.Change[]): { entries: Entry[]; firstChangedLine: number | undefined } {
|
|
35
|
+
const entries: Entry[] = [];
|
|
36
|
+
let oldNum = 1;
|
|
37
|
+
let newNum = 1;
|
|
38
|
+
let firstChangedLine: number | undefined;
|
|
39
|
+
|
|
40
|
+
for (const part of parts) {
|
|
41
|
+
const lines = part.value.split("\n");
|
|
42
|
+
// `diff.diffLines` always terminates with an empty string from the trailing
|
|
43
|
+
// newline — drop it so we don't emit a ghost row per part.
|
|
44
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") {
|
|
45
|
+
lines.pop();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for (const text of lines) {
|
|
49
|
+
if (part.added) {
|
|
50
|
+
if (firstChangedLine === undefined) firstChangedLine = newNum;
|
|
51
|
+
entries.push({ kind: "added", newLine: newNum, text });
|
|
52
|
+
newNum++;
|
|
53
|
+
} else if (part.removed) {
|
|
54
|
+
if (firstChangedLine === undefined) firstChangedLine = newNum;
|
|
55
|
+
entries.push({ kind: "removed", oldLine: oldNum, text });
|
|
56
|
+
oldNum++;
|
|
57
|
+
} else {
|
|
58
|
+
entries.push({ kind: "context", oldLine: oldNum, newLine: newNum, text });
|
|
59
|
+
oldNum++;
|
|
60
|
+
newNum++;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return { entries, firstChangedLine };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Render an expanded entry stream into gutter-formatted output lines. Context
|
|
70
|
+
* runs are collapsed when their length exceeds the visible head + tail window.
|
|
71
|
+
*/
|
|
72
|
+
function render(entries: Entry[], contextLines: number, lineNumWidth: number): string[] {
|
|
73
|
+
const pad = (n: number) => String(n).padStart(lineNumWidth, " ");
|
|
74
|
+
const blankGutter = " ".repeat(lineNumWidth);
|
|
75
|
+
const out: string[] = [];
|
|
76
|
+
|
|
77
|
+
let i = 0;
|
|
78
|
+
while (i < entries.length) {
|
|
79
|
+
const entry = entries[i];
|
|
80
|
+
|
|
81
|
+
if (entry.kind === "added") {
|
|
82
|
+
out.push(`+${pad(entry.newLine)} ${entry.text}`);
|
|
83
|
+
i++;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (entry.kind === "removed") {
|
|
87
|
+
out.push(`-${pad(entry.oldLine)} ${entry.text}`);
|
|
88
|
+
i++;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// entry.kind === "context" — buffer the whole run before deciding.
|
|
93
|
+
const runStart = i;
|
|
94
|
+
while (i < entries.length && entries[i].kind === "context") {
|
|
95
|
+
i++;
|
|
96
|
+
}
|
|
97
|
+
const runEnd = i;
|
|
98
|
+
const runLen = runEnd - runStart;
|
|
99
|
+
|
|
100
|
+
const hasChangeBefore = runStart > 0;
|
|
101
|
+
const hasChangeAfter = runEnd < entries.length;
|
|
102
|
+
|
|
103
|
+
// Context that isn't adjacent to any change is dead weight — drop it.
|
|
104
|
+
if (!hasChangeBefore && !hasChangeAfter) continue;
|
|
105
|
+
|
|
106
|
+
const head = hasChangeBefore ? contextLines : 0;
|
|
107
|
+
const tail = hasChangeAfter ? contextLines : 0;
|
|
108
|
+
|
|
109
|
+
const writeAt = (idx: number) => {
|
|
110
|
+
const e = entries[idx] as ContextEntry;
|
|
111
|
+
out.push(` ${pad(e.oldLine)} ${e.text}`);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
if (runLen <= head + tail) {
|
|
115
|
+
for (let j = runStart; j < runEnd; j++) writeAt(j);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
for (let j = 0; j < head; j++) writeAt(runStart + j);
|
|
120
|
+
out.push(` ${blankGutter} ...`);
|
|
121
|
+
for (let j = tail; j > 0; j--) writeAt(runEnd - j);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function generateDiffString(
|
|
128
|
+
oldContent: string,
|
|
129
|
+
newContent: string,
|
|
130
|
+
contextLines = 4,
|
|
131
|
+
): { diff: string; firstChangedLine: number | undefined } {
|
|
132
|
+
const parts = Diff.diffLines(oldContent, newContent);
|
|
133
|
+
const { entries, firstChangedLine } = expand(parts);
|
|
134
|
+
|
|
135
|
+
// Gutter width: pad to the widest 1-indexed line number that can appear.
|
|
136
|
+
const oldLineCount = oldContent.split("\n").length;
|
|
137
|
+
const newLineCount = newContent.split("\n").length;
|
|
138
|
+
const lineNumWidth = String(Math.max(oldLineCount, newLineCount)).length;
|
|
139
|
+
|
|
140
|
+
const lines = render(entries, contextLines, lineNumWidth);
|
|
141
|
+
|
|
142
|
+
return { diff: lines.join("\n"), firstChangedLine };
|
|
143
|
+
}
|