refactor(coding-agent)!: removed href/hline helpers and fixed blank-line payload semantics
- Removed `href`, `hrefr`, and `hline` Handlebars helpers along with shared hashline anchor state; unused by any template. - Changed blank lines between ops from silent separators to literal payload lines appended to the open op. - Added overlapping-delete validation to reject before/after-block patch patterns. - Simplified hashline prompt doc, removing template-helper examples and tightening rules.
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Removed the `href`, `hrefr`, and `hline` Handlebars prompt helpers along with the shared hashline anchor state; none were referenced by any built-in or user prompt template
|
||||
|
||||
## [15.5.0] - 2026-05-26
|
||||
|
||||
### Added
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
parseFrontmatter,
|
||||
prompt,
|
||||
} from "@oh-my-pi/pi-utils";
|
||||
import { HL_LINE_BODY_SEP } from "../hashline/hash";
|
||||
import { jtdToTypeScript } from "../tools/jtd-to-typescript";
|
||||
import { parseCommandArgs, substituteArgs } from "../utils/command-args";
|
||||
|
||||
@@ -30,130 +29,6 @@ prompt.registerHelper("jtdToTypeScript", (schema: unknown): string => {
|
||||
}
|
||||
});
|
||||
|
||||
function formatHashlineRef(lineNum: unknown, content: unknown): { num: number; text: string; ref: string } {
|
||||
const num = typeof lineNum === "number" ? lineNum : Number.parseInt(String(lineNum), 10);
|
||||
const raw = typeof content === "string" ? content : String(content ?? "");
|
||||
const text = raw.replace(/\\t/g, "\t").replace(/\\n/g, "\n").replace(/\\r/g, "\r");
|
||||
const ref = `${num}`;
|
||||
return { num, text, ref };
|
||||
}
|
||||
|
||||
interface HashlineHelperRef {
|
||||
line: number;
|
||||
ref: string;
|
||||
}
|
||||
|
||||
interface HashlineHelperState {
|
||||
last?: HashlineHelperRef;
|
||||
byLine: Map<number, HashlineHelperRef>;
|
||||
}
|
||||
|
||||
const HL_HELPER_STATE = Symbol("hashlineHelperState");
|
||||
|
||||
interface HashlineHelperStateHolder {
|
||||
[HL_HELPER_STATE]?: HashlineHelperState;
|
||||
}
|
||||
|
||||
function isHelperOptions(value: unknown): value is prompt.HelperOptions {
|
||||
return typeof value === "object" && value !== null && "hash" in value;
|
||||
}
|
||||
|
||||
function splitHelperArgs(args: unknown[]): { positional: unknown[]; options?: prompt.HelperOptions } {
|
||||
const maybeOptions = args.at(-1);
|
||||
if (!isHelperOptions(maybeOptions)) return { positional: args };
|
||||
return { positional: args.slice(0, -1), options: maybeOptions };
|
||||
}
|
||||
|
||||
function getHashlineHelperState(context: unknown, options: prompt.HelperOptions | undefined): HashlineHelperState {
|
||||
const data = options?.data;
|
||||
const root = data?.root;
|
||||
const holderTarget = data && typeof data === "object" ? data : root && typeof root === "object" ? root : context;
|
||||
if (!holderTarget || typeof holderTarget !== "object") {
|
||||
throw new Error("hashline prompt helpers require an object render context");
|
||||
}
|
||||
|
||||
const holder = holderTarget as HashlineHelperStateHolder;
|
||||
if (!holder[HL_HELPER_STATE]) {
|
||||
holder[HL_HELPER_STATE] = { byLine: new Map() };
|
||||
}
|
||||
return holder[HL_HELPER_STATE];
|
||||
}
|
||||
|
||||
function isLineNumberArg(value: unknown): boolean {
|
||||
const num = typeof value === "number" ? value : Number.parseInt(String(value), 10);
|
||||
return Number.isFinite(num);
|
||||
}
|
||||
|
||||
function rememberHashlineRef(state: HashlineHelperState, line: number, ref: string): void {
|
||||
const entry = { line, ref };
|
||||
state.last = entry;
|
||||
state.byLine.set(line, entry);
|
||||
}
|
||||
|
||||
function requireStoredHashlineRef(state: HashlineHelperState, lineArg?: unknown): string {
|
||||
if (lineArg === undefined) {
|
||||
if (!state.last) {
|
||||
throw new Error("{{href}} requires a previous {{hline}} call in the same prompt render");
|
||||
}
|
||||
return state.last.ref;
|
||||
}
|
||||
|
||||
const line = typeof lineArg === "number" ? lineArg : Number.parseInt(String(lineArg), 10);
|
||||
const entry = state.byLine.get(line);
|
||||
if (!entry) {
|
||||
throw new Error(`{{href ${line}}} requires a previous {{hline ${line} ...}} call in the same prompt render`);
|
||||
}
|
||||
return entry.ref;
|
||||
}
|
||||
|
||||
function wrapHashlineRef(ref: string, args: unknown[]): string {
|
||||
const preStr = typeof args[0] === "string" ? args[0] : "";
|
||||
const postStr = typeof args[1] === "string" ? args[1] : "";
|
||||
return `${preStr}${ref}${postStr}`;
|
||||
}
|
||||
|
||||
function resolveHashlineRef(state: HashlineHelperState, args: unknown[]): string {
|
||||
if (args.length === 0) return requireStoredHashlineRef(state);
|
||||
const [first, second, ...rest] = args;
|
||||
if (isLineNumberArg(first)) {
|
||||
if (second === undefined) return requireStoredHashlineRef(state, first);
|
||||
const { ref } = formatHashlineRef(first, second);
|
||||
return wrapHashlineRef(ref, rest);
|
||||
}
|
||||
return wrapHashlineRef(requireStoredHashlineRef(state), args);
|
||||
}
|
||||
|
||||
/**
|
||||
* {{href lineNum "content"}} — compute a hashline line ref for prompt examples.
|
||||
* {{href lineNum}} — quote the ref remembered by the earlier {{hline lineNum "..."}}
|
||||
* {{href}} — quote the ref from the previous {{hline}} call.
|
||||
* {{href "[" "]"}} — wrap the previous {{hline}} ref with pre/post chars.
|
||||
* Returns `"lineNum"` (e.g., `"42"`), or `"[42]"` when pre/post are supplied.
|
||||
*/
|
||||
prompt.registerHelper("href", function (this: unknown, ...args: unknown[]): string {
|
||||
const { positional, options } = splitHelperArgs(args);
|
||||
const state = getHashlineHelperState(this, options);
|
||||
return JSON.stringify(resolveHashlineRef(state, positional));
|
||||
});
|
||||
prompt.registerHelper("hrefr", function (this: unknown, ...args: unknown[]): string {
|
||||
const { positional, options } = splitHelperArgs(args);
|
||||
const state = getHashlineHelperState(this, options);
|
||||
return resolveHashlineRef(state, positional);
|
||||
});
|
||||
|
||||
/**
|
||||
* {{hline lineNum "content"}} — format a full read-style line with prefix.
|
||||
* Returns `"lineNum:content"` (colon between line number and content).
|
||||
*/
|
||||
prompt.registerHelper("hline", function (this: unknown, ...args: unknown[]): string {
|
||||
const { positional, options } = splitHelperArgs(args);
|
||||
const [lineNum, content] = positional;
|
||||
const { num, ref, text } = formatHashlineRef(lineNum, content);
|
||||
const state = getHashlineHelperState(this, options);
|
||||
rememberHashlineRef(state, num, ref);
|
||||
return `${ref}${HL_LINE_BODY_SEP}${text}`;
|
||||
});
|
||||
|
||||
const INLINE_ARG_SHELL_PATTERN = /\$(?:ARGUMENTS|@(?:\[\d+(?::\d*)?\])?|\d+)/;
|
||||
const INLINE_ARG_TEMPLATE_PATTERN = /\{\{[\s\S]*?(?:\b(?:arguments|ARGUMENTS|args)\b|\barg\s+[^}]+)[\s\S]*?\}\}/;
|
||||
|
||||
|
||||
@@ -30,7 +30,6 @@ type PendingOp =
|
||||
interface Pending {
|
||||
op: PendingOp;
|
||||
payload: string[];
|
||||
pendingBlanks: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,16 +80,16 @@ export class HashlineExecutor {
|
||||
this.#terminated = true;
|
||||
return;
|
||||
case "header":
|
||||
this.#flushPending(false);
|
||||
this.#flushPending();
|
||||
return;
|
||||
case "blank":
|
||||
if (this.#pending) this.#pending.pendingBlanks++;
|
||||
if (this.#pending) this.#pending.payload.push("");
|
||||
return;
|
||||
case "payload":
|
||||
this.#handlePayload(token.text, token.lineNum);
|
||||
return;
|
||||
case "op-delete":
|
||||
this.#flushPending(false);
|
||||
this.#flushPending();
|
||||
if (token.trailingPayload) {
|
||||
throw new Error(
|
||||
`line ${token.lineNum}: ${HL_OP_DELETE} deletes only. Payload is forbidden after ${HL_OP_DELETE}; use ${HL_OP_REPLACE} to replace.`,
|
||||
@@ -102,32 +101,34 @@ export class HashlineExecutor {
|
||||
}
|
||||
return;
|
||||
case "op-insert":
|
||||
this.#flushPending(false);
|
||||
this.#flushPending();
|
||||
this.#pending = {
|
||||
op: { kind: "insert", cursor: token.cursor, lineNum: token.lineNum },
|
||||
payload: [token.inlineBody ?? ""],
|
||||
pendingBlanks: 0,
|
||||
};
|
||||
return;
|
||||
case "op-replace":
|
||||
this.#flushPending(false);
|
||||
this.#flushPending();
|
||||
validateRangeOrder(token.range, token.lineNum);
|
||||
this.#pending = {
|
||||
op: { kind: "replace", range: token.range, lineNum: token.lineNum },
|
||||
payload: [token.inlineBody ?? ""],
|
||||
pendingBlanks: 0,
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush any open pending op (including its trailing blank lines, which
|
||||
* are payload-significant) and return the accumulated edits and
|
||||
* warnings. The executor is single-use; reset() is required for reuse.
|
||||
* Flush any open pending op (with its full accumulated payload, blanks
|
||||
* included) and return the accumulated edits and warnings. The executor
|
||||
* is single-use; reset() is required for reuse.
|
||||
* Throws if two replace/delete ops target the same line — that pattern
|
||||
* means the diff is painting a before/after picture instead of stating
|
||||
* the final state, and applying both would silently duplicate content.
|
||||
*/
|
||||
end(): { edits: HashlineEdit[]; warnings: string[] } {
|
||||
this.#flushPending(true);
|
||||
this.#flushPending();
|
||||
this.#validateNoOverlappingDeletes();
|
||||
return { edits: this.#edits, warnings: this.#warnings };
|
||||
}
|
||||
|
||||
@@ -140,16 +141,44 @@ export class HashlineExecutor {
|
||||
this.#terminated = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Each `:` / `!` op contributes a delete edit per line in its range; if
|
||||
* any line ends up targeted by deletes originating from two different
|
||||
* source ops (distinguished by their `lineNum`), the patch is internally
|
||||
* inconsistent. Common shape: a "before" `A-B:` followed by an "after"
|
||||
* `A-B:` over the same range, or an `A-B:` that overlaps a later `N!` /
|
||||
* `N:`. The applier would run both literally and the file would end up
|
||||
* with two copies of the line, not a chosen winner.
|
||||
*/
|
||||
#validateNoOverlappingDeletes(): void {
|
||||
const sourceLinesByAnchor = new Map<number, number[]>();
|
||||
for (const edit of this.#edits) {
|
||||
if (edit.kind !== "delete") continue;
|
||||
let sourceLines = sourceLinesByAnchor.get(edit.anchor.line);
|
||||
if (sourceLines === undefined) {
|
||||
sourceLines = [];
|
||||
sourceLinesByAnchor.set(edit.anchor.line, sourceLines);
|
||||
}
|
||||
if (!sourceLines.includes(edit.lineNum)) sourceLines.push(edit.lineNum);
|
||||
}
|
||||
for (const [anchorLine, sourceLines] of sourceLinesByAnchor) {
|
||||
if (sourceLines.length < 2) continue;
|
||||
const [firstOp, secondOp] = [...sourceLines].sort((a, b) => a - b);
|
||||
throw new Error(
|
||||
`line ${secondOp}: anchor line ${anchorLine} is already targeted by the ${HL_OP_REPLACE}/${HL_OP_DELETE} op on line ${firstOp}. ` +
|
||||
`Issue ONE op per range; payload is only the final desired content, never a before/after pair.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#handlePayload(text: string, lineNum: number): void {
|
||||
if (this.#pending) {
|
||||
this.#flushPendingBlanks();
|
||||
this.#pending.payload.push(text);
|
||||
return;
|
||||
}
|
||||
|
||||
// Whitespace-only payload outside any pending op is a visual
|
||||
// separator (matches the legacy outer-loop isBlankLine skip);
|
||||
// only fully-empty lines arrive as `blank` tokens.
|
||||
// Whitespace-only payload outside any pending op is silently dropped;
|
||||
// fully empty lines arrive as `blank` tokens.
|
||||
if (text.trim().length === 0) return;
|
||||
// Orphan payload outside any pending op: pick the most specific
|
||||
// diagnostic so the model sees the actionable hint.
|
||||
@@ -174,16 +203,9 @@ export class HashlineExecutor {
|
||||
);
|
||||
}
|
||||
|
||||
#flushPendingBlanks(): void {
|
||||
if (!this.#pending) return;
|
||||
for (let count = 0; count < this.#pending.pendingBlanks; count++) this.#pending.payload.push("");
|
||||
this.#pending.pendingBlanks = 0;
|
||||
}
|
||||
|
||||
#flushPending(includeTrailingBlanks: boolean): void {
|
||||
#flushPending(): void {
|
||||
const pending = this.#pending;
|
||||
if (!pending) return;
|
||||
if (includeTrailingBlanks) this.#flushPendingBlanks();
|
||||
|
||||
const { op, payload } = pending;
|
||||
const linesToInsert = payload;
|
||||
|
||||
@@ -1,110 +1,59 @@
|
||||
Your patch language is a compact, line-anchored edit format.
|
||||
|
||||
A patch contains one or more file sections. Each anchored section starts with `¶PATH#HASH`, copied verbatim from the latest `read`/`search` output. `HASH` is a 4-hex file hash; `¶PATH` without `#HASH` is allowed only for new-file / `BOF` / `EOF` boundary inserts.
|
||||
|
||||
Operations reference lines by bare line number (`5`, `123`). Payload text is verbatim — NEVER escape unicode. The tool has NO awareness of language, indentation, brackets, fences, or table widths. Emit valid syntax in replacements/insertions.
|
||||
|
||||
<ops>
|
||||
¶PATH#HASH header: subsequent anchored ops apply to PATH at file hash HASH
|
||||
¶PATH unbound header: only BOF/EOF boundary inserts
|
||||
LINE↑PAYLOAD insert ABOVE the anchored line (or BOF)
|
||||
LINE↓PAYLOAD insert BELOW the anchored line (or EOF)
|
||||
A-B:PAYLOAD replace the inclusive range A..B with PAYLOAD
|
||||
A:PAYLOAD shorthand for A-A:PAYLOAD
|
||||
A-B! delete the inclusive range A..B (payload forbidden)
|
||||
A! shorthand for A-A!
|
||||
</ops>
|
||||
|
||||
<payload>
|
||||
- The first payload line is whatever follows the sigil on the op line. Additional payload lines follow on the next lines and append after the first.
|
||||
- An empty inline IS an empty first line. So bare `A↓` / `A↑` insert one blank line; bare `A:` / `A-B:` replace with one blank line. `A↓\nfoo` inserts blank-then-`foo`, NOT just `foo`.
|
||||
- Payload ends at the next op, next `¶PATH`, envelope marker, or EOF. Blank lines immediately before a next op or `¶PATH` are dropped; blank lines between content lines are preserved.
|
||||
Patch payload is a series of hunks: `¶PATH#HASH` header followed by any number of operations. `HASH` should be copied as is from read/search. Missing? Re-`read`.
|
||||
- No context rows, no gutters.
|
||||
- NEVER prefix payload with diff syntax.
|
||||
- NEVER restate unchanged lines "for context".
|
||||
- Payload indentation is literal.
|
||||
</payload>
|
||||
|
||||
<ops>
|
||||
LINE↑PAYLOAD insert before (or BOF↑)
|
||||
LINE↓PAYLOAD insert after (or EOF↓)
|
||||
A-B:PAYLOAD replace A..B (or A: == A..A)
|
||||
A-B! delete A..B (or A! == A..A)
|
||||
</ops>
|
||||
|
||||
<rules>
|
||||
- The sigil tells where content lands: `↑` above, `↓` below, `:` replaces, `!` deletes.
|
||||
- **Payload is only what's NEW relative to your range.** `:` replaces inside; `↑`/`↓` add at anchor. NEVER repeat the anchor line or neighbors — that duplicates them.
|
||||
- **Pick a self-contained unit.** Touching a multiline construct (return, array, brace block, JSX element)? Widen the range to span it. Don't bisect.
|
||||
- Smallest op wins: add with `↑`/`↓`; replace with `:`; delete with `!`.
|
||||
- Anchors reference the file as last read. ONE patch, ONE coordinate space — later ops still use original line numbers.
|
||||
- **Payload is only what's NEW.** `:` replaces inside; `↑`/`↓` add at anchor. NEVER repeat anchor lines or neighbors.
|
||||
- **Go small.** Add → `↑`/`↓`; replace → `:`; delete → `!`.
|
||||
- **Line numbers are frozen references to what you have seen.** Later ops still use original line numbers.
|
||||
</rules>
|
||||
|
||||
<common-failures>
|
||||
- **NEVER replay past your range.** Stop before B+1; extend B if it must go.
|
||||
- **NEVER duplicate chunks inside one payload.**
|
||||
- **Read lines look like replace ops.** `84:content` already means "make line 84 equal to content" — don't echo a context line before it.
|
||||
- **NEVER replay past your range.** Stop before B+1; extend B if needed.
|
||||
- **Read lines look like replace ops.** `84:content` = "make line 84 content" — don't echo context before it.
|
||||
- **NEVER fabricate file hashes.** Missing? Re-`read`.
|
||||
- **`A!` deletes silently.** Deleting a line that closes/opens a block (`}`, `} else {`, `})`, `*/`) breaks structure with no parse error.
|
||||
- **Pure removal uses `A-B!`, NEVER `A-B:something`.** If you have nothing to put in the range, use `!`. `A-B:X` where line `A-1` or `B+1` already reads `X` silently produces two copies of `X` — the tool trusts your payload literally. Before writing `A-B:payload`, glance at `A-1` and `B+1` and confirm payload doesn't echo either.
|
||||
</common-failures>
|
||||
|
||||
<case file="mod.ts">
|
||||
¶mod.ts#1a2b
|
||||
{{hline 1 'const TITLE = "Mr";'}}
|
||||
{{hline 2 'export function greet(name) {'}}
|
||||
{{hline 3 ' return ['}}
|
||||
{{hline 4 ' TITLE,'}}
|
||||
{{hline 5 ' name?.trim() || "guest",'}}
|
||||
{{hline 6 ' ].join(" ");'}}
|
||||
{{hline 7 "}"}}
|
||||
</case>
|
||||
<example>
|
||||
```a.ts#1a2b
|
||||
1:const X = "a";
|
||||
2:export function f() { return X; }
|
||||
```
|
||||
|
||||
<examples>
|
||||
# Replace one line (inline payload preserves original indentation)
|
||||
¶mod.ts#1a2b
|
||||
{{hrefr 1}}:const TITLE = "Mrs";
|
||||
|
||||
# Replace a multiline statement — first line inline, rest below
|
||||
¶mod.ts#1a2b
|
||||
{{hrefr 3}}-{{hrefr 6}}: return [
|
||||
"Mrs",
|
||||
name?.trim() || "guest",
|
||||
].join(" ");
|
||||
|
||||
# Insert ABOVE / BELOW a line
|
||||
¶mod.ts#1a2b
|
||||
{{hrefr 4}}↓ "Dr",
|
||||
{{hrefr 5}}↑ "Dr",
|
||||
|
||||
# Delete one line / blank a line / insert a blank line
|
||||
¶mod.ts#1a2b
|
||||
{{hrefr 5}}!
|
||||
{{hrefr 6}}:
|
||||
{{hrefr 7}}↑
|
||||
|
||||
# Create a file / append to one (hash optional for boundary-only inserts)
|
||||
¶new.ts
|
||||
BOF↓export const done = true;
|
||||
¶mod.ts
|
||||
EOF↓export const done = true;
|
||||
|
||||
# Multi-file patch
|
||||
¶src/a.ts#1a2b
|
||||
12:const enabled = true;
|
||||
¶src/b.ts#3c4d
|
||||
20!
|
||||
</examples>
|
||||
# replace, insert after, delete
|
||||
```
|
||||
¶a.ts#1a2b
|
||||
1:const X = "b";
|
||||
1↓const Y = "c";
|
||||
2!
|
||||
```
|
||||
</example>
|
||||
|
||||
<anti-pattern>
|
||||
# WRONG — replaces 2 lines just to add one.
|
||||
¶mod.ts#1a2b
|
||||
{{hrefr 1}}-{{hrefr 2}}:const TITLE = "Mr";
|
||||
const DEBUG = false;
|
||||
export function greet(name) {
|
||||
|
||||
# RIGHT — one-line insert
|
||||
¶mod.ts#1a2b
|
||||
{{hrefr 1}}↓const DEBUG = false;
|
||||
|
||||
# WRONG — bisects a multiline statement
|
||||
¶mod.ts#1a2b
|
||||
{{hrefr 4}}-{{hrefr 5}}: "Dr",
|
||||
name?.trim() || "guest",
|
||||
|
||||
# RIGHT — widen to the full statement
|
||||
¶mod.ts#1a2b
|
||||
{{hrefr 3}}-{{hrefr 6}}: return [
|
||||
"Dr",
|
||||
name?.trim() || "guest",
|
||||
].join(" ");
|
||||
# WRONG — INSERT used to change a line (old line survives)
|
||||
1↓const X = "b";
|
||||
# WRONG — echoing read-style lines as context before the real op
|
||||
1:const X = "a";
|
||||
1-2:const X = "b";
|
||||
export const Y = X;
|
||||
</anti-pattern>
|
||||
|
||||
<critical>
|
||||
- One op per range, ever.
|
||||
- Pick op precisely. Update: `:`, add: `↑`/`↓`, remove: `!`.
|
||||
- Payload is only what's NEW; never repeat anchor lines or neighbors.
|
||||
- Anchor exactly; don't anchor neighbors.
|
||||
</critical>
|
||||
|
||||
@@ -366,12 +366,11 @@ describe("hashline parser — suffix-op syntax", () => {
|
||||
expect(applyDiff(content, diff)).toBe("aaa\nfirst\n\n\nafter\nccc");
|
||||
});
|
||||
|
||||
it("drops blank lines between ops (separator, not payload)", () => {
|
||||
// Blank lines immediately before a next op are visual separators, not
|
||||
// payload. This prevents agents from silently inflating a payload and
|
||||
// shifting downstream line numbers.
|
||||
it("appends blank lines between ops to the previous payload", () => {
|
||||
// Blank lines while a payload run is open are payload, never silent
|
||||
// separators — every blank in `1:AAA\n\n\n3:CCC` ends up on disk.
|
||||
const diff = [`${sameLineRange(tag(1, "aaa"))}:AAA`, "", "", `${sameLineRange(tag(3, "ccc"))}:CCC`].join("\n");
|
||||
expect(applyDiff(content, diff)).toBe("AAA\nbbb\nCCC");
|
||||
expect(applyDiff(content, diff)).toBe("AAA\n\n\nbbb\nCCC");
|
||||
});
|
||||
|
||||
it("treats a bare insert op as inserting one empty line", () => {
|
||||
@@ -439,6 +438,44 @@ describe("hashline parser — suffix-op syntax", () => {
|
||||
/deletes only\. Payload is forbidden after !; use : to replace/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects two replace ops targeting the same single line", () => {
|
||||
const diff = `${tag(2, "bbb")}:BBB\n${tag(2, "bbb")}:BBB2`;
|
||||
expect(() => parseHashline(diff).edits).toThrow(/anchor line 2 is already targeted by the .+ op on line 1/);
|
||||
});
|
||||
|
||||
it("rejects two replace ops covering the same range (before/after-block pattern)", () => {
|
||||
const diff = `${tag(2, "bbb")}-${tag(3, "ccc")}:OLD\nOLD2\n${tag(2, "bbb")}-${tag(3, "ccc")}:NEW\nNEW2`;
|
||||
expect(() => parseHashline(diff).edits).toThrow(
|
||||
/Issue ONE op per range; payload is only the final desired content/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a replace overlapping a later delete", () => {
|
||||
const diff = `${tag(2, "bbb")}-${tag(4, "ddd")}:X\n${tag(3, "ccc")}!`;
|
||||
expect(() => parseHashline(diff).edits).toThrow(/anchor line 3 is already targeted by the .+ op on line 1/);
|
||||
});
|
||||
|
||||
it("rejects two deletes on the same line", () => {
|
||||
const diff = `${tag(2, "bbb")}!\n${tag(2, "bbb")}!`;
|
||||
expect(() => parseHashline(diff).edits).toThrow(/anchor line 2 is already targeted by the .+ op on line 1/);
|
||||
});
|
||||
|
||||
it("accepts multiple inserts at the same anchor (sequential, not duplicates)", () => {
|
||||
// Two ↑ at the same line is a legitimate accumulation pattern — both
|
||||
// inserts above land in source order. Only deletes/replaces are
|
||||
// considered overlapping.
|
||||
const diff = `${tag(2, "bbb")}↑X\n${tag(2, "bbb")}↑Y`;
|
||||
expect(() => parseHashline(diff).edits).not.toThrow();
|
||||
});
|
||||
|
||||
it("accepts a replace alongside an insert at the same anchor", () => {
|
||||
// `N:foo` deletes line N and inserts at before_anchor: N; `N↑bar`
|
||||
// adds another insert at the same cursor. No conflicting delete, so
|
||||
// this is allowed.
|
||||
const diff = `${tag(2, "bbb")}:NEW\n${tag(2, "bbb")}↑ABOVE`;
|
||||
expect(() => parseHashline(diff).edits).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hashline — file hash binding", () => {
|
||||
@@ -987,29 +1024,29 @@ describe("hashline apply — brace-delete soft warning", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("hashline parser — blank line is a separator before next op", () => {
|
||||
it("blank line between ops is NOT absorbed into previous payload", () => {
|
||||
describe("hashline parser — blank line extends the open payload", () => {
|
||||
it("blank line between ops is appended to the previous payload", () => {
|
||||
const text = "a\nb\nc\nd\ne\n";
|
||||
const ops = `${header("a.ts", text)}\n1:A\n\n3:C\n`;
|
||||
const { diff } = splitHashlineInput(ops);
|
||||
// Both replaces land on their target lines without inflating either payload.
|
||||
expect(applyDiff(text, diff)).toBe("A\nb\nC\nd\ne\n");
|
||||
// `1:A` payload becomes [A, ""]; line 3 is then replaced with C.
|
||||
expect(applyDiff(text, diff)).toBe("A\n\nb\nC\nd\ne\n");
|
||||
});
|
||||
|
||||
it("multiple blank lines between ops are also dropped", () => {
|
||||
it("multiple blank lines between ops are all appended as payload", () => {
|
||||
const text = "a\nb\nc\nd\ne\n";
|
||||
const ops = `${header("a.ts", text)}\n1:A\n\n\n\n3:C\n`;
|
||||
const { diff } = splitHashlineInput(ops);
|
||||
expect(applyDiff(text, diff)).toBe("A\nb\nC\nd\ne\n");
|
||||
expect(applyDiff(text, diff)).toBe("A\n\n\n\nb\nC\nd\ne\n");
|
||||
});
|
||||
|
||||
it("blank-only payload before next op blanks the line", () => {
|
||||
// Agent typed `2:` then a blank separator then `4:D`. Under bare-`A:`
|
||||
// blank-replace semantics, `2:` blanks line 2 and `4:D` replaces line 4.
|
||||
it("bare A: followed by a blank line replaces the line with two blanks", () => {
|
||||
// `2:` seeds payload with `[""]`, the standalone blank line appends
|
||||
// another `""`, then `4:D` flushes the run.
|
||||
const text = "a\nb\nc\nd\ne\n";
|
||||
const ops = `${header("a.ts", text)}\n2:\n\n4:D\n`;
|
||||
const { diff } = splitHashlineInput(ops);
|
||||
expect(applyDiff(text, diff)).toBe("a\n\nc\nD\ne\n");
|
||||
expect(applyDiff(text, diff)).toBe("a\n\n\nc\nD\ne\n");
|
||||
});
|
||||
|
||||
it("blank line inside payload between two content lines is preserved", () => {
|
||||
|
||||
@@ -229,52 +229,6 @@ describe("parseCommandArgs + substituteArgs integration", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Hashline prompt helpers
|
||||
// ============================================================================
|
||||
|
||||
describe("hashline prompt helpers", () => {
|
||||
function createPromptTemplate(content: string): PromptTemplate {
|
||||
return {
|
||||
name: "test-template",
|
||||
description: "Test template",
|
||||
content,
|
||||
source: "test",
|
||||
};
|
||||
}
|
||||
|
||||
function expandPrompt(content: string): string {
|
||||
return expandPromptTemplate("/test-template", [createPromptTemplate(content)]);
|
||||
}
|
||||
|
||||
test("href and hrefr should reuse anchors remembered from hline", () => {
|
||||
const result = expandPrompt(
|
||||
'{{hline 2 "const timeout = 5000;"}}\nquoted={{href 2}}\nraw={{hrefr 2}}\nlast={{hrefr}}',
|
||||
);
|
||||
const [line, quoted, raw, last] = result.split("\n");
|
||||
const ref = line.split(":", 1)[0];
|
||||
|
||||
expect(line).toBe(`${ref}:const timeout = 5000;`);
|
||||
expect(quoted).toBe(`quoted="${ref}"`);
|
||||
expect(raw).toBe(`raw=${ref}`);
|
||||
expect(last).toBe(`last=${ref}`);
|
||||
});
|
||||
|
||||
test("href and hrefr should still support explicit content without hline state", () => {
|
||||
const result = expandPrompt('quoted={{href 5 "\treturn clean;"}}\nraw={{hrefr 5 "\treturn clean;"}}');
|
||||
const [quoted, raw] = result.split("\n");
|
||||
const ref = raw.slice("raw=".length);
|
||||
|
||||
expect(quoted).toBe(`quoted="${ref}"`);
|
||||
expect(ref).toBe("5");
|
||||
});
|
||||
|
||||
test("href should not reuse hline state across prompt renders", () => {
|
||||
expect(expandPrompt('{{hline 1 "const x = 1;"}}\n{{hrefr}}')).toBe("1:const x = 1;\n1");
|
||||
expect(() => expandPrompt("{{hrefr}}")).toThrow("previous {{hline}}");
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// expandSlashCommand + expandPromptTemplate fallback behavior
|
||||
// ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user