pi-hashline-edit-pro 0.16.2 → 0.16.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/README.md CHANGED
@@ -68,8 +68,7 @@ Replaces using the `HASH│content` anchors from `read` output to target lines p
68
68
  {
69
69
  "path": "src/main.ts",
70
70
  "changes": [
71
- { "hash_range_inclusive": ["ve7", "ve7"], "content_lines": [" console.log('hashline');"] }
72
- ]
71
+ { "content_lines": [" console.log('hashline');"], "hash_range_inclusive": ["ve7", "ve7"] }
73
72
  }
74
73
  ```
75
74
 
@@ -77,9 +76,9 @@ Replaces using the `HASH│content` anchors from `read` output to target lines p
77
76
 
78
77
  ```json
79
78
  {
80
- "path": "src/main.ts",
79
+ "content_lines": [" console.log('hashline');"],
81
80
  "hash_range_inclusive": ["ve7", "ve7"],
82
- "content_lines": [" console.log('hashline');"]
81
+ "path": "src/main.ts"
83
82
  }
84
83
  ```
85
84
 
@@ -89,7 +88,7 @@ Replaces using the `HASH│content` anchors from `read` output to target lines p
89
88
  | `content_lines` | Literal replacement content, one string per line (use `[]` to delete the range). |
90
89
 
91
90
  - **Request structure validation.** The request envelope (`path`, `changes` in bulk mode; `path`, `hash_range_inclusive`, `content_lines` in flat mode) and individual edit items are validated before any file I/O. Unknown fields, missing required fields, invalid types, and malformed anchors are rejected with `[E_BAD_SHAPE]` or `[E_BAD_REF]`.
92
- - **Legacy dialect rejected.** The native top-level `oldText`/`newText` (and `old_text`/`new_text`) dialect is rejected with `[E_LEGACY_SHAPE]`. The error message tells the model to call `read` first and send `{hash_range_inclusive: ["<START>", "<END>"], content_lines: [...]}`.
91
+ - **Legacy dialect rejected.** The native top-level `oldText`/`newText` (and `old_text`/`new_text`) dialect is rejected with `[E_LEGACY_SHAPE]`. The error message tells the model to call `read` first and send `{content_lines: [...], hash_range_inclusive: ["<START>", "<END>"]}`.
93
92
  - **Batched atomicity (bulk mode).** All edits in a single call validate against the same pre-edit snapshot and apply bottom-up, so the hashes from a single `read` call remain valid across all edits in the batch.
94
93
 
95
94
  ### Stable hashing across edits
package/index.ts CHANGED
@@ -33,7 +33,9 @@ export default function (pi: ExtensionAPI): void {
33
33
  try {
34
34
  const store = await loadHashStore();
35
35
  await pruneHashStore(store);
36
- } catch {}
36
+ } catch (err) {
37
+ console.error("Failed to load or prune hash store:", err);
38
+ }
37
39
  const mode = await readReplaceMode();
38
40
  if (mode === "flat") {
39
41
  regReplaceFlat(pi);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "0.16.2",
3
+ "version": "0.16.3",
4
4
  "description": "Strict hashline read/replace tool for pi-coding-agent with hash-anchored edits (3-char, 18-bit, perfect hashing)",
5
5
  "main": "index.ts",
6
6
  "repository": {
@@ -11,64 +11,62 @@ read({ path: "src/main.ts" })
11
11
 
12
12
  2. Copy the 3-character HASH (before `│`) into `hash_range_inclusive`:
13
13
  ```json
14
- { "path": "src/main.ts", "changes": [
15
- { "hash_range_inclusive": ["MQX", "MQX"], "content_lines": ["const x = 99;"] }
16
- ] }
14
+ { "changes": [
15
+ { "content_lines": ["const x = 99;"], "hash_range_inclusive": ["MQX", "MQX"] }
16
+ ], "path": "src/main.ts" }
17
17
  ```
18
18
 
19
19
  Examples:
20
20
 
21
21
  1. Single line replace:
22
22
  ```json
23
- { "path": "src/main.ts", "changes": [
24
- { "hash_range_inclusive": ["MQX", "MQX"], "content_lines": ["const x = 1;"] }
25
- ] }
23
+ { "changes": [
24
+ { "content_lines": ["const x = 1;"], "hash_range_inclusive": ["MQX", "MQX"] }
25
+ ], "path": "src/main.ts" }
26
26
  ```
27
27
 
28
28
  2. Range replace (3 lines → 3 new lines):
29
29
  ```json
30
- { "path": "src/main.ts", "changes": [
31
- { "hash_range_inclusive": ["ZPM", "VRW"], "content_lines": [
30
+ { "changes": [
31
+ { "content_lines": [
32
32
  "function greet(name) {",
33
33
  " return `Hello, ${name}`;",
34
34
  "}"
35
- ] }
36
- ] }
35
+ ], "hash_range_inclusive": ["ZPM", "VRW"] }
36
+ ], "path": "src/main.ts" }
37
37
  ```
38
38
 
39
39
  3. Multiple regions in one call (delete two non-adjacent ranges):
40
40
  ```json
41
- { "path": "src/server.ts", "changes": [
42
- { "hash_range_inclusive": ["aB3", "xY7"], "content_lines": [] },
43
- { "hash_range_inclusive": ["MQX", "ZPM"], "content_lines": [] }
44
- ] }
41
+ { "changes": [
42
+ { "content_lines": [], "hash_range_inclusive": ["aB3", "xY7"] },
43
+ { "content_lines": [], "hash_range_inclusive": ["MQX", "ZPM"] }
44
+ ], "path": "src/server.ts" }
45
45
  ```
46
46
 
47
47
  4. Append after the last line (include the old last line so the new line is added after it):
48
48
  ```json
49
- { "path": "src/main.ts", "changes": [
50
- { "hash_range_inclusive": ["ZPM", "ZPM"], "content_lines": ["old last line", "new line"] }
51
- ] }
49
+ { "changes": [
50
+ { "content_lines": ["old last line", "new line"], "hash_range_inclusive": ["ZPM", "ZPM"] }
51
+ ], "path": "src/main.ts" }
52
52
  ```
53
53
 
54
54
  5. Seed content into an empty file (replace the single empty-line hash returned by read):
55
55
  ```json
