@xmoon76/dsh-pi-tui 0.1.2 → 0.1.4

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.
@@ -49,11 +49,19 @@ const CHECKSUMMED_OPTIONS = {
49
49
 
50
50
  /**
51
51
  * Walk concatenated Zstandard frames and return each frame's byte range.
52
- * Vendored from `dsh-session-persistence-jsonl/lib/index.js`
52
+ * Adapted from `dsh-session-persistence-jsonl/lib/index.js`
53
53
  * (`scanZstdFrames`): parses the frame header (content-size flags) and block
54
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.
55
63
  * @param buffer - the raw artifact bytes.
56
- * @returns `{ frames, tornStart }` — `tornStart` when the tail is truncated.
64
+ * @returns `{ frames, tornStart?, garbageStart? }`.
57
65
  */
58
66
  export function scanZstdFrames(buffer) {
59
67
  const frames = []
@@ -61,12 +69,12 @@ export function scanZstdFrames(buffer) {
61
69
  while (offset < buffer.length) {
62
70
  const start = offset
63
71
  if (buffer.length - offset < 4) return { frames, tornStart: start }
64
- if (buffer.readUInt32LE(offset) !== ZSTD_MAGIC) throw new Error(`corrupt Zstandard session log: invalid frame magic at byte ${offset}`)
72
+ if (buffer.readUInt32LE(offset) !== ZSTD_MAGIC) return { frames, garbageStart: start }
65
73
  offset += 4
66
74
  if (offset === buffer.length) return { frames, tornStart: start }
67
75
  const descriptor = buffer.readUInt8(offset)
68
76
  offset += 1
69
- if ((descriptor & 24) !== 0) throw new Error(`corrupt Zstandard session log: reserved frame-header bit at byte ${offset - 1}`)
77
+ if ((descriptor & 24) !== 0) return { frames, garbageStart: start }
70
78
  const contentSizeFlag = descriptor >>> 6
71
79
  const singleSegment = (descriptor & 32) !== 0
72
80
  const checksum = (descriptor & 4) !== 0
@@ -83,7 +91,7 @@ export function scanZstdFrames(buffer) {
83
91
  const lastBlock = (blockHeader & 1) !== 0
84
92
  const blockType = blockHeader >>> 1 & 3
85
93
  const blockSize = blockHeader >>> 3
86
- if (blockType === 3) throw new Error(`corrupt Zstandard session log: reserved block type at byte ${offset - 3}`)
94
+ if (blockType === 3) return { frames, garbageStart: start }
87
95
  const payloadBytes = blockType === 1 ? 1 : blockSize
88
96
  if (buffer.length - offset < payloadBytes) return { frames, tornStart: start }
89
97
  offset += payloadBytes
@@ -99,19 +107,77 @@ export function scanZstdFrames(buffer) {
99
107
  }
100
108
 
101
109
  /**
102
- * Decompress a complete concatenated-frame artifact into its JSONL text.
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.
103
141
  * @param buffer - raw artifact bytes.
104
142
  * @param zstdDecompressSync - `node:zlib` zstd decompressor (one frame per call).
105
- * @returns `{ text, frames }`.
106
143
  */
107
- export function decompressFrames(buffer, zstdDecompressSync) {
108
- const { frames } = scanZstdFrames(buffer)
109
- if (frames.length === 0) throw new Error('empty or header-less Zstandard session log')
110
- const parts = []
111
- for (const frame of frames) {
112
- parts.push(zstdDecompressSync(buffer.subarray(frame.start, frame.end)))
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))
113
155
  }
114
- return { text: Buffer.concat(parts).toString('utf8'), frames: frames.length }
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 }
115
181
  }
116
182
 
117
183
  /**
@@ -121,24 +187,31 @@ export function decompressFrames(buffer, zstdDecompressSync) {
121
187
  * batch, so a whole-log single-frame artifact is structurally valid zstd but
122
188
  * rejected by dsh (`corrupt Zstandard session log: first frame is not
123
189
  * exactly one header line`) — `session.list`, `load`, and `readFrom` all
124
- * fail on it.
190
+ * fail on it. A torn tail is NEVER reported as ok, even when the complete
191
+ * prefix is laid out correctly.
125
192
  * @param buffer - raw artifact bytes.
126
193
  * @param zstdDecompressSync - `node:zlib` zstd decompressor (one frame per call).
127
194
  * @returns `{ ok: true }` or `{ ok: false, issue }`.
128
195
  */
129
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) {
130
208
  const { frames } = scanZstdFrames(buffer)
131
- if (frames.length === 0) return { ok: false, issue: 'empty or header-less Zstandard session log' }
132
- let plaintext
133
- try {
134
- plaintext = zstdDecompressSync(buffer.subarray(frames[0].start, frames[0].end))
135
- } catch (error) {
136
- return { ok: false, issue: `corrupt Zstandard session log: header frame failed validation (${error instanceof Error ? error.message : String(error)})` }
137
- }
138
- if (plaintext.length === 0 || plaintext.indexOf(0x0A) !== plaintext.length - 1) {
139
- return { ok: false, issue: 'corrupt Zstandard session log: first frame is not exactly one header line' }
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)))
140
213
  }
