@xmoon76/dsh-pi-tui 0.4.1 → 0.4.3-alpha.2

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.
@@ -1,672 +0,0 @@
1
- /**
2
- * @xmoon76/dsh-pi-tui/scripts/repair-core — pure session-log repair logic.
3
- *
4
- * dsh persists one session as a JSONL artifact where every event's `seq`
5
- * must equal its position (`event.seq === index`), wrapped in concatenated
6
- * Zstandard frames (one frame per write-behind flush). Two dsh processes
7
- * writing one session concurrently can mint the same seq (each numbers from
8
- * its own in-memory log length), which makes the artifact unreadable —
9
- * `SessionLogScanner` throws `corrupt session log: seq gap in committed
10
- * region`. This module repairs such logs:
11
- *
12
- * - duplicate seq (`got < expected`) — renumbers every event from the first
13
- * collision onward so `seq === index` again, remapping cross-references
14
- * (`sourceEventSeqs`, `messageSeqs`, `sourceEventSeq`) through the
15
- * old→new mapping;
16
- * - missing seq / unparsable row (`got > expected` or unparsable JSON) —
17
- * truncates at the first broken event (the missing events cannot be
18
- * recreated) and reports the loss;
19
- * - wrong frame layout (first frame is not exactly the header line, e.g. a
20
- * whole-log single frame) — re-frames the log into the dsh layout:
21
- * header line alone in the first frame, then one frame per line;
22
- * - otherwise — no-op.
23
- *
24
- * The chunk packing rows (`*-chunks` with `seq0`) are expanded by
25
- * `decodeStorageRecord` on read; the repaired log writes one event per line
26
- * (no packing), which the same reader accepts.
27
- *
28
- * The decoder (`decodeStorageRecord`) and encoder (`packChunkRuns`) come
29
- * from the installed dsh (`@deepseek-ai/dsh-session`) and are injected so
30
- * the logic stays testable. The frame walker is vendored from
31
- * `dsh-session-persistence-jsonl` (upstream commit 0ba0dcbd4d39) so the
32
- * script needs no runtime import for it.
33
- * @module repair-core
34
- */
35
-
36
- import { constants, zstdDecompressSync } from 'node:zlib'
37
-
38
- /** Zstandard frame magic, little-endian (`\x28\xB5\x2F\xFD`). */
39
- export const ZSTD_MAGIC = 0xfd2fb528
40
-
41
- /**
42
- * Compress with the same content-checksum flag the harness writer uses
43
- * (`dsh-session-persistence-jsonl`'s `compressZstdFrame` passes
44
- * `ZSTD_c_checksumFlag: 1`).
45
- */
46
- const CHECKSUMMED_OPTIONS = {
47
- params: { [constants.ZSTD_c_checksumFlag]: 1 },
48
- }
49
-
50
- /**
51
- * Walk concatenated Zstandard frames and return each frame's byte range.
52
- * Adapted from `dsh-session-persistence-jsonl/lib/index.js`
53
- * (`scanZstdFrames`): parses the frame header (content-size flags) and block
54
- * headers to find frame boundaries without trusting content sizes.
55
- *
56
- * Divergence from the upstream walker: instead of throwing on an invalid
57
- * frame, the walk STOPS and reports where the tail became unreadable —
58
- * `tornStart` when a frame merely ran out of bytes (truncated tail),
59
- * `garbageStart` when the bytes do not parse as a frame (e.g. random bytes
60
- * appended after the last complete frame). The complete frames collected
61
- * before the damage point are always a safe salvageable prefix, which is
62
- * what the repair path truncates to.
63
- * @param buffer - the raw artifact bytes.
64
- * @returns `{ frames, tornStart?, garbageStart? }`.
65
- */
66
- export function scanZstdFrames(buffer) {
67
- const frames = []
68
- let offset = 0
69
- while (offset < buffer.length) {
70
- const start = offset
71
- if (buffer.length - offset < 4) return { frames, tornStart: start }
72
- if (buffer.readUInt32LE(offset) !== ZSTD_MAGIC) return { frames, garbageStart: start }
73
- offset += 4
74
- if (offset === buffer.length) return { frames, tornStart: start }
75
- const descriptor = buffer.readUInt8(offset)
76
- offset += 1
77
- if ((descriptor & 24) !== 0) return { frames, garbageStart: start }
78
- const contentSizeFlag = descriptor >>> 6
79
- const singleSegment = (descriptor & 32) !== 0
80
- const checksum = (descriptor & 4) !== 0
81
- const dictionaryFlag = descriptor & 3
82
- const dictionaryBytes = dictionaryFlag === 3 ? 4 : dictionaryFlag
83
- const contentSizeBytes = contentSizeFlag === 0 ? (singleSegment ? 1 : 0) : 1 << contentSizeFlag
84
- const remainingHeaderBytes = (singleSegment ? 0 : 1) + dictionaryBytes + contentSizeBytes
85
- if (buffer.length - offset < remainingHeaderBytes) return { frames, tornStart: start }
86
- offset += remainingHeaderBytes
87
- for (;;) {
88
- if (buffer.length - offset < 3) return { frames, tornStart: start }
89
- const blockHeader = buffer.readUIntLE(offset, 3)
90
- offset += 3
91
- const lastBlock = (blockHeader & 1) !== 0
92
- const blockType = blockHeader >>> 1 & 3
93
- const blockSize = blockHeader >>> 3
94
- if (blockType === 3) return { frames, garbageStart: start }
95
- const payloadBytes = blockType === 1 ? 1 : blockSize
96
- if (buffer.length - offset < payloadBytes) return { frames, tornStart: start }
97
- offset += payloadBytes
98
- if (lastBlock) break
99
- }
100
- if (checksum) {
101
- if (buffer.length - offset < 4) return { frames, tornStart: start }
102
- offset += 4
103
- }
104
- frames.push({ start, end: offset })
105
- }
106
- return { frames }
107
- }
108
-
109
- /**
110
- * Lay out the damage diagnosis for a damaged tail, with byte accounting.
111
- * @param kind - `'torn'` (truncated mid-frame) or `'garbage'` (non-frame bytes).
112
- * @param offset - byte offset where the damage starts.
113
- * @param totalBytes - file size.
114
- * @param completeBytes - bytes of the complete frames before the damage.
115
- */
116
- function tailIssue(kind, offset, totalBytes, completeBytes) {
117
- return kind === 'torn'
118
- ? `corrupt Zstandard session log: torn tail — an incomplete frame starts at byte ${offset} of ${totalBytes}; ${completeBytes} complete byte(s) are salvageable`
119
- : `corrupt Zstandard session log: trailing garbage — bytes that are not a valid frame start at byte ${offset} of ${totalBytes}; ${completeBytes} complete byte(s) are salvageable`
120
- }
121
-
122
- /**
123
- * Full frame-layout diagnosis of an artifact. Unlike the old boolean
124
- * `scanZstdLayout`, the status is explicit:
125
- *
126
- * - `healthy` — every frame is complete, the first frame decodes to exactly
127
- * one header line, and every complete frame ends on a JSONL record
128
- * boundary;
129
- * - `torn-tail` — the tail is truncated or garbage; the complete prefix is
130
- * still laid out correctly (when there is any);
131
- * - `invalid-first-frame` — the first frame does not decode to exactly one
132
- * header line (e.g. a whole-log single frame);
133
- * - `invalid-jsonl-boundary` — a complete frame does not end on a JSONL
134
- * record boundary;
135
- * - `decode-failure` — a complete frame cannot be decompressed;
136
- * - `empty` — no frames and no damage (zero-length or header-less artifact).
137
- *
138
- * The result carries `tornStart`/`garbageStart` offsets plus
139
- * `totalBytes`/`completeBytes` so `--scan` can report exactly what a repair
140
- * would keep and drop.
141
- * @param buffer - raw artifact bytes.
142
- * @param zstdDecompressSync - `node:zlib` zstd decompressor (one frame per call).
143
- */
144
- export function scanFrameLayout(buffer, zstdDecompressSync) {
145
- const totalBytes = buffer.length
146
- const { frames, tornStart, garbageStart } = scanZstdFrames(buffer)
147
- const completeBytes = frames.length === 0 ? 0 : frames[frames.length - 1].end
148
- const damage = tornStart ?? garbageStart
149
- const damageKind = tornStart !== undefined ? 'torn' : garbageStart !== undefined ? 'garbage' : undefined
150
- const withDamage = (status, issue) => ({ status, frames, tornStart, garbageStart, totalBytes, completeBytes, issue })
151
-
152
- if (frames.length === 0) {
153
- if (damage === undefined) return withDamage('empty', 'empty or header-less Zstandard session log')
154
- return withDamage('torn-tail', tailIssue(damageKind, damage, totalBytes, completeBytes))
155
- }
156
- let plaintext
157
- try {
158
- plaintext = zstdDecompressSync(buffer.subarray(frames[0].start, frames[0].end))
159
- } catch (error) {
160
- return withDamage('decode-failure', `corrupt Zstandard session log: header frame failed validation (${error instanceof Error ? error.message : String(error)})`)
161
- }
162
- if (plaintext.length === 0 || plaintext.indexOf(0x0A) !== plaintext.length - 1) {
163
- return withDamage('invalid-first-frame', 'corrupt Zstandard session log: first frame is not exactly one header line')
164
- }
165
- // Every complete frame must end on a JSONL record boundary: the harness
166
- // appends whole records per flush frame, so a frame ending mid-record
167
- // means corruption the reader will trip over later.
168
- for (let index = 1; index < frames.length; index += 1) {
169
- let frameText
170
- try {
171
- frameText = zstdDecompressSync(buffer.subarray(frames[index].start, frames[index].end))
172
- } catch (error) {
173
- return withDamage('decode-failure', `corrupt Zstandard session log: frame ${index + 1} failed validation (${error instanceof Error ? error.message : String(error)})`)
174
- }
175
- if (frameText.length === 0 || frameText[frameText.length - 1] !== 0x0A) {
176
- return withDamage('invalid-jsonl-boundary', `corrupt Zstandard session log: frame ${index + 1} does not end on a JSONL record boundary`)
177
- }
178
- }
179
- if (damage !== undefined) return withDamage('torn-tail', tailIssue(damageKind, damage, totalBytes, completeBytes))
180
- return { status: 'healthy', frames, totalBytes, completeBytes }
181
- }
182
-
183
- /**
184
- * Validate the frame layout the dsh reader requires: the first frame must
185
- * decompress to EXACTLY one newline-terminated header line (the harness's
186
- * `assertZstdHeaderFrame`). The harness appends ONE zstd frame per flush
187
- * batch, so a whole-log single-frame artifact is structurally valid zstd but
188
- * rejected by dsh (`corrupt Zstandard session log: first frame is not
189
- * exactly one header line`) — `session.list`, `load`, and `readFrom` all
190
- * fail on it. A torn tail is NEVER reported as ok, even when the complete
191
- * prefix is laid out correctly.
192
- * @param buffer - raw artifact bytes.
193
- * @param zstdDecompressSync - `node:zlib` zstd decompressor (one frame per call).
194
- * @returns `{ ok: true }` or `{ ok: false, issue }`.
195
- */
196
- export function scanZstdLayout(buffer, zstdDecompressSync) {
197
- const scan = scanFrameLayout(buffer, zstdDecompressSync)
198
- return scan.status === 'healthy' ? { ok: true } : { ok: false, issue: scan.issue }
199
- }
200
-
201
- /**
202
- * Decompress a complete concatenated-frame artifact into its JSONL text.
203
- * @param buffer - raw artifact bytes.
204
- * @param zstdDecompressSync - `node:zlib` zstd decompressor (one frame per call).
205
- * @returns `{ text, frames }`.
206
- */
207
- export function decompressFrames(buffer, zstdDecompressSync) {
208
- const { frames } = scanZstdFrames(buffer)
209
- if (frames.length === 0) throw new Error('empty or header-less Zstandard session log')
210
- const parts = []
211
- for (const frame of frames) {
212
- parts.push(zstdDecompressSync(buffer.subarray(frame.start, frame.end)))
213
- }
214
- return { text: Buffer.concat(parts).toString('utf8'), frames: frames.length }
215
- }
216
-
217
- /**
218
- * Compress a repaired log in the dsh frame layout: the header line alone in
219
- * the first frame, then the remaining lines chunked into ~16 KiB plaintext
220
- * frames. The harness reader requires the first frame to decode to exactly
221
- * one header line, and appends one frame per flush batch; writing the whole
222
- * log as one frame is the layout bug that made repaired logs unreadable.
223
- * Chunking keeps the file near its original size (one frame per event
224
- * balloons ~10x from per-frame zstd overhead) while keeping torn-tail
225
- * recovery at sub-KiB-of-events granularity.
226
- */
227
- export function compressLog(text, zstdCompressSync) {
228
- const lines = text.split('\n')
229
- const frames = []
230
- // Frame one: exactly the header line (the reader's assertZstdHeaderFrame).
231
- if (lines.length > 0 && lines[0] !== '') {
232
- frames.push(zstdCompressSync(Buffer.from(`${lines[0]}\n`, 'utf8'), CHECKSUMMED_OPTIONS))
233
- }
234
- let chunk = []
235
- let chunkBytes = 0
236
- const flushChunk = () => {
237
- if (chunk.length === 0) return
238
- frames.push(zstdCompressSync(Buffer.from(`${chunk.join('\n')}\n`, 'utf8'), CHECKSUMMED_OPTIONS))
239
- chunk = []
240
- chunkBytes = 0
241
- }
242
- for (let index = 1; index < lines.length; index += 1) {
243
- const line = lines[index]
244
- if (line === '' && index === lines.length - 1) continue // trailing newline of the last record
245
- chunk.push(line)
246
- chunkBytes += line.length + 1
247
- if (chunkBytes >= 16 * 1024) flushChunk()
248
- }
249
- flushChunk()
250
- // Node's zstdCompressSync can emit a trailing EMPTY frame after the content
251
- // frame (observed when the compressed output lands on a 16 KiB boundary,
252
- // e.g. exactly 16384 bytes): the returned buffer is then two concatenated
253
- // frames, the second a valid zero-content frame. The dsh reader tolerates
254
- // it, but every non-first frame must end on a JSONL record boundary, so
255
- // the layout gate flags it. The harness writer uses the async compressor,
256
- // which never emits it. Strip such empty frames from each compressed
257
- // output so a repaired artifact always matches the harness layout exactly.
258
- if (frames.length > 0) {
259
- const stripped = []
260
- for (const frame of frames) {
261
- const { frames: inner } = scanZstdFrames(frame)
262
- if (inner.length === 1) {
263
- stripped.push(frame)
264
- continue
265
- }
266
- for (let i = 0; i < inner.length; i += 1) {
267
- const segment = frame.subarray(inner[i].start, inner[i].end)
268
- try {
269
- if (zstdDecompressSync(segment).length === 0) continue // empty frame
270
- } catch {
271
- // A frame that fails to decompress is content — keep it so the
272
- // post-write verify can report it instead of silently dropping it.
273
- }
274
- stripped.push(segment)
275
- }
276
- }
277
- // Always use the stripped result: stripping is identity for single-frame
278
- // chunks, and a chunk that expands to N segments with an empty frame
279
- // dropped can end up with the same element count as before (e.g. one
280
- // two-frame chunk -> one kept segment), so a length comparison would
281
- // wrongly fall through to the unstripped concat below.
282
- return Buffer.concat(stripped)
283
- }
284
- return frames.length > 0 ? Buffer.concat(frames) : zstdCompressSync(Buffer.alloc(0), CHECKSUMMED_OPTIONS)
285
- }
286
-
287
- /**
288
- * Scan a JSONL session text with the storage-format reader semantics:
289
- * header on line 0, every following line decodes (possibly expanding packed
290
- * chunk rows) into events that must be contiguous (`event.seq === index`).
291
- * Unlike the real scanner, ALL events are retained (renumber repair needs
292
- * the tail) and every row is examined; the FIRST issue is reported.
293
- * @param text - decompressed JSONL session text.
294
- * @param decodeStorageRecord - `@deepseek-ai/dsh-session` row decoder.
295
- * @param frames - optional per-frame `{ startLine, endLine }` ranges (1-based,
296
- * endLine exclusive) for writer-segment resolution; undefined disables.
297
- * @returns `{ header, events, issue }`.
298
- */
299
- export function scanEvents(text, decodeStorageRecord, frames) {
300
- const lines = text.split('\n')
301
- const header = lines[0] ?? ''
302
- const events = []
303
- let issue
304
- const frameOfLine = (line) => {
305
- if (frames === undefined) return -1
306
- for (let frame = 0; frame < frames.length; frame += 1) {
307
- if (line >= frames[frame].startLine && line < frames[frame].endLine) return frame
308
- }
309
- return -1
310
- }
311
- for (let lineIndex = 1; lineIndex < lines.length; lineIndex += 1) {
312
- const raw = lines[lineIndex]
313
- if (raw === '') continue
314
- let decoded
315
- try {
316
- decoded = decodeStorageRecord(JSON.parse(raw))
317
- } catch {
318
- issue ??= {
319
- kind: 'unparsable',
320
- index: events.length,
321
- line: lineIndex + 1,
322
- message: `corrupt session log: unparsable committed event at line ${lineIndex + 1}`,
323
- }
324
- continue
325
- }
326
- const frame = frameOfLine(lineIndex + 1)
327
- for (const event of decoded) {
328
- if (event.seq !== events.length) {
329
- issue ??= {
330
- kind: event.seq < events.length ? 'duplicate' : 'gap',
331
- index: events.length,
332
- line: lineIndex + 1,
333
- expected: events.length,
334
- got: event.seq,
335
- message: `corrupt session log: seq gap in committed region at line ${lineIndex + 1} (expected ${events.length}, got ${event.seq})`,
336
- }
337
- }
338
- // Non-enumerable tags: encodeLog must never serialize them into the
339
- // repaired artifact. `frame` is the writer segment (flush frame) the
340
- // row arrived in; -1 when frame info is unavailable.
341
- Object.defineProperty(event, 'line', { value: lineIndex + 1, enumerable: false, configurable: true })
342
- Object.defineProperty(event, 'frame', { value: frame, enumerable: false, configurable: true })
343
- events.push(event)
344
- }
345
- }
346
- return { header, events, issue }
347
- }
348
-
349
- /**
350
- * Decompress each complete frame of an artifact separately and return the
351
- * 1-based JSONL line range it covers (`endLine` exclusive). Together with
352
- * {@link scanEvents} this attributes events to the flush frame (writer
353
- * segment) they arrived in, which duplicate-seq reference resolution uses.
354
- */
355
- export function frameLineRanges(buffer, zstdDecompressSync) {
356
- const { frames } = scanZstdFrames(buffer)
357
- const ranges = []
358
- let line = 1
359
- for (const frame of frames) {
360
- const plain = zstdDecompressSync(buffer.subarray(frame.start, frame.end)).toString('utf8')
361
- const startLine = line
362
- line += plain.split('\n').length - 1
363
- ranges.push({ startLine, endLine: line })
364
- }
365
- return ranges
366
- }
367
-
368
- /** Keys whose array values are seq references in event `data`. */
369
- const SEQ_REF_ARRAY_KEYS = new Set(['sourceEventSeqs', 'messageSeqs'])
370
-
371
- /**
372
- * Visit every seq cross-reference inside one event's `data`: the
373
- * `sourceEventSeqs`/`messageSeqs` array values and the `sourceEventSeq`
374
- * scalar, at any nesting depth.
375
- * @param event - a session event.
376
- * @param visit - `(container, key, index, value) => void`; `index` is the
377
- * array position for array references and undefined for the scalar, so the
378
- * caller can write the replacement precisely (never by value — a remapped
379
- * reference could equal another reference's old value).
380
- */
381
- function eachSeqRef(event, visit) {
382
- const walk = (node) => {
383
- if (node === null || typeof node !== 'object') return
384
- if (Array.isArray(node)) {
385
- for (const item of node) walk(item)
386
- return
387
- }
388
- for (const key of Object.keys(node)) {
389
- const value = node[key]
390
- if (SEQ_REF_ARRAY_KEYS.has(key) && Array.isArray(value)) {
391
- for (let index = 0; index < value.length; index += 1) {
392
- if (typeof value[index] === 'number') visit(node, key, index, value[index])
393
- }
394
- } else if (key === 'sourceEventSeq' && typeof value === 'number') {
395
- visit(node, key, undefined, value)
396
- } else {
397
- walk(value)
398
- }
399
- }
400
- }
401
- walk(event.data)
402
- }
403
-
404
- /**
405
- * Resolve one seq reference through the old→new map, honoring a duplicate
406
- * strategy for values that occur more than once:
407
- * - `first` — bind to the FIRST occurrence (the reference stays unchanged);
408
- * - `last` — bind to the LAST occurrence (remapped through the map);
409
- * - `segment` — bind to the UNIQUE occurrence in the same writer segment
410
- * (flush frame) as the referencing event. A single same-frame candidate
411
- * resolves to that occurrence's index (after renumbering `seq === index`),
412
- * NEVER to the global last occurrence (`map`), which may live in a
413
- * different frame when the seq repeats three or more times across frames.
414
- * Multiple same-frame candidates are ambiguous — the caller refuses.
415
- * No same-frame candidate falls back to `first` (documented); without
416
- * frame metadata the strategy degrades to `first`.
417
- * @param value - the referenced (old) seq.
418
- * @param strategy - the duplicate-reference strategy, or undefined.
419
- * @param fromEvent - the referencing event (for the segment strategy).
420
- * @param map - old→new mapping (last occurrence wins per value).
421
- * @param occurrences - seq value → array of event indices.
422
- * @param events - the scanned events (for occurrence frame lookup).
423
- * @returns `{ kind: 'resolved', seq }` — write this seq;
424
- * `{ kind: 'keep' }` — leave the reference unchanged (first);
425
- * `{ kind: 'ambiguous', candidates }` — same-frame binding not
426
- * unique (only possible under `segment`).
427
- */
428
- function resolveReference(value, strategy, fromEvent, map, occurrences, events) {
429
- if (strategy === 'last') return { kind: 'resolved', seq: map.get(value) }
430
- if (strategy === 'segment' && fromEvent.frame >= 0) {
431
- const candidates = occurrences.get(value) ?? []
432
- const sameFrame = candidates.filter(index => events[index]?.frame === fromEvent.frame)
433
- if (sameFrame.length === 1) {
434
- // The occurrence index IS the new seq after renumbering.
435
- return { kind: 'resolved', seq: sameFrame[0] }
436
- }
437
- if (sameFrame.length > 1) return { kind: 'ambiguous', candidates: sameFrame }
438
- // No same-frame occurrence: fall back to first (documented).
439
- return { kind: 'keep' }
440
- }
441
- // 'first' and the segment fallback: the first occurrence keeps its seq.
442
- return { kind: 'keep' }
443
- }
444
-
445
- /**
446
- * Remap seq cross-references inside one event's `data` through the
447
- * old→new mapping. Only values `>= threshold` are remapped: everything
448
- * below the first collision is a pre-collision event whose seq is
449
- * unchanged. Returns the number of remapped references and — for the
450
- * `segment` strategy — the first same-frame-ambiguous reference, if any
451
- * (pre-checked by repairEvents, so this is defensive).
452
- */
453
- function remapReferences(event, eventIndex, threshold, map, occurrences, strategy, events) {
454
- let changed = 0
455
- let ambiguous = undefined
456
- eachSeqRef(event, (container, key, index, ref) => {
457
- let next
458
- if (strategy !== undefined && (occurrences.get(ref)?.length ?? 0) > 1) {
459
- const outcome = resolveReference(ref, strategy, event, map, occurrences, events)
460
- if (outcome.kind === 'ambiguous') {
461
- ambiguous ??= {
462
- seq: ref,
463
- key,
464
- eventIndex,
465
- line: event.line,
466
- frame: event.frame,
467
- candidates: outcome.candidates,
468
- sameFrameConflict: true,
469
- }
470
- return
471
- }
472
- next = outcome.kind === 'resolved' ? outcome.seq : undefined
473
- } else if (ref >= threshold && map.has(ref)) {
474
- next = map.get(ref)
475
- }
476
- if (next !== undefined && next !== ref) {
477
- if (index === undefined) container[key] = next
478
- else container[key][index] = next
479
- changed += 1
480
- }
481
- })
482
- return { changed, ambiguous }
483
- }
484
-
485
- /** The duplicate-seq reference resolution strategies (CLI: --duplicate-reference). */
486
- export const DUPLICATE_REFERENCE_STRATEGIES = ['first', 'last', 'segment']
487
-
488
- /**
489
- * Repair the scanned log. Mutates the events array's `seq` fields and the
490
- * reference fields it remaps.
491
- *
492
- * Duplicate-seq semantics (stage C): a reference to a seq value that occurs
493
- * MORE THAN ONCE in the log cannot be uniquely attributed — it may name the
494
- * pre-collision event or one of the collision writer's events. When such
495
- * ambiguous references exist, the default is to REFUSE (`action: 'refuse'`)
496
- * with a full report ({@link AmbiguousReference} entries: conflict seq,
497
- * referencing event, candidates, line/frame) so the user can pick a
498
- * `duplicateReference` strategy. Only when every reference is uniquely
499
- * resolvable does the repair renumber and remap automatically.
500
- *
501
- * The reference-mapping rule for unambiguous values: values below the
502
- * collision index name pre-collision events (unchanged); values at or above
503
- * it can only name post-collision events (renumbered through the map).
504
- * @param events - events from {@link scanEvents}.
505
- * @param issue - the first issue from {@link scanEvents}, if any.
506
- * @param options - `{ duplicateReference?: 'first' | 'last' | 'segment' }`.
507
- * @returns the action taken, the renumber delta, counts, and — for a
508
- * refused ambiguous log — the ambiguity report.
509
- */
510
- export function repairEvents(events, issue, options = {}) {
511
- // Defensive: a non-enum strategy (a caller bypassing the CLI check) must
512
- // never silently degrade to `first` — that would rewrite ambiguous
513
- // references without the user's explicit choice.
514
- if (options.duplicateReference !== undefined && !DUPLICATE_REFERENCE_STRATEGIES.includes(options.duplicateReference)) {
515
- throw new Error(`unknown duplicate-reference strategy "${options.duplicateReference}" (first|last|segment)`)
516
- }
517
- if (issue === undefined) {
518
- return { events, action: 'none', fromIndex: -1, delta: 0, changed: 0, refsChanged: 0 }
519
- }
520
- if (issue.kind !== 'duplicate') {
521
- const kept = events.slice(0, issue.index)
522
- return {
523
- events: kept,
524
- action: 'truncate',
525
- fromIndex: issue.index,
526
- delta: 0,
527
- changed: events.length - kept.length,
528
- refsChanged: 0,
529
- message: issue.message,
530
- }
531
- }
532
- // Occurrence table: which old seq values repeat, and where.
533
- const occurrences = new Map()
534
- events.forEach((event, index) => {
535
- const list = occurrences.get(event.seq)
536
- if (list === undefined) occurrences.set(event.seq, [index])
537
- else list.push(index)
538
- })
539
- const duplicated = new Set()
540
- for (const [value, indices] of occurrences) {
541
- if (indices.length > 1) duplicated.add(value)
542
- }
543
- // Ambiguity analysis: references from events at/after the first collision
544
- // to a duplicated value cannot be attributed. Pre-collision events can
545
- // only reference pre-collision events (backward), and pre-collision seqs
546
- // are all unique, so their references are never ambiguous.
547
- const ambiguous = []
548
- for (let index = issue.index; index < events.length; index += 1) {
549
- const event = events[index]
550
- eachSeqRef(event, (container, key, position, ref) => {
551
- if (duplicated.has(ref)) {
552
- ambiguous.push({
553
- seq: ref,
554
- key,
555
- eventIndex: index,
556
- line: event.line,
557
- frame: event.frame,
558
- candidates: occurrences.get(ref),
559
- })
560
- }
561
- })
562
- }
563
- // The `segment` strategy adds a stricter rule: the same-frame binding must
564
- // be UNIQUE. A seq that repeats three or more times can place several
565
- // occurrences in the referencing event's own frame — those references are
566
- // ambiguous even with a strategy, and the repair refuses rather than
567
- // picking one silently.
568
- if (options.duplicateReference === 'segment') {
569
- for (let index = issue.index; index < events.length; index += 1) {
570
- const event = events[index]
571
- if (!(event.frame >= 0)) continue
572
- eachSeqRef(event, (container, key, position, ref) => {
573
- if (!duplicated.has(ref)) return
574
- const sameFrame = (occurrences.get(ref) ?? []).filter(candidate => events[candidate]?.frame === event.frame)
575
- if (sameFrame.length > 1) {
576
- ambiguous.push({
577
- seq: ref,
578
- key,
579
- eventIndex: index,
580
- line: event.line,
581
- frame: event.frame,
582
- candidates: sameFrame,
583
- sameFrameConflict: true,
584
- })
585
- }
586
- })
587
- }
588
- }
589
- const frameConflicts = ambiguous.filter(entry => entry.sameFrameConflict === true)
590
- if (options.duplicateReference === undefined && ambiguous.length > 0) {
591
- return {
592
- events,
593
- action: 'refuse',
594
- fromIndex: issue.index,
595
- delta: issue.index - issue.got,
596
- changed: 0,
597
- refsChanged: 0,
598
- ambiguous,
599
- message: `${ambiguous.length} ambiguous seq reference(s) to duplicated value(s); refusing automatic repair (pass --duplicate-reference=first|last|segment)`,
600
- }
601
- }
602
- if (options.duplicateReference === 'segment' && frameConflicts.length > 0) {
603
- return {
604
- events,
605
- action: 'refuse',
606
- fromIndex: issue.index,
607
- delta: issue.index - issue.got,
608
- changed: 0,
609
- refsChanged: 0,
610
- ambiguous: frameConflicts,
611
- message: `${frameConflicts.length} ambiguous seq reference(s): multiple candidate occurrences in the referencing event's frame; refusing (a segment binding must be unique)`,
612
- }
613
- }
614
- // Duplicate seq: renumber every event from the first collision onward so
615
- // `seq === index` again.
616
- const repaired = events
617
- const map = new Map()
618
- for (let index = issue.index; index < repaired.length; index += 1) {
619
- const event = repaired[index]
620
- map.set(event.seq, index)
621
- event.seq = index
622
- }
623
- let refsChanged = 0
624
- for (let index = 0; index < repaired.length; index += 1) {
625
- const event = repaired[index]
626
- // The duplicate strategy only applies to post-collision events: a
627
- // pre-collision reference points backward and can never mean a
628
- // post-collision event, so it must never be remapped by a strategy.
629
- const remapped = remapReferences(event, index, issue.index, map, occurrences, index >= issue.index ? options.duplicateReference : undefined, events)
630
- refsChanged += remapped.changed
631
- if (remapped.ambiguous !== undefined) {
632
- // Defensive: the pre-check above should have caught every same-frame
633
- // conflict already; if one slips through, refuse rather than write.
634
- ambiguous.push(remapped.ambiguous)
635
- }
636
- }
637
- if (ambiguous.some(entry => entry.sameFrameConflict === true)) {
638
- const frameConflicts = ambiguous.filter(entry => entry.sameFrameConflict === true)
639
- return {
640
- events: repaired,
641
- action: 'refuse',
642
- fromIndex: issue.index,
643
- delta: issue.index - issue.got,
644
- changed: 0,
645
- refsChanged: 0,
646
- ambiguous: frameConflicts,
647
- message: `${frameConflicts.length} ambiguous seq reference(s): multiple candidate occurrences in the referencing event's frame; refusing (a segment binding must be unique)`,
648
- }
649
- }
650
- return {
651
- events: repaired,
652
- action: 'renumber',
653
- fromIndex: issue.index,
654
- delta: issue.index - issue.got,
655
- changed: repaired.length - issue.index,
656
- refsChanged,
657
- ambiguous: ambiguous.length === 0 ? undefined : ambiguous,
658
- resolvedStrategy: ambiguous.length === 0 ? undefined : options.duplicateReference,
659
- message: issue.message,
660
- }
661
- }
662
-
663
- /**
664
- * Encode the repaired log: the original header line verbatim, then one JSON
665
- * row per event (packed chunk rows are expanded — the standard reader
666
- * accepts unpacked rows).
667
- */
668
- export function encodeLog(header, events) {
669
- const lines = [header]
670
- for (const event of events) lines.push(JSON.stringify(event))
671
- return `${lines.join('\n')}\n`
672
- }