56
- { "path": "src/main.ts", "changes": [
57
- { "hash_range_inclusive": ["aB3", "aB3"], "content_lines": ["first line", "second line"] }
58
- ] }
56
+ { "changes": [
57
+ { "content_lines": ["first line", "second line"], "hash_range_inclusive": ["aB3", "aB3"] }
58
+ ], "path": "src/main.ts" }
59
59
  ```
60
60
 
61
61
  ⚠️ Common mistake: do not copy the `HASH│` prefix into `content_lines`.
62
62
 
63
63
  Wrong:
64
64
  ```json
65
- { "hash_range_inclusive": ["F4T", "F4T"], "content_lines": ["F4T│import { x } from \"./x\";"] }
66
- ```
65
+ { "content_lines": ["F4T│import { x } from \"./x\";"], "hash_range_inclusive": ["F4T", "F4T"] }
67
66
 
68
67
  Right:
69
68
  ```json
70
- { "hash_range_inclusive": ["F4T", "F4T"], "content_lines": ["import { x } from \"./x\";"] }
71
- ```
69
+ { "content_lines": ["import { x } from \"./x\";"], "hash_range_inclusive": ["F4T", "F4T"] }
72
70
 
73
71
  `hash_range_inclusive` uses the hash anchor. `content_lines` uses literal file content only — the same text that appears after the `│` in `read` output.
74
72
 
@@ -76,13 +74,11 @@ Right:
76
74
 
77
75
  Wrong:
78
76
  ```json
79
- { "hash_range_inclusive": ["F4T│import { x } from \"./x\";", "F4T│import { x } from \"./x\";"], "content_lines": [...] }
80
- ```
77
+ { "content_lines": [...], "hash_range_inclusive": ["F4T│import { x } from \"./x\";", "F4T│import { x } from \"./x\";"] }
81
78
 
82
79
  Right:
83
80
  ```json
84
- { "hash_range_inclusive": ["F4T", "F4T"], "content_lines": [...] }
85
- ```
81
+ { "content_lines": [...], "hash_range_inclusive": ["F4T", "F4T"] }
86
82
 
87
83
  Rules:
88
84
  - `hash_range_inclusive` is a pair `[start, end]`. A single-line replace is `hash_range_inclusive: ["X", "X"]`.
@@ -107,14 +103,12 @@ function greet() {
107
103
  ```
108
104
  A model might write:
109
105
  ```json
110
- { "hash_range_inclusive": ["X", "Y"], "content_lines": [" const y = 2;", " return y;", "}"] }
111
- ```
106
+ { "content_lines": [" const y = 2;", " return y;", "}"], "hash_range_inclusive": ["X", "Y"] }
112
107
  This produces `function greet() {\n const y = 2;\n return y;\n}\n}` — the `}` appears twice because it was already on line 4 (outside the range) AND in `content_lines`.
113
108
 
114
109
  Right: Only include the new lines that belong in the range:
115
110
  ```json
116
- { "hash_range_inclusive": ["X", "Y"], "content_lines": [" const y = 2;", " return y;"] }
117
- ```
111
+ { "content_lines": [" const y = 2;", " return y;"], "hash_range_inclusive": ["X", "Y"] }
118
112
  The `}` on line 4 is outside the range and stays in place.
119
113
 
120
114
  Error recovery:
@@ -122,10 +116,10 @@ Error recovery:
122
116
  - `[E_BAD_REF]` — malformed HASH. Re-read and try again.
123
117
  - `[E_BAD_OP]` — invalid operation (e.g. start line > end line).
124
118
  - `[E_BAD_SHAPE]` — malformed request or change item (missing fields, wrong types, unknown fields).
125
- - `[E_LEGACY_SHAPE]` — old `oldText`/`newText` or `old_text`/`new_text` format detected. Use `{hash_range_inclusive, content_lines}` instead.
119
+ - `[E_LEGACY_SHAPE]` — old `oldText`/`newText` or `old_text`/`new_text` format detected. Use `{content_lines, hash_range_inclusive}` instead.
126
120
  - `[E_EDIT_CONFLICT]` — two changes overlap on the same line range. Make changes non-overlapping.
127
121
  - `[E_AMBIGUOUS_ANCHOR]` — hash collision. Call `read` to get fresh anchors.
128
- - `[E_BARE_HASH_PREFIX]` — a `content_lines` entry starts with `HASH│`. Remove the hash prefix; keep only the literal line content that appears after `│` in `read` output. `hash_range_inclusive` uses hashes, `content_lines` does not.
122
+ - `[E_BARE_HASH_PREFIX]` — a `content_lines` entry starts with `HASH│`. Remove the hash prefix; keep only the literal line content that appears after `│` in `read` output. `content_lines` uses file content only, `hash_range_inclusive` uses hash anchors.
129
123
  - `[E_INVALID_PATCH]` — a `content_lines` entry matches the diff preview's `+HASH│…` addition-row form. Use literal file content. (Plain `+`/`-` lines are not rejected — they are written literally.)
130
124
  - `[E_WOULD_EMPTY]` — edit would empty a non-empty file.
131
125
  - `[E_FILE_TOO_LARGE]` — file exceeds the 1,000,000-line edit limit. Use `write` or a non-line-based approach for very large files.
@@ -9,51 +9,49 @@ read({ path: "src/main.ts" })
9
9
 
10
10
  2. Copy the 3-character HASH (before `│`) into `hash_range_inclusive`:
11
11
  ```json
12
- { "path": "src/main.ts", "hash_range_inclusive": ["MQX", "MQX"], "content_lines": ["const x = 99;"] }
12
+ { "content_lines": ["const x = 99;"], "hash_range_inclusive": ["MQX", "MQX"], "path": "src/main.ts" }
13
13
  ```
14
14
 
15
15
  Examples:
16
16
 
17
17
  1. Single line replace:
18
18
  ```json
19
- { "path": "src/main.ts", "hash_range_inclusive": ["MQX", "MQX"], "content_lines": ["const x = 1;"] }
19
+ { "content_lines": ["const x = 1;"], "hash_range_inclusive": ["MQX", "MQX"], "path": "src/main.ts" }
20
20
  ```
21
21
 
22
22
  2. Range replace (3 lines → 3 new lines):
