@dev-loops/core 1.0.0-rc.5 → 1.0.0-rc.6

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.
@@ -0,0 +1,588 @@
1
+ /**
2
+ * review-lineage.mjs — additive review-lineage base + per-fix-round delta
3
+ * composition (issue #1468 slice 5).
4
+ *
5
+ * A new head after a fix used to rebuild a full head-specific briefing. This
6
+ * module introduces a stable review-lineage base plus deterministic per-round
7
+ * delta artifacts, so round 2+ appends only what changed instead of replacing
8
+ * the whole context.
9
+ *
10
+ * Artifact model (Section E of the #1468 spec):
11
+ *
12
+ * review-lineage-base
13
+ * lineage identity + gate + stable contracts/instructions + original
14
+ * review target + original full diff.
15
+ *
16
+ * round-N-delta
17
+ * exact base/reviewed SHAs + the fix diff + validation evidence + an
18
+ * independent findings verification checklist.
19
+ *
20
+ * Composition contract:
21
+ *
22
+ * round-N request = [lineage base][delta 1][delta 2]...[delta N][angle suffix]
23
+ *
24
+ * Composition is append-only and byte-deterministic: the composed request is
25
+ * the ordered concatenation of the lineage base and the individual delta
26
+ * artifacts, never a parse/reserialize of the full PR context as a replacement
27
+ * block. Round N+1 appends exactly one new delta segment; every prior segment
28
+ * is byte-identical (same ref + same hash) — that is what the
29
+ * "does not rebuild the full PR context" test asserts.
30
+ *
31
+ * Carry-forward semantics are unchanged: a carried clean angle still records
32
+ * its original reviewer and prior head. This module only preserves that
33
+ * provenance in the composed request; it does not decide carry-forward (that
34
+ * stays in gate-carry-forward.mjs) and it never fabricates a verdict.
35
+ *
36
+ * This module is pure and offline: no GitHub, no harness, no clock.
37
+ */
38
+ import { sha256Hex } from "./review-dispatch-plan.mjs";
39
+
40
+ // Full-length SHA only: GitHub abbreviated prefixes (7-39 hex) must NOT validate
41
+ // as an "exact SHA" (findings: input-validation). Accept only a full 40-hex
42
+ // (SHA-1) or 64-hex (SHA-256) digest.
43
+ const HEX = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i;
44
+
45
+ /** @param {string} value @returns {boolean} */
46
+ function isHexSha(value) {
47
+ return typeof value === "string" && HEX.test(value.trim().toLowerCase());
48
+ }
49
+
50
+ const isPlainObject = (v) => v != null && typeof v === "object" && !Array.isArray(v) && !Buffer.isBuffer(v);
51
+
52
+ /**
53
+ * Recursively sort object keys for a byte-deterministic serialization. Nested
54
+ * caller-supplied objects (validationEvidence, findingsChecklist entries) may
55
+ * arrive with arbitrary key insertion order; canonicalizing here guarantees the
56
+ * rendered segment bytes are key-order-independent (findings: determinism).
57
+ */
58
+ function stableStringify(value) {
59
+ if (Array.isArray(value)) return value.map(stableStringify);
60
+ if (Buffer.isBuffer(value)) return `__buffer:${value.toString("hex")}`;
61
+ if (isPlainObject(value)) {
62
+ const out = {};
63
+ for (const key of Object.keys(value).sort()) out[key] = stableStringify(value[key]);
64
+ return out;
65
+ }
66
+ return value;
67
+ }
68
+
69
+ /** Canonical byte-serialization of an artifact (sorted keys, JSON). */
70
+ function canonicalJson(value) {
71
+ return JSON.stringify(stableStringify(value));
72
+ }
73
+
74
+
75
+ /** Normalize a text-ish field (string | string[] | Buffer) to a canonical string. */
76
+ function normalizeText(value, label) {
77
+ if (value == null) return "";
78
+ if (Buffer.isBuffer(value)) return `__buffer:${value.toString("hex")}`;
79
+ if (Array.isArray(value)) return value.map((s) => String(s)).join("\n");
80
+ if (typeof value === "string") return value;
81
+ return JSON.stringify(value);
82
+ }
83
+
84
+ const HEX64 = /^sha256:[0-9a-f]{64}$/;
85
+
86
+ /** @param {string} v */
87
+ function isSha256(v) {
88
+ return typeof v === "string" && HEX64.test(v);
89
+ }
90
+
91
+ function requireLineageId(lineageId) {
92
+ if (typeof lineageId !== "string" || lineageId.trim().length === 0) {
93
+ throw new Error("review-lineage requires a non-empty lineageId");
94
+ }
95
+ }
96
+
97
+ function requireGate(gate) {
98
+ if (typeof gate !== "string" || gate.trim().length === 0) {
99
+ throw new Error("review-lineage requires a non-empty gate");
100
+ }
101
+ }
102
+
103
+ /* ------------------------------------------------------------------ *
104
+ * Review-lineage base
105
+ * ------------------------------------------------------------------ */
106
+
107
+ /**
108
+ * Build a deterministic review-lineage base artifact for a PR review lineage.
109
+ *
110
+ * The base holds the stable material that is identical across every fix round
111
+ * of the lineage: the gate, the stable contracts/instructions, and the ORIGINAL
112
+ * review target with the ORIGINAL full diff. `baseHash` is a deterministic
113
+ * fingerprint over the canonicalized base so any consumer can prove two runs
114
+ * share a byte-identical base without re-comparing bodies.
115
+ *
116
+ * @param {object} input
117
+ * @param {string} input.lineageId - lineage identity (per PR review lineage).
118
+ * @param {string} input.gate - draft_gate | pre_approval_gate | ...
119
+ * @param {string} input.originalHead - the head SHA the original (round-1) review targeted.
120
+ * @param {string|string[]|Buffer} input.originalDiff - the original full diff.
121
+ * @param {string|string[]|Buffer} [input.stableContracts] - stable contracts/instructions.
122
+ * @returns {Readonly<object>} frozen review-lineage-base artifact.
123
+ */
124
+ export function buildReviewLineageBase({ lineageId, gate, originalHead, originalDiff, stableContracts } = {}) {
125
+ requireLineageId(lineageId);
126
+ requireGate(gate);
127
+ if (!isHexSha(originalHead)) {
128
+ throw new Error(`review-lineage originalHead must be a hex sha, got ${JSON.stringify(originalHead)}`);
129
+ }
130
+ if (originalDiff == null || String(originalDiff).length === 0) {
131
+ throw new Error("review-lineage base requires an originalDiff (the original full diff)");
132
+ }
133
+ const base = {
134
+ kind: "review-lineage-base",
135
+ lineageId,
136
+ gate,
137
+ originalHead: originalHead.trim().toLowerCase(),
138
+ originalDiff: normalizeText(originalDiff, "originalDiff"),
139
+ stableContracts: normalizeText(stableContracts, "stableContracts"),
140
+ };
141
+ return Object.freeze({
142
+ ...base,
143
+ baseHash: sha256Hex(base),
144
+ });
145
+ }
146
+
147
+ /* ------------------------------------------------------------------ *
148
+ * Per-fix-round delta
149
+ * ------------------------------------------------------------------ */
150
+
151
+ /**
152
+ * Build a deterministic per-fix-round delta artifact.
153
+ *
154
+ * A delta records, for one fix round, the exact base/head SHAs, the actual fix
155
+ * diff, the validation evidence, and an INDEPENDENT findings verification
156
+ * checklist (not agreement-seeking verdict prose). `deltaHash` fingerprints the
157
+ * canonicalized delta so identical inputs always yield byte-identical deltas.
158
+ *
159
+ * @param {object} input
160
+ * @param {string} input.lineageId - must match the owning lineage.
161
+ * @param {number} input.round - 1-based fix-round number.
162
+ * @param {string} input.gate
163
+ * @param {string} input.baseHead - the head assumed as the delta's base.
164
+ * @param {string} input.reviewedHead - the head actually reviewed in this round.
165
+ * @param {string|string[]|Buffer} input.fixDiff - the actual fix diff for this round.
166
+ * @param {string|string[]|Buffer|object} [input.validationEvidence] - validation evidence.
167
+ * @param {Array<object>} [input.findingsChecklist] - independent findings checklist.
168
+ * @returns {Readonly<object>} frozen round-delta artifact.
169
+ */
170
+ export function buildFixRoundDelta({ lineageId, round, gate, baseHead, reviewedHead, fixDiff, validationEvidence, findingsChecklist } = {}) {
171
+ requireLineageId(lineageId);
172
+ requireGate(gate);
173
+ if (!Number.isInteger(round) || round < 1) {
174
+ throw new Error(`buildFixRoundDelta round must be a positive integer, got ${JSON.stringify(round)}`);
175
+ }
176
+ if (!isHexSha(baseHead) || !isHexSha(reviewedHead)) {
177
+ throw new Error("buildFixRoundDelta baseHead and reviewedHead must be hex shas");
178
+ }
179
+ if (fixDiff == null || String(fixDiff).length === 0) {
180
+ throw new Error("buildFixRoundDelta requires a fixDiff (the actual fix diff)");
181
+ }
182
+ if (findingsChecklist != null) {
183
+ if (!Array.isArray(findingsChecklist)) {
184
+ throw new Error("buildFixRoundDelta findingsChecklist must be an array");
185
+ }
186
+ for (let i = 0; i < findingsChecklist.length; i++) {
187
+ const f = findingsChecklist[i];
188
+ if (typeof f !== "object" || f === null || Array.isArray(f)) {
189
+ throw new Error(`findingsChecklist[${i}] must be an object entry`);
190
+ }
191
+ }
192
+ }
193
+ const delta = {
194
+ kind: "round-delta",
195
+ lineageId,
196
+ round,
197
+ gate,
198
+ baseHead: baseHead.trim().toLowerCase(),
199
+ reviewedHead: reviewedHead.trim().toLowerCase(),
200
+ fixDiff: normalizeText(fixDiff, "fixDiff"),
201
+ ...(validationEvidence != null
202
+ ? {
203
+ validationEvidence:
204
+ typeof validationEvidence === "object" &&
205
+ !Array.isArray(validationEvidence) &&
206
+ !Buffer.isBuffer(validationEvidence)
207
+ ? JSON.parse(JSON.stringify(validationEvidence))
208
+ : normalizeText(validationEvidence, "validationEvidence"),
209
+ }
210
+ : {}),
211
+ ...(findingsChecklist != null ? { findingsChecklist: JSON.parse(JSON.stringify(findingsChecklist)) } : {}),
212
+ };
213
+ return Object.freeze({
214
+ ...delta,
215
+ deltaHash: sha256Hex(delta),
216
+ });
217
+ }
218
+
219
+ /* ------------------------------------------------------------------ *
220
+ * Append-only round request composition
221
+ * ------------------------------------------------------------------ */
222
+
223
+ export const LINEAGE_BASE_SLOT = "lineageBase";
224
+ export const ROUND_DELTA_SLOT = "roundDelta";
225
+ export const ANGLE_SUFFIX_SLOT = "angleSuffix";
226
+
227
+ /**
228
+ * Compose the round-N request as an append-only ordered segment list:
229
+ *
230
+ * [lineage base][delta 1]...[delta N][angle suffix]
231
+ *
232
+ * The returned `segments` carry individual artifact bytes + hashes so a
233
+ * consumer can render the request by concatenating segment bytes IN ORDER
234
+ * (never parsing/reserializing prior segments). Round N+1 appends exactly one
235
+ * new delta segment: callers should REUSE the prior composed segments (or the
236
+ * base + prior deltas) rather than rebuilding the full PR context, and this
237
+ * function's contract + tests pin that reuse property.
238
+ *
239
+ * Segments before the new delta are byte-identical to the prior round's
240
+ * (same `ref` + same `hash`), which is the mechanical proof of append-only
241
+ * composition (AC-2: does not rebuild the full PR context as a replacement
242
+ * block).
243
+ *
244
+ * `carriedAngles` provenance is preserved unchanged (carry-forward semantics):
245
+ * each entry is { angle, originalReviewer, priorHead } and is folded into the
246
+ * composed hash so a carried angle's provenance is pinned — but never
247
+ * fabricated. A carried clean angle keeps exactly the original reviewer and
248
+ * prior head it was recorded with.
249
+ *
250
+ * @param {object} input
251
+ * @param {object} input.lineageBase - a valid review-lineage-base artifact.
252
+ * @param {object[]} [input.priorDeltas] - ordered round deltas (1..N-1).
253
+ * @param {object} input.newDelta - the delta being appended for this round.
254
+ * @param {string|Buffer} [input.angleSuffix] - angle-specific suffix.
255
+ * @param {Array<{angle:string, originalReviewer:string, priorHead:string}>} [input.carriedAngles]
256
+ * @returns {Readonly<object>} composed round request.
257
+ */
258
+ export function composeRoundRequest({ lineageBase, priorDeltas = [], newDelta, angleSuffix, carriedAngles = [] } = {}) {
259
+ if (!lineageBase || lineageBase.kind !== "review-lineage-base" || !isSha256(lineageBase.baseHash)) {
260
+ throw new Error("composeRoundRequest requires a valid lineage base artifact");
261
+ }
262
+ if (!Array.isArray(priorDeltas)) throw new Error("composeRoundRequest priorDeltas must be an array");
263
+ if (!newDelta || newDelta.kind !== "round-delta" || !isSha256(newDelta.deltaHash)) {
264
+ throw new Error("composeRoundRequest requires a valid round-delta artifact");
265
+ }
266
+ if (newDelta.lineageId !== lineageBase.lineageId) {
267
+ throw new Error("composeRoundRequest newDelta lineageId must match the lineage base");
268
+ }
269
+ if (!Array.isArray(carriedAngles)) throw new Error("composeRoundRequest carriedAngles must be an array");
270
+ // Every delta in the lineage (prior + new) must share the base's gate
271
+ // (findings: scope — gate consistency) and carry a valid deltaHash
272
+ // (findings: input-validation — prior-delta hash validation).
273
+ const allDeltas = [...priorDeltas, newDelta];
274
+ for (let i = 0; i < priorDeltas.length; i++) {
275
+ const d = priorDeltas[i];
276
+ if (!d || d.kind !== "round-delta" || d.lineageId !== lineageBase.lineageId) {
277
+ throw new Error("composeRoundRequest priorDeltas must be valid round-delta artifacts of the same lineage");
278
+ }
279
+ if (!isSha256(d.deltaHash)) {
280
+ throw new Error(`composeRoundRequest priorDeltas[${i}].deltaHash must be a valid sha256:<64hex>`);
281
+ }
282
+ if (d.gate !== lineageBase.gate) {
283
+ throw new Error("composeRoundRequest priorDeltas gate must match the lineage base gate");
284
+ }
285
+ if (d.round !== i + 1) {
286
+ throw new Error(`composeRoundRequest priorDeltas must be contiguous from round 1; expected round ${i + 1}, got ${d.round}`);
287
+ }
288
+ }
289
+ if (newDelta.round !== priorDeltas.length + 1) {
290
+ throw new Error(
291
+ `composeRoundRequest newDelta.round must follow the prior deltas; expected ${priorDeltas.length + 1}, got ${newDelta.round}`,
292
+ );
293
+ }
294
+ if (newDelta.gate !== lineageBase.gate) {
295
+ throw new Error("composeRoundRequest newDelta gate must match the lineage base gate");
296
+ }
297
+ // SHA-chain continuity (findings: correctness / scope): the delta chain must
298
+ // be anchored to the lineage base and head-linked end-to-end — round-1's
299
+ // baseHead must equal the base's originalHead, and each later delta's
300
+ // baseHead must equal the immediately prior delta's reviewedHead. This keeps
301
+ // the "exact SHAs" record truthful: a composed request can never claim a base
302
+ // that was not actually the prior reviewed head.
303
+ for (let i = 0; i < allDeltas.length; i++) {
304
+ const d = allDeltas[i];
305
+ if (i === 0) {
306
+ if (d.baseHead !== lineageBase.originalHead) {
307
+ throw new Error(
308
+ `composeRoundRequest round-1 baseHead ${d.baseHead} must equal the lineage base originalHead ${lineageBase.originalHead}`,
309
+ );
310
+ }
311
+ } else if (d.baseHead !== allDeltas[i - 1].reviewedHead) {
312
+ throw new Error(
313
+ `composeRoundRequest round-${d.round} baseHead ${d.baseHead} must equal prior round reviewedHead ${allDeltas[i - 1].reviewedHead}`,
314
+ );
315
+ }
316
+ }
317
+
318
+ const segments = [
319
+ {
320
+ slot: LINEAGE_BASE_SLOT,
321
+ ref: "lineage-base",
322
+ hash: lineageBase.baseHash,
323
+ bytes: canonicalJson(lineageBase),
324
+ },
325
+ ];
326
+ const deltas = [...priorDeltas, newDelta];
327
+ for (const d of deltas) {
328
+ segments.push({
329
+ slot: ROUND_DELTA_SLOT,
330
+ ref: `round-${String(d.round).padStart(2, "0")}`,
331
+ round: d.round,
332
+ hash: d.deltaHash,
333
+ bytes: canonicalJson(d),
334
+ });
335
+ }
336
+ if (angleSuffix != null && String(angleSuffix).length > 0) {
337
+ segments.push({
338
+ slot: ANGLE_SUFFIX_SLOT,
339
+ ref: "angle-suffix",
340
+ bytes: Buffer.isBuffer(angleSuffix) ? angleSuffix.toString("utf8") : String(angleSuffix),
341
+ });
342
+ }
343
+
344
+ const normalizedCarried = carriedAngles.map((c) => {
345
+ if (typeof c !== "object" || c === null) throw new Error("composeRoundRequest carriedAngles entries must be objects");
346
+ if (typeof c.angle !== "string" || typeof c.originalReviewer !== "string" || !isHexSha(c.priorHead)) {
347
+ throw new Error("composeRoundRequest carriedAngles entries require { angle, originalReviewer, priorHead }");
348
+ }
349
+ return {
350
+ angle: c.angle,
351
+ originalReviewer: c.originalReviewer,
352
+ priorHead: c.priorHead.trim().toLowerCase(), // trim + lower, consistent with other SHA fields
353
+ };
354
+ });
355
+
356
+ const composedRequest = {
357
+ kind: "composed-round-request",
358
+ lineageId: lineageBase.lineageId,
359
+ gate: lineageBase.gate,
360
+ round: newDelta.round,
361
+ segments,
362
+ carriedAngles: normalizedCarried,
363
+ };
364
+ return Object.freeze({
365
+ ...composedRequest,
366
+ composedHash: sha256Hex(composedRequest),
367
+ });
368
+ }
369
+
370
+ /**
371
+ * Render a composed round request to a single byte string by concatenating the
372
+ * ordered segment bytes. Purely a convenience for consumers that need a flat
373
+ * briefing block; the append-only/exact-reuse contract is honored by the
374
+ * segment list itself (callers may also render segments individually).
375
+ *
376
+ * @param {object} composed - a composed-round-request artifact.
377
+ * @returns {string} concatenated segment bytes in order.
378
+ */
379
+ export function renderComposedRequest(composed) {
380
+ if (!composed || composed.kind !== "composed-round-request" || !Array.isArray(composed.segments)) {
381
+ throw new Error("renderComposedRequest requires a composed-round-request artifact");
382
+ }
383
+ return composed.segments.map((s) => (s.bytes == null ? "" : String(s.bytes))).join("");
384
+ }
385
+
386
+ /* ------------------------------------------------------------------ *
387
+ * Compaction / rebase policy (issue #1468 slice 6)
388
+ * ------------------------------------------------------------------ */
389
+
390
+ /**
391
+ * Default lineage compaction threshold: the maximum number of accumulated
392
+ * round-delta segments a review lineage may carry before it MUST be compacted
393
+ * (rebased). Provider prompt caches are bounded by breakpoint/lookback limits
394
+ * and context-window size; unbounded delta accumulation would eventually
395
+ * overflow them. Consumers may raise or lower this, and may additionally set a
396
+ * byte budget (`maxLineageBytes`) to bound provider-visible context size
397
+ * directly.
398
+ *
399
+ * A rebase is triggered when EITHER bound is exceeded:
400
+ * - round-delta count exceeds `maxRounds` (default 20), OR
401
+ * - the composed lineage byte size exceeds `maxLineageBytes` when provided.
402
+ */
403
+ export const DEFAULT_LINEAGE_MAX_ROUNDS = 20;
404
+
405
+ /**
406
+ * Total byte size of a lineage's accumulated artifact content (base + all
407
+ * accumulated deltas), using the same canonical byte-serialization that
408
+ * `composeRoundRequest` renders. Used to enforce a byte budget on the only part
409
+ * of a round request that grows across fix rounds (round-delta accumulation).
410
+ * It counts the lineage artifacts themselves, not the per-round angle-suffix /
411
+ * carried-angle context (constant regardless of round count).
412
+ *
413
+ * @param {object} input
414
+ * @param {object} input.lineageBase - valid review-lineage-base.
415
+ * @param {object[]} [input.deltas] - accumulated round deltas.
416
+ * @returns {number} total composed byte size.
417
+ */
418
+ export function lineageByteSize({ lineageBase, deltas = [] } = {}) {
419
+ if (!lineageBase || lineageBase.kind !== "review-lineage-base" || !isSha256(lineageBase.baseHash)) {
420
+ throw new Error("lineageByteSize requires a valid lineage base artifact");
421
+ }
422
+ if (!Array.isArray(deltas)) throw new Error("lineageByteSize deltas must be an array");
423
+ const utf8Length = (v) => Buffer.byteLength(canonicalJson(v), "utf8");
424
+ let total = utf8Length(lineageBase);
425
+ for (let i = 0; i < deltas.length; i++) {
426
+ const d = deltas[i];
427
+ if (!d || d.kind !== "round-delta" || !isSha256(d.deltaHash)) {
428
+ throw new Error(`lineageByteSize deltas[${i}] must be a valid round-delta artifact`);
429
+ }
430
+ if (d.lineageId !== lineageBase.lineageId || d.gate !== lineageBase.gate) {
431
+ throw new Error(
432
+ `lineageByteSize deltas[${i}] must share the lineage base's lineageId and gate (parity with rebaseLineage/composeRoundRequest)`,
433
+ );
434
+ }
435
+ total += utf8Length(d);
436
+ }
437
+ return total;
438
+ }
439
+
440
+ /**
441
+ * Decide whether a review lineage must be compacted (rebased) now, against the
442
+ * compaction threshold(s). Pure predicate — it never mutates the lineage.
443
+ *
444
+ * @param {object} input
445
+ * @param {object} input.lineageBase - valid review-lineage-base.
446
+ * @param {object[]} [input.deltas] - accumulated round deltas.
447
+ * @param {number} [input.maxRounds] - max delta rounds before a rebase (default
448
+ * {@link DEFAULT_LINEAGE_MAX_ROUNDS}).
449
+ * @param {number} [input.maxLineageBytes] - optional byte budget over the
450
+ * accumulated lineage (base + deltas — the ONLY part of a round request that
451
+ * grows with fix rounds); a lineage whose accumulated size exceeds it must
452
+ * be rebased. Per-round angle-suffix and carried-angle context are constant
453
+ * regardless of round count, so they are deliberately not part of this
454
+ * growing-lineage bound.
455
+ * @returns {{ requiresCompaction: boolean, reason: string|null, deltaCount: number, lineageBytes: number, maxRounds: number, maxLineageBytes: number|null }}
456
+ */
457
+ export function checkLineageCompaction({ lineageBase, deltas = [], maxRounds = DEFAULT_LINEAGE_MAX_ROUNDS, maxLineageBytes } = {}) {
458
+ if (!Number.isInteger(maxRounds) || maxRounds < 1) {
459
+ throw new Error(`checkLineageCompaction maxRounds must be a positive integer, got ${JSON.stringify(maxRounds)}`);
460
+ }
461
+ if (maxLineageBytes != null && (!Number.isInteger(maxLineageBytes) || maxLineageBytes < 1)) {
462
+ throw new Error(`checkLineageCompaction maxLineageBytes must be a positive integer, got ${JSON.stringify(maxLineageBytes)}`);
463
+ }
464
+ if (!Array.isArray(deltas)) throw new Error("checkLineageCompaction deltas must be an array");
465
+ const deltaCount = deltas.length;
466
+ const lineageBytes = lineageByteSize({ lineageBase, deltas });
467
+ let reason = null;
468
+ if (deltaCount > maxRounds) {
469
+ reason = `delta count ${deltaCount} exceeds maxRounds ${maxRounds}`;
470
+ } else if (maxLineageBytes != null && lineageBytes > maxLineageBytes) {
471
+ reason = `composed lineage bytes ${lineageBytes} exceed maxLineageBytes ${maxLineageBytes}`;
472
+ }
473
+ return {
474
+ requiresCompaction: reason !== null,
475
+ reason,
476
+ deltaCount,
477
+ lineageBytes,
478
+ maxRounds,
479
+ maxLineageBytes: maxLineageBytes ?? null,
480
+ };
481
+ }
482
+
483
+ /**
484
+ * Compact (rebase) a review lineage.
485
+ *
486
+ * When the delta accumulation crosses the compaction threshold, the lineage is
487
+ * rebased: the accumulated deltas are folded into a NEW compacted base whose
488
+ * `originalHead` advances to the current (latest reviewed) head and whose
489
+ * `originalDiff` becomes the cumulative diff (the base's original full diff
490
+ * merged with every accepted fix diff, in order). Future rounds append fresh
491
+ * deltas to this compacted base, so the COMPOSED request stays within the
492
+ * provider breakpoint/lookback + context budget instead of growing unbounded.
493
+ *
494
+ * Rebase behaviour guarantees:
495
+ * - The compacted base keeps the same `lineageId` and `gate` and is itself a
496
+ * valid `review-lineage-base`; `composeRoundRequest` accepts it unchanged.
497
+ * - Composition rules are preserved: a new round-1 delta whose `baseHead`
498
+ * equals the compacted base's `originalHead` composes cleanly and
499
+ * byte-deterministically (SHA-chain continuity + append-only contract).
500
+ * - The rebase is traceable: the compacted base records `rebaseSourceBaseHash`
501
+ * (the base it was compacted from) and `compactedRoundCount` (the total
502
+ * number of fix rounds folded into this compacted lineage so far). Prior
503
+ * delta artifacts remain available (append-only history); only the COMPOSED
504
+ * request is recomposed from the compacted base.
505
+ *
506
+ * @param {object} input
507
+ * @param {object} input.lineageBase - valid review-lineage-base.
508
+ * @param {object[]} [input.deltas] - all accumulated round deltas in order.
509
+ * @param {string|string[]|Buffer} [input.currentDiff] - optional cumulative diff
510
+ * for the rebased `originalDiff`; defaults to the base's original diff merged
511
+ * with every delta's fix diff.
512
+ * @returns {Readonly<object>} compacted review-lineage-base artifact.
513
+ */
514
+ export function rebaseLineage({ lineageBase, deltas = [], currentDiff } = {}) {
515
+ if (!lineageBase || lineageBase.kind !== "review-lineage-base" || !isSha256(lineageBase.baseHash)) {
516
+ throw new Error("rebaseLineage requires a valid lineage base artifact");
517
+ }
518
+ if (!Array.isArray(deltas)) throw new Error("rebaseLineage deltas must be an array");
519
+ for (let i = 0; i < deltas.length; i++) {
520
+ const d = deltas[i];
521
+ if (!d || d.kind !== "round-delta" || d.lineageId !== lineageBase.lineageId) {
522
+ throw new Error("rebaseLineage deltas must be valid round-delta artifacts of the same lineage");
523
+ }
524
+ if (!isSha256(d.deltaHash)) {
525
+ throw new Error(`rebaseLineage deltas[${i}].deltaHash must be a valid sha256:<64hex>`);
526
+ }
527
+ if (!isHexSha(d.baseHead) || !isHexSha(d.reviewedHead)) {
528
+ throw new Error(`rebaseLineage deltas[${i}].baseHead/reviewedHead must be full hex SHAs`);
529
+ }
530
+ if (d.gate !== lineageBase.gate) {
531
+ throw new Error("rebaseLineage deltas gate must match the lineage base gate");
532
+ }
533
+ if (d.round !== i + 1) {
534
+ throw new Error(`rebaseLineage deltas must be contiguous from round 1; expected round ${i + 1}, got ${d.round}`);
535
+ }
536
+ if (d.fixDiff == null || String(d.fixDiff).length === 0) {
537
+ throw new Error(`rebaseLineage deltas[${i}] requires a non-empty fixDiff (the actual fix diff)`);
538
+ }
539
+ }
540
+ // SHA-chain continuity across the accumulated deltas (keep the composed
541
+ // request truthful — same rule `composeRoundRequest` enforces).
542
+ for (let i = 0; i < deltas.length; i++) {
543
+ const d = deltas[i];
544
+ if (i === 0) {
545
+ if (d.baseHead !== lineageBase.originalHead) {
546
+ throw new Error(
547
+ `rebaseLineage round-1 baseHead ${d.baseHead} must equal the lineage base originalHead ${lineageBase.originalHead}`,
548
+ );
549
+ }
550
+ } else if (d.baseHead !== deltas[i - 1].reviewedHead) {
551
+ throw new Error(
552
+ `rebaseLineage round-${d.round} baseHead ${d.baseHead} must equal prior round reviewedHead ${deltas[i - 1].reviewedHead}`,
553
+ );
554
+ }
555
+ }
556
+
557
+ let newOriginalHead = lineageBase.originalHead;
558
+ if (deltas.length > 0) newOriginalHead = deltas[deltas.length - 1].reviewedHead.trim().toLowerCase();
559
+
560
+ let diff;
561
+ if (currentDiff != null) {
562
+ const isEmpty = Buffer.isBuffer(currentDiff) ? currentDiff.length === 0 : String(currentDiff).length === 0;
563
+ if (isEmpty) {
564
+ throw new Error("rebaseLineage currentDiff must be non-empty");
565
+ }
566
+ diff = normalizeText(currentDiff, "currentDiff");
567
+ } else if (deltas.length === 0) {
568
+ diff = lineageBase.originalDiff;
569
+ } else {
570
+ diff = [lineageBase.originalDiff, ...deltas.map((d) => normalizeText(d.fixDiff, "fixDiff"))].join("\n");
571
+ }
572
+
573
+ const compacted = {
574
+ kind: "review-lineage-base",
575
+ lineageId: lineageBase.lineageId,
576
+ gate: lineageBase.gate,
577
+ originalHead: newOriginalHead,
578
+ originalDiff: diff,
579
+ stableContracts: lineageBase.stableContracts,
580
+ compaction: true,
581
+ rebaseSourceBaseHash: lineageBase.baseHash,
582
+ compactedRoundCount: (lineageBase.compactedRoundCount ?? 0) + deltas.length,
583
+ };
584
+ return Object.freeze({
585
+ ...compacted,
586
+ baseHash: sha256Hex(compacted),
587
+ });
588
+ }
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { realpathSync } from "node:fs";
11
+ import path from "node:path";
11
12
 
