pi-supernova 0.0.15 → 0.2.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/ledger.js CHANGED
@@ -1,13 +1,3 @@
1
- // Seen-ledger: the model's context window is a memory. Nothing that already reached the
2
- // model in this session is sent again verbatim. A run of identical lines (≥ MIN_RUN, with
3
- // enough substantive lines) collapses to one marker that cites the earlier program and,
4
- // when the lines came from a file, path:a–b, so one read(path, a, n) recovers them.
5
- //
6
- // This is not compression: every collapsed line already exists, verbatim, in the model's
7
- // context. Changed lines are never collapsed, so a re-read after an edit shows exactly the
8
- // delta. Lines the current program read with an explicit offset/limit are pinned and always
9
- // shown: that is the model asking for a specific window on purpose.
10
-
11
1
  const MIN_RUN = 6;
12
2
  const MIN_SUBSTANTIVE = 4;
13
3
  const MAX_CANDIDATES = 8;
@@ -15,136 +5,118 @@ const DEFAULT_WINDOW = 40;
15
5
  const MAX_STORED_LINES = 200_000;
16
6
 
17
7
  function hashLine(line) {
18
- let h = 0x811c9dc5;
19
- for (let i = 0; i < line.length; i++) {
20
- h ^= line.charCodeAt(i);
21
- h = Math.imul(h, 0x01000193);
22
- }
23
- return h >>> 0;
8
+ let hash = 0x811c9dc5;
9
+ for (let i = 0; i < line.length; i++) hash = Math.imul(hash ^ line.charCodeAt(i), 0x01000193);
10
+ return hash >>> 0;
24
11
  }