23
23
  ```json
24
- { "path": "src/main.ts", "hash_range_inclusive": ["ZPM", "VRW"], "content_lines": [
25
- "function greet(name) {",
26
- " return `Hello, ${name}`;",
27
- "}"
28
- ] }
24
+ { "content_lines": [
25
+ "function greet(name) {",
26
+ " return `Hello, ${name}`;",
27
+ "}"
28
+ ], "hash_range_inclusive": ["ZPM", "VRW"], "path": "src/main.ts" }
29
29
  ```
30
30
 
31
31
  3. Delete a range:
32
32
  ```json
33
- { "path": "src/server.ts", "hash_range_inclusive": ["aB3", "xY7"], "content_lines": [] }
33
+ { "content_lines": [], "hash_range_inclusive": ["aB3", "xY7"], "path": "src/server.ts" }
34
34
  ```
35
35
 
36
36
  4. Append after the last line (include the old last line so the new line is added after it):
37
37
  ```json
38
- { "path": "src/main.ts", "hash_range_inclusive": ["ZPM", "ZPM"], "content_lines": ["old last line", "new line"] }
38
+ { "content_lines": ["old last line", "new line"], "hash_range_inclusive": ["ZPM", "ZPM"], "path": "src/main.ts" }
39
39
  ```
40
40
 
41
41
  5. Seed content into an empty file (replace the single empty-line hash returned by read):
42
42
  ```json
43
- { "path": "src/main.ts", "hash_range_inclusive": ["aB3", "aB3"], "content_lines": ["first line", "second line"] }
43
+ { "content_lines": ["first line", "second line"], "hash_range_inclusive": ["aB3", "aB3"], "path": "src/main.ts" }
44
44
  ```
45
45
 
46
46
  ⚠️ Common mistake: do not copy the `HASH│` prefix into `content_lines`.
47
47
 
48
48
  Wrong:
49
49
  ```json
50
- { "hash_range_inclusive": ["F4T", "F4T"], "content_lines": ["F4T│import { x } from \"./x\";"] }
51
- ```
50
+ { "content_lines": ["F4T│import { x } from \"./x\";"], "hash_range_inclusive": ["F4T", "F4T"] }
52
51
 
53
52
  Right:
54
53
  ```json
55
- { "hash_range_inclusive": ["F4T", "F4T"], "content_lines": ["import { x } from \"./x\";"] }
56
- ```
54
+ { "content_lines": ["import { x } from \"./x\";"], "hash_range_inclusive": ["F4T", "F4T"] }
57
55
 
58
56
  `hash_range_inclusive` uses the hash anchor. `content_lines` uses literal file content only — the same text that appears after the `│` in `read` output.
59
57
 
@@ -61,13 +59,11 @@ Right:
61
59
 
62
60
  Wrong:
63
61
  ```json
64
- { "hash_range_inclusive": ["F4T│import { x } from \"./x\";", "F4T│import { x } from \"./x\";"], "content_lines": [...] }
65
- ```
62
+ { "content_lines": [...], "hash_range_inclusive": ["F4T│import { x } from \"./x\";", "F4T│import { x } from \"./x\";"] }
66
63
 
67
64
  Right:
68
65
  ```json
69
- { "hash_range_inclusive": ["F4T", "F4T"], "content_lines": [...] }
70
- ```
66
+ { "content_lines": [...], "hash_range_inclusive": ["F4T", "F4T"] }
71
67
 
72
68
  Rules:
73
69
  - `hash_range_inclusive` is a pair `[start, end]`. A single-line replace is `hash_range_inclusive: ["X", "X"]`.
@@ -93,14 +89,12 @@ function greet() {
93
89
  ```
94
90
  A model might write:
95
91
  ```json
96
- { "hash_range_inclusive": ["X", "Y"], "content_lines": [" const y = 2;", " return y;", "}"] }
97
- ```
92
+ { "content_lines": [" const y = 2;", " return y;", "}"], "hash_range_inclusive": ["X", "Y"] }
98
93
  This produces `function greet() {\n const y = 2;\n return y;\n}\n}` — the `}` appears twice because it was already on line 4 (outside the range) AND in `content_lines`.
99
94
 
100
95
  Right: Only include the new lines that belong in the range:
101
96
  ```json
102
- { "hash_range_inclusive": ["X", "Y"], "content_lines": [" const y = 2;", " return y;"] }
103
- ```
97
+ { "content_lines": [" const y = 2;", " return y;"], "hash_range_inclusive": ["X", "Y"] }
104
98
  The `}` on line 4 is outside the range and stays in place.
105
99
 
106
100
  Error recovery:
@@ -108,9 +102,9 @@ Error recovery:
108
102
  - `[E_BAD_REF]` — malformed HASH. Re-read and try again.
109
103
  - `[E_BAD_OP]` — invalid operation (e.g. start line > end line).
110
104
  - `[E_BAD_SHAPE]` — malformed request or change item (missing fields, wrong types, unknown fields).
111
- - `[E_LEGACY_SHAPE]` — old `oldText`/`newText` or `old_text`/`new_text` format detected. Use `{hash_range_inclusive, content_lines}` instead.
105
+ - `[E_LEGACY_SHAPE]` — old `oldText`/`newText` or `old_text`/`new_text` format detected. Use `{content_lines, hash_range_inclusive}` instead.
112
106
  - `[E_AMBIGUOUS_ANCHOR]` — hash collision. Call `read` to get fresh anchors.
113
- - `[E_BARE_HASH_PREFIX]` — a `content_lines` entry starts with `HASH│`. Remove the hash prefix; keep only the literal line content that appears after `│` in `read` output. `hash_range_inclusive` uses hashes, `content_lines` does not.
107
+ - `[E_BARE_HASH_PREFIX]` — a `content_lines` entry starts with `HASH│`. Remove the hash prefix; keep only the literal line content that appears after `│` in `read` output. `content_lines` uses file content only, `hash_range_inclusive` uses hash anchors.
114
108
  - `[E_INVALID_PATCH]` — a `content_lines` entry matches the diff preview's `+HASH│…` addition-row form. Use literal file content. (Plain `+`/`-` lines are not rejected — they are written literally.)
115
109
  - `[E_WOULD_EMPTY]` — edit would empty a non-empty file.
116
110
  - `[E_FILE_TOO_LARGE]` — file exceeds the 1,000,000-line edit limit. Use `write` or a non-line-based approach for very large files.
@@ -21,14 +21,14 @@ read({ path: "src/main.ts" })
21
21
 
22
22
  **Bulk mode:**
23
23
  ```json