12
13
  // ---------------------------------------------------------------------------
13
14
  // Worktree path helpers
@@ -111,6 +112,85 @@ export function isListedWorktree(cwd, worktreePaths) {
111
112
  });
112
113
  }
113
114
 
115
+ /**
116
+ * Resolve the root of the listed git worktree that contains `cwd`.
117
+ *
118
+ * Mirrors `isListedWorktree`'s matching (realpath-resolved, tmp/worktrees/-scoped,
119
+ * exact-or-subdirectory) but returns the worktree ROOT instead of a boolean, so
120
+ * callers can address files relative to the worktree's own subtree (`packages/`,
121
+ * `node_modules/`) rather than the possibly-nested `cwd`.
122
+ *
123
+ * @param {string} cwd - Absolute or relative path inside the worktree.
124
+ * @param {string[]} worktreePaths - Array of paths from `parseAllWorktreePaths`.
125
+ * @returns {string | null} The worktree root path, or null when `cwd` is not inside a listed worktree.
126
+ */
127
+ export function resolveContainingWorktreeRoot(cwd, worktreePaths) {
128
+ let resolvedCwd;
129
+ try { resolvedCwd = realpathSync(cwd); } catch { resolvedCwd = cwd; }
130
+ const normalizedCwd = resolvedCwd.replace(/\\/g, "/").replace(/\/+$/u, "");
131
+ for (const p of worktreePaths) {
132
+ let resolvedP;
133
+ try { resolvedP = realpathSync(p); } catch { resolvedP = p; }
134
+ const normalizedP = resolvedP.replace(/\\/g, "/").replace(/\/+$/u, "");
135
+ if (!isUnderWorktreePath(normalizedP)) continue;
136
+ if (normalizedCwd === normalizedP || normalizedCwd.startsWith(normalizedP + "/")) {
137
+ return normalizedP;
138
+ }
139
+ }
140
+ return null;
141
+ }
142
+
143
+ /**
144
+ * Check whether a worktree's `node_modules/@dev-loops/core` resolves into the
145
+ * worktree's OWN `packages/core`, not the main checkout's (#1627).
146
+ *
147
+ * A link escaping to the main checkout silently tests main's core instead of the
148
+ * branch's (WORKTREE-DEPS-ISOLATED / WORKTREE-CREATE-PROVISION): the forbidden
149
+ * state is a worktree whose node_modules resolves dependencies from the main
150
+ * checkout.
151
+ *
152
+ * Tolerates consumer repos with no `packages/core` (no monorepo core to isolate,
153
+ * so the requirement is vacuously satisfied), and worktrees whose
154
+ * `node_modules/@dev-loops/core` link is absent (nothing resolves out of tree, so
155
+ * there is no escape to refuse). Only a link that RESOLVES and points outside the
156
+ * worktree's own `packages/core` is treated as the escaping (non-isolated) state.
157
+ * The worktree root is resolved from `cwd` via the listed worktree paths so a
158
+ * nested `cwd` still addresses the worktree's own subtree.
159
+ *
160
+ * @param {string} cwd - Absolute or relative path inside the worktree.
161
+ * @param {string[]} worktreePaths - Array of paths from `parseAllWorktreePaths`.
162
+ * @returns {boolean} true when isolated (or no core to isolate); false when the
163
+ * core link escapes the worktree's own `packages/core`.
164
+ */
165
+ export function isWorktreeCoreIsolated(cwd, worktreePaths) {
166
+ const root = resolveContainingWorktreeRoot(cwd, worktreePaths);
167
+ if (root === null) {
168
+ // Not resolving to a listed worktree — isolation is enforced elsewhere
169
+ // (isListedWorktree / isUnderWorktreePath); vacuously satisfied here.
170
+ return true;
171
+ }
172
+ const coreDir = path.join(root, "packages", "core");
173
+ const linkPath = path.join(root, "node_modules", "@dev-loops", "core");
174
+ const normalize = (p) => {
175
+ try {
176
+ return realpathSync(p).replace(/\\/g, "/").replace(/\/+$/u, "");
177
+ } catch {
178
+ return null;
179
+ }
180
+ };
181
+ const coreReal = normalize(coreDir);
182
+ if (coreReal === null) {
183
+ // Consumer repo with no packages/core — no monorepo core to isolate.
184
+ return true;
185
+ }
186
+ const linkReal = normalize(linkPath);
187
+ if (linkReal === null) {
188
+ // node_modules/@dev-loops/core absent — nothing resolves out of tree.
189
+ return true;
190
+ }
191
+ return linkReal === coreReal;
192
+ }
193
+
114
194
 
115
195
 
116
196
  // ---------------------------------------------------------------------------