25
-
26
- function substantive(line) {
27
- return line.trim().length >= 8;
12
+ function substantive(line) { return line.trim().length >= 8; }
13
+ function newHistory() {
14
+ return { results: new Map(), occurrences: new Map(), storedLines: 0, latestCall: 0,
15
+ stats: { programs: 0, returnedChars: 0, collapsedChars: 0, collapsedRuns: 0 } };
28
16
  }
29
17
 
30
18
  export class SeenLedger {
31
- constructor({ window = DEFAULT_WINDOW } = {}) {
19
+ constructor({ window = DEFAULT_WINDOW, history } = {}) {
32
20
  this.window = window;
33
- this.results = new Map(); // call → { hashes: Uint32Array, lines: string[] }
34
- this.occurrences = new Map(); // hash → [{ call, index }]
35
- this.origins = new Map(); // hash → { path, line } (provenance recorded by the bridge)
36
- this.pinned = new Set(); // "path:line" pinned by the current program
37
- this.storedLines = 0;
38
- this.stats = { programs: 0, returnedChars: 0, collapsedChars: 0, collapsedRuns: 0 };
21
+ this.history = history ?? newHistory();
22
+ this.origins = new Map();
23
+ this.pinned = new Set();
39
24
  }
25
+ get results() { return this.history.results; }
26
+ get occurrences() { return this.history.occurrences; }
27
+ get storedLines() { return this.history.storedLines; }
28
+ get stats() { return this.history.stats; }
29
+ fork() { return new SeenLedger({ window: this.window, history: this.history }); }
40
30
 
41
31
  reset() {
42
- this.results.clear();
43
- this.occurrences.clear();
32
+ Object.assign(this.history, newHistory());
44
33
  this.origins.clear();
45
34
  this.pinned.clear();
46
- this.storedLines = 0;
47
- this.stats = { programs: 0, returnedChars: 0, collapsedChars: 0, collapsedRuns: 0 };
48
35
  }
49
36
 
50
37
  beginProgram(call) {
38
+ this.origins.clear();
51
39
  this.pinned.clear();
52
40
  this.stats.programs++;
53
- for (const old of [...this.results.keys()]) {
54
- if (old <= call - this.window) this.forget(old);
55
- }
41
+ this.history.latestCall = Math.max(this.history.latestCall, call);
42
+ for (const old of this.results.keys()) if (old <= this.history.latestCall - this.window) this.forget(old);
56
43
  }
57
44
 
58
45
  forget(call) {
59
46
  const entry = this.results.get(call);
60
47
  if (!entry) return;
61
- for (let i = 0; i < entry.hashes.length; i++) {
62
- const list = this.occurrences.get(entry.hashes[i]);
63
- if (!list) continue;
64
- const kept = list.filter((o) => o.call !== call);
65
- if (kept.length) this.occurrences.set(entry.hashes[i], kept);
66
- else this.occurrences.delete(entry.hashes[i]);
48
+ for (const hash of new Set(entry.hashes)) {
49
+ const kept = (this.occurrences.get(hash) ?? []).filter(item => item.call !== call);
50
+ if (kept.length) this.occurrences.set(hash, kept);
51
+ else this.occurrences.delete(hash);
67
52
  }
68
- this.storedLines -= entry.hashes.length;
53
+ this.history.storedLines -= entry.lines.length;
69
54
  this.results.delete(call);
70
55
  }
71
56
 
72
- /** Provenance for lines the bridge is about to hand to the program: file text, outlines, evidence spans. */
73
57
  recordOrigin(path, firstLine, lines, pin = false) {
58
+ if (this.window === 0) return;
74
59
  for (let i = 0; i < lines.length; i++) {
75
- if (!substantive(lines[i])) continue;
76
- this.origins.set(hashLine(lines[i]), { path, line: firstLine + i });
77
- if (pin) this.pinned.add(path + ":" + (firstLine + i));
60
+ const text = lines[i];
61
+ if (!substantive(text)) continue;
62
+ if (this.origins.size >= MAX_STORED_LINES && !this.origins.has(text)) this.origins.delete(this.origins.keys().next().value);
63
+ this.origins.set(text, { path, line: firstLine + i });
64
+ if (pin) this.pinned.add(text);
78
65
  }
79
66
  }
80
67
 
81
- isPinned(hash) {
82
- const o = this.origins.get(hash);
83
- return o !== undefined && this.pinned.has(o.path + ":" + o.line);
84
- }
85
-
86
- /** Length of the identical run between lines[i…] and earlier result `cand`, stopping at pinned lines. */
87
- runLength(hashes, cand, i) {
88
- const earlier = this.results.get(cand.call);
68
+ runLength(lines, hashes, candidate, index) {
69
+ const earlier = this.results.get(candidate.call);
89
70
  if (!earlier) return 0;
90
- let k = 0;
91
- while (i + k < hashes.length && cand.index + k < earlier.hashes.length && earlier.hashes[cand.index + k] === hashes[i + k] && !this.isPinned(hashes[i + k])) k++;
92
- return k;
71
+ let count = 0;
72
+ while (index + count < lines.length && candidate.index + count < earlier.lines.length
73
+ && hashes[index + count] === earlier.hashes[candidate.index + count]
74
+ && lines[index + count] === earlier.lines[candidate.index + count]
75
+ && !this.pinned.has(lines[index + count])) count++;
76
+ return count;
93
77
  }
94
78
 
95
- /** Longest earlier run starting at lines[i]; null when shorter than MIN_RUN or not substantive enough. */
96
- longestRun(hashes, lines, i) {
97
- const candidates = this.occurrences.get(hashes[i]);
79
+ longestRun(lines, hashes, index, call) {
80
+ const candidates = this.occurrences.get(hashes[index]);
98
81
  if (!candidates) return null;
99
82
  let best = null;
100
- for (const cand of candidates.slice(-MAX_CANDIDATES)) {
101
- const length = this.runLength(hashes, cand, i);
102
- if (length >= MIN_RUN && (!best || length > best.length)) best = { call: cand.call, index: cand.index, length };
83
+ for (const candidate of candidates.filter(item => item.call < call).slice(-MAX_CANDIDATES)) {
84
+ const length = this.runLength(lines, hashes, candidate, index);
85
+ if (length >= MIN_RUN && (!best || length > best.length)) best = { ...candidate, length };
103
86
  }
104
87
  if (!best) return null;
105
- const substantiveCount = lines.slice(i, i + best.length).filter(substantive).length;
106
- return substantiveCount >= MIN_SUBSTANTIVE ? best : null;
88
+ let count = 0;
89
+ for (let i = index; i < index + best.length; i++) if (substantive(lines[i])) count++;
90
+ return count >= MIN_SUBSTANTIVE ? best : null;
107
91
  }
108
92
 
109
- /** "path:a–b" when every line of the run has consecutive provenance in one file, else "". */
110
- citation(hashes, i, length) {
111
- const first = this.origins.get(hashes[i]);
93
+ citation(lines, index, run) {
94
+ const earlier = this.results.get(run.call);
95
+ const origin = offset => this.origins.get(lines[index + offset]) ?? earlier?.origins[run.index + offset];
96
+ const first = origin(0);
112
97
  if (!first) return "";
113
- let expectLine = first.line;
114
- for (let k = 0; k < length; k++) {
115
- const o = this.origins.get(hashes[i + k]);
116
- if (o) {
117
- if (o.path !== first.path || o.line < expectLine) return "";
118
- expectLine = o.line + 1;
119
- } else {
120
- expectLine++;
121
- }
98
+ let expected = first.line;
99
+ for (let i = 0; i < run.length; i++) {
100
+ const item = origin(i);
101
+ if (item && (item.path !== first.path || item.line !== expected)) return "";
102
+ expected++;
122
103
  }
123
- return first.path + ":" + first.line + "–" + (expectLine - 1);
104
+ return first.path + ":" + first.line + "–" + (expected - 1);
124
105
  }
125
106
 
126
- /**
127
- * Collapse runs already shown; returns the text to send and remembers exactly that text as call N.
128
- */
129
107
  dedupe(text, call) {
130
- const lines = text.split("\n");
131
- if (lines.length < MIN_RUN) {
108
+ if (this.window === 0) {
132
109
  this.stats.returnedChars += text.length;
133
- this.remember(call, lines);
134
110
  return text;
135
111
  }
136
- const hashes = new Uint32Array(lines.length);
137
- for (let i = 0; i < lines.length; i++) hashes[i] = hashLine(lines[i]);
112
+ const lines = text.split("\n");
113
+ const hashes = Uint32Array.from(lines, hashLine);
138
114
  const out = [];
139
115
  let collapsedChars = 0;
140
- for (let i = 0; i < lines.length; ) {
141
- const run = substantive(lines[i]) ? this.longestRun(hashes, lines, i) : null;
142
- if (!run) {
143
- out.push(lines[i]);
144
- i++;
145
- continue;
146
- }
147
- const cite = this.citation(hashes, i, run.length);
116
+ for (let i = 0; i < lines.length;) {
117
+ const run = substantive(lines[i]) ? this.longestRun(lines, hashes, i, call) : null;
118
+ if (!run) { out.push(lines[i++]); continue; }
119
+ const cite = this.citation(lines, i, run);
148
120
  out.push("⋯ " + run.length + " lines same as #" + run.call + (cite ? " · " + cite : "") + " ⋯");
149
121
  for (let k = 0; k < run.length; k++) collapsedChars += lines[i + k].length + 1;
150
122
  this.stats.collapsedRuns++;
@@ -158,21 +130,21 @@ export class SeenLedger {
158
130
  }
159
131
 
160
132
  remember(call, lines) {
161
- if (this.storedLines + lines.length > MAX_STORED_LINES) {
162
- for (const old of [...this.results.keys()].sort((a, b) => a - b)) {
163
- this.forget(old);
164
- if (this.storedLines + lines.length <= MAX_STORED_LINES) break;
165
- }
133
+ if (this.window === 0 || call <= this.history.latestCall - this.window || lines.length > MAX_STORED_LINES) return;
134
+ if (this.results.has(call)) this.forget(call);
135
+ for (const old of [...this.results.keys()].sort((a, b) => a - b)) {
136
+ if (this.storedLines + lines.length <= MAX_STORED_LINES) break;
137
+ this.forget(old);
166
138
  }
167
- const hashes = new Uint32Array(lines.length);
139
+ const hashes = Uint32Array.from(lines, hashLine);
140
+ const origins = lines.map(line => this.origins.get(line));
168
141
  for (let i = 0; i < lines.length; i++) {
169
- hashes[i] = hashLine(lines[i]);
170
142
  if (!substantive(lines[i])) continue;
171
143
  let list = this.occurrences.get(hashes[i]);
172
144
  if (!list) this.occurrences.set(hashes[i], (list = []));
173
145
  list.push({ call, index: i });
174
146
  }
175
- this.results.set(call, { hashes });
176
- this.storedLines += lines.length;
147
+ this.results.set(call, { hashes, lines, origins });
148
+ this.history.storedLines += lines.length;
177
149
  }
178
150
  }
package/omp-frame.js CHANGED
@@ -127,7 +127,6 @@ function renderPortableFrame(theme, { header, sections = [], state = "pending",
127
127
  const border = borderPaint(theme, state, borderColor);
128
128
  const bgFn = bgFnForState(theme, state);
129
129
  const h = box.horizontal;
130
- const v = box.vertical;
131
130
  const cap = h.repeat(3);
132
131
 
133
132
  const paintBar = (leftChar, rightChar, label) => {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-supernova",
3
- "version": "0.0.15",
4
- "description": "Dual-host CodeMode for Pi/OMP: progressive tool discovery, result bottleneck, and Amdahl Auto parallel.",
3
+ "version": "0.2.0",
4
+ "description": "CodeMode for Pi and OMP: read, write, edit, and bash with source selection and bounded results.",
5
5
  "type": "module",
6
6
  "author": "AdityaVG13",
7
7
  "license": "MIT",
@@ -90,5 +90,9 @@
90
90
  "bugs": {
91
91
  "url": "https://github.com/AdityaVG13/pi-stack/issues"
92
92
  },
93
- "homepage": "https://github.com/AdityaVG13/pi-stack/tree/main/packages/pi-supernova#readme"
93
+ "homepage": "https://github.com/AdityaVG13/pi-stack/tree/main/packages/pi-supernova#readme",
94
+ "dependencies": {
95
+ "acorn": "^8.18.0",
96
+ "string-width": "^8.2.2"
97
+ }
94
98
  }
package/parallel.js CHANGED
@@ -1,47 +1,55 @@
1
+ import { isFunction } from "./decode.js";
1
2
 
2
- import { isString } from "./decode.js";
3
- export function isMutatingTool(name, config) {
4
- const exact = new Set(config.mutatingTools || []);
5
- if (exact.has(name)) return true;
6
- const prefixes = config.mutatingPrefixes || [];
7
- for (const prefix of prefixes) {
8
- if (isString(prefix) && prefix.length > 0 && name.startsWith(prefix)) return true;
3
+ const READ_ONLY_TOOLS = new Set(["read", "grep", "glob", "find", "ls", "snap", "evidence", "surface", "asgrep_search", "asgrep_status", "ast_grep", "web_search"]);
4
+ const READ_ONLY_LSP = new Set(["definition", "references", "hover", "symbols", "diagnostics", "implementation", "type_definition", "incoming_calls", "outgoing_calls"]);
5
+
6
+ export function isMutatingTool(name, config = {}, args = {}, definition) {
7
+ if ((config.mutatingTools ?? []).includes(name)) return true;
8
+ if ((config.mutatingPrefixes ?? []).some(prefix => prefix && name.startsWith(prefix))) return true;
9
+ if (definition?.annotations?.readOnlyHint === true) return false;
10
+ if (name === "lsp") {
11
+ const action = args.action ?? args.operation;
12
+ if (READ_ONLY_LSP.has(action)) return false;
13
+ if (["rename", "rename_file"].includes(action)) return args.apply !== false;
14
+ if (action === "code_actions") return args.apply === true;
15
+ return true;
9
16
  }
10
- return false;
11
- }
12
- async function runSerial(list) {
13
- const out = [];
14
- for (const thunk of list) out.push(await thunk());
15
- return out;
17
+ if (name === "todo") return args.op !== "view";
18
+ if (name === "hub") return !["list", "ps", "logs", "describe"].includes(args.op);
19
+ return !READ_ONLY_TOOLS.has(name);
16
20
  }
17
- function shouldParallelize(mode, anyMutating, count) {
18
- if (mode === "parallel") return true;
19
- if (mode !== "auto") return false;
20
- if (anyMutating) return false;
21
- return count > 1;
21
+
22
+ function requireArray(value, name) {
23
+ if (!Array.isArray(value)) throw new TypeError(name + " requires an array");
24
+ return value;
22
25
  }
26
+
23
27
  export async function runParallelWave(thunks, meta, options = {}) {
24
- const list = Array.isArray(thunks) ? thunks : [];
25
- if (list.length === 0) return { results: [], mode: "serial", reason: "empty" };
28
+ const list = requireArray(thunks, "parallel wave");
29
+ if (list.some(item => !isFunction(item))) throw new TypeError("parallel wave requires functions");
30
+ if (!list.length) return { results: [], mode: "serial", reason: "empty" };
26
31
  const { mode = "auto", config = {} } = options;
27
- const names = Array.isArray(meta?.names) ? meta.names : [];
28
- const anyMutating = names.some((n) => isString(n) && isMutatingTool(n, config));
29
- if (shouldParallelize(mode, anyMutating, list.length)) {
30
- const results = await Promise.all(list.map((thunk) => thunk()));
31
- return { results, mode: "parallel", reason: "independent-reads" };
32
+ const names = meta?.names ?? [];
33
+ const mutating = names.length !== list.length || names.some((name, i) => isMutatingTool(name, config, meta?.calls?.[i]?.args, meta?.definitions?.[i]));
34
+ if (!mutating && (mode === "parallel" || (mode === "auto" && list.length > 1))) {
35
+ // Do not finish a wave while its already-started host calls are still running.
36
+ const settled = await Promise.allSettled(list.map(thunk => Promise.resolve().then(thunk)));
37
+ const failure = settled.find(item => item.status === "rejected");
38
+ if (failure) throw failure.reason;
39
+ return { results: settled.map(item => item.value), mode: "parallel", reason: "independent-reads" };
32
40
  }
33
- const results = await runSerial(list);
34
- if (anyMutating) return { results, mode: "serial", reason: "mutating" };
35
- return { results, mode: "serial", reason: "single-or-forced" };
41
+ const results = [];
42
+ for (const thunk of list) results.push(await thunk());
43
+ return { results, mode: "serial", reason: mutating ? "mutating" : "single-or-forced" };
36
44
  }
45
+
37
46
  export async function parallel(items) {
38
- const list = Array.isArray(items) ? items : [];
39
- return Promise.all(list.map((item) => (item instanceof Function ? item() : item)));
47
+ return Promise.all(requireArray(items, "parallel").map(item => isFunction(item) ? item() : item));
40
48
  }
49
+
41
50
  export async function pipeline(items, ...stages) {
42
- let current = Array.isArray(items) ? items.slice() : [];
43
- for (const stage of stages) {
44
- current = await Promise.all(current.map((item) => stage(item)));
45
- }
51
+ let current = requireArray(items, "pipeline");
52
+ if (stages.some(stage => !isFunction(stage))) throw new TypeError("pipeline stages must be functions");
53
+ for (const stage of stages) current = await Promise.all(current.map(item => stage(item)));
46
54
  return current;
47
55
  }
package/patch.js CHANGED
@@ -3,104 +3,94 @@ import { isString } from "./decode.js";
3
3
  function parseHunkHeader(line) {
4
4
  const match = /^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@/.exec(line);
5
5
  if (!match) return null;
6
- return {
7
- oldStart: parseInt(match[1], 10),
8
- oldLength: match[2] !== undefined ? parseInt(match[2], 10) : 1,
9
- newStart: parseInt(match[3], 10),
10
- newLength: match[4] !== undefined ? parseInt(match[4], 10) : 1,
11
- lines: [],
12
- };
13
- }
14
-
15
- function isHunkLine(line) {
16
- return line.startsWith("+") || line.startsWith("-") || line.startsWith(" ");
6
+ return { oldStart: Number(match[1]), oldLength: match[2] === undefined ? 1 : Number(match[2]),
7
+ newStart: Number(match[3]), newLength: match[4] === undefined ? 1 : Number(match[4]), lines: [], noNewline: [] };
17
8
  }
18
9
 
19
10
  export function parsePatchHunks(patchText) {
20
- const patchLines = patchText.replace(/\r\n/g, "\n").split("\n");
21
11
  const hunks = [];
22
- let current = null;
23
-
24
- for (const line of patchLines) {
12
+ let current;
13
+ let oldCount = 0;
14
+ let newCount = 0;
15
+ for (const line of patchText.split("\n")) {
25
16
  const header = parseHunkHeader(line);
26
17
  if (header) {
27
- if (current) hunks.push(current);
28
18
  current = header;
29
- } else if (current && isHunkLine(line)) {
19
+ hunks.push(current);
20
+ oldCount = 0;
21
+ newCount = 0;
22
+ } else if (current && line.startsWith("\")) {
23
+ if (!current.lines.length) throw new Error("newline marker requires a preceding hunk line");
24
+ current.noNewline.push(current.lines.length - 1);
25
+ } else if (current && /^[+ -]/.test(line)) {
26
+ if (oldCount === current.oldLength && newCount === current.newLength && /^--- |^\+\+\+ /.test(line)) {
27
+ throw new Error("apply_patch accepts one file at a time");
28
+ }
30
29
  current.lines.push(line);
30
+ if (line[0] !== "+") oldCount++;
31
+ if (line[0] !== "-") newCount++;
31
32
  }
32
33
  }
33
- if (current) hunks.push(current);
34
- if (hunks.length === 0) {
35
- throw new Error("no valid patch hunks found (expected @@ -old,len +new,len @@)");
36
- }
34
+ if (!hunks.length) throw new Error("no valid patch hunks found (expected @@ -old,len +new,len @@)");
37
35
  return hunks;
38
36
  }
39
37
 
40
- function findHunkMatch(fileLines, expectedOld, nominal) {
41
- const matchAt = (idx) => {
42
- if (idx < 0 || idx + expectedOld.length > fileLines.length) return false;
43
- for (let j = 0; j < expectedOld.length; j++) {
44
- if (fileLines[idx + j] !== expectedOld[j]) return false;
45
- }
46
- return true;
47
- };
38
+ function splitFile(text) {
39
+ if (!text) return [];
40
+ const chunks = text.split("\n");
41
+ const trailing = chunks.at(-1) === "";
42
+ if (trailing) chunks.pop();
43
+ return chunks.map((chunk, index) => {
44
+ const newline = index < chunks.length - 1 || trailing;
45
+ const crlf = newline && chunk.endsWith("\r");
46
+ return { text: crlf ? chunk.slice(0, -1) : chunk, ending: newline ? (crlf ? "\r\n" : "\n") : "" };
47
+ });
48
+ }
48
49
 
50
+ function findHunkMatch(fileLines, expectedOld, nominal) {
51
+ const matchAt = index => index >= 0 && index + expectedOld.length <= fileLines.length
52
+ && expectedOld.every((line, i) => fileLines[index + i].text === line);
49
53
  if (matchAt(nominal)) return nominal;
50
- const maxDelta = Math.max(fileLines.length, 100);
51
- for (let delta = 1; delta <= maxDelta; delta++) {
54
+ if (!expectedOld.length) return -1;
55
+ for (let delta = 1; delta <= Math.max(fileLines.length, 100); delta++) {
52
56
  if (matchAt(nominal + delta)) return nominal + delta;
53
57
  if (matchAt(nominal - delta)) return nominal - delta;
54
58
  }
55
59
  return -1;
56
60
  }
57
61
 
58
- function splitHunkLines(hunk) {
59
- const expectedOld = [];
60
- const newLines = [];
61
- for (const hLine of hunk.lines) {
62
- if (hLine.startsWith("-")) {
63
- expectedOld.push(hLine.slice(1));
64
- } else if (hLine.startsWith("+")) {
65
- newLines.push(hLine.slice(1));
66
- } else {
67
- const val = hLine.startsWith(" ") ? hLine.slice(1) : "";
68
- expectedOld.push(val);
69
- newLines.push(val);
70
- }
71
- }
72
- return { expectedOld, newLines };
73
- }
74
-
75
62
  export function applyPatchToText(originalText, patchText) {
76
- if (!isString(patchText) || !patchText.trim()) {
77
- throw new Error("apply_patch requires non-empty patch");
78
- }
79
-
63
+ if (!isString(patchText) || !patchText.trim()) throw new Error("apply_patch requires non-empty patch");
80
64
  const hunks = parsePatchHunks(patchText);
81
- let fileLines = originalText.replace(/\r\n/g, "\n").split("\n");
82
- const hasTrailingNewline = originalText.endsWith("\n");
65
+ const fileLines = splitFile(originalText);
66
+ const ending = fileLines.find(line => line.ending)?.ending ?? "\n";
83
67
  let offsetShift = 0;
84
-
68
+ let relocationShift = 0;
85
69
  for (let h = 0; h < hunks.length; h++) {
86
70
  const hunk = hunks[h];
87
- const { expectedOld, newLines } = splitHunkLines(hunk);
88
-
89
- if (expectedOld.length !== hunk.oldLength || newLines.length !== hunk.newLength) {
90
- throw new Error(`patch hunk ${h + 1} length does not match its header`);
91
- }
92
-
93
- const nominal = Math.max(0, hunk.oldStart - 1 + offsetShift);
94
- const matchIdx = findHunkMatch(fileLines, expectedOld, nominal);
95
- if (matchIdx === -1) {
96
- throw new Error(`patch hunk ${h + 1} rejected at line ${hunk.oldStart}: context did not match`);
71
+ const textOf = line => line.slice(1).replace(/\r$/, "");
72
+ const expectedOld = hunk.lines.filter(line => line[0] !== "+").map(textOf);
73
+ const newCount = hunk.lines.filter(line => line[0] !== "-").length;
74
+ if (expectedOld.length !== hunk.oldLength || newCount !== hunk.newLength) throw new Error("patch hunk " + (h + 1) + " length does not match its header");
75
+ // The new coordinate also handles BSD diff's -1,0 header at file start.
76
+ const nominal = hunk.oldLength === 0 ? hunk.newStart - 1 + relocationShift : hunk.oldStart - 1 + offsetShift;
77
+ const matchIndex = findHunkMatch(fileLines, expectedOld, nominal);
78
+ if (matchIndex < 0) throw new Error("patch hunk " + (h + 1) + " rejected at line " + hunk.oldStart + ": context did not match");
79
+ const replacement = [];
80
+ let oldIndex = matchIndex;
81
+ for (let i = 0; i < hunk.lines.length; i++) {
82
+ const line = hunk.lines[i];
83
+ if (line[0] === "+") {
84
+ replacement.push({ text: textOf(line), ending: hunk.noNewline.includes(i) ? "" : line.endsWith("\r") ? "\r\n" : ending });
85
+ } else {
86
+ const original = fileLines[oldIndex++];
87
+ if (hunk.noNewline.includes(i) && original.ending) throw new Error("patch newline marker does not match the file");
88
+ if (line[0] === " ") replacement.push(original);
89
+ }
97
90
  }
98
-
99
- fileLines.splice(matchIdx, expectedOld.length, ...newLines);
100
- offsetShift += (matchIdx - nominal) + (newLines.length - expectedOld.length);
91
+ fileLines.splice(matchIndex, expectedOld.length, ...replacement);
92
+ relocationShift += matchIndex - nominal;
93
+ offsetShift += matchIndex - nominal + replacement.length - expectedOld.length;
101
94
  }
102
-
103
- let resultText = fileLines.join("\n");
104
- if (hasTrailingNewline && !resultText.endsWith("\n")) resultText += "\n";
105
- return { resultText, hunkCount: hunks.length };
95
+ return { resultText: fileLines.map(line => line.text + line.ending).join(""), hunkCount: hunks.length };
106
96
  }