24
- { "path": "src/main.ts", "changes": [
25
- { "hash_range_inclusive": ["MQX", "MQX"], "content_lines": ["const x = 99;"] }
26
- ] }
24
+ { "changes": [
25
+ { "content_lines": ["const x = 99;"], "hash_range_inclusive": ["MQX", "MQX"] }
26
+ ], "path": "src/main.ts" }
27
27
  ```
28
28
 
29
29
  **Flat mode:**
30
30
  ```json
31
- { "path": "src/main.ts", "hash_range_inclusive": ["MQX", "MQX"], "content_lines": ["const x = 99;"] }
31
+ { "content_lines": ["const x = 99;"], "hash_range_inclusive": ["MQX", "MQX"], "path": "src/main.ts" }
32
32
  ```
33
33
 
34
34
  Examples:
@@ -37,85 +37,83 @@ Examples:
37
37
 
38
38
  Bulk:
39
39
  ```json
40
- { "path": "src/main.ts", "changes": [
41
- { "hash_range_inclusive": ["MQX", "MQX"], "content_lines": ["const x = 1;"] }
42
- ] }
40
+ { "changes": [
41
+ { "content_lines": ["const x = 1;"], "hash_range_inclusive": ["MQX", "MQX"] }
42
+ ], "path": "src/main.ts" }
43
43
  ```
44
44
 
45
45
  Flat:
46
46
  ```json
47
- { "path": "src/main.ts", "hash_range_inclusive": ["MQX", "MQX"], "content_lines": ["const x = 1;"] }
47
+ { "content_lines": ["const x = 1;"], "hash_range_inclusive": ["MQX", "MQX"], "path": "src/main.ts" }
48
48
  ```
49
49
 
50
50
  2. Range replace (3 lines → 3 new lines):
51
51
 
52
52
  Bulk:
53
53
  ```json
54
- { "path": "src/main.ts", "changes": [
55
- { "hash_range_inclusive": ["ZPM", "VRW"], "content_lines": [
54
+ { "changes": [
55
+ { "content_lines": [
56
56
  "function greet(name) {",
57
57
  " return `Hello, ${name}`;",
58
58
  "}"
59
- ] }
60
- ] }
59
+ ], "hash_range_inclusive": ["ZPM", "VRW"] }
60
+ ], "path": "src/main.ts" }
61
61
  ```
62
62
 
63
63
  Flat:
64
64
  ```json
65
- { "path": "src/main.ts", "hash_range_inclusive": ["ZPM", "VRW"], "content_lines": [
66
- "function greet(name) {",
67
- " return `Hello, ${name}`;",
68
- "}"
69
- ] }
65
+ { "content_lines": [
66
+ "function greet(name) {",
67
+ " return `Hello, ${name}`;",
68
+ "}"
69
+ ], "hash_range_inclusive": ["ZPM", "VRW"], "path": "src/main.ts" }
70
70
  ```
71
71
 
72
72
  3. Multiple regions in one call (bulk mode only — flat mode supports one edit per call):
73
73
  ```json
74
- { "path": "src/server.ts", "changes": [
75
- { "hash_range_inclusive": ["aB3", "xY7"], "content_lines": [] },
76
- { "hash_range_inclusive": ["MQX", "ZPM"], "content_lines": [] }
77
- ] }
74
+ { "changes": [
75
+ { "content_lines": [], "hash_range_inclusive": ["aB3", "xY7"] },
76
+ { "content_lines": [], "hash_range_inclusive": ["MQX", "ZPM"] }
77
+ ], "path": "src/server.ts" }
78
78
  ```
79
79
 
80
80
  4. Append after the last line (include the old last line so the new line is added after it):
81
81
 
82
82
  Bulk:
83
83
  ```json
84
- { "path": "src/main.ts", "changes": [
85
- { "hash_range_inclusive": ["ZPM", "ZPM"], "content_lines": ["old last line", "new line"] }
86
- ] }
84
+ { "changes": [
85
+ { "content_lines": ["old last line", "new line"], "hash_range_inclusive": ["ZPM", "ZPM"] }
86
+ ], "path": "src/main.ts" }
87
87
  ```
88
88
 
89
89
  Flat:
90
90
  ```json
91
- { "path": "src/main.ts", "hash_range_inclusive": ["ZPM", "ZPM"], "content_lines": ["old last line", "new line"] }
91
+ { "content_lines": ["old last line", "new line"], "hash_range_inclusive": ["ZPM", "ZPM"], "path": "src/main.ts" }
92
92
  ```
93
93
 
94
94
  5. Seed content into an empty file (replace the single empty-line hash returned by read):
95
95
 
96
96
  Bulk:
97
97
  ```json
98
- { "path": "src/main.ts", "changes": [
99
- { "hash_range_inclusive": ["aB3", "aB3"], "content_lines": ["first line", "second line"] }
100
- ] }
98
+ { "changes": [
99
+ { "content_lines": ["first line", "second line"], "hash_range_inclusive": ["aB3", "aB3"] }
100
+ ], "path": "src/main.ts" }
101
101
  ```
102
102
 
103
103
  Flat:
104
104
  ```json
105
- { "path": "src/main.ts", "hash_range_inclusive": ["aB3", "aB3"], "content_lines": ["first line", "second line"] }
105
+ { "content_lines": ["first line", "second line"], "hash_range_inclusive": ["aB3", "aB3"], "path": "src/main.ts" }
106
106
  ```
107
107
 
108
108
  ⚠️ Common mistake: do not copy the `HASH│` prefix into `content_lines`.
109
109
 
110
110
  Wrong:
111
111
  ```json
112
- { "hash_range_inclusive": ["F4T", "F4T"], "content_lines": ["F4T│import { x } from \"./x\";"] }
113
- ```
112
+ { "content_lines": ["F4T│import { x } from \"./x\";"], "hash_range_inclusive": ["F4T", "F4T"] }
114
113
 
115
114
  Right:
116
115
  ```json
117
- { "hash_range_inclusive": ["F4T", "F4T"], "content_lines": ["import { x } from \"./x\";"] }
118
- ```
116
+ { "content_lines": ["import { x } from \"./x\";"], "hash_range_inclusive": ["F4T", "F4T"] }
119
117
 
120
118
  `hash_range_inclusive` uses the hash anchor. `content_lines` uses literal file content only — the same text that appears after the `│` in `read` output.
121
119
 
@@ -123,13 +121,11 @@ Right:
123
121
 
124
122
  Wrong:
125
123
  ```json
