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,244 @@
|
|
|
1
|
+
# Multi-Edit — Enhanced Edit Tool
|
|
2
|
+
|
|
3
|
+
A pi extension that replaces the built-in `edit` tool with a more powerful version that supports **batch edits** across multiple files and **Codex-style patch payloads** — all validated against a virtual filesystem before any real changes are written.
|
|
4
|
+
|
|
5
|
+
## Origins
|
|
6
|
+
|
|
7
|
+
Initially derived from [mitsuhiko/agent-stuff](https://github.com/mitsuhiko/agent-stuff)'s `pi-extensions/multi-edit.ts`. The substrate has since been substantially rewritten — the patch engine is now a recursive-descent parser over a line cursor with `indexOf`-based hunk anchoring, the diff renderer is a two-pass design, and the classic edit path gained atomic multi-file rollback, eager write-permission preflight, curly-quote fallback matching, a read-cache backed workspace, and `context-guard:file-modified` event integration. Shape-level divergences (notably the `Hunk { oldBlock, newBlock }` shape vs upstream's `UpdateChunk { oldLines[], newLines[] }`) and dropped compatibility corners are documented below.
|
|
8
|
+
|
|
9
|
+
## Overview
|
|
10
|
+
|
|
11
|
+
The standard `edit` tool handles one `oldText → newText` replacement at a time. Multi-Edit extends it with three modes so an agent can make many targeted changes in a single tool call, dramatically reducing round-trips and the risk of partial edits leaving the codebase in an inconsistent state.
|
|
12
|
+
|
|
13
|
+
All modes run a **preflight pass** on a virtual (in-memory) copy of the filesystem first. If any replacement fails, no real files are touched.
|
|
14
|
+
|
|
15
|
+
## Modes
|
|
16
|
+
|
|
17
|
+
### 1. Single (classic)
|
|
18
|
+
|
|
19
|
+
Identical to the built-in `edit` tool. Provide `path`, `oldText`, and `newText`.
|
|
20
|
+
|
|
21
|
+
```jsonc
|
|
22
|
+
{
|
|
23
|
+
"path": "src/index.ts",
|
|
24
|
+
"oldText": "const foo = 1;",
|
|
25
|
+
"newText": "const foo = 2;",
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### 2. Multi (batch array)
|
|
30
|
+
|
|
31
|
+
Pass a `multi` array of edit objects. Each item has `path`, `oldText`, and `newText`. A top-level `path` can be set as a default that individual items inherit when they omit their own `path`.
|
|
32
|
+
|
|
33
|
+
```jsonc
|
|
34
|
+
{
|
|
35
|
+
"path": "src/utils.ts", // inherited by items that omit path
|
|
36
|
+
"multi": [
|
|
37
|
+
{
|
|
38
|
+
"oldText": "import foo from 'foo';",
|
|
39
|
+
"newText": "import foo from '@scope/foo';",
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"path": "src/other.ts", // overrides the top-level path
|
|
43
|
+
"oldText": "const bar = 0;",
|
|
44
|
+
"newText": "const bar = 42;",
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
You can also mix a top-level single edit with `multi` — the top-level edit is prepended as the first item in the batch:
|
|
51
|
+
|
|
52
|
+
```jsonc
|
|
53
|
+
{
|
|
54
|
+
"path": "src/index.ts",
|
|
55
|
+
"oldText": "version: 1",
|
|
56
|
+
"newText": "version: 2",
|
|
57
|
+
"multi": [{ "oldText": "// old comment", "newText": "// new comment" }],
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### 3. Patch (Codex-style)
|
|
62
|
+
|
|
63
|
+
Pass a `patch` string delimited by `*** Begin Patch` / `*** End Patch`. This format supports adding, deleting, and updating files with hunk-based diffs — similar to the patch format used by OpenAI Codex.
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
*** Begin Patch
|
|
67
|
+
*** Add File: src/new-file.ts
|
|
68
|
+
+export const greeting = "hello";
|
|
69
|
+
*** Delete File: src/deprecated.ts
|
|
70
|
+
*** Update File: src/existing.ts
|
|
71
|
+
@@ function oldName() {
|
|
72
|
+
-function oldName() {
|
|
73
|
+
+function newName() {
|
|
74
|
+
*** End Patch
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
**Supported operations inside a patch:**
|
|
78
|
+
|
|
79
|
+
| Header | Effect |
|
|
80
|
+
| ------------------------- | ------------------------------------------------------------------- |
|
|
81
|
+
| `*** Add File: <path>` | Creates (or overwrites) the file with `+`-prefixed lines as content |
|
|
82
|
+
| `*** Delete File: <path>` | Removes the file (errors if it doesn't exist) |
|
|
83
|
+
| `*** Update File: <path>` | Applies one or more `@@`-delimited hunks to the file |
|
|
84
|
+
|
|
85
|
+
> **Note:** `*** Move to:` (rename) operations are not supported and will throw an error.
|
|
86
|
+
|
|
87
|
+
#### Codex apply_patch compatibility
|
|
88
|
+
|
|
89
|
+
The patch engine implements a pragmatic subset of the Codex `apply_patch` format. The following edge cases are intentionally **not** supported and raise a parse error instead of degrading silently:
|
|
90
|
+
|
|
91
|
+
| Feature | Status | Notes |
|
|
92
|
+
| --------------------------------- | --------- | ----------------------------------------------------------------------------------------- |
|
|
93
|
+
| `@@` hunk header | Required | Every hunk inside an `*** Update File:` block must start with `@@` |
|
|
94
|
+
| Trailing-whitespace tolerance | Supported | Hunks fall back to per-line `trimEnd` matching when exact `indexOf` misses |
|
|
95
|
+
| Full trim / unicode-normalized | Dropped | Only `trimEnd` is supported — normalize curly quotes or dashes in the patch before sending |
|
|
96
|
+
| `*** End of File` sentinel hunks | Dropped | Use a normal hunk anchored on the last real line |
|
|
97
|
+
| `*** Move to:` rename | Rejected | Emit an Add + Delete pair instead |
|
|
98
|
+
|
|
99
|
+
These restrictions keep the parser simpler and more predictable than a full 4-pass fuzzy matcher while still catching the most common class of whitespace mismatch.
|
|
100
|
+
|
|
101
|
+
## Key Features
|
|
102
|
+
|
|
103
|
+
### Preflight Validation
|
|
104
|
+
|
|
105
|
+
Before writing a single byte to disk, every edit is applied to a virtual (in-memory) snapshot of the affected files. If any replacement fails — wrong `oldText`, file not found, missing context — the entire operation is aborted and no real files are modified. The preflight also checks write permissions against the real filesystem, so read-only targets fail fast before any virtual apply is attempted.
|
|
106
|
+
|
|
107
|
+
### Atomic Multi-File Rollback
|
|
108
|
+
|
|
109
|
+
When a classic batch spans multiple files, the applier snapshots each file's pre-edit content before writing it. If a later file in the batch fails mid-write, every file already written is restored from its snapshot on a best-effort basis — the original failure is still surfaced, but the filesystem ends up in its pre-batch state.
|
|
110
|
+
|
|
111
|
+
### Positional Ordering for Same-File Edits
|
|
112
|
+
|
|
113
|
+
When multiple edits target the same file, they are automatically sorted by their position in the **original** file content (top-to-bottom). This ensures the forward-search cursor works correctly regardless of the order the model listed the edits.
|
|
114
|
+
|
|
115
|
+
### Quote-Normalized Matching for Classic Edits
|
|
116
|
+
|
|
117
|
+
Classic `oldText` lookups escalate through an ordered list of normalizer passes applied to both `oldText` and file content. The first pass that locates the transformed string wins:
|
|
118
|
+
|
|
119
|
+
1. **Exact** — character-for-character match
|
|
120
|
+
2. **Curly → straight quotes** — `'` / `'` / `"` / `"` in the model's `oldText` are rewritten to ASCII before the second search
|
|
121
|
+
3. **Trailing whitespace tolerance** — per-line `trimEnd` on both sides catches the most frequent class of mismatch (model generates trailing spaces the file doesn't have, or vice versa)
|
|
122
|
+
|
|
123
|
+
Extending the chain is a matter of appending another normalizer to the `MATCH_PASSES` array in `classic.ts`.
|
|
124
|
+
|
|
125
|
+
Patch `@@` hunks also support a `trimEnd` fallback — when the exact `indexOf` misses, the applier retries with per-line trailing-whitespace stripping on both the hunk and the file content.
|
|
126
|
+
|
|
127
|
+
### Redundant Edit Detection
|
|
128
|
+
|
|
129
|
+
If the same `oldText → newText` pair appears more than once in a `multi` batch for the same file (e.g. the model over-counted occurrences), subsequent duplicates are skipped gracefully with a success status rather than raising an error.
|
|
130
|
+
|
|
131
|
+
### Diff Generation
|
|
132
|
+
|
|
133
|
+
Every successful edit returns a unified diff attached to the tool result so the agent and user can inspect exactly what changed. For multi-file operations, per-file diffs are concatenated. The first changed line number is also surfaced for UI scrolling.
|
|
134
|
+
|
|
135
|
+
### Path Inheritance
|
|
136
|
+
|
|
137
|
+
In `multi` mode, items that omit `path` automatically inherit the top-level `path`. This is convenient when most edits target a single file with one or two exceptions.
|
|
138
|
+
|
|
139
|
+
## Parameters
|
|
140
|
+
|
|
141
|
+
| Parameter | Type | Description |
|
|
142
|
+
| --------- | ----------------------- | ------------------------------------------------------------------------------------------------------------ |
|
|
143
|
+
| `path` | `string` (optional) | Target file path (absolute or relative to cwd). Serves as default for `multi` items. |
|
|
144
|
+
| `oldText` | `string` (optional) | Exact text to find and replace. Must match including all whitespace. |
|
|
145
|
+
| `newText` | `string` (optional) | Replacement text. |
|
|
146
|
+
| `multi` | `EditItem[]` (optional) | Array of `{ path?, oldText, newText }` objects for batch mode. |
|
|
147
|
+
| `patch` | `string` (optional) | Codex-style patch payload (`*** Begin Patch … *** End Patch`). Mutually exclusive with all other parameters. |
|
|
148
|
+
|
|
149
|
+
**`EditItem` shape:**
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
{
|
|
153
|
+
path?: string; // inherits top-level path if omitted
|
|
154
|
+
oldText: string;
|
|
155
|
+
newText: string;
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Dependencies
|
|
160
|
+
|
|
161
|
+
| Package | Role |
|
|
162
|
+
| ------------------------------- | --------------------------------------------------- |
|
|
163
|
+
| `@mariozechner/pi-coding-agent` | `ExtensionAPI` type and tool registration |
|
|
164
|
+
| `@sinclair/typebox` | Runtime JSON Schema / TypeBox parameter definitions |
|
|
165
|
+
| `diff` | Line-level diff generation for result output |
|
|
166
|
+
|
|
167
|
+
## Error Handling
|
|
168
|
+
|
|
169
|
+
| Situation | Behaviour |
|
|
170
|
+
| -------------------------------------------------------------------- | ------------------------------------------------------ |
|
|
171
|
+
| `patch` used together with `path`/`oldText`/`newText`/`multi` | Throws immediately — parameters are mutually exclusive |
|
|
172
|
+
| Incomplete top-level edit (e.g. `path` + `oldText` but no `newText`) | Throws listing the missing fields |
|
|
173
|
+
| `multi` item missing `path` and no top-level `path` set | Throws identifying which item is affected |
|
|
174
|
+
| `oldText` not found in file | Preflight throws; no files are modified |
|
|
175
|
+
| `patch` context line not found | Preflight throws; no files are modified |
|
|
176
|
+
| File does not exist or is not writable | Throws before any mutations |
|
|
177
|
+
| Patch `*** Move to:` operation | Throws — not supported |
|
|
178
|
+
|
|
179
|
+
## Performance vs Base Edit
|
|
180
|
+
|
|
181
|
+
Measured across 38 real pi sessions (81 JSONL files) using `npm run bench -- --from-session --all`. Sessions are auto-classified: **base** = only single `path/oldText/newText` calls; **multi-edit** = uses `multi` or `patch` at least once.
|
|
182
|
+
|
|
183
|
+
### Headline Numbers
|
|
184
|
+
|
|
185
|
+
| Metric | Base | Multi-Edit | Delta |
|
|
186
|
+
| ------------------- | ----: | ---------: | ----------: |
|
|
187
|
+
| Sessions | 22 | 16 | |
|
|
188
|
+
| Tool calls | 92 | 137 | |
|
|
189
|
+
| Logical edits | 92 | 247 | |
|
|
190
|
+
| Edits / tool call | 1.00 | 1.80 | +0.80 |
|
|
191
|
+
| Failure rate | 6.5% | 6.6% | +0.0 pp |
|
|
192
|
+
| P50 duration | 7 ms | 11 ms | |
|
|
193
|
+
| P95 duration | 31 ms | 25 ms | |
|
|
194
|
+
| Cost / logical edit | $0.29 | $0.17 | -41.8% |
|
|
195
|
+
| Calls saved vs base | — | 110 | 44.5% fewer |
|
|
196
|
+
|
|
197
|
+
### What the data says
|
|
198
|
+
|
|
199
|
+
**Wins:**
|
|
200
|
+
|
|
201
|
+
- **Cost per edit drops 42%**. Batching N edits into one tool call avoids N-1 round-trips of assistant→tool→assistant, each of which carries the full conversation context as input tokens. At $0.17 vs $0.29 per logical edit, multi-edit pays for itself on any batch ≥ 2.
|
|
202
|
+
- **44.5% fewer tool calls**. 110 hypothetical round-trips eliminated. This is time the model spends re-reading its own context, waiting for tool dispatch, and generating boilerplate tool-call framing — all wasted.
|
|
203
|
+
- **P95 latency is lower** (25 ms vs 31 ms). The preflight + read cache avoids wasted disk I/O on doomed edits, and the cache deduplicates reads when multiple edits touch the same file.
|
|
204
|
+
|
|
205
|
+
**Neutral / watch items:**
|
|
206
|
+
|
|
207
|
+
- **P50 latency is slightly higher** (11 ms vs 7 ms). Expected: multi-edit does a full preflight pass before the real write. The delta is negligible per-edit (~2 ms extra for the safety guarantee).
|
|
208
|
+
|
|
209
|
+
### Mode Breakdown (pre-v1.5.1)
|
|
210
|
+
|
|
211
|
+
| Mode | Calls | % | Failure rate |
|
|
212
|
+
| ------ | ----: | --: | -----------: |
|
|
213
|
+
| single | 61 | 45% | 1.6% |
|
|
214
|
+
| patch | 41 | 30% | 9.8% |
|
|
215
|
+
| multi | 35 | 25% | 11.4% |
|
|
216
|
+
|
|
217
|
+
Root-cause analysis of the 8 multi/patch failures showed: 5 were trailing-whitespace mismatches in `oldText`, 2 were batch-poisoned (1 bad edit killed 5 good siblings), and 1 was a legitimate ENOENT. Three fixes shipped in v1.5.1 to address this:
|
|
218
|
+
|
|
219
|
+
1. **`trimEnd` matching for classic edits** — `findActualString` now tries a per-line `trimEnd` normalization pass on both `oldText` and file content when exact and curly-quote passes miss. Catches the dominant failure class (model generates trailing spaces the file doesn't have, or vice versa).
|
|
220
|
+
2. **Partial success for multi batches** — when one edit in a batch can't be found, the remaining edits are still applied. Failures are reported individually instead of aborting the entire batch. A 6-edit call with 1 bad edit now produces 5 successes + 1 failure instead of 0 + 6.
|
|
221
|
+
3. **`trimEnd` fallback for patch hunks** — the patch applier now falls back to per-line `trimEnd` matching when exact `indexOf` misses, using the same strategy for both `oldBlock` and `contextPrefix` anchors.
|
|
222
|
+
|
|
223
|
+
**Projected impact:** multi mode failure rate ~11.4% → ~3%, patch mode ~9.8% → ~2.5%. The batch-poisoning fix alone eliminates the inflated failure count — previously a single bad edit in a 6-edit batch counted as 1 failed tool call; now it counts as 1 failed edit + 5 successes.
|
|
224
|
+
|
|
225
|
+
Multi-edit sessions still use `single` mode 45% of the time — room to push batch adoption via prompt guidelines.
|
|
226
|
+
|
|
227
|
+
### Running the Benchmark & Analysis
|
|
228
|
+
|
|
229
|
+
The `benchmark-edits` tool provides two modes: a **synthetic benchmark** that measures engine latency on controlled scenarios, and a **session analysis** mode that parses historical pi session JSONL logs to compute cost, token, failure, and throughput metrics.
|
|
230
|
+
|
|
231
|
+
```bash
|
|
232
|
+
# Synthetic benchmark — built-in scenarios
|
|
233
|
+
npm run bench
|
|
234
|
+
|
|
235
|
+
# Custom scenario file (JSON array — see header comment in benchmark-edits.ts)
|
|
236
|
+
npm run bench -- scenarios.json
|
|
237
|
+
|
|
238
|
+
# Session analysis — all pi sessions
|
|
239
|
+
npm run bench -- --from-session --all
|
|
240
|
+
|
|
241
|
+
# Specific session files or directories
|
|
242
|
+
npm run bench -- --from-session ~/.pi/agent/sessions/<project-dir>/
|
|
243
|
+
npm run bench -- --from-session session1.jsonl session2.jsonl
|
|
244
|
+
```
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* applyClassicEdits — contract tests.
|
|
3
|
+
*
|
|
4
|
+
* Pins the classic-edit behavior before Phase C polish. Each test uses its own
|
|
5
|
+
* tmpdir so they can run in parallel.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, test } from "node:test";
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
|
|
14
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
15
|
+
|
|
16
|
+
import { applyClassicEdits, findActualString } from "../classic.ts";
|
|
17
|
+
import type { EditItem, Workspace } from "../types.ts";
|
|
18
|
+
import { createRealWorkspace } from "../workspace.ts";
|
|
19
|
+
|
|
20
|
+
// Stub ExtensionAPI — only `events.emit` is touched by the real workspace.
|
|
21
|
+
const stubPi: ExtensionAPI = {
|
|
22
|
+
events: { emit: () => {} },
|
|
23
|
+
} as unknown as ExtensionAPI;
|
|
24
|
+
|
|
25
|
+
async function withTmp<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
|
26
|
+
const dir = await mkdtemp(join(tmpdir(), "multi-edit-classic-"));
|
|
27
|
+
try {
|
|
28
|
+
return await fn(dir);
|
|
29
|
+
} finally {
|
|
30
|
+
await rm(dir, { recursive: true, force: true });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function makeWorkspace(): Workspace {
|
|
35
|
+
return createRealWorkspace(stubPi);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("applyClassicEdits — single edit", () => {
|
|
39
|
+
test("replaces oldText with newText", async () => {
|
|
40
|
+
await withTmp(async (dir) => {
|
|
41
|
+
const file = join(dir, "a.txt");
|
|
42
|
+
await writeFile(file, "hello world\n");
|
|
43
|
+
|
|
44
|
+
const edits: EditItem[] = [{ path: "a.txt", oldText: "hello", newText: "HI" }];
|
|
45
|
+
const results = await applyClassicEdits(edits, makeWorkspace(), dir);
|
|
46
|
+
|
|
47
|
+
assert.equal(results.length, 1);
|
|
48
|
+
assert.equal(results[0].success, true);
|
|
49
|
+
assert.equal(await readFile(file, "utf-8"), "HI world\n");
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("accepts absolute paths", async () => {
|
|
54
|
+
await withTmp(async (dir) => {
|
|
55
|
+
const file = join(dir, "abs.txt");
|
|
56
|
+
await writeFile(file, "foo\n");
|
|
57
|
+
const edits: EditItem[] = [{ path: file, oldText: "foo", newText: "bar" }];
|
|
58
|
+
const results = await applyClassicEdits(edits, makeWorkspace(), dir);
|
|
59
|
+
assert.equal(results[0].success, true);
|
|
60
|
+
assert.equal(await readFile(file, "utf-8"), "bar\n");
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("returns failure when oldText is not found", async () => {
|
|
65
|
+
await withTmp(async (dir) => {
|
|
66
|
+
const file = join(dir, "a.txt");
|
|
67
|
+
await writeFile(file, "hello world\n");
|
|
68
|
+
const edits: EditItem[] = [{ path: "a.txt", oldText: "absent", newText: "x" }];
|
|
69
|
+
await assert.rejects(() => applyClassicEdits(edits, makeWorkspace(), dir), /Could not find the exact text/);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("applyClassicEdits — multi edit, same file", () => {
|
|
75
|
+
test("applies two edits listed in bottom-up order (positional reordering)", async () => {
|
|
76
|
+
await withTmp(async (dir) => {
|
|
77
|
+
const file = join(dir, "a.txt");
|
|
78
|
+
await writeFile(file, "aaa\nbbb\nccc\n");
|
|
79
|
+
|
|
80
|
+
const edits: EditItem[] = [
|
|
81
|
+
{ path: "a.txt", oldText: "ccc", newText: "CCC" },
|
|
82
|
+
{ path: "a.txt", oldText: "aaa", newText: "AAA" },
|
|
83
|
+
];
|
|
84
|
+
const results = await applyClassicEdits(edits, makeWorkspace(), dir);
|
|
85
|
+
|
|
86
|
+
assert.equal(results.every((r) => r.success), true);
|
|
87
|
+
assert.equal(await readFile(file, "utf-8"), "AAA\nbbb\nCCC\n");
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("skips redundant duplicate edit when only one occurrence exists", async () => {
|
|
92
|
+
await withTmp(async (dir) => {
|
|
93
|
+
const file = join(dir, "a.txt");
|
|
94
|
+
await writeFile(file, "foo bar\n");
|
|
95
|
+
|
|
96
|
+
const edits: EditItem[] = [
|
|
97
|
+
{ path: "a.txt", oldText: "foo", newText: "FOO" },
|
|
98
|
+
{ path: "a.txt", oldText: "foo", newText: "FOO" },
|
|
99
|
+
];
|
|
100
|
+
const results = await applyClassicEdits(edits, makeWorkspace(), dir);
|
|
101
|
+
|
|
102
|
+
assert.equal(results[0].success, true);
|
|
103
|
+
assert.equal(results[1].success, true);
|
|
104
|
+
assert.match(results[1].message, /Skipped redundant edit/);
|
|
105
|
+
assert.equal(await readFile(file, "utf-8"), "FOO bar\n");
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("applyClassicEdits — multi edit, multiple files", () => {
|
|
111
|
+
test("applies edits across two files successfully", async () => {
|
|
112
|
+
await withTmp(async (dir) => {
|
|
113
|
+
const a = join(dir, "a.txt");
|
|
114
|
+
const b = join(dir, "b.txt");
|
|
115
|
+
await writeFile(a, "alpha\n");
|
|
116
|
+
await writeFile(b, "beta\n");
|
|
117
|
+
|
|
118
|
+
const edits: EditItem[] = [
|
|
119
|
+
{ path: "a.txt", oldText: "alpha", newText: "ALPHA" },
|
|
120
|
+
{ path: "b.txt", oldText: "beta", newText: "BETA" },
|
|
121
|
+
];
|
|
122
|
+
const results = await applyClassicEdits(edits, makeWorkspace(), dir);
|
|
123
|
+
|
|
124
|
+
assert.equal(results.every((r) => r.success), true);
|
|
125
|
+
assert.equal(await readFile(a, "utf-8"), "ALPHA\n");
|
|
126
|
+
assert.equal(await readFile(b, "utf-8"), "BETA\n");
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("rolls back first file when second file's edit fails", async () => {
|
|
131
|
+
await withTmp(async (dir) => {
|
|
132
|
+
const a = join(dir, "a.txt");
|
|
133
|
+
const b = join(dir, "b.txt");
|
|
134
|
+
await writeFile(a, "alpha\n");
|
|
135
|
+
await writeFile(b, "beta\n");
|
|
136
|
+
|
|
137
|
+
const edits: EditItem[] = [
|
|
138
|
+
{ path: "a.txt", oldText: "alpha", newText: "ALPHA" },
|
|
139
|
+
{ path: "b.txt", oldText: "NOT_PRESENT", newText: "x" },
|
|
140
|
+
];
|
|
141
|
+
|
|
142
|
+
await assert.rejects(
|
|
143
|
+
() => applyClassicEdits(edits, makeWorkspace(), dir, undefined, { rollbackOnError: true }),
|
|
144
|
+
/Could not find the exact text/,
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
// a.txt should be restored; b.txt unchanged
|
|
148
|
+
assert.equal(await readFile(a, "utf-8"), "alpha\n");
|
|
149
|
+
assert.equal(await readFile(b, "utf-8"), "beta\n");
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("applyClassicEdits — quote fallback (findActualString)", () => {
|
|
155
|
+
test("matches curly-quote oldText against straight-quote file content", async () => {
|
|
156
|
+
await withTmp(async (dir) => {
|
|
157
|
+
const file = join(dir, "q.txt");
|
|
158
|
+
// File has straight quotes; model's oldText has curly quotes (common when
|
|
159
|
+
// model was trained on formatted prose).
|
|
160
|
+
await writeFile(file, 'say "hello" to the world\n');
|
|
161
|
+
|
|
162
|
+
const edits: EditItem[] = [
|
|
163
|
+
{ path: "q.txt", oldText: "say \u201Chello\u201D", newText: 'SAY "HELLO"' },
|
|
164
|
+
];
|
|
165
|
+
const results = await applyClassicEdits(edits, makeWorkspace(), dir);
|
|
166
|
+
assert.equal(results[0].success, true);
|
|
167
|
+
assert.equal(await readFile(file, "utf-8"), 'SAY "HELLO" to the world\n');
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("findActualString returns exact match when no normalization is needed", () => {
|
|
172
|
+
const content = "hello world";
|
|
173
|
+
const match = findActualString(content, "hello", 0);
|
|
174
|
+
assert.ok(match);
|
|
175
|
+
assert.equal(match!.pos, 0);
|
|
176
|
+
assert.equal(match!.actualOldText, "hello");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("findActualString falls back to curly→straight normalization of oldText", () => {
|
|
180
|
+
const content = "a 'b' c"; // straight quotes in the file
|
|
181
|
+
const match = findActualString(content, "a \u2018b\u2019 c", 0);
|
|
182
|
+
assert.ok(match);
|
|
183
|
+
assert.equal(match!.pos, 0);
|
|
184
|
+
// actualOldText is the normalized (straight) form that was actually matched
|
|
185
|
+
assert.equal(match!.actualOldText, "a 'b' c");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("findActualString returns undefined when no match after normalization", () => {
|
|
189
|
+
const match = findActualString("nothing here", "missing", 0);
|
|
190
|
+
assert.equal(match, undefined);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("applyClassicEdits — read-only file", () => {
|
|
195
|
+
test("rejects before any mutation when target is read-only", async () => {
|
|
196
|
+
await withTmp(async (dir) => {
|
|
197
|
+
const file = join(dir, "ro.txt");
|
|
198
|
+
await writeFile(file, "readonly\n");
|
|
199
|
+
await chmod(file, 0o444);
|
|
200
|
+
|
|
201
|
+
const edits: EditItem[] = [{ path: "ro.txt", oldText: "readonly", newText: "X" }];
|
|
202
|
+
try {
|
|
203
|
+
await assert.rejects(() => applyClassicEdits(edits, makeWorkspace(), dir));
|
|
204
|
+
assert.equal(await readFile(file, "utf-8"), "readonly\n");
|
|
205
|
+
} finally {
|
|
206
|
+
await chmod(file, 0o644);
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
describe("applyClassicEdits — trailing whitespace tolerance", () => {
|
|
213
|
+
test("matches oldText with trailing spaces when file has none", async () => {
|
|
214
|
+
await withTmp(async (dir) => {
|
|
215
|
+
const file = join(dir, "ws.ts");
|
|
216
|
+
await writeFile(file, "const x = 1;\nconst y = 2;\n");
|
|
217
|
+
|
|
218
|
+
const edits: EditItem[] = [
|
|
219
|
+
{ path: "ws.ts", oldText: "const x = 1; \nconst y = 2; \n", newText: "const x = 10;\nconst y = 20;\n" },
|
|
220
|
+
];
|
|
221
|
+
const results = await applyClassicEdits(edits, makeWorkspace(), dir);
|
|
222
|
+
assert.equal(results[0].success, true);
|
|
223
|
+
assert.equal(await readFile(file, "utf-8"), "const x = 10;\nconst y = 20;\n");
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("findActualString falls back to trimEnd when exact and quote passes miss", () => {
|
|
228
|
+
const content = "line one\nline two\n";
|
|
229
|
+
const match = findActualString(content, "line one \nline two \n", 0);
|
|
230
|
+
assert.ok(match, "should find a match via trimEnd normalization");
|
|
231
|
+
assert.equal(match!.pos, 0);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
describe("applyClassicEdits — continueOnError (partial success)", () => {
|
|
236
|
+
test("applies successful edits and records failures without aborting", async () => {
|
|
237
|
+
await withTmp(async (dir) => {
|
|
238
|
+
const file = join(dir, "partial.ts");
|
|
239
|
+
await writeFile(file, "aaa\nbbb\nccc\n");
|
|
240
|
+
|
|
241
|
+
const edits: EditItem[] = [
|
|
242
|
+
{ path: "partial.ts", oldText: "aaa", newText: "AAA" },
|
|
243
|
+
{ path: "partial.ts", oldText: "DOES_NOT_EXIST", newText: "X" },
|
|
244
|
+
{ path: "partial.ts", oldText: "ccc", newText: "CCC" },
|
|
245
|
+
];
|
|
246
|
+
const results = await applyClassicEdits(edits, makeWorkspace(), dir, undefined, {
|
|
247
|
+
continueOnError: true,
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
assert.equal(results[0].success, true, "first edit should succeed");
|
|
251
|
+
assert.equal(results[1].success, false, "second edit should fail");
|
|
252
|
+
assert.equal(results[2].success, true, "third edit should still succeed");
|
|
253
|
+
assert.equal(await readFile(file, "utf-8"), "AAA\nbbb\nCCC\n");
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("all-or-nothing when continueOnError is false", async () => {
|
|
258
|
+
await withTmp(async (dir) => {
|
|
259
|
+
const file = join(dir, "strict.ts");
|
|
260
|
+
await writeFile(file, "aaa\nbbb\nccc\n");
|
|
261
|
+
|
|
262
|
+
const edits: EditItem[] = [
|
|
263
|
+
{ path: "strict.ts", oldText: "aaa", newText: "AAA" },
|
|
264
|
+
{ path: "strict.ts", oldText: "DOES_NOT_EXIST", newText: "X" },
|
|
265
|
+
{ path: "strict.ts", oldText: "ccc", newText: "CCC" },
|
|
266
|
+
];
|
|
267
|
+
await assert.rejects(() =>
|
|
268
|
+
applyClassicEdits(edits, makeWorkspace(), dir, undefined, {
|
|
269
|
+
continueOnError: false,
|
|
270
|
+
rollbackOnError: true,
|
|
271
|
+
}),
|
|
272
|
+
);
|
|
273
|
+
// File should be untouched due to rollback.
|
|
274
|
+
assert.equal(await readFile(file, "utf-8"), "aaa\nbbb\nccc\n");
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* generateDiffString — contract tests.
|
|
3
|
+
*
|
|
4
|
+
* Locks the rendered diff format before Phase C rewrites the renderer.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, test } from "node:test";
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
|
|
10
|
+
import { generateDiffString } from "../diff.ts";
|
|
11
|
+
|
|
12
|
+
describe("generateDiffString", () => {
|
|
13
|
+
test("returns empty output and undefined firstChangedLine when content is identical", () => {
|
|
14
|
+
const { diff, firstChangedLine } = generateDiffString("a\nb\nc\n", "a\nb\nc\n");
|
|
15
|
+
assert.equal(diff, "");
|
|
16
|
+
assert.equal(firstChangedLine, undefined);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("single-line replacement emits + and - markers with line numbers", () => {
|
|
20
|
+
const before = "line1\nline2\nline3\n";
|
|
21
|
+
const after = "line1\nCHANGED\nline3\n";
|
|
22
|
+
const { diff, firstChangedLine } = generateDiffString(before, after);
|
|
23
|
+
assert.match(diff, /-2 line2/);
|
|
24
|
+
assert.match(diff, /\+2 CHANGED/);
|
|
25
|
+
assert.equal(firstChangedLine, 2);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("shows leading context before a change", () => {
|
|
29
|
+
const before = "a\nb\nc\nd\nTARGET\n";
|
|
30
|
+
const after = "a\nb\nc\nd\nREPLACED\n";
|
|
31
|
+
const { diff } = generateDiffString(before, after, 4);
|
|
32
|
+
for (const ctx of ["a", "b", "c", "d"]) {
|
|
33
|
+
assert.ok(diff.includes(` ${ctx}`) || diff.match(new RegExp(`\\s\\d+ ${ctx}`)), `missing context '${ctx}' in:\n${diff}`);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("firstChangedLine is 1-indexed against the new content", () => {
|
|
38
|
+
const before = "one\ntwo\nthree\nfour\n";
|
|
39
|
+
const after = "one\ntwo\nthree\nFOUR\n";
|
|
40
|
+
const { firstChangedLine } = generateDiffString(before, after);
|
|
41
|
+
assert.equal(firstChangedLine, 4);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("pads line numbers to the width of the largest line number", () => {
|
|
45
|
+
const before = Array.from({ length: 12 }, (_, i) => `line${i + 1}`).join("\n") + "\n";
|
|
46
|
+
const after = before.replace("line6", "CHANGED");
|
|
47
|
+
const { diff } = generateDiffString(before, after);
|
|
48
|
+
// 12 lines → 2-char wide line numbers
|
|
49
|
+
assert.match(diff, /- 6 line6/);
|
|
50
|
+
assert.match(diff, /\+ 6 CHANGED/);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("collapses large unchanged runs between two changes with '...' marker", () => {
|
|
54
|
+
const lines: string[] = [];
|
|
55
|
+
for (let i = 1; i <= 40; i++) lines.push(`line${i}`);
|
|
56
|
+
const before = lines.join("\n") + "\n";
|
|
57
|
+
const after = before.replace("line1\n", "FIRST\n").replace("line40", "LAST");
|
|
58
|
+
const { diff } = generateDiffString(before, after, 4);
|
|
59
|
+
assert.ok(diff.includes("..."), `expected collapsed marker in:\n${diff}`);
|
|
60
|
+
assert.match(diff, /-\s?1 line1/);
|
|
61
|
+
assert.match(diff, /\+\s?1 FIRST/);
|
|
62
|
+
assert.match(diff, /-40 line40/);
|
|
63
|
+
assert.match(diff, /\+40 LAST/);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("handles addition-only (empty → content)", () => {
|
|
67
|
+
const { diff, firstChangedLine } = generateDiffString("", "new line\n");
|
|
68
|
+
assert.match(diff, /\+1 new line/);
|
|
69
|
+
assert.equal(firstChangedLine, 1);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("handles deletion-only (content → empty)", () => {
|
|
73
|
+
const { diff, firstChangedLine } = generateDiffString("gone\n", "");
|
|
74
|
+
assert.match(diff, /-1 gone/);
|
|
75
|
+
assert.equal(firstChangedLine, 1);
|
|
76
|
+
});
|
|
77
|
+
});
|