backpass 0.1.11 → 0.1.13
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 +51 -12
- package/package.json +1 -1
- package/src/analyze.js +32 -11
- package/src/cli.js +3 -3
- package/src/commands/bootstrap.js +4 -2
- package/src/commands/propose.js +21 -1
- package/src/config.js +4 -3
- package/src/consolidate.js +110 -0
- package/src/diff.js +2 -2
- package/src/discovery/index.js +13 -4
- package/src/fold.js +50 -9
- package/src/gap-ledger.js +137 -20
- package/src/prompts/analysis.md +28 -7
- package/src/prompts/annotate.md +14 -8
- package/src/prompts/consolidate.md +29 -0
- package/src/prompts/synthesis.md +15 -6
- package/src/proposal.js +64 -0
- package/src/sample.js +39 -17
- package/src/state.js +57 -12
- package/src/synthesize.js +6 -4
- package/src/transcript.js +15 -0
- package/src/workspace.js +129 -1
package/src/sample.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
|
|
1
3
|
import { parseMaxTranscripts, parseSince } from "./config.js";
|
|
2
4
|
import { color, info } from "./logger.js";
|
|
5
|
+
import { transcriptIdentity } from "./transcript.js";
|
|
3
6
|
|
|
4
7
|
/**
|
|
5
8
|
* Recency-weighted capping of the discovered transcript set.
|
|
@@ -14,7 +17,22 @@ import { color, info } from "./logger.js";
|
|
|
14
17
|
* Sampling uses the Efraimidis-Spirakis one-pass scheme: key = -ln(u) / weight with
|
|
15
18
|
* u ~ U(0, 1), keep the N smallest keys. That is exactly weighted sampling without
|
|
16
19
|
* replacement (an exponential race with rate = weight), needs no rejection loop, and is
|
|
17
|
-
* O(n log n).
|
|
20
|
+
* O(n log n).
|
|
21
|
+
*
|
|
22
|
+
* `u` is NOT drawn from a shared PRNG stream stepped once per transcript - that would
|
|
23
|
+
* make every transcript's draw depend on how many other transcripts came before it in
|
|
24
|
+
* the array, so an unrelated insertion or a reordering would reshuffle everyone's draw
|
|
25
|
+
* (and, with `config.seed` defaulting to null, the CLI reseeded from `Math.random()` on
|
|
26
|
+
* every invocation, so even an unchanged rerun drew a different sample - see the
|
|
27
|
+
* `sample-reuse` regression tests). Instead each transcript's `u` is `sampleUnit`, a hash
|
|
28
|
+
* of that transcript's canonical discovery identity, never its array position, plus the
|
|
29
|
+
* configured seed. That makes sampling: (1)
|
|
30
|
+
* deterministic and sticky by default, with no persisted state - the same corpus and
|
|
31
|
+
* config always draw the same `u` per transcript; (2) stable under growth - a transcript
|
|
32
|
+
* already in the corpus keeps the exact same `u` (and so the same key, modulo its own
|
|
33
|
+
* weight) when other transcripts are added, removed, or reordered; new transcripts just
|
|
34
|
+
* compete for slots on the same footing. `--seed` still selects a different, equally
|
|
35
|
+
* reproducible hash input.
|
|
18
36
|
*
|
|
19
37
|
* This module is pure: it never touches the cache, so evidence for a sampled transcript
|
|
20
38
|
* is reused by the analyzer exactly as before.
|
|
@@ -22,18 +40,19 @@ import { color, info } from "./logger.js";
|
|
|
22
40
|
|
|
23
41
|
export const DEFAULT_SAMPLE_HALF_LIFE = "14d";
|
|
24
42
|
|
|
25
|
-
/**
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
43
|
+
/**
|
|
44
|
+
* Deterministic draw in [0, 1) for one transcript: a SHA-256 of its durable identity
|
|
45
|
+
* and `seed` (or a fixed default when unseeded), so it depends only on that transcript and
|
|
46
|
+
* the configured seed - never on discovery order, the cap, or which other
|
|
47
|
+
* transcripts are present. Discovery removes duplicate canonical identities, while
|
|
48
|
+
* distinct identities that happen to draw equal keys are ordered by identity.
|
|
49
|
+
*/
|
|
50
|
+
export function sampleUnit(transcript, seed) {
|
|
51
|
+
const digest = crypto
|
|
52
|
+
.createHash("sha256")
|
|
53
|
+
.update(`${seed ?? "default"} ${transcriptIdentity(transcript)}`, "utf8")
|
|
54
|
+
.digest();
|
|
55
|
+
return digest.readUInt32BE(0) / 0x100000000;
|
|
37
56
|
}
|
|
38
57
|
|
|
39
58
|
/** Epoch ms a transcript is dated to: the session start, else the file mtime. */
|
|
@@ -67,13 +86,16 @@ export function sampleTranscripts(
|
|
|
67
86
|
{ seed, now = Date.now(), halfLife = DEFAULT_SAMPLE_HALF_LIFE } = {},
|
|
68
87
|
) {
|
|
69
88
|
if (count === null || transcripts.length <= count) return transcripts;
|
|
70
|
-
const random = seededRandom(seed ?? Math.floor(Math.random() * 0xffffffff));
|
|
71
89
|
const halfLifeMs = parseSince(halfLife) ?? Infinity;
|
|
72
90
|
const keyed = transcripts.map((transcript, index) => {
|
|
73
|
-
const u =
|
|
74
|
-
return {
|
|
91
|
+
const u = sampleUnit(transcript, seed) || Number.EPSILON;
|
|
92
|
+
return {
|
|
93
|
+
index,
|
|
94
|
+
identity: transcriptIdentity(transcript),
|
|
95
|
+
key: -Math.log(u) / recencyWeight(transcript, { now, halfLifeMs }),
|
|
96
|
+
};
|
|
75
97
|
});
|
|
76
|
-
keyed.sort((a, b) => a.key - b.key);
|
|
98
|
+
keyed.sort((a, b) => a.key - b.key || (a.identity < b.identity ? -1 : a.identity > b.identity ? 1 : 0));
|
|
77
99
|
const kept = new Set(keyed.slice(0, count).map((k) => k.index));
|
|
78
100
|
return transcripts.filter((_, index) => kept.has(index));
|
|
79
101
|
}
|
package/src/state.js
CHANGED
|
@@ -5,6 +5,7 @@ import crypto from "node:crypto";
|
|
|
5
5
|
import { STATE_DIRNAME } from "./config.js";
|
|
6
6
|
import { warn } from "./logger.js";
|
|
7
7
|
import { ensureLocalExclude } from "./repo.js";
|
|
8
|
+
import { transcriptIdentity } from "./transcript.js";
|
|
8
9
|
|
|
9
10
|
/** The line every command writes to the repo's local git exclude for the state dir. */
|
|
10
11
|
export const STATE_EXCLUDE_LINE = `${STATE_DIRNAME}/`;
|
|
@@ -16,7 +17,7 @@ export const STATE_EXCLUDE_LINE = `${STATE_DIRNAME}/`;
|
|
|
16
17
|
* the tracked `.gitignore`:
|
|
17
18
|
*
|
|
18
19
|
* scan-cache.json path+mtime+size -> association verdict (design section 2.2)
|
|
19
|
-
* evidence/<
|
|
20
|
+
* evidence/<identity>.json per-transcript tier-1 analysis output (design section 3)
|
|
20
21
|
* evidence-summary.json folded evidence (stage 2)
|
|
21
22
|
* proposal.json latest parseable tier-2 synthesis; absent if none was produced (stage 3)
|
|
22
23
|
* rejections.json edits the human rejected, and the evidence weight behind them
|
|
@@ -76,25 +77,58 @@ export class State {
|
|
|
76
77
|
this.writeJsonFile(this.scanCachePath, cache);
|
|
77
78
|
}
|
|
78
79
|
|
|
79
|
-
evidencePath(
|
|
80
|
-
|
|
80
|
+
evidencePath(transcript) {
|
|
81
|
+
const identity = transcript && typeof transcript === "object" ? transcriptIdentity(transcript) : transcript;
|
|
82
|
+
return path.join(this.evidenceDir, `${safeFileName(identity)}.json`);
|
|
81
83
|
}
|
|
82
84
|
|
|
83
|
-
readEvidence(
|
|
84
|
-
|
|
85
|
+
readEvidence(transcript) {
|
|
86
|
+
const currentPath = this.evidencePath(transcript);
|
|
87
|
+
const current = this.readJsonFile(currentPath, null);
|
|
88
|
+
if (!transcript || typeof transcript !== "object") return current;
|
|
89
|
+
|
|
90
|
+
const identity = transcriptIdentity(transcript);
|
|
91
|
+
const legacyPath = this.evidencePath(transcript.id);
|
|
92
|
+
if (legacyPath === currentPath) return current;
|
|
93
|
+
const legacy = this.readJsonFile(legacyPath, null);
|
|
94
|
+
const legacyMatches = legacy?.transcript && transcriptIdentity(legacy.transcript) === identity;
|
|
95
|
+
if (current) {
|
|
96
|
+
const currentMatches = current.transcript && transcriptIdentity(current.transcript) === identity;
|
|
97
|
+
const migrated = currentMatches ? migrateEvidenceRecord(current, transcript, identity) : current;
|
|
98
|
+
if (migrated !== current) this.writeJsonFile(currentPath, migrated);
|
|
99
|
+
if (legacyMatches) fs.rmSync(legacyPath, { force: true });
|
|
100
|
+
return migrated;
|
|
101
|
+
}
|
|
102
|
+
if (!legacyMatches) return null;
|
|
103
|
+
|
|
104
|
+
const migrated = migrateEvidenceRecord(legacy, transcript, identity);
|
|
105
|
+
// Publish the upgraded record atomically before removing the legacy path. If the
|
|
106
|
+
// process stops between these operations, the next read prefers the valid canonical
|
|
107
|
+
// record and merely cleans up the duplicate.
|
|
108
|
+
this.writeJsonFile(currentPath, migrated);
|
|
109
|
+
fs.rmSync(legacyPath, { force: true });
|
|
110
|
+
return migrated;
|
|
85
111
|
}
|
|
86
112
|
|
|
87
|
-
writeEvidence(
|
|
88
|
-
this.writeJsonFile(this.evidencePath(
|
|
113
|
+
writeEvidence(transcript, evidence) {
|
|
114
|
+
this.writeJsonFile(this.evidencePath(transcript), evidence);
|
|
89
115
|
}
|
|
90
116
|
|
|
91
117
|
listEvidence() {
|
|
92
118
|
if (!fs.existsSync(this.evidenceDir)) return [];
|
|
93
|
-
|
|
119
|
+
const records = fs
|
|
94
120
|
.readdirSync(this.evidenceDir)
|
|
95
|
-
.filter((
|
|
96
|
-
.map((
|
|
97
|
-
.filter(Boolean);
|
|
121
|
+
.filter((file) => file.endsWith(".json"))
|
|
122
|
+
.map((file) => ({ file, record: this.readJsonFile(path.join(this.evidenceDir, file), null) }))
|
|
123
|
+
.filter(({ record }) => Boolean(record));
|
|
124
|
+
const unique = new Map();
|
|
125
|
+
for (const item of records) {
|
|
126
|
+
const identity = item.record.transcript ? transcriptIdentity(item.record.transcript) : `file:${item.file}`;
|
|
127
|
+
const canonical = item.record.transcript ? path.basename(this.evidencePath(item.record.transcript)) : item.file;
|
|
128
|
+
const existing = unique.get(identity);
|
|
129
|
+
if (!existing || item.file === canonical) unique.set(identity, item);
|
|
130
|
+
}
|
|
131
|
+
return [...unique.values()].map(({ record }) => record);
|
|
98
132
|
}
|
|
99
133
|
|
|
100
134
|
readSummary() {
|
|
@@ -158,12 +192,23 @@ export function sha256(text) {
|
|
|
158
192
|
return crypto.createHash("sha256").update(text, "utf8").digest("hex");
|
|
159
193
|
}
|
|
160
194
|
|
|
195
|
+
function migrateEvidenceRecord(record, transcript, identity) {
|
|
196
|
+
const legacyKey = `${transcript.mtimeMs}:${transcript.bytes}:${record.memoryHash}`;
|
|
197
|
+
const key = record.key === legacyKey ? evidenceKey(transcript, record.memoryHash) : record.key;
|
|
198
|
+
if (record.transcript?.identity === identity && record.key === key) return record;
|
|
199
|
+
return {
|
|
200
|
+
...record,
|
|
201
|
+
transcript: { ...record.transcript, identity },
|
|
202
|
+
key,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
161
206
|
/**
|
|
162
207
|
* Cache key for a transcript's analysis: the transcript's own content signature plus
|
|
163
208
|
* the memory-file hash it was judged against. Either changing invalidates the evidence.
|
|
164
209
|
*/
|
|
165
210
|
export function evidenceKey(transcript, memoryHash) {
|
|
166
|
-
return `${transcript.mtimeMs}:${transcript.bytes}:${memoryHash}`;
|
|
211
|
+
return `${transcriptIdentity(transcript)}:${transcript.mtimeMs}:${transcript.bytes}:${memoryHash}`;
|
|
167
212
|
}
|
|
168
213
|
|
|
169
214
|
/**
|
package/src/synthesize.js
CHANGED
|
@@ -70,10 +70,12 @@ function budgetRule(memoryFile, config, maxEdits) {
|
|
|
70
70
|
`This file is ALREADY ${Math.abs(remaining)} tokens OVER budget, so this run is a SHRINK ` +
|
|
71
71
|
`PLAN. You are NOT expected to reach ${config.budgetTokens} tokens in one run - the ` +
|
|
72
72
|
`${maxEdits}-edit cap for this run makes that impossible and later runs continue the work. ` +
|
|
73
|
-
`What is required is real progress: the edit set MUST be net-negative, so lead with
|
|
74
|
-
`
|
|
75
|
-
`
|
|
76
|
-
`
|
|
73
|
+
`What is required is real progress: the edit set MUST be net-negative, so lead with skill ` +
|
|
74
|
+
`extractions of long, narrow, crisply-triggered sections - extraction frees the same ` +
|
|
75
|
+
`always-loaded tokens and loses nothing, and it never needs removal evidence. Deleting an ` +
|
|
76
|
+
`instruction outright still needs its harm-evidence floor; the budget never lowers that ` +
|
|
77
|
+
`bar. Any addition must name the removal or extraction that pays for it. Make the largest ` +
|
|
78
|
+
`honest reduction you can justify from the evidence.`
|
|
77
79
|
);
|
|
78
80
|
}
|
|
79
81
|
if (remaining < config.budgetTokens * 0.15) {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export function transcriptIdentity(transcript) {
|
|
4
|
+
if (typeof transcript?.identity === "string" && transcript.identity) return transcript.identity;
|
|
5
|
+
const harness = String(transcript?.harness ?? "");
|
|
6
|
+
let nativeId = String(transcript?.nativeId ?? transcript?.id ?? "");
|
|
7
|
+
if (transcript?.nativeId == null && harness && nativeId.startsWith(`${harness}-`)) {
|
|
8
|
+
nativeId = nativeId.slice(harness.length + 1);
|
|
9
|
+
}
|
|
10
|
+
const source = String(transcript?.path ?? "");
|
|
11
|
+
return crypto
|
|
12
|
+
.createHash("sha256")
|
|
13
|
+
.update(JSON.stringify([harness, nativeId, source]), "utf8")
|
|
14
|
+
.digest("hex");
|
|
15
|
+
}
|
package/src/workspace.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
|
|
4
|
-
import { anchoredHunks } from "./diff.js";
|
|
4
|
+
import { anchoredHunks, countOccurrences, span } from "./diff.js";
|
|
5
|
+
import { parseMemoryUnits } from "./memory.js";
|
|
5
6
|
import { parseFrontmatter } from "./skills.js";
|
|
6
7
|
import { sha256 } from "./state.js";
|
|
7
8
|
|
|
@@ -94,6 +95,112 @@ export function parseSkillFile(relative, text) {
|
|
|
94
95
|
};
|
|
95
96
|
}
|
|
96
97
|
|
|
98
|
+
/**
|
|
99
|
+
* One line's identity for extraction-recovery checks: verbatim modulo the noise a
|
|
100
|
+
* faithful move is allowed to make. Unicode dashes fold to "-" (house style normalizes
|
|
101
|
+
* them during moves) and interior whitespace collapses; anything more is a real change.
|
|
102
|
+
*/
|
|
103
|
+
export function normalizeRecoveryLine(line) {
|
|
104
|
+
return String(line ?? "")
|
|
105
|
+
.replace(/[‐-―−]/g, "-")
|
|
106
|
+
.replace(/\s+/g, " ")
|
|
107
|
+
.trim();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The normalized non-blank lines of a set of file texts, with occurrence counts. */
|
|
111
|
+
export function recoveredLineCounts(texts) {
|
|
112
|
+
const counts = new Map();
|
|
113
|
+
for (const text of texts) {
|
|
114
|
+
for (const line of String(text ?? "").split("\n")) {
|
|
115
|
+
const normalized = normalizeRecoveryLine(line);
|
|
116
|
+
if (normalized) counts.set(normalized, (counts.get(normalized) || 0) + 1);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return counts;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Split one pure-removal memory-file hunk at the boundary between text that lands in a
|
|
124
|
+
* created skill and text that vanishes. Adjacent removals merge into one measured change
|
|
125
|
+
* (`anchoredHunks`), so without this split an extraction and an unrelated deletion that
|
|
126
|
+
* happen to sit next to each other in the file fuse into a single accept/reject decision.
|
|
127
|
+
* The boundary is a decision boundary, and it falls on instruction-unit edges because a
|
|
128
|
+
* skill carries whole sections.
|
|
129
|
+
*
|
|
130
|
+
* Returns the sub-hunks, or null when there is nothing to split (one kind only) or the
|
|
131
|
+
* sub-hunks cannot be given unique, non-overlapping spans, in which case the merged hunk
|
|
132
|
+
* is kept instead.
|
|
133
|
+
*/
|
|
134
|
+
export function splitRemovalHunk(hunk, { oldText, oldLines, recovered }) {
|
|
135
|
+
// A removal that reaches the file's true tail cannot be split into independent
|
|
136
|
+
// sub-hunks: `span`'s tail rule gives the final sub-hunk a LEADING newline, and that
|
|
137
|
+
// is the same character its predecessor owns as its trailing newline. Applying one
|
|
138
|
+
// decision then consumes the separator the other one's `find` needs, so the pair
|
|
139
|
+
// composes in only one order - which breaks the any-subset-any-order contract the
|
|
140
|
+
// writer relies on. No non-overlapping anchoring exists at that seam; keep the
|
|
141
|
+
// merged hunk instead (a file ending in "\n" is unaffected: its final split("\n")
|
|
142
|
+
// element is the empty string, which no text removal reaches).
|
|
143
|
+
if (hunk.oldEnd === oldLines.length) return null;
|
|
144
|
+
|
|
145
|
+
const lineKinds = new Map();
|
|
146
|
+
for (let lineNo = hunk.oldStart; lineNo <= hunk.oldEnd; lineNo += 1) {
|
|
147
|
+
const normalized = normalizeRecoveryLine(oldLines[lineNo - 1]);
|
|
148
|
+
if (!normalized) continue;
|
|
149
|
+
const remaining = recovered.get(normalized) || 0;
|
|
150
|
+
lineKinds.set(lineNo, remaining > 0 ? "recovered" : "deleted");
|
|
151
|
+
if (remaining > 0) recovered.set(normalized, remaining - 1);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
for (const unit of parseMemoryUnits(oldText)) {
|
|
155
|
+
const start = Math.max(unit.startLine, hunk.oldStart);
|
|
156
|
+
const end = Math.min(unit.endLine, hunk.oldEnd);
|
|
157
|
+
if (start > end) continue;
|
|
158
|
+
const kinds = new Set();
|
|
159
|
+
for (let lineNo = start; lineNo <= end; lineNo += 1) {
|
|
160
|
+
if (lineKinds.has(lineNo)) kinds.add(lineKinds.get(lineNo));
|
|
161
|
+
}
|
|
162
|
+
if (kinds.size > 1) return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
for (let lineNo = hunk.oldStart; lineNo <= hunk.oldEnd; lineNo += 1) {
|
|
166
|
+
if (!/^#{1,6}\s+/.test(oldLines[lineNo - 1] || "")) continue;
|
|
167
|
+
let contentLine = lineNo + 1;
|
|
168
|
+
while (contentLine <= hunk.oldEnd && !normalizeRecoveryLine(oldLines[contentLine - 1])) contentLine += 1;
|
|
169
|
+
if (contentLine > hunk.oldEnd || /^#{1,6}\s+/.test(oldLines[contentLine - 1] || "")) continue;
|
|
170
|
+
if (lineKinds.get(lineNo) !== lineKinds.get(contentLine)) return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Group the removed lines into maximal runs by recovery; blank lines never start a
|
|
174
|
+
// run and attach to whichever run surrounds them.
|
|
175
|
+
const runs = [];
|
|
176
|
+
for (let lineNo = hunk.oldStart; lineNo <= hunk.oldEnd; lineNo += 1) {
|
|
177
|
+
const kind = lineKinds.get(lineNo);
|
|
178
|
+
if (!kind) continue;
|
|
179
|
+
const current = runs[runs.length - 1];
|
|
180
|
+
if (current && current.kind === kind) current.last = lineNo;
|
|
181
|
+
else runs.push({ kind, first: lineNo, last: lineNo });
|
|
182
|
+
}
|
|
183
|
+
if (runs.length < 2) return null;
|
|
184
|
+
|
|
185
|
+
const subHunks = runs.map((run, index) => {
|
|
186
|
+
const start = index === 0 ? hunk.oldStart : runs[index - 1].last + 1;
|
|
187
|
+
const end = index === runs.length - 1 ? hunk.oldEnd : run.last;
|
|
188
|
+
const find = span(oldLines, start - 1, end);
|
|
189
|
+
return {
|
|
190
|
+
find,
|
|
191
|
+
replace: "",
|
|
192
|
+
oldStart: start,
|
|
193
|
+
oldEnd: end,
|
|
194
|
+
removed: end - start + 1,
|
|
195
|
+
added: 0,
|
|
196
|
+
lines: oldLines.slice(start - 1, end).map((text) => ({ type: "del", text })),
|
|
197
|
+
};
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
if (subHunks.some((sub) => !sub.find || countOccurrences(oldText, sub.find) !== 1)) return null;
|
|
201
|
+
return subHunks;
|
|
202
|
+
}
|
|
203
|
+
|
|
97
204
|
/**
|
|
98
205
|
* Everything that differs between the originals and the workspace now, as changes with
|
|
99
206
|
* stable ids the annotate turn refers to:
|
|
@@ -138,6 +245,27 @@ export function measureWorkspace(workspace) {
|
|
|
138
245
|
changes.push({ kind: "created", file: relative, text, skill: parseSkillFile(relative, text) });
|
|
139
246
|
}
|
|
140
247
|
|
|
248
|
+
// With the created files known, split any memory-file removal that mixes extracted
|
|
249
|
+
// text (recovered in a created file) with deleted text, so the deletion is its own
|
|
250
|
+
// measured change and stays independently decidable.
|
|
251
|
+
const createdTexts = changes.filter((c) => c.kind === "created").map((c) => c.text);
|
|
252
|
+
if (createdTexts.length) {
|
|
253
|
+
const recovered = recoveredLineCounts(createdTexts);
|
|
254
|
+
const oldText = originals.get(memoryPath) ?? "";
|
|
255
|
+
const oldLines = oldText.split("\n");
|
|
256
|
+
const measured = [];
|
|
257
|
+
for (const change of changes) {
|
|
258
|
+
if (change.kind !== "hunk" || change.file !== memoryPath || !change.removed || change.added) {
|
|
259
|
+
measured.push(change);
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
const subHunks = splitRemovalHunk(change, { oldText, oldLines, recovered });
|
|
263
|
+
if (subHunks) measured.push(...subHunks.map((sub) => ({ kind: "hunk", file: memoryPath, ...sub })));
|
|
264
|
+
else measured.push(change);
|
|
265
|
+
}
|
|
266
|
+
changes.splice(0, changes.length, ...measured);
|
|
267
|
+
}
|
|
268
|
+
|
|
141
269
|
changes.forEach((change, index) => {
|
|
142
270
|
change.id = `H${index + 1}`;
|
|
143
271
|
});
|