126
- { "hash_range_inclusive": ["F4T│import { x } from \"./x\";", "F4T│import { x } from \"./x\";"], "content_lines": [...] }
127
- ```
124
+ { "content_lines": [...], "hash_range_inclusive": ["F4T│import { x } from \"./x\";", "F4T│import { x } from \"./x\";"] }
128
125
 
129
126
  Right:
130
127
  ```json
131
- { "hash_range_inclusive": ["F4T", "F4T"], "content_lines": [...] }
132
- ```
128
+ { "content_lines": [...], "hash_range_inclusive": ["F4T", "F4T"] }
133
129
 
134
130
  Rules:
135
131
  - `hash_range_inclusive` is a pair `[start, end]`. A single-line replace is `hash_range_inclusive: ["X", "X"]`.
@@ -156,14 +152,12 @@ function greet() {
156
152
  ```
157
153
  A model might write:
158
154
  ```json
159
- { "hash_range_inclusive": ["X", "Y"], "content_lines": [" const y = 2;", " return y;", "}"] }
160
- ```
155
+ { "content_lines": [" const y = 2;", " return y;", "}"], "hash_range_inclusive": ["X", "Y"] }
161
156
  This produces `function greet() {\n const y = 2;\n return y;\n}\n}` — the `}` appears twice because it was already on line 4 (outside the range) AND in `content_lines`.
162
157
 
163
158
  Right: Only include the new lines that belong in the range:
164
159
  ```json
165
- { "hash_range_inclusive": ["X", "Y"], "content_lines": [" const y = 2;", " return y;"] }
166
- ```
160
+ { "content_lines": [" const y = 2;", " return y;"], "hash_range_inclusive": ["X", "Y"] }
167
161
  The `}` on line 4 is outside the range and stays in place.
168
162
 
169
163
  Error recovery:
@@ -171,10 +165,10 @@ Error recovery:
171
165
  - `[E_BAD_REF]` — malformed HASH. Re-read and try again.
172
166
  - `[E_BAD_OP]` — invalid operation (e.g. start line > end line).
173
167
  - `[E_BAD_SHAPE]` — malformed request or change item (missing fields, wrong types, unknown fields).
174
- - `[E_LEGACY_SHAPE]` — old `oldText`/`newText` or `old_text`/`new_text` format detected. Use `{hash_range_inclusive, content_lines}` instead.
168
+ - `[E_LEGACY_SHAPE]` — old `oldText`/`newText` or `old_text`/`new_text` format detected. Use `{content_lines, hash_range_inclusive}` instead.
175
169
  - `[E_EDIT_CONFLICT]` — two changes overlap on the same line range. Make changes non-overlapping.
176
170
  - `[E_AMBIGUOUS_ANCHOR]` — hash collision. Call `read` to get fresh anchors.
177
- - `[E_BARE_HASH_PREFIX]` — a `content_lines` entry starts with `HASH│`. Remove the hash prefix; keep only the literal line content that appears after `│` in `read` output. `hash_range_inclusive` uses hashes, `content_lines` does not.
171
+ - `[E_BARE_HASH_PREFIX]` — a `content_lines` entry starts with `HASH│`. Remove the hash prefix; keep only the literal line content that appears after `│` in `read` output. `content_lines` uses file content only, `hash_range_inclusive` uses hash anchors.
178
172
  - `[E_INVALID_PATCH]` — a `content_lines` entry matches the diff preview's `+HASH│…` addition-row form. Use literal file content. (Plain `+`/`-` lines are not rejected — they are written literally.)
179
173
  - `[E_WOULD_EMPTY]` — edit would empty a non-empty file.
180
174
  - `[E_FILE_TOO_LARGE]` — file exceeds the 1,000,000-line edit limit. Use `write` or a non-line-based approach for very large files.
@@ -6,7 +6,7 @@ import { toCwd } from "./path-utils";
6
6
  import { detectEnding, toLF, stripBOM } from "./replace-diff";
7
7
  import { abortIf } from "./runtime";
8
8
  import { assertText, valAccess } from "./validation";
