@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.
- package/CHANGELOG.md +9 -0
- package/CORE-ALIGNMENT-TASKS.md +17 -3
- package/CORE-SOURCE-INVENTORY.md +6 -6
- package/DEVELOPMENT.md +15 -1
- package/README.md +20 -3
- package/RELEASE-NOTES.md +15 -11
- package/index.mjs +1 -1
- package/package.json +1 -1
- package/src/candidate-verification.mjs +1 -1
- package/src/coverage-plan.mjs +20 -6
- package/src/cpp-migration-bindings.mjs +149 -0
- package/src/evidence-context.mjs +96 -0
- package/src/group-interaction.mjs +48 -0
- package/src/group-primary.mjs +14 -3
- package/src/interaction-risk.mjs +71 -0
- package/src/migration-contracts.mjs +160 -0
- package/src/migration-regression.mjs +103 -0
- package/src/model-contract.mjs +3 -3
- package/src/model-tools.mjs +6 -4
- package/src/prompts.mjs +1 -0
- package/src/regression-assessment.mjs +8 -2
- package/src/report-summary.mjs +59 -0
- package/src/retrieval-loop.mjs +16 -3
- package/src/review-reader-allocation.mjs +46 -4
- package/src/review-resume.mjs +67 -2
- package/src/review-stage-state.mjs +143 -0
- package/src/review.mjs +169 -42
- package/src/service.mjs +7 -7
- package/src/stage-continuation.mjs +74 -0
- package/src/verification-loop.mjs +9 -4
package/src/retrieval-loop.mjs
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
}
|
package/src/review-resume.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|