141
- return { ok: true }
214
+ return { text: Buffer.concat(parts).toString('utf8'), frames: frames.length }
142
215
  }
143
216
 
144
217
  /**
@@ -185,13 +258,22 @@ export function compressLog(text, zstdCompressSync) {
185
258
  * the tail) and every row is examined; the FIRST issue is reported.
186
259
  * @param text - decompressed JSONL session text.
187
260
  * @param decodeStorageRecord - `@deepseek-ai/dsh-session` row decoder.
261
+ * @param frames - optional per-frame `{ startLine, endLine }` ranges (1-based,
262
+ * endLine exclusive) for writer-segment resolution; undefined disables.
188
263
  * @returns `{ header, events, issue }`.
189
264
  */
190
- export function scanEvents(text, decodeStorageRecord) {
265
+ export function scanEvents(text, decodeStorageRecord, frames) {
191
266
  const lines = text.split('\n')
192
267
  const header = lines[0] ?? ''
193
268
  const events = []
194
269
  let issue
270
+ const frameOfLine = (line) => {
271
+ if (frames === undefined) return -1
272
+ for (let frame = 0; frame < frames.length; frame += 1) {
273
+ if (line >= frames[frame].startLine && line < frames[frame].endLine) return frame
274
+ }
275
+ return -1
276
+ }
195
277
  for (let lineIndex = 1; lineIndex < lines.length; lineIndex += 1) {
196
278
  const raw = lines[lineIndex]
197
279
  if (raw === '') continue
@@ -207,6 +289,7 @@ export function scanEvents(text, decodeStorageRecord) {
207
289
  }
208
290
  continue
209
291
  }
292
+ const frame = frameOfLine(lineIndex + 1)
210
293
  for (const event of decoded) {
211
294
  if (event.seq !== events.length) {
212
295
  issue ??= {
@@ -218,23 +301,50 @@ export function scanEvents(text, decodeStorageRecord) {
218
301
  message: `corrupt session log: seq gap in committed region at line ${lineIndex + 1} (expected ${events.length}, got ${event.seq})`,
219
302
  }
220
303
  }
304
+ // Non-enumerable tags: encodeLog must never serialize them into the
305
+ // repaired artifact. `frame` is the writer segment (flush frame) the
306
+ // row arrived in; -1 when frame info is unavailable.
307
+ Object.defineProperty(event, 'line', { value: lineIndex + 1, enumerable: false, configurable: true })
308
+ Object.defineProperty(event, 'frame', { value: frame, enumerable: false, configurable: true })
221
309
  events.push(event)
222
310
  }
223
311
  }
224
312
  return { header, events, issue }
225
313
  }
226
314
 
315
+ /**
316
+ * Decompress each complete frame of an artifact separately and return the
317
+ * 1-based JSONL line range it covers (`endLine` exclusive). Together with
318
+ * {@link scanEvents} this attributes events to the flush frame (writer
319
+ * segment) they arrived in, which duplicate-seq reference resolution uses.
320
+ */
321
+ export function frameLineRanges(buffer, zstdDecompressSync) {
322
+ const { frames } = scanZstdFrames(buffer)
323
+ const ranges = []
324
+ let line = 1
325
+ for (const frame of frames) {
326
+ const plain = zstdDecompressSync(buffer.subarray(frame.start, frame.end)).toString('utf8')
327
+ const startLine = line
328
+ line += plain.split('\n').length - 1
329
+ ranges.push({ startLine, endLine: line })
330
+ }
331
+ return ranges
332
+ }
333
+
227
334
  /** Keys whose array values are seq references in event `data`. */
228
335
  const SEQ_REF_ARRAY_KEYS = new Set(['sourceEventSeqs', 'messageSeqs'])
229
336
 
230
337
  /**
231
- * Remap seq cross-references inside one event's `data` through the
232
- * old→new mapping. Only values `>= threshold` are remapped: everything
233
- * below the first collision is a pre-collision event whose seq is
234
- * unchanged. Returns the number of remapped references.
338
+ * Visit every seq cross-reference inside one event's `data`: the
339
+ * `sourceEventSeqs`/`messageSeqs` array values and the `sourceEventSeq`
340
+ * scalar, at any nesting depth.
341
+ * @param event - a session event.
342
+ * @param visit - `(container, key, index, value) => void`; `index` is the
343
+ * array position for array references and undefined for the scalar, so the
344
+ * caller can write the replacement precisely (never by value — a remapped
345
+ * reference could equal another reference's old value).
235
346
  */
236
- function remapReferences(event, threshold, map) {
237
- let changed = 0
347
+ function eachSeqRef(event, visit) {
238
348
  const walk = (node) => {
239
349
  if (node === null || typeof node !== 'object') return
240
350
  if (Array.isArray(node)) {
@@ -244,33 +354,132 @@ function remapReferences(event, threshold, map) {
244
354
  for (const key of Object.keys(node)) {
245
355
  const value = node[key]
246
356
  if (SEQ_REF_ARRAY_KEYS.has(key) && Array.isArray(value)) {
247
- for (let i = 0; i < value.length; i += 1) {
248
- const ref = value[i]
249
- if (typeof ref === 'number' && ref >= threshold && map.has(ref)) {
250
- value[i] = map.get(ref)
251
- changed += 1
252
- }
357
+ for (let index = 0; index < value.length; index += 1) {
358
+ if (typeof value[index] === 'number') visit(node, key, index, value[index])
253
359
  }
254
- } else if (key === 'sourceEventSeq' && typeof value === 'number' && value >= threshold && map.has(value)) {
255
- node[key] = map.get(value)
256
- changed += 1
360
+ } else if (key === 'sourceEventSeq' && typeof value === 'number') {
361
+ visit(node, key, undefined, value)
257
362
  } else {
258
363
  walk(value)
259
364
  }
260
365
  }
261
366
  }
262
367
  walk(event.data)
263
- return changed
264
368
  }
265
369
 
370
+ /**
371
+ * Resolve one seq reference through the old→new map, honoring a duplicate
372
+ * strategy for values that occur more than once:
373
+ * - `first` — bind to the FIRST occurrence (the reference stays unchanged);
374
+ * - `last` — bind to the LAST occurrence (remapped through the map);
375
+ * - `segment` — bind to the UNIQUE occurrence in the same writer segment
376
+ * (flush frame) as the referencing event. A single same-frame candidate
377
+ * resolves to that occurrence's index (after renumbering `seq === index`),
378
+ * NEVER to the global last occurrence (`map`), which may live in a
379
+ * different frame when the seq repeats three or more times across frames.
380
+ * Multiple same-frame candidates are ambiguous — the caller refuses.
381
+ * No same-frame candidate falls back to `first` (documented); without
382
+ * frame metadata the strategy degrades to `first`.
383
+ * @param value - the referenced (old) seq.
384
+ * @param strategy - the duplicate-reference strategy, or undefined.
385
+ * @param fromEvent - the referencing event (for the segment strategy).
386
+ * @param map - old→new mapping (last occurrence wins per value).
387
+ * @param occurrences - seq value → array of event indices.
388
+ * @param events - the scanned events (for occurrence frame lookup).
389
+ * @returns `{ kind: 'resolved', seq }` — write this seq;
390
+ * `{ kind: 'keep' }` — leave the reference unchanged (first);
391
+ * `{ kind: 'ambiguous', candidates }` — same-frame binding not
392
+ * unique (only possible under `segment`).
393
+ */
394
+ function resolveReference(value, strategy, fromEvent, map, occurrences, events) {
395
+ if (strategy === 'last') return { kind: 'resolved', seq: map.get(value) }
396
+ if (strategy === 'segment' && fromEvent.frame >= 0) {
397
+ const candidates = occurrences.get(value) ?? []
398
+ const sameFrame = candidates.filter(index => events[index]?.frame === fromEvent.frame)
399
+ if (sameFrame.length === 1) {
400
+ // The occurrence index IS the new seq after renumbering.
401
+ return { kind: 'resolved', seq: sameFrame[0] }
402
+ }
403
+ if (sameFrame.length > 1) return { kind: 'ambiguous', candidates: sameFrame }
404
+ // No same-frame occurrence: fall back to first (documented).
405
+ return { kind: 'keep' }
406
+ }
407
+ // 'first' and the segment fallback: the first occurrence keeps its seq.
408
+ return { kind: 'keep' }
409
+ }
410
+
411
+ /**
412
+ * Remap seq cross-references inside one event's `data` through the
413
+ * old→new mapping. Only values `>= threshold` are remapped: everything
414
+ * below the first collision is a pre-collision event whose seq is
415
+ * unchanged. Returns the number of remapped references and — for the
416
+ * `segment` strategy — the first same-frame-ambiguous reference, if any
417
+ * (pre-checked by repairEvents, so this is defensive).
418
+ */
419
+ function remapReferences(event, eventIndex, threshold, map, occurrences, strategy, events) {
420
+ let changed = 0
421
+ let ambiguous = undefined
422
+ eachSeqRef(event, (container, key, index, ref) => {
423
+ let next
424
+ if (strategy !== undefined && (occurrences.get(ref)?.length ?? 0) > 1) {
425
+ const outcome = resolveReference(ref, strategy, event, map, occurrences, events)
426
+ if (outcome.kind === 'ambiguous') {
427
+ ambiguous ??= {
428
+ seq: ref,
429
+ key,
430
+ eventIndex,
431
+ line: event.line,
432
+ frame: event.frame,
433
+ candidates: outcome.candidates,
434
+ sameFrameConflict: true,
435
+ }
436
+ return
437
+ }
438
+ next = outcome.kind === 'resolved' ? outcome.seq : undefined
439
+ } else if (ref >= threshold && map.has(ref)) {
440
+ next = map.get(ref)
441
+ }
442
+ if (next !== undefined && next !== ref) {
443
+ if (index === undefined) container[key] = next
444
+ else container[key][index] = next
445
+ changed += 1
446
+ }
447
+ })
448
+ return { changed, ambiguous }
449
+ }
450
+
451
+ /** The duplicate-seq reference resolution strategies (CLI: --duplicate-reference). */
452
+ export const DUPLICATE_REFERENCE_STRATEGIES = ['first', 'last', 'segment']
453
+
266
454
  /**
267
455
  * Repair the scanned log. Mutates the events array's `seq` fields and the
268
456
  * reference fields it remaps.
457
+ *
458
+ * Duplicate-seq semantics (stage C): a reference to a seq value that occurs
459
+ * MORE THAN ONCE in the log cannot be uniquely attributed — it may name the
460
+ * pre-collision event or one of the collision writer's events. When such
461
+ * ambiguous references exist, the default is to REFUSE (`action: 'refuse'`)
462
+ * with a full report ({@link AmbiguousReference} entries: conflict seq,
463
+ * referencing event, candidates, line/frame) so the user can pick a
464
+ * `duplicateReference` strategy. Only when every reference is uniquely
465
+ * resolvable does the repair renumber and remap automatically.
466
+ *
467
+ * The reference-mapping rule for unambiguous values: values below the
468
+ * collision index name pre-collision events (unchanged); values at or above
469
+ * it can only name post-collision events (renumbered through the map).
269
470
  * @param events - events from {@link scanEvents}.
270
471
  * @param issue - the first issue from {@link scanEvents}, if any.
271
- * @returns the action taken, the renumber delta, and counts.
472
+ * @param options - `{ duplicateReference?: 'first' | 'last' | 'segment' }`.
473
+ * @returns the action taken, the renumber delta, counts, and — for a
474
+ * refused ambiguous log — the ambiguity report.
272
475
  */
273
- export function repairEvents(events, issue) {
476
+ export function repairEvents(events, issue, options = {}) {
477
+ // Defensive: a non-enum strategy (a caller bypassing the CLI check) must
478
+ // never silently degrade to `first` — that would rewrite ambiguous
479
+ // references without the user's explicit choice.
480
+ if (options.duplicateReference !== undefined && !DUPLICATE_REFERENCE_STRATEGIES.includes(options.duplicateReference)) {
481
+ throw new Error(`unknown duplicate-reference strategy "${options.duplicateReference}" (first|last|segment)`)
482
+ }
274
483
  if (issue === undefined) {
275
484
  return { events, action: 'none', fromIndex: -1, delta: 0, changed: 0, refsChanged: 0 }
276
485
  }
@@ -286,10 +495,90 @@ export function repairEvents(events, issue) {
286
495
  message: issue.message,
287
496
  }
288
497
  }
498
+ // Occurrence table: which old seq values repeat, and where.
499
+ const occurrences = new Map()
500
+ events.forEach((event, index) => {
501
+ const list = occurrences.get(event.seq)
502
+ if (list === undefined) occurrences.set(event.seq, [index])
503
+ else list.push(index)
504
+ })
505
+ const duplicated = new Set()
506
+ for (const [value, indices] of occurrences) {
507
+ if (indices.length > 1) duplicated.add(value)
508
+ }
509
+ // Ambiguity analysis: references from events at/after the first collision
510
+ // to a duplicated value cannot be attributed. Pre-collision events can
511
+ // only reference pre-collision events (backward), and pre-collision seqs
512
+ // are all unique, so their references are never ambiguous.
513
+ const ambiguous = []
514
+ for (let index = issue.index; index < events.length; index += 1) {
515
+ const event = events[index]
516
+ eachSeqRef(event, (container, key, position, ref) => {
517
+ if (duplicated.has(ref)) {
518
+ ambiguous.push({
519
+ seq: ref,
520
+ key,
521
+ eventIndex: index,
522
+ line: event.line,
523
+ frame: event.frame,
524
+ candidates: occurrences.get(ref),
525
+ })
526
+ }
527
+ })
528
+ }
529
+ // The `segment` strategy adds a stricter rule: the same-frame binding must
530
+ // be UNIQUE. A seq that repeats three or more times can place several
531
+ // occurrences in the referencing event's own frame — those references are
532
+ // ambiguous even with a strategy, and the repair refuses rather than
533
+ // picking one silently.
534
+ if (options.duplicateReference === 'segment') {
535
+ for (let index = issue.index; index < events.length; index += 1) {
536
+ const event = events[index]
537
+ if (!(event.frame >= 0)) continue
538
+ eachSeqRef(event, (container, key, position, ref) => {
539
+ if (!duplicated.has(ref)) return
540
+ const sameFrame = (occurrences.get(ref) ?? []).filter(candidate => events[candidate]?.frame === event.frame)
541
+ if (sameFrame.length > 1) {
542
+ ambiguous.push({
543
+ seq: ref,
544
+ key,
545
+ eventIndex: index,
546
+ line: event.line,
547
+ frame: event.frame,
548
+ candidates: sameFrame,
549
+ sameFrameConflict: true,
550
+ })
551
+ }
552
+ })
553
+ }
554
+ }
555
+ const frameConflicts = ambiguous.filter(entry => entry.sameFrameConflict === true)
556
+ if (options.duplicateReference === undefined && ambiguous.length > 0) {
557
+ return {
558
+ events,
559
+ action: 'refuse',
560
+ fromIndex: issue.index,
561
+ delta: issue.index - issue.got,
562
+ changed: 0,
563
+ refsChanged: 0,
564
+ ambiguous,
565
+ message: `${ambiguous.length} ambiguous seq reference(s) to duplicated value(s); refusing automatic repair (pass --duplicate-reference=first|last|segment)`,
566
+ }
567
+ }
568
+ if (options.duplicateReference === 'segment' && frameConflicts.length > 0) {
569
+ return {
570
+ events,
571
+ action: 'refuse',
572
+ fromIndex: issue.index,
573
+ delta: issue.index - issue.got,
574
+ changed: 0,
575
+ refsChanged: 0,
576
+ ambiguous: frameConflicts,
577
+ message: `${frameConflicts.length} ambiguous seq reference(s): multiple candidate occurrences in the referencing event's frame; refusing (a segment binding must be unique)`,
578
+ }
579
+ }
289
580
  // Duplicate seq: renumber every event from the first collision onward so
290
- // `seq === index` again. The old→new map resolves cross-references: values
291
- // below the collision index name pre-collision events (unchanged); values
292
- // at or above it can only name post-collision events (renumbered).
581
+ // `seq === index` again.
293
582
  const repaired = events
294
583
  const map = new Map()
295
584
  for (let index = issue.index; index < repaired.length; index += 1) {
@@ -298,8 +587,31 @@ export function repairEvents(events, issue) {
298
587
  event.seq = index
299
588
  }
300
589
  let refsChanged = 0
301
- for (const event of repaired) {
302
- refsChanged += remapReferences(event, issue.index, map)
590
+ for (let index = 0; index < repaired.length; index += 1) {
591
+ const event = repaired[index]
592
+ // The duplicate strategy only applies to post-collision events: a
593
+ // pre-collision reference points backward and can never mean a
594
+ // post-collision event, so it must never be remapped by a strategy.
595
+ const remapped = remapReferences(event, index, issue.index, map, occurrences, index >= issue.index ? options.duplicateReference : undefined, events)
596
+ refsChanged += remapped.changed
597
+ if (remapped.ambiguous !== undefined) {
598
+ // Defensive: the pre-check above should have caught every same-frame
599
+ // conflict already; if one slips through, refuse rather than write.
600
+ ambiguous.push(remapped.ambiguous)
601
+ }
602
+ }
603
+ if (ambiguous.some(entry => entry.sameFrameConflict === true)) {
604
+ const frameConflicts = ambiguous.filter(entry => entry.sameFrameConflict === true)
605
+ return {
606
+ events: repaired,
607
+ action: 'refuse',
608
+ fromIndex: issue.index,
609
+ delta: issue.index - issue.got,
610
+ changed: 0,
611
+ refsChanged: 0,
612
+ ambiguous: frameConflicts,
613
+ message: `${frameConflicts.length} ambiguous seq reference(s): multiple candidate occurrences in the referencing event's frame; refusing (a segment binding must be unique)`,
614
+ }
303
615
  }
304
616
  return {
305
617
  events: repaired,
@@ -308,6 +620,8 @@ export function repairEvents(events, issue) {
308
620
  delta: issue.index - issue.got,
309
621
  changed: repaired.length - issue.index,
310
622
  refsChanged,
623
+ ambiguous: ambiguous.length === 0 ? undefined : ambiguous,
624
+ resolvedStrategy: ambiguous.length === 0 ? undefined : options.duplicateReference,
311
625
  message: issue.message,
312
626
  }
313
627
  }