9
-
9
+ import { visLines } from "./utils";
10
10
  export interface NormFile {
11
11
  absolutePath: string;
12
12
  normalized: string;
@@ -40,7 +40,7 @@ export async function readNormFile(
40
40
  const normalized = toLF(rawContent);
41
41
 
42
42
  if (maxLines !== undefined) {
43
- const lineCount = normalized.split("\n").length;
43
+ const lineCount = visLines(normalized).length;
44
44
  if (lineCount > maxLines) {
45
45
  throw new Error(
46
46
  `[E_FILE_TOO_LARGE] ${path} has ${lineCount} lines, exceeding the ${maxLines}-line edit limit. Hashline editing targets source-sized files; for very large files use write or a non-line-based approach.`,
@@ -9,10 +9,10 @@ export type RAnchor = {
9
9
  hashMatched: boolean;
10
10
  };
11
11
 
12
- export type HEdit = { hash_range_inclusive: [Anchor, Anchor]; content_lines: string[] };
12
+ export type HEdit = { content_lines: string[]; hash_range_inclusive: [Anchor, Anchor] };
13
13
  export type RHEdit = {
14
- hash_range_inclusive: [RAnchor, RAnchor];
15
14
  content_lines: string[];
15
+ hash_range_inclusive: [RAnchor, RAnchor];
16
16
  };
17
17
 
18
18
  interface HMismatch {
@@ -37,8 +37,8 @@ export interface NEdit {
37
37
  }
38
38
 
39
39
  export type HTEdit = {
40
- hash_range_inclusive: [string, string];
41
40
  content_lines: string[];
41
+ hash_range_inclusive: [string, string];
42
42
  };
43
43
 
44
44
  function resAnchor(
@@ -75,51 +75,52 @@ function assertAligned(
75
75
  }
76
76
  }
77
77
 
78
+
78
79
  export function fmtMismatch(
79
- mismatches: HMismatch[],
80
- fileLines: string[],
81
- fileHashes: string[],
82
- filePath?: string,
83
- ): string {
84
- assertAligned(fileLines, fileHashes, "fmtMismatch");
85
-
86
- const out: string[] = [];
87
- const notFound = mismatches.filter((m) => m.kind === "not_found");
88
- const ambiguous = mismatches.filter((m) => m.kind === "ambiguous");
89
-
90
- const refList = notFound.map((m) => `"${m.ref.hash}"`).join(", ");
91
- if (notFound.length > 0) {
92
- out.push(
93
- `[E_STALE_ANCHOR] ${notFound.length} stale anchor${notFound.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}: ${refList}. Call read() to get fresh anchors, then copy the 3-char HASH of the start and end of the range you are replacing into hash_range_inclusive of your next replace call.`
94
- );
95
- }
96
- if (ambiguous.length > 0) {
97
- if (out.length > 0) out.push("");
98
- out.push(
99
- `[E_AMBIGUOUS_ANCHOR] ${ambiguous.length} ambiguous anchor${ambiguous.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}. Call read() to get fresh anchors, then copy the 3-char HASH of the start and end of the range you are replacing into hash_range_inclusive of your next replace call.`
100
- );
101
- for (const m of ambiguous) {
102
- const sample = (m.candidates ?? []).slice(0, 5);
103
- const more =
104
- (m.candidates?.length ?? 0) > sample.length
105
- ? `, ... (+${(m.candidates?.length ?? 0) - sample.length} more)`
106
- : "";
107
- const lines = sample
108
- .map((line) => {
109
- const content = fileLines[line - 1] ?? "";
110
- return ` ${line}: ${fileHashes[line - 1]}│${content}`;
111
- })
112
- .join("\n");
113
- out.push(
114
- ` Hash "${m.ref.hash}" matches lines ${sample.join(", ")}${more}.\n${lines}`,
115
- );
116
- }
117
- }
80
+ mismatches: HMismatch[],
81
+ fileLines: string[],
82
+ fileHashes: string[],
83
+ filePath?: string,
84
+ ): string {
85
+ assertAligned(fileLines, fileHashes, "fmtMismatch");
86
+
87
+ const out: string[] = [];
88
+ const notFound = mismatches.filter((m) => m.kind === "not_found");
89
+ const ambiguous = mismatches.filter((m) => m.kind === "ambiguous");
90
+
91
+ const refList = notFound.map((m) => `"${m.ref.hash}"`).join(", ");
92
+ if (notFound.length > 0) {
93
+ out.push(
94
+ `[E_STALE_ANCHOR] ${notFound.length} stale anchor${notFound.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}: ${refList}. Call read() to get fresh anchors, then copy the 3-char HASH of the start and end of the range you are replacing into hash_range_inclusive of your next replace call.`
95
+ );
96
+ }
97
+ if (ambiguous.length > 0) {
98
+ if (out.length > 0) out.push("");
99
+ out.push(
100
+ `[E_AMBIGUOUS_ANCHOR] ${ambiguous.length} ambiguous anchor${ambiguous.length > 1 ? "s" : ""}${filePath ? ` in ${filePath}` : ""}. Call read() to get fresh anchors, then copy the 3-char HASH of the start and end of the range you are replacing into hash_range_inclusive of your next replace call.`
101
+ );
102
+ for (const m of ambiguous) {
103
+ const sample = (m.candidates ?? []).slice(0, 5);
104
+ const more =
105
+ (m.candidates?.length ?? 0) > sample.length
106
+ ? `, ... (+${(m.candidates?.length ?? 0) - sample.length} more)`
107
+ : "";
108
+ const lines = sample
109
+ .map((line) => {
110
+ const content = fileLines[line - 1] ?? "";
111
+ return ` ${line}: ${fileHashes[line - 1]}│${content}`;
112
+ })
113
+ .join("\n");
114
+ out.push(
115
+ ` Hash "${m.ref.hash}" matches lines ${sample.join(", ")}${more}.\n${lines}`,
116
+ );
117
+ }
118
+ }
118
119
 
119
- return out.join("\n");
120
+ return out.join("\n");
120
121
  }
121
122
 
122
- const ITEM_KS = new Set(["hash_range_inclusive", "content_lines"]);
123
+ const ITEM_KS = new Set(["content_lines", "hash_range_inclusive"]);
123
124
 
124
125
  function isStrArr(value: unknown): value is string[] {
125
126
  return (
@@ -136,7 +137,7 @@ function isStrPair(value: unknown): value is [string, string] {
136
137
  }
137
138
 
138
139
  function assertItem(edit: Record<string, unknown>, index: number): void {
139
- rejectUnknownFields(edit, ITEM_KS, `Edit ${index}`, "Each edit takes only { hash_range_inclusive, content_lines }.");
140
+ rejectUnknownFields(edit, ITEM_KS, `Edit ${index}`, "Each edit takes only { content_lines, hash_range_inclusive }.");
140
141
 
141
142
  if ("hash_range_inclusive" in edit && !isStrPair(edit.hash_range_inclusive)) {
142
143
  throw new Error(
@@ -163,8 +164,8 @@ export function resEdits(edits: HTEdit[]): HEdit[] {
163
164
 
164
165
  const replaceLines = parseText(edit.content_lines);
165
166
  result.push({
166
- hash_range_inclusive: [parseHashRef(edit.hash_range_inclusive[0]), parseHashRef(edit.hash_range_inclusive[1])],
167
167
  content_lines: replaceLines,
168
+ hash_range_inclusive: [parseHashRef(edit.hash_range_inclusive[0]), parseHashRef(edit.hash_range_inclusive[1])],
168
169
  });
169
170
  }
170
171
  return result;
@@ -214,7 +215,7 @@ export function assertNoBarePrefix(
214
215
  : `${matchedCount} match file line hashes — strong evidence the prefix was copied from read output.`;
215
216
 
216
217
  throw new Error(
217
- `[E_BARE_HASH_PREFIX] ${suspects.length} edit line(s) start with a hash-like prefix (${locations}). Example: ${JSON.stringify(exampleLine)}. ${linesHint} Remove the "HASH│" prefix from each affected content_lines entry; keep only the literal line content that appears after "│" in read output. Remember: hash_range_inclusive uses hash anchors, content_lines uses file content only.`
218
+ `[E_BARE_HASH_PREFIX] ${suspects.length} edit line(s) start with a hash-like prefix (${locations}). Example: ${JSON.stringify(exampleLine)}. ${linesHint} Remove the "HASH│" prefix from each affected content_lines entry; keep only the literal line content that appears after "│" in read output. Remember: content_lines uses file content only, hash_range_inclusive uses hash anchors.`
218
219
  );
219
220
  }
220
221
 
@@ -290,8 +291,8 @@ export function valEdits(
290
291
  const leading = checkBoundaryDup(prevLine, replacementFirstLine, "leading", startResolved.line - 2, fileLines, resolved.length);
291
292
  if (leading) boundaryWarnings.push(leading);
292
293
  resolved.push({
293
- hash_range_inclusive: [startResolved, endResolved],
294
294
  content_lines: edit.content_lines,
295
+ hash_range_inclusive: [startResolved, endResolved],
295
296
  });
296
297
  }
297
298
 
@@ -1,89 +1,25 @@
1
1
  import { isRec, has } from "./utils";
2
2
 
3
- function tryParseJSON<T>(value: unknown, guard: (v: unknown) => v is T): T | undefined {
4
- if (typeof value !== "string") return undefined;
5
- try {
6
- const parsed: unknown = JSON.parse(value);
7
- if (guard(parsed)) return parsed;
8
- } catch {}
9
- return undefined;
10
- }
11
-
12
- function coerceEditArray(items: unknown[]): { result: unknown[]; warnings: string[] } {
13
- const warnings: string[] = [];
14
- const result = items
15
- .map((item: unknown) => {
16
- if (typeof item === "string") {
17
- const parsed = tryParseJSON(item, isRec);
18
- if (parsed) {
19
- warnings.push("Edit item was passed as a JSON string instead of a native object. Use native JSON values, not serialized strings.");
20
- return parsed;
21
- }
22
- }
23
- return item;
24
- })
25
- .map((change: unknown) => {
26
- if (!isRec(change)) return change;
27
- if (typeof change.content_lines !== "string") return change;
28
- const parsed = tryParseJSON(change.content_lines, (v): v is string[] =>
29
- Array.isArray(v) && v.every((i) => typeof i === "string"),
30
- );
31
- if (parsed) {
32
- warnings.push("content_lines was passed as a JSON string inside an edit item. Use a native array of strings.");
33
- return { ...change, content_lines: parsed };
34
- }
35
- return change;
36
- });
37
- return { result, warnings };
3
+ export function normalizeFilePath(record: Record<string, unknown>): void {
4
+ if (typeof record.path !== "string" && typeof record.file_path === "string") {
5
+ record.path = record.file_path;
6
+ delete record.file_path;
7
+ }
38
8
  }
39
9
 
40
10
  function normalizeField(
41
11
  record: Record<string, unknown>,
42
12
  from: string,
43
13
  to: string,
44
- ): string | undefined {
45
- if (!has(record, from)) return undefined;
46
- const raw = tryParseJSON(record[from], Array.isArray) ?? record[from];
47
- const wasString = typeof record[from] === "string" && raw !== record[from];
14
+ ): void {
15
+ if (!has(record, from)) return;
16
+ const raw = record[from];
48
17
  if (Array.isArray(raw)) {
49
- const { result, warnings } = coerceEditArray(raw);
50
- record[to] = result;
51
- if (warnings.length > 0) {
52
- return warnings.join(" ");
53
- }
54
- } else {
55
- const single =
56
- typeof raw === "string"
57
- ? tryParseJSON(raw, isRec)
58
- : isRec(raw)
59
- ? raw
60
- : undefined;
61
- if (single) {
62
- const { result, warnings } = coerceEditArray([single]);
63
- record[to] = result;
64
- if (warnings.length > 0) {
65
- return warnings.join(" ");
66
- }
67
- }
18
+ record[to] = raw;
19
+ } else if (isRec(raw)) {
20
+ record[to] = [raw];
68
21
  }
69
22
  if (from !== to) delete record[from];
70
- if (wasString) {
71
- return `Field "${from}" was passed as a JSON string instead of a native array. Use native JSON values, not serialized strings.`;
72
- }
73
- return undefined;
74
- }
75
-
76
- export function normalizeFilePath(record: Record<string, unknown>): void {
77
- if (typeof record.path !== "string" && typeof record.file_path === "string") {
78
- record.path = record.file_path;
79
- delete record.file_path;
80
- }
81
- }
82
-
83
- export function tryParseField(record: Record<string, unknown>, field: string): void {
84
- if (typeof record[field] === "string") {
85
- try { record[field] = JSON.parse(record[field] as string); } catch {}
86
- }
87
23
  }
88
24
 
89
25
  export function normReq(input: unknown): unknown {
@@ -92,42 +28,21 @@ export function normReq(input: unknown): unknown {
92
28
  }
93
29
 
94
30
  const record: Record<string, unknown> = { ...input };
95
- const warnings: string[] = [];
96
31
 
97
32
  normalizeFilePath(record);
98
33
 
99
- const w1 = normalizeField(record, "changes", "changes");
100
- if (w1) warnings.push(w1);
101
- const w2 = normalizeField(record, "edits", "changes");
102
- if (w2) warnings.push(w2);
34
+ normalizeField(record, "changes", "changes");
35
+ normalizeField(record, "edits", "changes");
103
36
 
104
37
  if (!Array.isArray(record.changes) && has(record, "hash_range_inclusive") && has(record, "content_lines")) {
105
- const hriRaw = record.hash_range_inclusive;
106
- const hri = tryParseJSON(hriRaw, (v): v is string[] =>
107
- Array.isArray(v) && v.length === 2 && v.every((i) => typeof i === "string")
108
- ) ?? hriRaw;
109
- if (typeof hriRaw === "string" && hri !== hriRaw) {
110
- warnings.push("hash_range_inclusive was passed as a JSON string instead of a native array. Use native JSON values.");
111
- }
112
-
113
- const clRaw = record.content_lines;
114
- const cl = tryParseJSON(clRaw, (v): v is string[] =>
115
- Array.isArray(v) && v.every((i) => typeof i === "string")
116
- ) ?? clRaw;
117
- if (typeof clRaw === "string" && cl !== clRaw) {
118
- warnings.push("content_lines was passed as a JSON string instead of a native array. Use native JSON values.");
119
- }
120
-
38
+ const hri = record.hash_range_inclusive;
39
+ const cl = record.content_lines;
121
40
  if (Array.isArray(hri) && Array.isArray(cl)) {
122
- record.changes = [{ hash_range_inclusive: hri, content_lines: cl }];
41
+ record.changes = [{ content_lines: cl, hash_range_inclusive: hri }];
123
42
  delete record.hash_range_inclusive;
124
43
  delete record.content_lines;
125
44
  }
126
45
  }
127
46
 
128
- if (warnings.length > 0) {
129
- (record as Record<string, unknown>)._normWarnings = warnings;
130
- }
131
-
132
47
  return record;
133
48
  }
package/src/replace.ts CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  restoreEndings,
12
12
  } from "./replace-diff";
13
13
  import { readNormFile } from "./file-reader";
14
- import { normReq, normalizeFilePath, tryParseField } from "./replace-normalize";
14
+ import { normReq, normalizeFilePath } from "./replace-normalize";
15
15
  import { isRec, has, rejectUnknownFields } from "./utils";
16
16
  import { MAX_HASH_LINES } from "./constants";
17
17
  import { resolveTarget, writeAtomic } from "./fs-write";
@@ -62,25 +62,25 @@ const hashRangeInclSchema = Type.Array(
62
62
 
63
63
  const changeItemSchema = Type.Object(
64
64
  {
65
- hash_range_inclusive: hashRangeInclSchema,
66
65
  content_lines: contentLinesSchema,
66
+ hash_range_inclusive: hashRangeInclSchema,
67
67
  },
68
68
  { additionalProperties: false },
69
69
  );
70
70
 
71
71
  export const editToolSchema = Type.Object(
72
72
  {
73
- path: Type.String({ description: "path" }),
74
73
  changes: Type.Array(changeItemSchema, { description: "changes over $path" }),
74
+ path: Type.String({ description: "path" }),
75
75
  },
76
76
  { additionalProperties: false },
77
77
  );
78
78
 
79
79
  export const flatEditToolSchema = Type.Object(
80
80
  {
81
- path: Type.String({ description: "path" }),
82
- hash_range_inclusive: hashRangeInclSchema,
83
81
  content_lines: contentLinesSchema,
82
+ hash_range_inclusive: hashRangeInclSchema,
83
+ path: Type.String({ description: "path" }),
84
84
  },
85
85
  { additionalProperties: false },
86
86
  );
@@ -127,7 +127,7 @@ export function assertReq(
127
127
  for (const legacyKey of ["oldText", "newText", "old_text", "new_text", "old_range", "start", "end", "lines"]) {
128
128
  if (has(request, legacyKey)) {
129
129
  throw new Error(
130
- `[E_LEGACY_SHAPE] "${legacyKey}" is not supported. Use {hash_range_inclusive: ["<START>", "<END>"], content_lines: [...]}.`
130
+ `[E_LEGACY_SHAPE] "${legacyKey}" is not supported. Use {content_lines: [...], hash_range_inclusive: ["<START>", "<END>"]}.`
131
131
  );
132
132
  }
133
133
  }
@@ -139,7 +139,7 @@ export function assertReq(
139
139
  }
140
140
 
141
141
  if (!Array.isArray(request.changes)) {
142
- throw new Error('[E_BAD_SHAPE] Edit request requires a "changes" array. Each change is { hash_range_inclusive: ["<START>", "<END>"], content_lines: [...] }.');
142
+ throw new Error('[E_BAD_SHAPE] Edit request requires a "changes" array. Each change is { content_lines: [...], hash_range_inclusive: ["<START>", "<END>"] }.');
143
143
  }
144
144
  }
145
145
 
@@ -311,8 +311,6 @@ export function buildToolDef(opts: { flat: boolean }): ToolDef {
311
311
  if (!isRec(args)) return args as any;
312
312
  const record = { ...args };
313
313
  normalizeFilePath(record);
314
- tryParseField(record, "hash_range_inclusive");
315
- tryParseField(record, "content_lines");
316
314
  return record as any;
317
315
  }
318
316
  : (args: unknown) =>
@@ -408,14 +406,17 @@ export function buildToolDef(opts: { flat: boolean }): ToolDef {
408
406
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
409
407
  const canonical = opts.flat
410
408
  ? normReq({
409
+ content_lines: (params as any).content_lines,
410
+ hash_range_inclusive: (params as any).hash_range_inclusive,
411
411
  path: (params as any).path,
412
412
  changes: [{
413
- hash_range_inclusive: (params as any).hash_range_inclusive,
414
413
  content_lines: (params as any).content_lines,
414
+ hash_range_inclusive: (params as any).hash_range_inclusive,
415
415
  }],
416
416
  })
417
417
  : normReq(params);
418
- const normWarnings = (canonical as Record<string, unknown>)._normWarnings as string[] | undefined;
418
+
419
+
419
420
  const normalizedParams = canonical as { path: string; changes: HTEdit[] };
420
421
  const path = normalizedParams.path;
421
422
  const absolutePath = toCwd(path, ctx.cwd);
@@ -448,10 +449,6 @@ export function buildToolDef(opts: { flat: boolean }): ToolDef {
448
449
  ? normalizedParams.changes.length
449
450
  : 0;
450
451
 
451
- if (normWarnings) {
452
- warnings.push(...normWarnings);
453
- }
454
-
455
452
  if (originalNormalized === result) {
456
453
  const noopSnapshotId = (await fileSnap(absolutePath)).snapshotId;
457
454
  return buildNoop({
package/src/utils.ts CHANGED
@@ -6,10 +6,23 @@ export function has(record: Record<string, unknown>, key: string): boolean {
6
6
  return Object.hasOwn(record, key);
7
7
  }
8
8
 
9
+ /**
10
+ * Splits text into visible lines, stripping the trailing empty element
11
+ * that `split("\n")` produces when the text ends with "\n".
12
+ *
13
+ * This is the canonical way to get user-visible lines from text.
14
+ * For internal hashing that needs the trailing empty string (e.g.
15
+ * `_lineHashesPure`, `mapStableHashes`, `buildIdx`), use
16
+ * `text.split("\n")` directly with a comment explaining why.
17
+ */
18
+ export function splitLines(text: string): string[] {
19
+ if (text.length === 0) return [];
20
+ const lines = text.split("\n");
21
+ return text.endsWith("\n") ? lines.slice(0, -1) : lines;
22
+ }
23
+
9
24
  export function visLines(text: string): string[] {
10
- if (text.length === 0) return [];
11
- const lines = text.split("\n");
12
- return text.endsWith("\n") ? lines.slice(0, -1) : lines;
25
+ return splitLines(text);
13
26
  }
14
27
 
15
28
  export function cntLines(text: string): number {