@oh-my-pi/hashline 17.2.13 → 17.2.15
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 +11 -0
- package/dist/types/format.d.ts +5 -0
- package/dist/types/messages.d.ts +11 -0
- package/package.json +3 -3
- package/src/apply.ts +45 -3
- package/src/format.ts +14 -2
- package/src/messages.ts +19 -0
- package/src/prompt.md +31 -31
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [17.2.15] - 2026-08-12
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added a post-apply parse advisory warning that alerts users when an applied edit fails to parse (despite the pre-edit content parsing successfully), helping catch balance-neutral misplacements that previously failed silently.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Fixed a bug where Rust lifetimes (e.g., `'static`) were incorrectly parsed as starting a string literal, which blinded the delimiter-balance scanner and could lead to silent signature deletions. Single-quote lexing on `.rs` files is now language-aware and correctly distinguishes lifetimes from character literals.
|
|
14
|
+
- Fixed an issue where terminal newlines in files were incorrectly exposed as editable blank rows.
|
|
15
|
+
|
|
5
16
|
## [17.2.12] - 2026-08-08
|
|
6
17
|
|
|
7
18
|
### Breaking Changes
|
package/dist/types/format.d.ts
CHANGED
|
@@ -77,5 +77,10 @@ export declare function describeAnchorExamples(linePrefix?: string): string;
|
|
|
77
77
|
export declare function formatHashlineHeader(filePath: string, fileHash: string): string;
|
|
78
78
|
/** Formats a single numbered line as `LINE:TEXT`. */
|
|
79
79
|
export declare function formatNumberedLine(lineNumber: number, line: string): string;
|
|
80
|
+
/**
|
|
81
|
+
* Split LF-delimited file text into lines hashline anchors can address.
|
|
82
|
+
* A terminal newline terminates the preceding line; it is not content.
|
|
83
|
+
*/
|
|
84
|
+
export declare function splitAddressableFileLines(text: string): string[];
|
|
80
85
|
/** Format file text with hashline-mode line-number prefixes for display. */
|
|
81
86
|
export declare function formatNumberedLines(text: string, startLine?: number): string;
|
package/dist/types/messages.d.ts
CHANGED
|
@@ -121,6 +121,17 @@ export declare function ambiguousLeadingCloserSpareMessage(startLine: number, en
|
|
|
121
121
|
* be literal prose), so the edit applies as authored and the author decides.
|
|
122
122
|
*/
|
|
123
123
|
export declare function midBlockRangeWarning(startLine: number, endLine: number, orphaned: number): string;
|
|
124
|
+
/**
|
|
125
|
+
* The applied result no longer parses while the pre-edit content did: the
|
|
126
|
+
* patch introduced a syntax error. Advisory, never a rejection — the applier
|
|
127
|
+
* honors the authored edit — but the breakage is machine-confirmed (the
|
|
128
|
+
* tree-sitter probe parsed the original and rejects the result), so it is
|
|
129
|
+
* surfaced in the same response instead of waiting for a compiler pass. The
|
|
130
|
+
* classic trigger is a balance-neutral misplacement: a statement landed on
|
|
131
|
+
* the wrong line number with no delimiter anomaly for the repair heuristics
|
|
132
|
+
* to notice.
|
|
133
|
+
*/
|
|
134
|
+
export declare function editBrokeParseWarning(firstChangedLine: number | undefined): string;
|
|
124
135
|
/**
|
|
125
136
|
* Internal invariant: `applyEdits` received an unresolved block edit;
|
|
126
137
|
* `resolveBlockEdits` must run first.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/hashline",
|
|
4
|
-
"version": "17.2.
|
|
4
|
+
"version": "17.2.15",
|
|
5
5
|
"description": "Hashline: a compact, line-anchored patch language and applier. Pluggable FS/IO so it works over disk, in-memory, or any custom backend.",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -33,8 +33,8 @@
|
|
|
33
33
|
"fmt": "biome format --write ."
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@oh-my-pi/pi-natives": "17.2.
|
|
37
|
-
"@oh-my-pi/pi-utils": "17.2.
|
|
36
|
+
"@oh-my-pi/pi-natives": "17.2.15",
|
|
37
|
+
"@oh-my-pi/pi-utils": "17.2.15"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@types/bun": "^1.3.14"
|
package/src/apply.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
ambiguousCloserSpareMessage,
|
|
16
16
|
ambiguousLeadingCloserSpareMessage,
|
|
17
17
|
blockInsertLandingShiftWarning,
|
|
18
|
+
editBrokeParseWarning,
|
|
18
19
|
midBlockRangeWarning,
|
|
19
20
|
REPLACEMENT_INDENT_AUTO_SHIFT_WARNING,
|
|
20
21
|
UNRESOLVED_BLOCK_INTERNAL,
|
|
@@ -257,13 +258,38 @@ interface DelimiterBalance {
|
|
|
257
258
|
brace: number;
|
|
258
259
|
}
|
|
259
260
|
|
|
261
|
+
/**
|
|
262
|
+
* Single-quote lexing mode for {@link computeDelimiterBalance}. `"literal"`:
|
|
263
|
+
* `'…'` is a same-line string (JS/Python/C-like default, the original
|
|
264
|
+
* behavior). `"rust"`: `'` opens a literal only when it lexes as a Rust char
|
|
265
|
+
* literal (`'a'`, `'\n'`, `'\u{7FFF}'`); any other `'` is a lifetime or
|
|
266
|
+
* apostrophe and stays an ordinary character — pairing arbitrary apostrophes
|
|
267
|
+
* would swallow real delimiters between two lifetimes (`<'a>(x: &'a str)`
|
|
268
|
+
* loses the `(`), and quote-state-to-EOL hides the opener on signature lines
|
|
269
|
+
* (`&'static str {` — the `extension()` incident).
|
|
270
|
+
*
|
|
271
|
+
* Module-scoped rather than threaded: the scan helpers are a dozen pure free
|
|
272
|
+
* functions all rooted in the synchronous `applyEdits` call, which sets the
|
|
273
|
+
* mode from its target path on every entry.
|
|
274
|
+
*/
|
|
275
|
+
let singleQuoteMode: "literal" | "rust" = "literal";
|
|
276
|
+
|
|
277
|
+
/** Set {@link singleQuoteMode} from the target file's extension. */
|
|
278
|
+
function setDelimiterScanLanguage(path: string | undefined): void {
|
|
279
|
+
singleQuoteMode = path?.endsWith(".rs") ? "rust" : "literal";
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Rust char literal at one position: `'a'`, `'\n'`, `'\x41'`, `'\u{7FFF}'`. */
|
|
283
|
+
const RUST_CHAR_LITERAL_RE = /^'(?:\\u\{[0-9a-fA-F_]{1,6}\}|\\x[0-9a-fA-F]{2}|\\.|[^\\'])'/;
|
|
284
|
+
|
|
260
285
|
/**
|
|
261
286
|
* Net `()` / `[]` / `{}` delta across `lines`, skipping delimiters inside line
|
|
262
287
|
* comments (`//`), block comments, and string/template literals. Block-comment
|
|
263
288
|
* and backtick-template state carry across lines; `"` / `'` reset at EOL since
|
|
264
|
-
* they cannot span lines.
|
|
265
|
-
*
|
|
266
|
-
*
|
|
289
|
+
* they cannot span lines. Single-quote handling follows the target language
|
|
290
|
+
* (see {@link singleQuoteMode}). Deliberately language-light otherwise:
|
|
291
|
+
* constructs it cannot classify (e.g. regex literals) are counted naively,
|
|
292
|
+
* which can only suppress a repair (the safe direction), never force one.
|
|
267
293
|
*/
|
|
268
294
|
function computeDelimiterBalance(lines: readonly string[]): DelimiterBalance {
|
|
269
295
|
const balance: DelimiterBalance = { paren: 0, bracket: 0, brace: 0 };
|
|
@@ -284,6 +310,13 @@ function computeDelimiterBalance(lines: readonly string[]): DelimiterBalance {
|
|
|
284
310
|
else if (ch === quote) quote = "";
|
|
285
311
|
continue;
|
|
286
312
|
}
|
|
313
|
+
if (ch === "'" && singleQuoteMode === "rust") {
|
|
314
|
+
// A real char literal is skipped whole; a lifetime (`'static`,
|
|
315
|
+
// `<'a>`) or apostrophe is an ordinary character.
|
|
316
|
+
const literal = RUST_CHAR_LITERAL_RE.exec(line.slice(i));
|
|
317
|
+
if (literal) i += literal[0].length - 1;
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
287
320
|
if (ch === '"' || ch === "'" || ch === "`") {
|
|
288
321
|
quote = ch;
|
|
289
322
|
continue;
|
|
@@ -1600,6 +1633,7 @@ function materializeEdits(originalLines: readonly string[], edits: readonly Appl
|
|
|
1600
1633
|
*/
|
|
1601
1634
|
export function applyEdits(text: string, edits: readonly Edit[], options: ApplyEditsOptions = {}): ApplyResult {
|
|
1602
1635
|
if (edits.length === 0) return { text, firstChangedLine: undefined };
|
|
1636
|
+
setDelimiterScanLanguage(options.path);
|
|
1603
1637
|
|
|
1604
1638
|
const fileLines = text.split("\n");
|
|
1605
1639
|
|
|
@@ -1633,6 +1667,14 @@ export function applyEdits(text: string, edits: readonly Edit[], options: ApplyE
|
|
|
1633
1667
|
const authored = repairReplacementBoundaries(targetEdits, fileLines, false);
|
|
1634
1668
|
const finish = (result: Materialized, warnings: string[]): ApplyResult => {
|
|
1635
1669
|
const merged = [...warnings, ...result.warnings];
|
|
1670
|
+
// Post-apply syntax advisory: the result stopped parsing while the
|
|
1671
|
+
// pre-edit text parsed, so this patch demonstrably introduced the
|
|
1672
|
+
// error. Catches balance-neutral misplacements (a statement swapped
|
|
1673
|
+
// onto the wrong line) that no delimiter heuristic can see. Both
|
|
1674
|
+
// probes are content-cached; a pathless call short-circuits to false.
|
|
1675
|
+
if (!parsesCleanly(options.path, result.text) && parsesCleanly(options.path, text)) {
|
|
1676
|
+
merged.push(editBrokeParseWarning(result.firstChangedLine));
|
|
1677
|
+
}
|
|
1636
1678
|
return {
|
|
1637
1679
|
text: result.text,
|
|
1638
1680
|
firstChangedLine: result.firstChangedLine,
|
package/src/format.ts
CHANGED
|
@@ -139,8 +139,20 @@ export function formatNumberedLine(lineNumber: number, line: string): string {
|
|
|
139
139
|
return `${lineNumber}${HL_LINE_BODY_SEP}${line}`;
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Split LF-delimited file text into lines hashline anchors can address.
|
|
144
|
+
* A terminal newline terminates the preceding line; it is not content.
|
|
145
|
+
*/
|
|
146
|
+
export function splitAddressableFileLines(text: string): string[] {
|
|
147
|
+
const lines = text.split("\n");
|
|
148
|
+
if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
|
|
149
|
+
return lines;
|
|
150
|
+
}
|
|
151
|
+
|
|
142
152
|
/** Format file text with hashline-mode line-number prefixes for display. */
|
|
143
153
|
export function formatNumberedLines(text: string, startLine = 1): string {
|
|
144
|
-
|
|
145
|
-
|
|
154
|
+
return text
|
|
155
|
+
.split("\n")
|
|
156
|
+
.map((line, i) => formatNumberedLine(startLine + i, line))
|
|
157
|
+
.join("\n");
|
|
146
158
|
}
|
package/src/messages.ts
CHANGED
|
@@ -366,6 +366,25 @@ export function midBlockRangeWarning(startLine: number, endLine: number, orphane
|
|
|
366
366
|
);
|
|
367
367
|
}
|
|
368
368
|
|
|
369
|
+
/**
|
|
370
|
+
* The applied result no longer parses while the pre-edit content did: the
|
|
371
|
+
* patch introduced a syntax error. Advisory, never a rejection — the applier
|
|
372
|
+
* honors the authored edit — but the breakage is machine-confirmed (the
|
|
373
|
+
* tree-sitter probe parsed the original and rejects the result), so it is
|
|
374
|
+
* surfaced in the same response instead of waiting for a compiler pass. The
|
|
375
|
+
* classic trigger is a balance-neutral misplacement: a statement landed on
|
|
376
|
+
* the wrong line number with no delimiter anomaly for the repair heuristics
|
|
377
|
+
* to notice.
|
|
378
|
+
*/
|
|
379
|
+
export function editBrokeParseWarning(firstChangedLine: number | undefined): string {
|
|
380
|
+
const at = firstChangedLine === undefined ? "" : ` near line ${firstChangedLine}`;
|
|
381
|
+
return (
|
|
382
|
+
`This edit introduced a syntax error${at}: the file parsed before the patch and no longer does. ` +
|
|
383
|
+
`It was applied exactly as written, so a line number or range endpoint is likely wrong — ` +
|
|
384
|
+
`re-read the touched region and re-issue a correcting edit.`
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
369
388
|
/**
|
|
370
389
|
* Internal invariant: `applyEdits` received an unresolved block edit;
|
|
371
390
|
* `resolveBlockEdits` must run first.
|
package/src/prompt.md
CHANGED
|
@@ -1,38 +1,38 @@
|
|
|
1
|
-
Line-anchored patch language: name original lines/gaps to replace, insert, cut, or paste
|
|
1
|
+
Line-anchored patch language: name original lines/gaps to replace, insert, cut, or paste; then give new content. `:` headers take `+` body rows; colonless paste `PUT`, `CUT`, `REM`, `MV` take none.
|
|
2
2
|
|
|
3
3
|
<headers>
|
|
4
|
-
|
|
4
|
+
Section: `[PATH#TAG]`; `TAG`: 4-hex snapshot from latest `read`/`search`, REQUIRED each section. New files: `write`; hashline edits existing files only.
|
|
5
5
|
</headers>
|
|
6
6
|
|
|
7
7
|
<ops>
|
|
8
|
-
`PUT N.=M
|
|
9
|
-
`PUT N
|
|
10
|
-
`PUT <N:` / `PUT >N
|
|
11
|
-
`PUT >N
|
|
12
|
-
`PUT <N` / `PUT >N` / `PUT N.=M @name` / `PUT N* @name
|
|
13
|
-
`CUT N.=M` / `CUT N
|
|
14
|
-
`REM
|
|
15
|
-
Single line: `PUT N.=N:` / `CUT N.=N`.
|
|
8
|
+
`PUT N.=M:`: replace original inclusive lines N–M with body.
|
|
9
|
+
`PUT N*:`: replace syntactic block beginning N; closing line resolved.
|
|
10
|
+
`PUT <N:` / `PUT >N:`: insert before/after N; `<1` head, `>$` tail.
|
|
11
|
+
`PUT >N*:`: insert after block N's end, at sibling depth. Append inside block: `PUT >M:`.
|
|
12
|
+
`PUT <N` / `PUT >N` / `PUT N.=M @name` / `PUT N* @name`: paste captured register at gap/range/resolved block; no `:` or body. Unlabeled gap paste: anonymous register; range/block paste: `@name` required.
|
|
13
|
+
`CUT N.=M` / `CUT N*`: delete and capture inclusive lines N–M / block N; anonymous or given `@name`.
|
|
14
|
+
`REM`: delete section file. `MV DEST`: move/rename (quote paths with spaces); prior edits apply to source, final content to `DEST`.
|
|
15
|
+
Single line: `PUT N.=N:` / `CUT N.=N`. Ranges name original inclusive touched lines; body length irrelevant.
|
|
16
16
|
</ops>
|
|
17
17
|
|
|
18
18
|
<body-rows>
|
|
19
|
-
Only
|
|
19
|
+
Only below `:` headers. Row: verbatim `+TEXT` (leading whitespace preserved); `+`: blank. NEVER `-old`, bare, or context rows: range deletes; body is final content. Keep line: exclude it from every range. Literal initial `-`/`+`: `- item` → `+- item`; `+ item` → `++ item`.
|
|
20
20
|
</body-rows>
|
|
21
21
|
|
|
22
22
|
<rules>
|
|
23
|
-
-
|
|
24
|
-
-
|
|
25
|
-
- Touch
|
|
26
|
-
-
|
|
27
|
-
- NEVER start
|
|
28
|
-
- Ranges
|
|
29
|
-
- Whole construct
|
|
30
|
-
- `PUT N*:` resolves
|
|
31
|
-
- Block ops
|
|
32
|
-
- Markdown
|
|
33
|
-
- Pure
|
|
34
|
-
- Move
|
|
35
|
-
- NEVER format/restyle
|
|
23
|
+
- Numbers and `#TAG`: latest `read`/`search` `LINE:TEXT`; numbers are original, never shifted by hunks.
|
|
24
|
+
- Each edit renumbers and changes `#TAG` → next numbers from edit response or fresh `read`.
|
|
25
|
+
- Touch displayed lines only; undisplayed hunks REJECTED. Far from read window: re-`read`; confirm construct.
|
|
26
|
+
- Elisions UNSEEN: `…`, `..`, collapsed `N-M:` rows. NEVER hunk in/across one; `read` first.
|
|
27
|
+
- NEVER start/end range mid-expression or mid-block.
|
|
28
|
+
- Ranges: changed lines only; NEVER widen over keepers. Non-adjacent changes: separate hunks.
|
|
29
|
+
- Whole construct: `PUT N*:`; internal lines: `PUT N.=M:`.
|
|
30
|
+
- `PUT N*:` resolves exactly node N. Leading decorators/attributes/doc-comments are separate nodes: point N at first decorator to include both. Standalone line-comments never swept: use `PUT N.=M:`.
|
|
31
|
+
- Block ops: opening line of multi-line construct, NEVER closer, last line, bare inner statement. One statement: plain `PUT N.=N:` / `CUT N.=N` / `PUT >N:`. At closer: `PUT >M:`.
|
|
32
|
+
- Markdown headings are block openers. Block op on `##`/`###`: whole section through deeper headings to next same/higher heading. After section `PUT >N*:`: end body with blank line to separate next heading.
|
|
33
|
+
- Pure addition: `PUT <N:` / `PUT >N:`, NEVER widened `PUT N.=M:`.
|
|
34
|
+
- Move: `CUT`+`PUT`; `CUT 5.=9 @fn` → `@fn`, `PUT >40 @fn` pastes. Single call-local move: unlabeled `CUT` + `PUT >40`. Named registers persist across edit calls.
|
|
35
|
+
- NEVER format/restyle with this tool; run project formatter.
|
|
36
36
|
</rules>
|
|
37
37
|
|
|
38
38
|
<example>
|
|
@@ -54,7 +54,7 @@ PUT 1.=3:
|
|
|
54
54
|
MV lib/greet.py
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
-
Markdown bullets —
|
|
57
|
+
Markdown bullets — file receives `- task`:
|
|
58
58
|
```
|
|
59
59
|
[PLAN.md#A1B2]
|
|
60
60
|
PUT >2:
|
|
@@ -62,7 +62,7 @@ PUT >2:
|
|
|
62
62
|
+ - nested task
|
|
63
63
|
```
|
|
64
64
|
|
|
65
|
-
Move `greet` to
|
|
65
|
+
Move `greet` to sibling file via named register; flows across sections:
|
|
66
66
|
```
|
|
67
67
|
[greet.py#A1B2]
|
|
68
68
|
CUT 1* @fn
|
|
@@ -70,7 +70,7 @@ CUT 1* @fn
|
|
|
70
70
|
PUT <1 @fn
|
|
71
71
|
```
|
|
72
72
|
|
|
73
|
-
`PUT 1*:` resolves lines 1–3 (`def`
|
|
73
|
+
`PUT 1*:` resolves lines 1–3 (`def` through `print(msg)`); line 4 separate, remains:
|
|
74
74
|
```
|
|
75
75
|
[greet.py#A1B2]
|
|
76
76
|
PUT 1*:
|
|
@@ -78,7 +78,7 @@ PUT 1*:
|
|
|
78
78
|
+ print(f"Hello, {name}")
|
|
79
79
|
```
|
|
80
80
|
|
|
81
|
-
Decorator/doc-comment
|
|
81
|
+
Decorator/doc-comment separate block: point N at decorator to include both; anchoring `def` line 2 orphans `@cache`:
|
|
82
82
|
```
|
|
83
83
|
[svc.py#C3D4]
|
|
84
84
|
PUT 1*:
|
|
@@ -127,7 +127,7 @@ PUT >20 @fn:
|
|
|
127
127
|
</anti-patterns>
|
|
128
128
|
|
|
129
129
|
<critical>
|
|
130
|
-
1. RE-GROUND AFTER EVERY EDIT
|
|
131
|
-
2. RANGES
|
|
132
|
-
3. BODY
|
|
130
|
+
1. RE-GROUND AFTER EVERY EDIT: edits renumber and change `#TAG`; take next numbers from edit response or fresh `read`. Stale tag/surprise: STOP; re-`read`.
|
|
131
|
+
2. RANGES TIGHT: changed lines only. Whole construct: `PUT N*:`.
|
|
132
|
+
3. BODY FINAL CONTENT: every row starts `+`; Markdown bullet: `+- item`, not `- item`.
|
|
133
133
|
</critical>
|