@feng0531/dsh-code-review 0.86.1 → 0.87.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -46,11 +46,13 @@ function roundBudgetError() {
46
46
  return error;
47
47
  }
48
48
 
49
- export async function retrievalLoop(model, request, scope, { correctProtocol = false, maxRounds = Number.MAX_SAFE_INTEGER, maxInputBytes = 96 * 1024, beforeCall = () => {}, onTruncated = () => {}, onRetrieved = () => {}, onBudgetLimited = () => {}, initialRetrieved = [], allowExhaustedInitial = false, enableSearchSeeds = false } = {}) {
49
+ export async function retrievalLoop(model, request, scope, { correctProtocol = false, maxRounds = Number.MAX_SAFE_INTEGER, maxInputBytes = 96 * 1024, beforeCall = () => {}, onTruncated = () => {}, onRetrieved = () => {}, onBudgetLimited = () => {}, initialRetrieved = [], initialContext, yieldOnBudget = false, allowExhaustedInitial = false, enableSearchSeeds = false } = {}) {
50
50
  if (!Number.isSafeInteger(maxRounds) || maxRounds < 0 || !Number.isSafeInteger(maxInputBytes) || maxInputBytes < 1) throw new Error('Invalid retrieval loop budget');
51
51
  // Only internal callers can seed this loop; the public model response cannot supply it.
52
52
  const allowedSeeds = new Set(scope.catalog().map(item => item.id));
53
- if (!Array.isArray(initialRetrieved) || initialRetrieved.length > 50 || initialRetrieved.some(item => item?.request?.kind !== 'read' || !allowedSeeds.has(item.request.id) || item.result?.sourceId !== item.request.id || typeof item.result?.text !== 'string')) throw new Error('Invalid initial retrieval evidence');
53
+ const validateSeeds = items => { if (!Array.isArray(items) || items.length > 50 || items.some(item => item?.request?.kind !== 'read' || !allowedSeeds.has(item.request.id) || item.result?.sourceId !== item.request.id || typeof item.result?.text !== 'string')) throw new Error('Invalid initial retrieval evidence'); };
54
+ validateSeeds(initialRetrieved);
55
+ if (initialContext !== undefined && typeof initialContext !== 'function') throw new Error('Invalid initial evidence context producer');
54
56
  const retrieved = structuredClone(initialRetrieved);
55
57
  if (retrieved.length) {
56
58
  const seeded = retrievalRequest(request, scope, retrieved);
@@ -60,6 +62,7 @@ export async function retrievalLoop(model, request, scope, { correctProtocol = f
60
62
  const delivered = new Set();
61
63
  let protocolCorrection, finalization, searchSeedAttempts = 0, currentRound = 0;
62
64
  const finalize = async error => {
65
+ if (yieldOnBudget && ['READER_CALL_BUDGET_EXCEEDED', 'READER_OUTPUT_BUDGET_EXCEEDED'].includes(error.code)) throw error;
63
66
  finalization = { code: error.code, reason: error.message, ...(scope.budget ? { budget: scope.budget() } : {}) };
64
67
  await onBudgetLimited(finalization);
65
68
  await request.onProtocolEvent?.({ action: 'retrieval-budget-finalization', ...finalization });
@@ -100,6 +103,16 @@ export async function retrievalLoop(model, request, scope, { correctProtocol = f
100
103
  request.signal?.throwIfAborted();
101
104
  beforeCall();
102
105
  request.signal?.throwIfAborted();
106
+ // Optional redelivery starts only after model-call admission. Never spend a
107
+ // stage's reader budget on a call already vetoed by the shared model gate.
108
+ if (round === 0 && !finalization && initialContext) {
109
+ const seeds = initialContext(retrieved, records => ({ ...request, ...nextRequest(records) }));
110
+ validateSeeds(seeds);
111
+ retrieved.push(...seeds);
112
+ ({ input, instructions } = nextRequest(retrieved));
113
+ if (!fits({ ...request, input, instructions })) throw new Error('Retrieval input budget exceeded');
114
+ request.signal?.throwIfAborted();
115
+ }
103
116
  for (const item of retrieved) {
104
117
  if (!item.result.omitted && !delivered.has(item)) {
105
118
  recordRetrieval(item.request, item.result, onTruncated, onRetrieved);
@@ -132,7 +145,7 @@ export async function retrievalLoop(model, request, scope, { correctProtocol = f
132
145
  batch = scope.transaction ? scope.transaction(execute) : execute();
133
146
  } catch (error) {
134
147
  request.signal?.throwIfAborted();
135
- if (!finalization && budgetFailure(error)) { await finalize(error); continue; }
148
+ if (!finalization && budgetFailure(error)) { if (yieldOnBudget) throw Object.assign(error, { pendingRequests: parseRetrievalResponse(parsed) }); await finalize(error); continue; }
136
149
  if (finalization || !correctProtocol || protocolCorrection || !(/Invalid retrieval batch|Invalid read request|Invalid search request|Invalid find request|Invalid line range|Line range outside source/.test(error.message))) throw error;
137
150
  protocolCorrection = { code: error.code ?? 'MODEL_INVALID_RETRIEVAL' };
138
151
  await request.onProtocolEvent?.({ action: 'retrieval-protocol-correction', code: protocolCorrection.code });
@@ -1,7 +1,9 @@
1
1
  // Per-stage admission over ONE approved root ledger, not independent readers.
2
2
  // Fixed proportional floors protect future stages; released unused floors become
3
3
  // shared capacity. No content/source identifiers are emitted by summary().
4
- export function createReviewReaderAllocation(root, units) {
4
+ export function createReviewReaderAllocation(root, units, { maxResumeAttempts = 2 } = {}) {
5
+ if (!Number.isSafeInteger(maxResumeAttempts) || maxResumeAttempts < 1 || maxResumeAttempts > 4) throw new Error('Invalid reader resume attempts');
6
+ let releaseRevision = 0; const yielded = [];
5
7
  if (!root || !['budget', 'usage', 'preflight', 'transaction', 'read', 'find', 'search', 'catalog'].every(key => typeof root[key] === 'function')) throw new Error('Invalid root reader allocation');
6
8
  if (!Array.isArray(units) || units.length > 2000) throw new Error('Invalid reader allocation units');
7
9
  const budget = root.budget();
@@ -9,7 +11,7 @@ export function createReviewReaderAllocation(root, units) {
9
11
  const states = new Map(); let totalWeight = 0;
10
12
  for (const unit of units) {
11
13
  if (!unit || typeof unit.id !== 'string' || !unit.id || unit.id.length > 500 || states.has(unit.id) || !Number.isSafeInteger(unit.weight) || unit.weight < 1 || unit.weight > 1000000) throw new Error('Invalid reader allocation unit');
12
- states.set(unit.id, { weight: unit.weight, calls: 0, outputBytes: 0, cacheHits: 0, released: false, inTransaction: false });
14
+ states.set(unit.id, { weight: unit.weight, calls: 0, outputBytes: 0, cacheHits: 0, released: false, inTransaction: false, yielded: false, resumeAttempts: 0, yieldRevision: 0, pendingReason: null });
13
15
  totalWeight += unit.weight;
14
16
  }
15
17
  for (const state of states.values()) {
@@ -17,7 +19,7 @@ export function createReviewReaderAllocation(root, units) {
17
19
  state.byteFloor = Math.floor(budget.remainingOutputBytes * state.weight / totalWeight);
18
20
  }
19
21
  const fail = kind => { const error = new Error('Reader allocation ' + kind + ' budget exceeded'); error.code = kind === 'call' ? 'READER_CALL_BUDGET_EXCEEDED' : 'READER_OUTPUT_BUDGET_EXCEEDED'; throw error; };
20
- const live = id => { const state = states.get(id); if (!state || state.released) throw new Error('Reader allocation unit unavailable'); return state; };
22
+ const live = id => { const state = states.get(id); if (!state || state.released || state.yielded) throw new Error('Reader allocation unit unavailable'); return state; };
21
23
  const available = state => {
22
24
  const remaining = root.budget(); let protectedCalls = 0, protectedBytes = 0;
23
25
  for (const other of states.values()) {
@@ -28,6 +30,25 @@ export function createReviewReaderAllocation(root, units) {
28
30
  return { ...remaining, remainingCalls: Math.max(0, remaining.remainingCalls - protectedCalls), remainingOutputBytes: Math.max(0, remaining.remainingOutputBytes - protectedBytes) };
29
31
  };
30
32
  const check = (state, cost) => { const remaining = available(state); if (cost.calls > remaining.remainingCalls) fail('call'); if (cost.outputBytes > remaining.remainingOutputBytes) fail('output'); };
33
+ const resumeWithCost = (id, costForAttempt) => {
34
+ const state = states.get(id);
35
+ if (!state || state.released || !state.yielded) throw new Error('Reader allocation unit unavailable');
36
+ if (state.resumeAttempts >= maxResumeAttempts) return { resumed: false, reason: 'READER_RESUME_ATTEMPTS_EXHAUSTED', attempts: state.resumeAttempts };
37
+ const head = yielded.find(key => { const row = states.get(key); return row && !row.released && row.yielded && row.resumeAttempts < maxResumeAttempts && row.yieldRevision < releaseRevision; });
38
+ if (head !== id || state.yieldRevision >= releaseRevision) return { resumed: false, reason: 'READER_RESUME_WAITING', attempts: state.resumeAttempts };
39
+ state.resumeAttempts++; state.yieldRevision = releaseRevision;
40
+ yielded.splice(yielded.indexOf(id), 1); yielded.push(id);
41
+ try {
42
+ const cost = costForAttempt();
43
+ if (!cost || !Number.isSafeInteger(cost.calls) || cost.calls < 0 || !Number.isSafeInteger(cost.outputBytes) || cost.outputBytes < 0) throw new Error('Reader allocation preflight unavailable');
44
+ check(state, cost);
45
+ state.yielded = false; state.pendingReason = null; yielded.splice(yielded.indexOf(id), 1);
46
+ return { resumed: true, reason: null, attempts: state.resumeAttempts };
47
+ } catch (error) {
48
+ if (!['READER_CALL_BUDGET_EXCEEDED', 'READER_OUTPUT_BUDGET_EXCEEDED'].includes(error?.code)) throw error;
49
+ return { resumed: false, reason: error.code, attempts: state.resumeAttempts };
50
+ }
51
+ };
31
52
  const scopes = new Map();
32
53
  return Object.freeze({
33
54
  scope(id) {
@@ -69,7 +90,28 @@ export function createReviewReaderAllocation(root, units) {
69
90
  });
70
91
  scopes.set(id, scope); return scope;
71
92
  },
72
- release(id) { const state = states.get(id); if (!state) return; if (state.inTransaction) throw new Error('Reader allocation transaction active'); state.released = true; },
93
+ // Yield retains this unit's floor; only the host owning a terminal stage may
94
+ // release it. Pausing a peer never grants permission to steal its reserve.
95
+ yield(id, { reason = 'READER_CALL_BUDGET_EXCEEDED' } = {}) {
96
+ const state = live(id);
97
+ if (state.inTransaction) throw new Error('Reader allocation transaction active');
98
+ if (!['READER_CALL_BUDGET_EXCEEDED', 'READER_OUTPUT_BUDGET_EXCEEDED'].includes(reason)) throw new Error('Invalid reader yield reason');
99
+ state.yielded = true; state.yieldRevision = releaseRevision; state.pendingReason = reason; yielded.push(id);
100
+ return { yielded: true, attempts: state.resumeAttempts };
101
+ },
102
+ resume(id, requests = []) {
103
+ const state = states.get(id);
104
+ if (!state || state.released || !state.yielded) throw new Error('Reader allocation unit unavailable');
105
+ if (!Array.isArray(requests) || !requests.length) return { resumed: false, reason: 'READER_RESUME_REQUESTS_REQUIRED', attempts: state.resumeAttempts };
106
+ return resumeWithCost(id, () => root.preflight(requests));
107
+ },
108
+ // Scalar admission is only a retry opportunity, never authority to read.
109
+ // Every actual read still goes through exact preflight and root accounting.
110
+ resumeBudget(id, { requiredCalls = 1, requiredOutputBytes = 1 } = {}) {
111
+ if (![requiredCalls, requiredOutputBytes].every(value => Number.isSafeInteger(value) && value >= 0) || requiredCalls + requiredOutputBytes === 0) throw new Error('Invalid reader resume budget');
112
+ return resumeWithCost(id, () => ({ calls: requiredCalls, outputBytes: requiredOutputBytes }));
113
+ },
114
+ release(id) { const state = states.get(id); if (!state) return; if (state.inTransaction) throw new Error('Reader allocation transaction active'); if (!state.released) { state.released = true; releaseRevision++; } },
73
115
  summary() { const usage = root.usage(); return { units: states.size, releasedUnits: [...states.values()].filter(state => state.released).length, calls: usage.calls, outputBytes: usage.outputBytes, ...(usage.cacheHits !== undefined ? { cacheHits: usage.cacheHits } : {}) }; },
74
116
  });
75
117
  }
@@ -1,8 +1,59 @@
1
1
  import { hash } from './content.mjs';
2
2
  import { validateBusinessGroups } from './business-groups.mjs';
3
+ import { prepareStageGenerations } from './review-stage-state.mjs';
4
+ import { createStageContinuation } from './stage-continuation.mjs';
3
5
 
4
6
  const canonical = value => Array.isArray(value) ? value.map(canonical) : value && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().filter(key => value[key] !== undefined).map(key => [key, canonical(value[key])])) : value;
5
7
  const digest = value => hash(JSON.stringify(canonical(value)));
8
+
9
+ const MAX_BYTES = 8 * 1024 * 1024;
10
+ const stageEnabled = options => options?.execution?.enableStageContinuation === true || options?.enableStageContinuation === true;
11
+ const invalid = () => { throw new Error('Invalid stage resume checkpoint'); };
12
+ const exact = (value, keys) => { if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(k => !keys.includes(k))) invalid(); };
13
+ const RECEIPT_KEYS = new Set(['receiptId', 'receipts', 'retrievalAudit', 'retrieved', 'resolveEvidence']);
14
+ function privateClone(value, { stripReceipts = false } = {}, seen = new Set(), depth = 0, budget = { nodes: 0, bytes: 0 }) {
15
+ if (++budget.nodes > 300000 || depth > 32) invalid();
16
+ if (typeof value === 'string') { budget.bytes += Buffer.byteLength(value); if (budget.bytes > MAX_BYTES) invalid(); return value; }
17
+ if (value === null || typeof value === 'boolean' || typeof value === 'number' && Number.isFinite(value)) return value;
18
+ if (!value || typeof value !== 'object' || seen.has(value) || ![Object.prototype, Array.prototype, null].includes(Object.getPrototypeOf(value))) invalid();
19
+ if (Array.isArray(value) && (value.length > 100000 || Object.keys(value).length !== value.length)) invalid();
20
+ seen.add(value); const out = Array.isArray(value) ? [] : {};
21
+ for (const key of Reflect.ownKeys(value)) {
22
+ if (Array.isArray(value) && key === 'length') continue;
23
+ if (typeof key !== 'string' || ['__proto__', 'constructor', 'toJSON', 'prototype'].includes(key)) invalid();
24
+ const d = Object.getOwnPropertyDescriptor(value, key); if (!d.enumerable || !Object.hasOwn(d, 'value')) invalid();
25
+ // Host report objects use optional undefined fields; normalize only exports.
26
+ // Imported checkpoints remain JSON-only and reject undefined explicitly.
27
+ if (stripReceipts && !Array.isArray(value) && d.value === undefined) continue;
28
+ if (RECEIPT_KEYS.has(key)) { if (stripReceipts) continue; invalid(); }
29
+ out[key] = privateClone(d.value, { stripReceipts }, seen, depth + 1, budget);
30
+ }
31
+ seen.delete(value);return out;
32
+ }
33
+ function bounded(value) { if (Buffer.byteLength(JSON.stringify(value)) > MAX_BYTES) invalid(); return value; }
34
+ function continuationState(snapshot, wrapper) {
35
+ exact(wrapper, ['options', 'state']);
36
+ const o = wrapper.options, cp = wrapper.state;
37
+ exact(o, ['rounds', 'maxInputBytes', 'enableMigrationContracts', 'enableMigrationRegression']);
38
+ if (!Number.isSafeInteger(o.rounds) || o.rounds < 1 || o.rounds > 50 || !Number.isSafeInteger(o.maxInputBytes) || o.maxInputBytes < 1 || o.maxInputBytes > 16 * 1024 * 1024 || typeof o.enableMigrationContracts !== 'boolean' || typeof o.enableMigrationRegression !== 'boolean' || !Array.isArray(cp?.units)) invalid();
39
+ const units = cp.units.map(({ id, kind, fileIds, dependencies }) => ({ id, kind, fileIds, dependencies }));
40
+ const machine = createStageContinuation(snapshot, o, { completedPrimary: cp.completedPrimary, units, maxAttempts: cp.maxAttempts, checkpoint: cp });
41
+ // Keep the original checkpoint (not the machine's running->pending projection).
42
+ // This is historical bookkeeping only, never automatically applied to a new run.
43
+ machine.summary();
44
+ return { options: structuredClone(o), state: structuredClone(cp) };
45
+ }
46
+ function prepareV2(value, snapshot) {
47
+ exact(value, ['schemaVersion', 'snapshotId', 'identityHash', 'completed', 'businessGrouping', 'interactions', 'stageGenerations', 'continuation']);
48
+ if (!Array.isArray(value.stageGenerations)) invalid();
49
+ const generations = prepareStageGenerations(snapshot, value.stageGenerations);
50
+ const done = new Set(value.completed.map(item => item.fileId));
51
+ if (generations.some(row => row.phase !== 'primary' || done.has(row.fileId) || row.needsFreshProvenance)) invalid();
52
+ const result = { ...value, stageGenerations: generations };
53
+ if (value.continuation !== undefined) result.continuation = continuationState(snapshot, value.continuation);
54
+ return bounded(result);
55
+ }
56
+
6
57
  const safeFile = file => !file?.stageFailures?.length && !file?.reviewPasses?.some(pass => pass.status !== 'completed') && !file?.windowCoverage?.pending?.length && file?.status === 'completed' && file.resumeEligible === true && !file.retrievalLimited && (!file.synthesisStatus || file.synthesisStatus === 'completed') && (!file.verificationStatus || !['pending', 'failed'].includes(file.verificationStatus));
7
58
 
8
59
  // Host-private, schema-versioned completed-chain state. A previous result grants
@@ -28,12 +79,25 @@ export function buildResumeState(snapshot, options, report) {
28
79
  const interactions = structuredClone((report.interactions ?? []).filter(item => Array.isArray(item.fileIds) && item.fileIds.length && item.status === 'completed' && item.fileIds.every(id => completed.some(row => row.fileId === id))));
29
80
  const identity = digest({ snapshot, options });
30
81
  const result = { schemaVersion: 1, snapshotId: snapshot.id, identityHash: identity, completed, ...(businessGrouping ? { businessGrouping } : {}), interactions };
82
+ if (stageEnabled(options)) {
83
+ result.schemaVersion = 2;
84
+ result.stageGenerations = prepareStageGenerations(snapshot, report.stageGenerations ?? []).filter(row => row.phase === 'primary' && !completed.some(item => item.fileId === row.fileId));
85
+ if (report.continuation !== undefined) result.continuation = continuationState(snapshot, privateClone({ options: report.continuationOptions, state: report.continuation }));
86
+ return prepareV2(bounded(privateClone(result, { stripReceipts: true })), snapshot);
87
+ }
31
88
  if (Buffer.byteLength(JSON.stringify(result)) > 8 * 1024 * 1024) throw new Error('Resume checkpoint exceeds storage limit');
32
89
  return result;
33
90
  }
34
91
 
35
92
  export function validateResumeState(value, snapshot, options) {
36
- if (!value || value.schemaVersion !== 1 || value.snapshotId !== snapshot?.id || value.identityHash !== digest({ snapshot, options }) || !Array.isArray(value.completed) || value.completed.length > snapshot.files.length) throw new Error('Resume identity mismatch; capture a fresh independent review');
93
+ // Select the version without invoking a caller-supplied accessor or inherited
94
+ // schema property before the strict schema-two descriptor scan.
95
+ if (!value || typeof value !== 'object') invalid();
96
+ const version = Object.getOwnPropertyDescriptor(value, 'schemaVersion');
97
+ if (!version || !Object.hasOwn(version, 'value')) invalid();
98
+ const v2 = version.value === 2;
99
+ if (v2) { if (!stageEnabled(options)) invalid(); value = bounded(privateClone(value)); }
100
+ if (!value || ![1, ...(stageEnabled(options) ? [2] : [])].includes(value.schemaVersion) || value.snapshotId !== snapshot?.id || value.identityHash !== digest({ snapshot, options }) || !Array.isArray(value.completed) || value.completed.length > snapshot.files.length) throw new Error('Resume identity mismatch; capture a fresh independent review');
37
101
  const seen = new Set();
38
102
  for (const item of value.completed) {
39
103
  const source = snapshot.files.find(file => file.id === item?.fileId && file.path === item.path && file.eligibility === 'reviewable');
@@ -45,9 +109,10 @@ export function validateResumeState(value, snapshot, options) {
45
109
  validateBusinessGroups({ groups: value.businessGrouping.groups.map(group => ({ fileIds: group.fileIds, reason: group.reason })) }, snapshot.files);
46
110
  }
47
111
  if (!Array.isArray(value.interactions) || value.interactions.some(item => !Array.isArray(item?.fileIds) || !item.fileIds.length || item.status !== 'completed' || item.fileIds.some(id => !seen.has(id)))) throw new Error('Invalid resume interactions');
48
- return structuredClone(value);
112
+ return v2 ? prepareV2(value, snapshot) : structuredClone(value);
49
113
  }
50
114
 
51
115
  export function resumePreview(state, sourceJobId) {
116
+ if (state.schemaVersion === 2) return { sourceJobId, reusableFiles: state.completed.length, fileIds: state.completed.map(item => item.fileId), reusableGenerations: state.stageGenerations.length, stageFileIds: [...new Set(state.stageGenerations.map(item => item.fileId))], notice: 'Exact-identity accepted primary generations may skip generation only. Pending chains are not completed reviews. Verification always runs again with fresh independent receipts; secondary stages restart at a fresh provenance boundary. Saved continuation is historical bookkeeping, not fresh completion or a new attempt allowance. Fresh preview confirmation and model authorization remain required.' };
52
117
  return { sourceJobId, reusableFiles: state.completed.length, fileIds: state.completed.map(item => item.fileId), notice: 'Only exact-identity completed file chains are reusable. Pending/interrupted stages run again; old findings retain prior-run provenance, not fresh model evidence. Fresh preview confirmation and model authorization are still required.' };
53
118
  }
@@ -0,0 +1,143 @@
1
+ import { hash } from './content.mjs';
2
+ import { changeMap } from './change-map.mjs';
3
+ import { changeWindows } from './change-windows.mjs';
4
+ import { sourceSegments } from './source-segments.mjs';
5
+ import { validateWindowFindings } from './window-findings.mjs';
6
+ import { validateAttribution } from './attribution.mjs';
7
+
8
+ const LIMIT = 8 * 1024 * 1024;
9
+ const PHASES = ['primary', 'window-synthesis', 'file-interaction'];
10
+ const DROP = ['verification', 'regression', 'priorRun', 'retrievalAudit', 'receipts', 'receiptId'];
11
+ const fail = () => { throw Object.assign(new Error('Invalid accepted stage generation'), { code: 'INVALID_STAGE_GENERATION' }); };
12
+ const text = value => typeof value === 'string' && value.trim().length > 0 && value.length <= 8000;
13
+ const identity = value => typeof value === 'string' && value.length > 0 && value.length <= 1000 && !/[\x00-\x1f]/.test(value);
14
+ const object = value => value && typeof value === 'object' && !Array.isArray(value);
15
+ function exact(value, allowed) { if (!object(value) || Object.keys(value).some(key => !allowed.includes(key))) fail(); }
16
+ // Private checkpoints are still imported data. Inspect descriptors before JSON
17
+ // serialization: getters/toJSON/prototypes must never execute during validation.
18
+ function clone(value, seen = new Set(), depth = 0, budget = { nodes: 0, bytes: 0 }) {
19
+ if (++budget.nodes > 200000 || depth > 24) fail();
20
+ if (typeof value === 'string') { budget.bytes += Buffer.byteLength(value); if (budget.bytes > LIMIT) fail(); return value; }
21
+ if (value === null || typeof value === 'boolean') return value;
22
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
23
+ if (!value || typeof value !== 'object' || seen.has(value)) fail();
24
+ if (Object.getPrototypeOf(value) !== (Array.isArray(value) ? Array.prototype : Object.prototype) && Object.getPrototypeOf(value) !== null) fail();
25
+ if (Array.isArray(value) && (value.length > 100000 || Object.keys(value).length !== value.length || Object.keys(value).some((key, index) => key !== String(index)))) fail();
26
+ seen.add(value);
27
+ const output = Array.isArray(value) ? [] : {};
28
+ for (const key of Reflect.ownKeys(value)) {
29
+ if (Array.isArray(value) && key === 'length') continue;
30
+ if (typeof key !== 'string' || ['__proto__', 'constructor', 'prototype', 'toJSON'].includes(key)) fail();
31
+ const d = Object.getOwnPropertyDescriptor(value, key);
32
+ if (!d.enumerable || !Object.hasOwn(d, 'value')) fail();
33
+ output[key] = clone(d.value, seen, depth + 1, budget);
34
+ }
35
+ seen.delete(value);
36
+ return output;
37
+ }
38
+
39
+ export function stageKey(fileId, { phase, round = 1, windowIds = [], groupId } = {}) {
40
+ if (!identity(fileId) || !PHASES.includes(phase) || !Number.isSafeInteger(round) || round < 1 || round > 50 || !Array.isArray(windowIds) || windowIds.length > 100000 || windowIds.some(id => !identity(id)) || new Set(windowIds).size !== windowIds.length || (groupId !== undefined && !identity(groupId)) || (phase === 'file-interaction' && !groupId)) fail();
41
+ return JSON.stringify([fileId, phase, round, [...windowIds].sort(), groupId ?? null]);
42
+ }
43
+
44
+ function normalizeFinding(raw, file, changes, phase, files) {
45
+ exact(raw, ['id', 'fileId', 'path', 'severity', 'title', 'evidence', 'trigger', 'impact', 'suggestion', 'anchor', 'attribution', 'interaction', 'reviewRound', ...DROP]);
46
+ if (raw.fileId !== file.id || (raw.path !== undefined && raw.path !== file.path) || !['critical', 'high', 'medium', 'low'].includes(raw.severity)) fail();
47
+ for (const key of ['title', 'evidence', 'trigger', 'impact', 'suggestion']) if (!text(raw[key])) fail();
48
+ const a = raw.anchor;
49
+ if (!object(a) || !['line', 'property', 'file'].includes(a.kind)) fail();
50
+ if (file.reviewMode === 'project' && (a.side === 'old' || a.kind === 'property')) fail();
51
+ let anchor;
52
+ if (a.kind === 'line') {
53
+ exact(a, ['kind', 'side', 'start', 'end', 'snippet']);
54
+ const side = a.side === 'old' ? file.left : a.side === 'new' ? file.right : null;
55
+ if (!side || typeof side.text !== 'string' || !Number.isSafeInteger(a.start) || !Number.isSafeInteger(a.end) || a.start < 1 || a.end < a.start || !text(a.snippet)) fail();
56
+ const lines = side.text.replaceAll('\r\n', '\n').split('\n');
57
+ if (a.end > lines.length || lines.slice(a.start - 1, a.end).join('\n') !== a.snippet.replaceAll('\r\n', '\n')) fail();
58
+ anchor = { kind: 'line', side: a.side, start: a.start, end: a.end, snippet: a.snippet };
59
+ } else if (a.kind === 'property') {
60
+ exact(a, ['kind', 'name']);
61
+ if (!file.properties?.some(p => p.name === a.name && p.old !== p.new)) fail();
62
+ anchor = { kind: 'property', name: a.name };
63
+ } else { exact(a, ['kind']); anchor = { kind: 'file' }; }
64
+ let attribution;
65
+ if (raw.attribution !== undefined) exact(raw.attribution, ['status', 'causality', 'precision', 'notice', 'editIds', 'properties', 'beforeBehavior', 'afterBehavior', 'reason']);
66
+ if (file.reviewMode === 'project') {
67
+ if (raw.attribution !== undefined && (raw.attribution.status !== 'not-applicable' || raw.attribution.causality !== 'current-source-only')) fail();
68
+ attribution = { status: 'not-applicable', causality: 'current-source-only' };
69
+ } else {
70
+ const value = raw.attribution?.status === 'missing' ? undefined : raw.attribution;
71
+ if (raw.attribution?.status === 'missing' && Object.keys(raw.attribution).some(k => !['status', 'causality'].includes(k))) fail();
72
+ attribution = validateAttribution(value, { ...file, properties: file.properties ?? [] }, changes);
73
+ if (raw.attribution?.status !== undefined && raw.attribution.status !== attribution.status) fail();
74
+ }
75
+ const result = { fileId: file.id, path: file.path, severity: raw.severity, anchor, attribution };
76
+ for (const key of ['title', 'evidence', 'trigger', 'impact', 'suggestion']) result[key] = raw[key];
77
+ const id = hash(JSON.stringify(result));
78
+ if (raw.id !== undefined && raw.id !== id) fail();
79
+ if (raw.interaction !== undefined) {
80
+ if (phase !== 'file-interaction') fail();
81
+ exact(raw.interaction, ['status', 'causality', 'editRefs']);
82
+ const refs = raw.interaction.editRefs;
83
+ if (!Array.isArray(refs) || refs.length < 2 || refs.length > 50) fail();
84
+ for (const ref of refs) {
85
+ exact(ref, ['fileId', 'editId']);
86
+ const target = files.get(ref.fileId);
87
+ if (!target || target.reviewMode === 'project') fail();
88
+ const edits = changeMap(target.left.text, target.right.text);
89
+ if (edits.precision !== 'exact' || !edits.edits.some(e => e.id === ref.editId)) fail();
90
+ }
91
+ if (new Set(refs.map(r => r.fileId)).size < 2 || new Set(refs.map(r => JSON.stringify([r.fileId, r.editId]))).size !== refs.length) fail();
92
+ const primary = refs.filter(r => r.fileId === file.id).map(r => r.editId);
93
+ if (attribution.status !== 'references-validated' || !primary.length || primary.some(id => !attribution.editIds.includes(id)) || attribution.editIds.some(id => !primary.includes(id)) || anchor.kind !== 'line' || !changes.edits.some(e => primary.includes(e.id) && e[anchor.side].count > 0 && anchor.start < e[anchor.side].start + e[anchor.side].count && anchor.end >= e[anchor.side].start)) fail();
94
+ result.interaction = { status: 'references-validated', causality: 'unverified', editRefs: refs.map(r => ({ ...r })) };
95
+ } else if (phase === 'file-interaction') fail();
96
+ return { id, ...result };
97
+ }
98
+
99
+ // Returns accepted generation payloads, NOT completed review stages. In particular,
100
+ // old verification and read delivery records never authorize a new verification.
101
+ // The enclosing resume state must bind the entire snapshot and execution options.
102
+ export function prepareStageGenerations(snapshot, records, { maxBytes = LIMIT, validateGeneration } = {}) {
103
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > LIMIT || (validateGeneration !== undefined && typeof validateGeneration !== 'function') || !Array.isArray(snapshot?.files) || !Array.isArray(records) || records.length > 100000) fail();
104
+ const safe = clone(records);
105
+ if (Buffer.byteLength(JSON.stringify(safe)) > maxBytes) fail();
106
+ const files = new Map(), allIds = new Set();
107
+ for (const file of snapshot.files) {
108
+ if (!identity(file?.id) || allIds.has(file.id)) fail();
109
+ allIds.add(file.id);
110
+ if (file.eligibility === 'reviewable') files.set(file.id, file);
111
+ }
112
+ const seen = new Set();
113
+ const output = safe.map(record => {
114
+ exact(record, ['key', 'fileId', 'path', 'phase', 'round', 'windowIds', 'groupId', 'generation', 'needsFreshProvenance', ...DROP]);
115
+ const file = files.get(record.fileId);
116
+ if (!file || (record.path !== undefined && record.path !== file.path)) fail();
117
+ const { phase, round = 1, windowIds = [], groupId } = record;
118
+ const key = stageKey(file.id, { phase, round, windowIds, groupId });
119
+ if (record.key !== key || seen.has(key) || (phase === 'file-interaction' && windowIds.length)) fail();
120
+ seen.add(key);
121
+ exact(record.generation, ['findings', 'limitations', ...DROP]);
122
+ const g = record.generation;
123
+ if (!Array.isArray(g.findings) || g.findings.length > 50 || !Array.isArray(g.limitations) || g.limitations.length > 50 || g.limitations.some(s => !text(s))) fail();
124
+ const changes = changeMap(file.left.text, file.right.text);
125
+ const findings = g.findings.map(raw => {
126
+ if (raw.reviewRound !== undefined && raw.reviewRound !== round) fail();
127
+ return normalizeFinding(raw, file, changes, phase, files);
128
+ });
129
+ const generation = { findings: [...new Map(findings.map(f => [f.id, f])).values()], limitations: [...g.limitations] };
130
+ if (windowIds.length || phase === 'window-synthesis') {
131
+ const windows = sourceSegments(changeWindows(file, changes) ?? []);
132
+ const selected = windows.filter(w => windowIds.includes(w.id));
133
+ if (!windowIds.length || selected.length !== windowIds.length) fail();
134
+ validateWindowFindings(generation.findings, selected, phase === 'window-synthesis');
135
+ }
136
+ const normalized = { key, fileId: file.id, path: file.path, phase, round, windowIds: [...windowIds].sort(), ...(groupId !== undefined ? { groupId } : {}), generation, needsFreshProvenance: phase !== 'primary' };
137
+ const checked = validateGeneration?.(clone(generation), file, clone(normalized));
138
+ if (checked === false || checked?.then) fail();
139
+ return normalized;
140
+ });
141
+ if (Buffer.byteLength(JSON.stringify(output)) > maxBytes) fail();
142
+ return output;
143
+ }