@playola-radio/slipstream 0.1.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.
Files changed (104) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +132 -0
  3. package/SKILL.md +63 -0
  4. package/THIRD-PARTY-NOTICES.md +36 -0
  5. package/contracts/interface/v2/schema.json +480 -0
  6. package/dist/answer-forwarder.js +58 -0
  7. package/dist/attribution-engine.js +164 -0
  8. package/dist/attribution-producer.js +153 -0
  9. package/dist/attribution-scoring.js +86 -0
  10. package/dist/attribution.js +120 -0
  11. package/dist/blob-source.js +100 -0
  12. package/dist/boundary-registry.js +47 -0
  13. package/dist/cas.js +99 -0
  14. package/dist/change-view.js +201 -0
  15. package/dist/claude-root-transcript.js +110 -0
  16. package/dist/cli.js +629 -0
  17. package/dist/clip-blob-reader.js +104 -0
  18. package/dist/clip-function-parser.js +83 -0
  19. package/dist/clip-language.js +15 -0
  20. package/dist/clip-projection-service.js +240 -0
  21. package/dist/clip-projection-worker.js +18 -0
  22. package/dist/clip-projection.js +585 -0
  23. package/dist/clip-worker-pool.js +118 -0
  24. package/dist/config.js +154 -0
  25. package/dist/control-client.js +98 -0
  26. package/dist/control-protocol.js +72 -0
  27. package/dist/daemon-location.js +33 -0
  28. package/dist/daemon.js +951 -0
  29. package/dist/display-fold.js +332 -0
  30. package/dist/engine.js +112 -0
  31. package/dist/entrypoint.js +17 -0
  32. package/dist/event.js +79 -0
  33. package/dist/evidence-ingest.js +60 -0
  34. package/dist/harness-context/claude.js +42 -0
  35. package/dist/harness-context/codex.js +49 -0
  36. package/dist/harness-context.js +49 -0
  37. package/dist/health.js +65 -0
  38. package/dist/http-reader.js +655 -0
  39. package/dist/http-security.js +50 -0
  40. package/dist/interface-projection.js +243 -0
  41. package/dist/interface-range-resolver.js +230 -0
  42. package/dist/interface-service.js +509 -0
  43. package/dist/interface-ts-pool.js +111 -0
  44. package/dist/interface-ts-worker.js +35 -0
  45. package/dist/interface-v2-comparison.js +135 -0
  46. package/dist/interface-v2-core.js +123 -0
  47. package/dist/interface-v2-typescript.js +448 -0
  48. package/dist/lock.js +197 -0
  49. package/dist/log-reader.js +148 -0
  50. package/dist/log.js +59 -0
  51. package/dist/maintenance.js +255 -0
  52. package/dist/mcp-forwarder.js +161 -0
  53. package/dist/mcp-protocol.js +92 -0
  54. package/dist/platform.js +21 -0
  55. package/dist/projection-admission.js +296 -0
  56. package/dist/projection-trace.js +17 -0
  57. package/dist/public-events.js +27 -0
  58. package/dist/question-hook.js +110 -0
  59. package/dist/questions.js +167 -0
  60. package/dist/reader-runtime.js +38 -0
  61. package/dist/reader.js +93 -0
  62. package/dist/recovery.js +303 -0
  63. package/dist/schema.js +108 -0
  64. package/dist/session.js +866 -0
  65. package/dist/sibling-module.js +10 -0
  66. package/dist/snapshot.js +15 -0
  67. package/dist/storage.js +111 -0
  68. package/dist/store-reader.js +185 -0
  69. package/dist/swift-grammar.js +268 -0
  70. package/dist/swift-interface-extract.js +344 -0
  71. package/dist/swift-interface.js +62 -0
  72. package/dist/swift-parse-host.js +174 -0
  73. package/dist/swift-parse-worker.js +34 -0
  74. package/dist/swift-parse.js +134 -0
  75. package/dist/swift-spans.js +62 -0
  76. package/dist/task-forwarder.js +152 -0
  77. package/dist/transcript/claude.js +199 -0
  78. package/dist/transcript/codex.js +132 -0
  79. package/dist/transcript/discovery.js +365 -0
  80. package/dist/transcript/file-reader.js +128 -0
  81. package/dist/transcript/fs-io.js +287 -0
  82. package/dist/transcript/runner.js +62 -0
  83. package/dist/transcript/steppers.js +30 -0
  84. package/dist/transcript/types.js +30 -0
  85. package/dist/transcript/watcher.js +133 -0
  86. package/dist/tui.js +168 -0
  87. package/package.json +60 -0
  88. package/schemas/projections/clip.v1.json +195 -0
  89. package/schemas/projections/clip.v2.json +212 -0
  90. package/schemas/projections/clip.v3.json +212 -0
  91. package/schemas/slipstream.capture.baseline.completed.v1.json +62 -0
  92. package/schemas/slipstream.capture.gap.v1.json +112 -0
  93. package/schemas/slipstream.change.attribution.v1.json +115 -0
  94. package/schemas/slipstream.enrichment.configured.v1.json +77 -0
  95. package/schemas/slipstream.enrichment.coverage.v1.json +95 -0
  96. package/schemas/slipstream.file.baselined.v1.json +122 -0
  97. package/schemas/slipstream.file.changed.v1.json +179 -0
  98. package/schemas/slipstream.harness.evidence.v1.json +145 -0
  99. package/schemas/slipstream.question.answered.v1.json +29 -0
  100. package/schemas/slipstream.question.dispatch_attempted.v1.json +28 -0
  101. package/schemas/slipstream.question.queued.v1.json +55 -0
  102. package/schemas/slipstream.session.resumed.v1.json +70 -0
  103. package/schemas/slipstream.session.started.v1.json +69 -0
  104. package/schemas/slipstream.task.started.v1.json +72 -0
