@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.
- package/LICENSE +202 -0
- package/README.md +132 -0
- package/SKILL.md +63 -0
- package/THIRD-PARTY-NOTICES.md +36 -0
- package/contracts/interface/v2/schema.json +480 -0
- package/dist/answer-forwarder.js +58 -0
- package/dist/attribution-engine.js +164 -0
- package/dist/attribution-producer.js +153 -0
- package/dist/attribution-scoring.js +86 -0
- package/dist/attribution.js +120 -0
- package/dist/blob-source.js +100 -0
- package/dist/boundary-registry.js +47 -0
- package/dist/cas.js +99 -0
- package/dist/change-view.js +201 -0
- package/dist/claude-root-transcript.js +110 -0
- package/dist/cli.js +629 -0
- package/dist/clip-blob-reader.js +104 -0
- package/dist/clip-function-parser.js +83 -0
- package/dist/clip-language.js +15 -0
- package/dist/clip-projection-service.js +240 -0
- package/dist/clip-projection-worker.js +18 -0
- package/dist/clip-projection.js +585 -0
- package/dist/clip-worker-pool.js +118 -0
- package/dist/config.js +154 -0
- package/dist/control-client.js +98 -0
- package/dist/control-protocol.js +72 -0
- package/dist/daemon-location.js +33 -0
- package/dist/daemon.js +951 -0
- package/dist/display-fold.js +332 -0
- package/dist/engine.js +112 -0
- package/dist/entrypoint.js +17 -0
- package/dist/event.js +79 -0
- package/dist/evidence-ingest.js +60 -0
- package/dist/harness-context/claude.js +42 -0
- package/dist/harness-context/codex.js +49 -0
- package/dist/harness-context.js +49 -0
- package/dist/health.js +65 -0
- package/dist/http-reader.js +655 -0
- package/dist/http-security.js +50 -0
- package/dist/interface-projection.js +243 -0
- package/dist/interface-range-resolver.js +230 -0
- package/dist/interface-service.js +509 -0
- package/dist/interface-ts-pool.js +111 -0
- package/dist/interface-ts-worker.js +35 -0
- package/dist/interface-v2-comparison.js +135 -0
- package/dist/interface-v2-core.js +123 -0
- package/dist/interface-v2-typescript.js +448 -0
- package/dist/lock.js +197 -0
- package/dist/log-reader.js +148 -0
- package/dist/log.js +59 -0
- package/dist/maintenance.js +255 -0
- package/dist/mcp-forwarder.js +161 -0
- package/dist/mcp-protocol.js +92 -0
- package/dist/platform.js +21 -0
- package/dist/projection-admission.js +296 -0
- package/dist/projection-trace.js +17 -0
- package/dist/public-events.js +27 -0
- package/dist/question-hook.js +110 -0
- package/dist/questions.js +167 -0
- package/dist/reader-runtime.js +38 -0
- package/dist/reader.js +93 -0
- package/dist/recovery.js +303 -0
- package/dist/schema.js +108 -0
- package/dist/session.js +866 -0
- package/dist/sibling-module.js +10 -0
- package/dist/snapshot.js +15 -0
- package/dist/storage.js +111 -0
- package/dist/store-reader.js +185 -0
- package/dist/swift-grammar.js +268 -0
- package/dist/swift-interface-extract.js +344 -0
- package/dist/swift-interface.js +62 -0
- package/dist/swift-parse-host.js +174 -0
- package/dist/swift-parse-worker.js +34 -0
- package/dist/swift-parse.js +134 -0
- package/dist/swift-spans.js +62 -0
- package/dist/task-forwarder.js +152 -0
- package/dist/transcript/claude.js +199 -0
- package/dist/transcript/codex.js +132 -0
- package/dist/transcript/discovery.js +365 -0
- package/dist/transcript/file-reader.js +128 -0
- package/dist/transcript/fs-io.js +287 -0
- package/dist/transcript/runner.js +62 -0
- package/dist/transcript/steppers.js +30 -0
- package/dist/transcript/types.js +30 -0
- package/dist/transcript/watcher.js +133 -0
- package/dist/tui.js +168 -0
- package/package.json +60 -0
- package/schemas/projections/clip.v1.json +195 -0
- package/schemas/projections/clip.v2.json +212 -0
- package/schemas/projections/clip.v3.json +212 -0
- package/schemas/slipstream.capture.baseline.completed.v1.json +62 -0
- package/schemas/slipstream.capture.gap.v1.json +112 -0
- package/schemas/slipstream.change.attribution.v1.json +115 -0
- package/schemas/slipstream.enrichment.configured.v1.json +77 -0
- package/schemas/slipstream.enrichment.coverage.v1.json +95 -0
- package/schemas/slipstream.file.baselined.v1.json +122 -0
- package/schemas/slipstream.file.changed.v1.json +179 -0
- package/schemas/slipstream.harness.evidence.v1.json +145 -0
- package/schemas/slipstream.question.answered.v1.json +29 -0
- package/schemas/slipstream.question.dispatch_attempted.v1.json +28 -0
- package/schemas/slipstream.question.queued.v1.json +55 -0
- package/schemas/slipstream.session.resumed.v1.json +70 -0
- package/schemas/slipstream.session.started.v1.json +69 -0
- 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
|
+
}
|