@@ -0,0 +1,585 @@
1
+ /**
2
+ * The clip projection: a READER-DERIVED public projection over the immutable
3
+ * before/after blobs of a `file.changed` event. It is NOT a log event, is never
4
+ * appended, and successful extraction is a function of (before bytes, after
5
+ * bytes, language, projection_version) — so identical blobs always yield the identical result and
6
+ * a dropped cache recomputes the same answer.
7
+ *
8
+ * This module has no I/O or threads. Span selection is deterministic and
9
+ * work-capped for fixed extractor outcomes; timeouts are transient exceptions.
10
+ * The reader resolves each side to a {@link SideInput} (reading CAS blobs) and
11
+ * calls {@link projectClips} with the tree-sitter extractor. This module has no
12
+ * runtime parser dependency; direct pure callers may supply the same extractor.
13
+ * A successful function-only result is ready; mixed/unreliable hunks fall back.
14
+ * Transient parser timeouts return prepared ranges and are never cached.
15
+ *
16
+ * Byte/line conventions (part of the published contract):
17
+ * - Spans reference the RAW blob bytes by ZERO-BASED HALF-OPEN byte offsets.
18
+ * - Lines split on `\n` (0x0a); a preceding `\r` stays in the line's bytes
19
+ * (CRLF is preserved). A trailing newline terminates the final line — no
20
+ * phantom empty last line. `line_start`/`line_end` are 1-based inclusive.
21
+ * - A span for line range [a,b] covers `[startOfLine(a), startOfLine(b+1))`,
22
+ * where `startOfLine(lastLine+1)` is the blob length; line b's terminator is
23
+ * included. `\n` is always a UTF-8 boundary, so spans land on UTF-8
24
+ * boundaries.
25
+ * - A `null` span means NO corresponding span on that side (created/deleted
26
+ * content), NEVER unreadable content. Unreadable/gone content is a non-`ready`
27
+ * status (or a per-side `unavailable` method) with an explicit reason.
28
+ */
29
+ export const CLIP_PROJECTION_VERSION = 'clip.v3';
30
+ /** Fixed extraction budgets. May only be tightened by an explicit option (raising
31
+ * a ceiling is a product decision, out of scope). */
32
+ export const MAX_UTF8_BYTES = 1024 * 1024; // parse only UTF-8 <= 1 MiB
33
+ export const MAX_ALIGN_CELLS = 8_000_000; // before x after line-pair product cap
34
+ export const MAX_LINES_PER_SIDE = 300; // across the whole clip array, per side
35
+ export const MAX_BYTES_PER_SIDE = 64 * 1024; // across the whole clip array, per side
36
+ export const DEFAULT_CONTEXT = 20; // fallback = changed ranges +/- 20 lines
37
+ const FALLBACK_REASON = 'function-extraction-unavailable';
38
+ export function projectClips(before, after, opts, extract) {
39
+ // Options may only TIGHTEN a locked ceiling, never raise it: clamp each to its
40
+ // constant. Raising a budget is a product decision, out of scope (see D4).
41
+ const limits = {
42
+ context: opts.context ?? DEFAULT_CONTEXT,
43
+ maxLines: Math.min(opts.maxLinesPerSide ?? MAX_LINES_PER_SIDE, MAX_LINES_PER_SIDE),
44
+ maxBytes: Math.min(opts.maxBytesPerSide ?? MAX_BYTES_PER_SIDE, MAX_BYTES_PER_SIDE),
45
+ maxCells: Math.min(opts.maxCells ?? MAX_ALIGN_CELLS, MAX_ALIGN_CELLS),
46
+ maxUtf8Bytes: Math.min(opts.maxBytes ?? MAX_UTF8_BYTES, MAX_UTF8_BYTES),
47
+ };
48
+ const a = resolveSide(after, limits.maxUtf8Bytes);
49
+ const b = resolveSide(before, limits.maxUtf8Bytes);
50
+ const build = (status, reason, clips) => ({
51
+ change_seq: opts.changeSeq,
52
+ projection_version: CLIP_PROJECTION_VERSION,
53
+ status,
54
+ fallback_reason: reason,
55
+ clips,
56
+ });
57
+ const skipped = (reason) => build('skipped', reason, []);
58
+ const unavailable = (reason) => build('unavailable', reason, []);
59
+ const fallback = (clips) => build('fallback', FALLBACK_REASON, clips);
60
+ // Materialize plans within budget. If not even one bounded clip fits (e.g. a
61
+ // single line wider than the byte ceiling), skip rather than invent or exceed.
62
+ const asFallback = (plans) => {
63
+ const clips = clipArray(plans, b, a, limits);
64
+ return clips.length > 0 ? fallback(clips) : skipped('clip-too-large');
65
+ };
66
+ // B2 extension point: bounded fallback is assembled BEFORE any parse. Only
67
+ // this pure core chooses spans; the I/O wrapper supplies the WASM extractor.
68
+ const enrich = (plans, blocks) => {
69
+ const prepared = asFallback(plans);
70
+ if (!extract)
71
+ return prepared;
72
+ const language = opts.language ?? 'unsupported';
73
+ const empty = { functions: [], errors: [] };
74
+ let bi, ai;
75
+ try {
76
+ bi = b.kind === 'text' ? extract(b.lines.join(''), language) : empty;
77
+ ai = a.kind === 'text' ? extract(a.lines.join(''), language) : empty;
78
+ }
79
+ catch {
80
+ bi = ai = { ...empty, reason: 'worker-error' };
81
+ }
82
+ const failure = [bi.reason, ai.reason].find(r => r === 'timeout') ?? bi.reason ?? ai.reason;
83
+ if (failure) {
84
+ prepared.fallback_reason = failure;
85
+ for (const c of prepared.clips)
86
+ for (const side of [c.before, c.after]) {
87
+ if (side.span)
88
+ side.reason = failure;
89
+ }
90
+ return prepared;
91
+ }
92
+ const selected = functionPlans(blocks, b, a, bi, ai, limits.context);
93
+ if (selected === null) {
94
+ prepared.fallback_reason = 'extraction-budget-exhausted';
95
+ for (const c of prepared.clips)
96
+ for (const side of [c.before, c.after]) {
97
+ if (side.span)
98
+ side.reason = 'extraction-budget-exhausted';
99
+ }
100
+ return prepared;
101
+ }
102
+ const clips = clipArray(selected, b, a, limits);
103
+ // An enclosing function can start with a line too large to render even when
104
+ // the changed hunk itself already fit. Keep that prepared hunk: `skipped`
105
+ // means no bounded clip exists at all, not merely that the expanded one did
106
+ // not fit.
107
+ if (!clips.length) {
108
+ if (prepared.clips.length === 0)
109
+ return skipped('clip-too-large');
110
+ prepared.fallback_reason = 'function-clip-too-large';
111
+ for (const c of prepared.clips)
112
+ for (const side of [c.before, c.after]) {
113
+ if (side.span)
114
+ side.reason = 'function-clip-too-large';
115
+ }
116
+ return prepared;
117
+ }
118
+ // B1 marks the last emitted SPAN on each omitted side. A function insertion
119
+ // may spend the other side's budget before this side has emitted any span;
120
+ // disclose that omission at projection level without falsifying a null side.
121
+ for (const side of ['before', 'after']) {
122
+ if (!clips.some(c => c[side].span) && selected.slice(clips.length).some(c => planHasContent(c[side]))) {
123
+ return build('fallback', `truncated-${side}`, clips);
124
+ }
125
+ }
126
+ // Inspect ALL plans, including any omitted by the budget: truncation must
127
+ // not conceal a fallback hunk and turn a mixed result into ready.
128
+ const reasons = selected.flatMap(c => [c.before.reason, c.after.reason]).filter(Boolean);
129
+ const hasFunction = selected.some(c => c.before.method === 'function' || c.after.method === 'function');
130
+ if (hasFunction && reasons.length === 0) {
131
+ return { change_seq: opts.changeSeq, projection_version: CLIP_PROJECTION_VERSION, status: 'ready', clips };
132
+ }
133
+ return build('fallback', reasons[0] ?? 'no-enclosing-function', clips);
134
+ };
135
+ // The after side drives the primary disposition: if we cannot show the result
136
+ // state, nothing else matters.
137
+ if (a.kind === 'gone')
138
+ return unavailable(`after-${a.origin}`);
139
+ if (a.kind === 'skip')
140
+ return skipped(a.reason);
141
+ if (a.kind === 'absent') {
142
+ if (b.kind === 'text') {
143
+ return enrich([{ before: wholeRange(), after: nullSide('absent') }], [{ before: { s0: 0, e0: b.lines.length }, after: { s0: 0, e0: 0 } }]);
144
+ }
145
+ if (b.kind === 'absent')
146
+ return skipped('no-content');
147
+ if (b.kind === 'gone')
148
+ return unavailable(`before-${b.origin}`);
149
+ return skipped(b.reason); // before oversize/not-utf8 while after deleted
150
+ }
151
+ // after is text from here on.
152
+ if (b.kind === 'text') {
153
+ if (b.lines.length * a.lines.length > limits.maxCells) {
154
+ return asFallback([wholeFileClip('diff-too-large')]);
155
+ }
156
+ const blocks = diffRawBlocks(b.lines, a.lines);
157
+ const hunks = mergeRawBlocks(blocks, b.lines.length, a.lines.length, limits.context);
158
+ if (hunks.length === 0) {
159
+ if (equalBytes(b.bytes, a.bytes))
160
+ return skipped('no-change');
161
+ return asFallback([wholeFileClip('no-line-change')]);
162
+ }
163
+ const plans = hunks.map((h) => ({
164
+ // A raw-empty side (pure insertion/deletion) is null even though context
165
+ // padding makes the merged range non-empty: the contract requires no
166
+ // corresponding span there, never a context-only span.
167
+ before: h.beforeRawEmpty
168
+ ? { kind: 'null', method: 'changed-range' }
169
+ : { kind: 'range', range: h.before, method: 'changed-range' },
170
+ after: h.afterRawEmpty
171
+ ? { kind: 'null', method: 'changed-range' }
172
+ : { kind: 'range', range: h.after, method: 'changed-range' },
173
+ }));
174
+ return enrich(plans, blocks);
175
+ }
176
+ // after text, before not text: cannot diff, show after whole-file.
177
+ if (b.kind === 'absent') {
178
+ return enrich([{ before: nullSide('absent'), after: wholeRange() }], [{ before: { s0: 0, e0: 0 }, after: { s0: 0, e0: a.lines.length } }]);
179
+ }
180
+ const beforeReason = b.kind === 'gone' ? `before-${b.origin}` : `before-${b.reason}`;
181
+ const clips = clipArray([{ before: nullSide('unavailable', beforeReason), after: wholeRange() }], b, a, limits);
182
+ if (clips.length > 0)
183
+ return fallback(clips);
184
+ // Nothing of the after side fits the budget. When the before side is genuinely
185
+ // gone (GC'd/uncaptured), this outcome is availability-dependent — a restored
186
+ // before blob would diff and likely fit — so surface it as `unavailable` (never
187
+ // cached) rather than a stale `skipped`. A present-but-unparseable before
188
+ // (oversize/binary) is deterministic, so it stays a cacheable skip.
189
+ return b.kind === 'gone' ? unavailable(beforeReason) : skipped('clip-too-large');
190
+ }
191
+ function resolveSide(side, maxUtf8Bytes) {
192
+ switch (side.kind) {
193
+ case 'absent':
194
+ return { kind: 'absent' };
195
+ case 'oversize':
196
+ return { kind: 'skip', reason: 'oversize' };
197
+ case 'missing':
198
+ return { kind: 'gone', origin: 'missing' };
199
+ case 'unavailable':
200
+ return { kind: 'gone', origin: 'unavailable' };
201
+ case 'bytes': {
202
+ const bytes = side.bytes;
203
+ if (bytes.length > maxUtf8Bytes)
204
+ return { kind: 'skip', reason: 'oversize' };
205
+ const text = decodeUtf8(bytes);
206
+ if (text === null)
207
+ return { kind: 'skip', reason: 'not-utf8' };
208
+ const starts = indexLineStarts(bytes);
209
+ const lines = lineContents(text);
210
+ return { kind: 'text', bytes, starts, len: bytes.length, lines };
211
+ }
212
+ }
213
+ }
214
+ /** Decode UTF-8 strictly; a NUL byte (binary marker) or an invalid sequence
215
+ * returns null. `ignoreBOM` keeps a leading BOM in the text so a BOM-only change
216
+ * is a real diff difference, matching the raw-byte spans. Mirrors the
217
+ * binary/not-text detection used elsewhere. */
218
+ function decodeUtf8(bytes) {
219
+ for (let i = 0; i < bytes.length; i++)
220
+ if (bytes[i] === 0)
221
+ return null;
222
+ try {
223
+ return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
224
+ }
225
+ catch {
226
+ return null;
227
+ }
228
+ }
229
+ /** Byte offset of each line start. A `\n` starts a new line at the next byte
230
+ * unless it is the final byte (then it only terminates the last line). Empty
231
+ * input is zero lines. */
232
+ function indexLineStarts(bytes) {
233
+ if (bytes.length === 0)
234
+ return [];
235
+ const starts = [0];
236
+ for (let i = 0; i < bytes.length - 1; i++) {
237
+ if (bytes[i] === 0x0a)
238
+ starts.push(i + 1);
239
+ }
240
+ return starts;
241
+ }
242
+ /** Per-line content strings that PRESERVE terminators (a trailing `\r` and the
243
+ * `\n`) and final-newline presence, so a line-ending or trailing-newline change
244
+ * is a real diff difference rather than a silently hidden one. Used only for
245
+ * line comparison; spans are always computed from raw bytes. The result length
246
+ * matches {@link indexLineStarts}. */
247
+ function lineContents(text) {
248
+ if (text === '')
249
+ return [];
250
+ const parts = text.split('\n');
251
+ const trailingNewline = parts[parts.length - 1] === '';
252
+ if (trailingNewline)
253
+ parts.pop();
254
+ return parts.map((p, i) => (i < parts.length - 1 || trailingNewline ? p + '\n' : p));
255
+ }
256
+ function equalBytes(a, b) {
257
+ return Buffer.from(a.buffer, a.byteOffset, a.byteLength).equals(Buffer.from(b.buffer, b.byteOffset, b.byteLength));
258
+ }
259
+ /** Line-level LCS diff grouped into hunks of changed lines +/- `context`
260
+ * unchanged lines. Hunks whose context windows would touch are merged. */
261
+ function diffRawBlocks(before, after) {
262
+ const n = before.length;
263
+ const m = after.length;
264
+ const w = m + 1;
265
+ const dp = new Int32Array((n + 1) * w);
266
+ for (let i = n - 1; i >= 0; i--) {
267
+ for (let j = m - 1; j >= 0; j--) {
268
+ dp[i * w + j] = before[i] === after[j]
269
+ ? dp[(i + 1) * w + (j + 1)] + 1
270
+ : Math.max(dp[(i + 1) * w + j], dp[i * w + (j + 1)]);
271
+ }
272
+ }
273
+ // Raw change blocks between matched (equal) line pairs.
274
+ const blocks = [];
275
+ let i = 0;
276
+ let j = 0;
277
+ let pi = 0;
278
+ let pj = 0;
279
+ while (i < n && j < m) {
280
+ if (before[i] === after[j]) {
281
+ if (i > pi || j > pj)
282
+ blocks.push({ before: { s0: pi, e0: i }, after: { s0: pj, e0: j } });
283
+ i++;
284
+ j++;
285
+ pi = i;
286
+ pj = j;
287
+ }
288
+ else if (dp[(i + 1) * w + j] >= dp[i * w + (j + 1)]) {
289
+ i++;
290
+ }
291
+ else {
292
+ j++;
293
+ }
294
+ }
295
+ if (pi < n || pj < m)
296
+ blocks.push({ before: { s0: pi, e0: n }, after: { s0: pj, e0: m } });
297
+ return blocks;
298
+ }
299
+ function mergeRawBlocks(blocks, n, m, context) {
300
+ // Merge blocks separated by <= 2*context equal lines, then pad with context.
301
+ // Raw (pre-context) emptiness must survive the merge: a merged run is only
302
+ // "raw-empty" on a side if EVERY block it absorbed was empty on that side —
303
+ // one non-empty block anywhere in the run means real content is in play.
304
+ const merged = [];
305
+ for (const blk of blocks) {
306
+ const beforeEmpty = blk.before.s0 === blk.before.e0;
307
+ const afterEmpty = blk.after.s0 === blk.after.e0;
308
+ const last = merged[merged.length - 1];
309
+ if (last && blk.before.s0 - last.beforeRaw.e0 <= 2 * context) {
310
+ last.beforeRaw.e0 = blk.before.e0;
311
+ last.afterRaw.e0 = blk.after.e0;
312
+ last.beforeRawEmpty = last.beforeRawEmpty && beforeEmpty;
313
+ last.afterRawEmpty = last.afterRawEmpty && afterEmpty;
314
+ }
315
+ else {
316
+ merged.push({
317
+ before: { ...blk.before }, after: { ...blk.after },
318
+ beforeRaw: { ...blk.before }, afterRaw: { ...blk.after },
319
+ beforeRawEmpty: beforeEmpty, afterRawEmpty: afterEmpty,
320
+ });
321
+ }
322
+ }
323
+ return merged.map((h) => ({
324
+ before: { s0: Math.max(0, h.beforeRaw.s0 - context), e0: Math.min(n, h.beforeRaw.e0 + context) },
325
+ after: { s0: Math.max(0, h.afterRaw.s0 - context), e0: Math.min(m, h.afterRaw.e0 + context) },
326
+ beforeRawEmpty: h.beforeRawEmpty,
327
+ afterRawEmpty: h.afterRawEmpty,
328
+ }));
329
+ }
330
+ /** Split raw changes at function boundaries BEFORE adding fallback context.
331
+ * Pair segments by their order within the same LCS block, never by names or
332
+ * inferred identity. Repeated edits of one paired function share one clip.
333
+ */
334
+ function functionPlans(blocks, before, after, bi, ai, context) {
335
+ let work = 0;
336
+ let exhausted = false;
337
+ const withinBudget = (index) => (work += index.functions.length + index.errors.length + 1) <= 400_000;
338
+ // A line diff gives no character range within its changed line. At a function
339
+ // boundary, only use the function when it owns that whole line; otherwise a
340
+ // same-line top-level declaration could be incorrectly reported as function
341
+ // content. Interior rows are unambiguous.
342
+ const enclosesRow = (fn, row, side) => {
343
+ if (fn.s0 > row || row >= fn.e0)
344
+ return false;
345
+ const line = side.lines[row];
346
+ return (fn.s0 !== row || line.slice(0, fn.c0).trim() === '')
347
+ && (fn.e0 !== row + 1 || line.slice(fn.c1).trim() === '');
348
+ };
349
+ const segments = (range, side, index) => {
350
+ if (side.kind !== 'text' || range.s0 === range.e0)
351
+ return [];
352
+ const plans = [];
353
+ for (let row = range.s0; row < range.e0;) {
354
+ if (!withinBudget(index))
355
+ return [];
356
+ // Smallest enclosing span wins (nested functions are independently useful).
357
+ const fn = index.functions.filter(f => enclosesRow(f, row, side))
358
+ .sort((x, y) => (x.e0 - x.s0) - (y.e0 - y.s0) || y.s0 - x.s0)[0];
359
+ if (fn) {
360
+ plans.push({ kind: 'range', range: fn, method: 'function' });
361
+ // Do not jump past an ambiguous final boundary row. It can carry a
362
+ // contiguous top-level edit after the closing brace and needs its own
363
+ // changed-range fallback.
364
+ const finalRow = fn.e0 - 1;
365
+ row = Math.min(enclosesRow(fn, finalRow, side) ? fn.e0 : finalRow, range.e0);
366
+ }
367
+ else {
368
+ const start = row++;
369
+ while (row < range.e0 && !index.functions.some(f => enclosesRow(f, row, side))) {
370
+ if (!withinBudget(index))
371
+ return [];
372
+ row++;
373
+ }
374
+ // Separating blank lines in a created/deleted block need no standalone
375
+ // clip. If these are the entire edit, the caller preserves the fallback.
376
+ if (side.lines.slice(start, row).every(line => line.trim() === ''))
377
+ continue;
378
+ const damaged = index.errors.some(e => e.s0 < row && start < e.e0);
379
+ plans.push({ kind: 'range', method: 'changed-range',
380
+ range: { s0: Math.max(0, start - context), e0: Math.min(side.lines.length, row + context) },
381
+ reason: damaged ? 'parse-error-in-enclosing-scope' : 'no-enclosing-function' });
382
+ }
383
+ }
384
+ return plans;
385
+ };
386
+ const absent = (side) => side.kind === 'absent'
387
+ ? nullSide('absent') : { kind: 'null', method: 'changed-range' };
388
+ const output = [];
389
+ const seen = new Set();
390
+ for (const block of blocks) {
391
+ const bs = segments(block.before, before, bi);
392
+ const as = segments(block.after, after, ai);
393
+ if (work > 400_000)
394
+ return null;
395
+ // A body insertion/deletion can still have an enclosing function on its
396
+ // zero-line side. Require an interior anchor and a surviving enclosing
397
+ // boundary on the other side; a whole newly inserted function stays null.
398
+ const counterpart = (empty, other, plans, index) => {
399
+ if (empty.s0 !== empty.e0 || plans.length !== 1)
400
+ return undefined;
401
+ const p = plans[0];
402
+ if (p.kind !== 'range' || p.method !== 'function' || !p.range
403
+ || (p.range.s0 >= other.s0 && p.range.e0 <= other.e0))
404
+ return undefined;
405
+ // This scans the full index, so it belongs to the same deterministic
406
+ // selection budget as the row scans above.
407
+ if (!withinBudget(index)) {
408
+ exhausted = true;
409
+ return undefined;
410
+ }
411
+ const fn = index.functions.filter(f => f.s0 < empty.s0 && empty.s0 < f.e0)
412
+ .sort((x, y) => (x.e0 - x.s0) - (y.e0 - y.s0))[0];
413
+ return fn ? { kind: 'range', method: 'function', range: fn } : undefined;
414
+ };
415
+ if (!bs.length) {
416
+ const p = counterpart(block.before, block.after, as, bi);
417
+ if (p)
418
+ bs.push(p);
419
+ }
420
+ if (!as.length) {
421
+ const p = counterpart(block.after, block.before, bs, ai);
422
+ if (p)
423
+ as.push(p);
424
+ }
425
+ if (exhausted)
426
+ return null;
427
+ // Whitespace-only edits remain real changes, even outside any function.
428
+ if (!bs.length && !as.length) {
429
+ const h = mergeRawBlocks([block], before.kind === 'text' ? before.lines.length : 0, after.kind === 'text' ? after.lines.length : 0, context)[0];
430
+ const side = (range, empty, resolved) => empty ? absent(resolved)
431
+ : { kind: 'range', range, method: 'changed-range', reason: 'no-enclosing-function' };
432
+ output.push({ before: side(h.before, h.beforeRawEmpty, before), after: side(h.after, h.afterRawEmpty, after) });
433
+ }
434
+ for (let i = 0; i < Math.max(bs.length, as.length); i++) {
435
+ const plan = { before: bs[i] ?? absent(before), after: as[i] ?? absent(after) };
436
+ const key = JSON.stringify(plan);
437
+ if (!seen.has(key)) {
438
+ seen.add(key);
439
+ output.push(plan);
440
+ }
441
+ }
442
+ }
443
+ return output;
444
+ }
445
+ function nullSide(method, reason) {
446
+ return reason === undefined ? { kind: 'null', method } : { kind: 'null', method, reason };
447
+ }
448
+ function wholeRange() {
449
+ return { kind: 'range', range: null, method: 'whole-file' };
450
+ }
451
+ function wholeFileClip(reason) {
452
+ return {
453
+ before: { kind: 'range', range: null, method: 'whole-file', reason },
454
+ after: { kind: 'range', range: null, method: 'whole-file', reason },
455
+ };
456
+ }
457
+ /** Whether a plan carries actual content on the given side: a `range` plan with
458
+ * a non-empty range, or a `whole-file` plan. A `null` plan (absent/unavailable)
459
+ * or a zero-length `range` is NOT content — there is nothing there to omit. */
460
+ function planHasContent(plan) {
461
+ if (plan.kind === 'null')
462
+ return false;
463
+ if (plan.method === 'whole-file')
464
+ return true;
465
+ return plan.range === null || plan.range.e0 - plan.range.s0 > 0;
466
+ }
467
+ /** Materialize clip plans into clips, enforcing the per-side line/byte caps
468
+ * across the whole array. When a side's budget clips a span short, or prevents
469
+ * a later hunk from being emitted at all, the last emitted span on that side is
470
+ * marked `truncated` so the omission is disclosed rather than silent. Disclosure
471
+ * is precise: a side is only marked truncated if some OMITTED plan (the one
472
+ * that triggered the break, or a later one never reached) actually carries
473
+ * content on that side — dropping past a plan with nothing on a given side must
474
+ * not mark that side truncated. */
475
+ function clipArray(plans, before, after, limits) {
476
+ const beforeBudget = { lines: limits.maxLines, bytes: limits.maxBytes };
477
+ const afterBudget = { lines: limits.maxLines, bytes: limits.maxBytes };
478
+ const clips = [];
479
+ let beforeTrunc = false;
480
+ let afterTrunc = false;
481
+ for (let i = 0; i < plans.length; i++) {
482
+ const plan = plans[i];
483
+ const b = materialize(plan.before, before, beforeBudget);
484
+ const a = materialize(plan.after, after, afterBudget);
485
+ if (b.outcome === 'dropped' || a.outcome === 'dropped') {
486
+ // Every omitted plan is the current one (whichever side dropped it discards
487
+ // the whole clip) plus every later plan never reached. Either side may
488
+ // carry content in the omitted set independent of which side triggered the
489
+ // break, so scan both sides across the full omitted range.
490
+ for (const omitted of plans.slice(i)) {
491
+ if (planHasContent(omitted.before))
492
+ beforeTrunc = true;
493
+ if (planHasContent(omitted.after))
494
+ afterTrunc = true;
495
+ }
496
+ break;
497
+ }
498
+ clips.push({ before: b.side, after: a.side });
499
+ if (b.outcome === 'partial')
500
+ beforeTrunc = true;
501
+ if (a.outcome === 'partial')
502
+ afterTrunc = true;
503
+ if (b.outcome === 'partial' || a.outcome === 'partial') {
504
+ for (const omitted of plans.slice(i + 1)) {
505
+ if (planHasContent(omitted.before))
506
+ beforeTrunc = true;
507
+ if (planHasContent(omitted.after))
508
+ afterTrunc = true;
509
+ }
510
+ break;
511
+ }
512
+ }
513
+ if (beforeTrunc)
514
+ markLastSpanTruncated(clips, 'before');
515
+ if (afterTrunc)
516
+ markLastSpanTruncated(clips, 'after');
517
+ return clips;
518
+ }
519
+ /** Disclose truncation on the last emitted span of a side. The last CLIP may be
520
+ * a null-span insertion/deletion on that side, so walk back to the last clip
521
+ * that actually has a span there — otherwise a dropped later hunk would go
522
+ * silently undisclosed. */
523
+ function markLastSpanTruncated(clips, side) {
524
+ for (let i = clips.length - 1; i >= 0; i--) {
525
+ const span = clips[i][side].span;
526
+ if (span) {
527
+ span.truncated = true;
528
+ return;
529
+ }
530
+ }
531
+ }
532
+ function materialize(plan, side, budget) {
533
+ if (plan.kind === 'null')
534
+ return { side: sideOf(plan.method, null, plan.reason), outcome: 'ok' };
535
+ if (side.kind !== 'text') {
536
+ // whole-file plan against a non-text side is a bug in the caller.
537
+ return { side: { span: null, method: 'unavailable', reason: 'internal-error' }, outcome: 'ok' };
538
+ }
539
+ const range = plan.range ?? { s0: 0, e0: side.starts.length };
540
+ if (range.e0 - range.s0 <= 0) {
541
+ // A zero-length corresponding range (the empty side of an insertion/deletion):
542
+ // a null span, not budget exhaustion. Later hunks must still be processed.
543
+ return { side: sideOf(plan.method, null, plan.reason), outcome: 'empty' };
544
+ }
545
+ const clamped = clampToBudget(range, side, budget);
546
+ if (clamped === null)
547
+ return { side: sideOf(plan.method, null, plan.reason), outcome: 'dropped' };
548
+ budget.lines -= clamped.line_end - clamped.line_start + 1;
549
+ budget.bytes -= clamped.byte_end - clamped.byte_start;
550
+ return { side: sideOf(plan.method, clamped, plan.reason), outcome: clamped.truncated ? 'partial' : 'ok' };
551
+ }
552
+ function sideOf(method, span, reason) {
553
+ return reason === undefined ? { span, method } : { span, method, reason };
554
+ }
555
+ /** Trim a line range to the remaining line/byte budget, on whole-line
556
+ * granularity. The caller guarantees a non-empty range. Returns null when
557
+ * nothing fits — either the line budget is spent, or even a single line exceeds
558
+ * the byte budget (the locked 64 KiB clip ceiling is never exceeded, so a
559
+ * minified line wider than the ceiling yields no clip rather than an oversized
560
+ * one). */
561
+ function clampToBudget(range, side, budget) {
562
+ const total = range.e0 - range.s0; // > 0, guaranteed by caller
563
+ if (budget.lines <= 0)
564
+ return null;
565
+ let take = Math.min(total, budget.lines);
566
+ const byteStart = lineStartByte(side, range.s0);
567
+ let byteEnd = lineStartByte(side, range.s0 + take);
568
+ while (take > 1 && byteEnd - byteStart > budget.bytes) {
569
+ take--;
570
+ byteEnd = lineStartByte(side, range.s0 + take);
571
+ }
572
+ if (byteEnd - byteStart > budget.bytes)
573
+ return null; // even one line exceeds the byte budget
574
+ const truncated = take < total;
575
+ return {
576
+ byte_start: byteStart,
577
+ byte_end: byteEnd,
578
+ line_start: range.s0 + 1,
579
+ line_end: range.s0 + take,
580
+ truncated,
581
+ };
582
+ }
583
+ function lineStartByte(side, line0) {
584
+ return line0 < side.starts.length ? side.starts[line0] : side.len;
585
+ }