@allocator-one/rcl 4.5.4 → 4.5.5
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/README.md +3 -3
- package/dist/converge/attempt-budget.d.ts +14 -0
- package/dist/converge/attempt-budget.js +216 -1
- package/dist/converge/attempt-budget.js.map +1 -1
- package/dist/converge/launch-guard.js +23 -6
- package/dist/converge/launch-guard.js.map +1 -1
- package/dist/converge/launch-record.d.ts +36 -0
- package/dist/converge/launch-record.js +9 -3
- package/dist/converge/launch-record.js.map +1 -1
- package/dist/converge/legacy-launch-health.d.ts +36 -0
- package/dist/converge/native-lock.d.ts +3 -1
- package/dist/converge/native-lock.js +4 -4
- package/dist/converge/native-lock.js.map +1 -1
- package/dist/converge/ordinary-pending-export.d.ts +3 -0
- package/dist/converge/ordinary-pending-export.js +64 -17
- package/dist/converge/ordinary-pending-export.js.map +1 -1
- package/dist/converge/ordinary-pending-package.d.ts +11 -0
- package/dist/converge/ordinary-pending-package.js +18 -4
- package/dist/converge/ordinary-pending-package.js.map +1 -1
- package/dist/converge/pending-finalization-receipt.d.ts +4 -0
- package/dist/converge/pending-finalization-receipt.js +102 -0
- package/dist/converge/pending-finalization-receipt.js.map +1 -0
- package/dist/converge/pending-legacy-resume.d.ts +42 -3
- package/dist/converge/pending-legacy-resume.js +164 -32
- package/dist/converge/pending-legacy-resume.js.map +1 -1
- package/dist/converge/pending-recovery-source.d.ts +114 -0
- package/dist/converge/pending-recovery-source.js +36 -1
- package/dist/converge/pending-recovery-source.js.map +1 -1
- package/dist/converge/process-identity.d.ts +51 -0
- package/dist/converge/process-identity.js +190 -0
- package/dist/converge/process-identity.js.map +1 -0
- package/dist/converge/recovery-launch.js +39 -17
- package/dist/converge/recovery-launch.js.map +1 -1
- package/dist/converge/recovery-state.d.ts +55 -0
- package/dist/converge/recovery-state.js +306 -0
- package/dist/converge/recovery-state.js.map +1 -0
- package/dist/converge/run-state.d.ts +39 -1
- package/dist/converge/run-state.js +246 -18
- package/dist/converge/run-state.js.map +1 -1
- package/dist/converge/semantic-state.d.ts +55 -0
- package/dist/converge/semantic-state.js +666 -0
- package/dist/converge/semantic-state.js.map +1 -0
- package/dist/converge/stale-report.d.ts +36 -0
- package/dist/dispatch/async-lane.d.ts +12 -0
- package/dist/dispatch/async-lane.js +94 -0
- package/dist/dispatch/async-lane.js.map +1 -1
- package/dist/evidence/claim-recovery/validation/native-state.d.ts +22 -0
- package/dist/evidence/claim-recovery/validation/native-state.js +91 -9
- package/dist/evidence/claim-recovery/validation/native-state.js.map +1 -1
- package/dist/evidence/claim-recovery/validation/obligations.js +2 -1
- package/dist/evidence/claim-recovery/validation/obligations.js.map +1 -1
- package/dist/evidence/claim-recovery/validation/semantic-validation.d.ts +11 -0
- package/dist/evidence/claim-recovery/validation/semantic-validation.js +105 -28
- package/dist/evidence/claim-recovery/validation/semantic-validation.js.map +1 -1
- package/dist/evidence/claim-recovery/validation/types.d.ts +10 -0
- package/dist/evidence/original-run/lock-scope.d.ts +12 -6
- package/dist/evidence/original-run/lock-scope.js +12 -9
- package/dist/evidence/original-run/lock-scope.js.map +1 -1
- package/dist/index.js +31 -10
- package/dist/index.js.map +1 -1
- package/dist/telemetry/recovery/files.d.ts +4 -0
- package/dist/telemetry/recovery/files.js +15 -5
- package/dist/telemetry/recovery/files.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
import { recoverySourceSchema } from '../report/recovery-source.js';
|
|
2
|
+
import { decodeOriginalReport, decodeRecoveryDocument } from '../evidence/original-run/decode.js';
|
|
3
|
+
import { object } from '../evidence/original-run/remote.js';
|
|
4
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
5
|
+
import { link, mkdir, open, readFile, realpath, unlink } from 'node:fs/promises';
|
|
6
|
+
import { dirname } from 'node:path';
|
|
7
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
8
|
+
import { withOwnedNativeOperation } from './target-ownership.js';
|
|
9
|
+
import { effectivePendingIdentities, readNativeRecoveryMaterials, readNativeRecoverySourceJsons, recoveryAnchors, retainNativeRecoverySource } from './recovery-state.js';
|
|
10
|
+
import { recoveredDismissalsByRound } from '../evidence/claim-recovery/validation/native-state.js';
|
|
11
|
+
import { createRecoveredDismissalLookup, indexSemanticSightings, recoveredDismissalsBefore } from '../evidence/claim-recovery/validation/semantic-validation.js';
|
|
12
|
+
import { recoveryProjectionFreshness } from '../evidence/claim-recovery/validation/current-projection.js';
|
|
13
|
+
import { semanticCacheMatches } from '../evidence/claim-recovery/validation/semantic-cache.js';
|
|
14
|
+
import { migratedLegacyPendingRound } from '../evidence/claim-recovery/validation/obligations.js';
|
|
15
|
+
import { syncNativeDirectory } from './native-lock.js';
|
|
16
|
+
import { MAX_REPORT_BYTES, readStable } from '../telemetry/recovery/files.js';
|
|
17
|
+
export { migratedLegacyPendingRound };
|
|
18
|
+
import { z } from 'zod';
|
|
19
|
+
import { claimDescriptorSchema, compareClaims, semanticFindingKey } from '../consensus/claim-identity.js';
|
|
20
|
+
import { DEFAULT_SEVERITY_ORDER } from '../config/defaults.js';
|
|
21
|
+
import { ConvergeRunStateError, ConvergeRoundCapError, HARD_CONVERGE_ROUND_CAP, convergeRunStatePath, loadConvergeRunState, loadConvergeRunStateEvidence, writeStateIfUnchanged, validateRoundCap, findingGatingReason, verdictClearsPending } from './run-state.js';
|
|
22
|
+
const sha = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
23
|
+
const MAX_SEMANTIC_FINDINGS = 2_000;
|
|
24
|
+
function decodeBoundReport(raw) {
|
|
25
|
+
try {
|
|
26
|
+
const decoded = decodeOriginalReport(raw, { exactNumbers: true });
|
|
27
|
+
if (decoded.transformations.length > 0)
|
|
28
|
+
throw new Error('bound_report_transformations_unsupported');
|
|
29
|
+
return decoded.value;
|
|
30
|
+
}
|
|
31
|
+
catch (cause) {
|
|
32
|
+
throw new ConvergeRunStateError('Invalid immutable report JSON.', { cause });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Validate exact original bytes and caller bindings before any durable effect. */
|
|
36
|
+
export function bindRoundEvidence(options) {
|
|
37
|
+
const report = decodeBoundReport(options.evidence.reportJson);
|
|
38
|
+
if (!report || !Array.isArray(report.findings) ||
|
|
39
|
+
(report.belowThresholdFindings !== undefined && !Array.isArray(report.belowThresholdFindings))) {
|
|
40
|
+
throw new ConvergeRunStateError('Invalid immutable report findings.');
|
|
41
|
+
}
|
|
42
|
+
const run = report.run;
|
|
43
|
+
if (!run || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(run.id) ||
|
|
44
|
+
run.id !== options.runId || run.converge?.target !== options.target.trim() ||
|
|
45
|
+
!Number.isSafeInteger(run.converge.round) || run.converge.round !== options.round) {
|
|
46
|
+
throw new ConvergeRunStateError('Original report run, target and round must exactly match the caller binding.');
|
|
47
|
+
}
|
|
48
|
+
const described = [...report.findings, ...(report.belowThresholdFindings ?? [])].some(f => f?.claimDescriptor !== undefined);
|
|
49
|
+
const original = [...report.findings, ...(described ? report.belowThresholdFindings ?? [] : [])];
|
|
50
|
+
if (JSON.stringify(original) !== JSON.stringify(options.findings)) {
|
|
51
|
+
throw new ConvergeRunStateError('Selected findings differ from immutable report objects or original order.');
|
|
52
|
+
}
|
|
53
|
+
const reportSha256 = sha(options.evidence.reportJson);
|
|
54
|
+
return { runId: run.id, target: options.target.trim(), round: options.round, reportSha256,
|
|
55
|
+
sourcePath: `${convergeRunStatePath(options.gitCommonDir, options.target.trim())}.evidence/${reportSha256}.json` };
|
|
56
|
+
}
|
|
57
|
+
export async function retainReportEvidence(raw, binding) {
|
|
58
|
+
if (sha(raw) !== binding.reportSha256)
|
|
59
|
+
throw new ConvergeRunStateError('Original report digest changed.');
|
|
60
|
+
const bytes = Buffer.from(raw);
|
|
61
|
+
if (bytes.length > MAX_REPORT_BYTES) {
|
|
62
|
+
throw new ConvergeRunStateError('Immutable report exceeds the retained byte limit.');
|
|
63
|
+
}
|
|
64
|
+
const directory = dirname(binding.sourcePath);
|
|
65
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
66
|
+
await syncNativeDirectory(dirname(directory));
|
|
67
|
+
try {
|
|
68
|
+
const retained = await readStable(binding.sourcePath, bytes.length, { allowMissingSafeFlagsOnWindows: true });
|
|
69
|
+
if (!retained.raw.equals(bytes))
|
|
70
|
+
throw new ConvergeRunStateError('Original report digest changed.');
|
|
71
|
+
}
|
|
72
|
+
catch (e) {
|
|
73
|
+
const initialCode = e.code;
|
|
74
|
+
const initialMessage = e instanceof Error ? e.message : '';
|
|
75
|
+
if (initialCode !== 'ENOENT') {
|
|
76
|
+
if (initialMessage === 'oversized' || initialMessage === 'invalid_utf8') {
|
|
77
|
+
throw new ConvergeRunStateError('Original report digest changed.', { cause: e });
|
|
78
|
+
}
|
|
79
|
+
throw e;
|
|
80
|
+
}
|
|
81
|
+
const staging = `${binding.sourcePath}.${randomUUID()}.pending`;
|
|
82
|
+
let handle;
|
|
83
|
+
try {
|
|
84
|
+
handle = await open(staging, 'wx', 0o400);
|
|
85
|
+
await handle.writeFile(bytes);
|
|
86
|
+
await handle.sync();
|
|
87
|
+
await handle.close();
|
|
88
|
+
handle = undefined;
|
|
89
|
+
try {
|
|
90
|
+
await link(staging, binding.sourcePath);
|
|
91
|
+
}
|
|
92
|
+
catch (cause) {
|
|
93
|
+
if (cause.code !== 'EEXIST')
|
|
94
|
+
throw cause;
|
|
95
|
+
try {
|
|
96
|
+
const retained = await readStable(binding.sourcePath, bytes.length, { allowMissingSafeFlagsOnWindows: true });
|
|
97
|
+
if (!retained.raw.equals(bytes))
|
|
98
|
+
throw new ConvergeRunStateError('Original report digest changed.');
|
|
99
|
+
}
|
|
100
|
+
catch (conflict) {
|
|
101
|
+
if (conflict instanceof ConvergeRunStateError)
|
|
102
|
+
throw conflict;
|
|
103
|
+
const code = conflict.code;
|
|
104
|
+
const message = conflict instanceof Error ? conflict.message : '';
|
|
105
|
+
if (code === 'ENOENT' || message === 'oversized' || message === 'invalid_utf8') {
|
|
106
|
+
throw new ConvergeRunStateError('Original report digest changed.', { cause: conflict });
|
|
107
|
+
}
|
|
108
|
+
throw conflict;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
await handle?.close();
|
|
114
|
+
await unlink(staging).catch(cleanup => {
|
|
115
|
+
if (cleanup.code !== 'ENOENT')
|
|
116
|
+
throw cleanup;
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
await syncNativeDirectory(directory);
|
|
121
|
+
}
|
|
122
|
+
export async function verifyRoundBinding(binding, target, round, runId) {
|
|
123
|
+
let bytes;
|
|
124
|
+
try {
|
|
125
|
+
bytes = (await readStable(binding.sourcePath, MAX_REPORT_BYTES, { allowMissingSafeFlagsOnWindows: true })).raw;
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
throw new ConvergeRunStateError('Original bound report bytes unavailable; refusing verdict mutation.');
|
|
129
|
+
}
|
|
130
|
+
let report;
|
|
131
|
+
try {
|
|
132
|
+
report = decodeBoundReport(bytes.toString());
|
|
133
|
+
}
|
|
134
|
+
catch (cause) {
|
|
135
|
+
throw new ConvergeRunStateError('Original bound report is invalid JSON.', { cause });
|
|
136
|
+
}
|
|
137
|
+
if (sha(bytes) !== binding.reportSha256 || binding.target !== target || binding.round !== round || binding.runId !== runId ||
|
|
138
|
+
report?.run?.id !== runId || report.run?.converge?.target !== target || report.run?.converge?.round !== round) {
|
|
139
|
+
throw new ConvergeRunStateError('Inconsistent immutable original report binding; refusing verdict mutation.');
|
|
140
|
+
}
|
|
141
|
+
return report;
|
|
142
|
+
}
|
|
143
|
+
const bindingSchema = z.object({
|
|
144
|
+
runId: z.string().min(1), target: z.string().min(1), round: z.number().int().positive(),
|
|
145
|
+
reportSha256: z.string().regex(/^[a-f0-9]{64}$/), sourcePath: z.string().min(1),
|
|
146
|
+
});
|
|
147
|
+
const sightingSchema = bindingSchema.omit({ sourcePath: true }).extend({
|
|
148
|
+
findingRef: z.string().regex(/^f\d{3,}$/), reportKey: z.string().min(1), canonicalIdentity: z.string().min(1),
|
|
149
|
+
claimDescriptor: claimDescriptorSchema,
|
|
150
|
+
matchRationale: z.enum(['new_claim', 'exact_descriptor', 'supported_paraphrase', 'ambiguous', 'explicit_split']),
|
|
151
|
+
status: z.enum(['new', 'repeat', 'suppressed', 'regating']), suppressReason: z.string().optional(),
|
|
152
|
+
pendingRound: z.number().int().positive().nullable(),
|
|
153
|
+
severity: z.enum(['critical', 'important', 'minor', 'nitpick']), gating: z.string().min(1), belowThreshold: z.boolean(),
|
|
154
|
+
file: z.string(), category: z.string(), startLine: z.number().int().nonnegative(), endLine: z.number().int().nonnegative(),
|
|
155
|
+
});
|
|
156
|
+
/** All native consumers validate the same immutable sighting membership before effects. */
|
|
157
|
+
export async function validateSemanticState(state, gitCommonDir, recoveryLegacy) {
|
|
158
|
+
try {
|
|
159
|
+
await validateSemanticMembership(state, gitCommonDir, recoveryLegacy, await retainedRecoveredDismissalsByRound(state, gitCommonDir));
|
|
160
|
+
}
|
|
161
|
+
catch (cause) {
|
|
162
|
+
if (cause instanceof ConvergeRunStateError)
|
|
163
|
+
throw cause;
|
|
164
|
+
throw new ConvergeRunStateError('Invalid v2 sighting ledger or immutable source; refusing native mutation.', { cause });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
async function validateSemanticMembership(state, gitCommonDir, recoveryLegacy, recoveryDismissalsByRound) {
|
|
168
|
+
const requireIntegrity = (valid) => {
|
|
169
|
+
if (!valid)
|
|
170
|
+
throw new ConvergeRunStateError('Invalid v2 sighting ledger or immutable membership; refusing native mutation.');
|
|
171
|
+
};
|
|
172
|
+
requireIntegrity(Array.isArray(state.sightings));
|
|
173
|
+
const sightings = state.sightings;
|
|
174
|
+
const originalTitles = new Map();
|
|
175
|
+
requireIntegrity(sightings.every(s => sightingSchema.safeParse(s).success));
|
|
176
|
+
const { byRound: sightingsByRound, byIdentity: sightingsByIdentity, byIdentityAndRound: sightingsByIdentityAndRound } = indexSemanticSightings(sightings);
|
|
177
|
+
for (const anchor of recoveryAnchors(state)) {
|
|
178
|
+
requireIntegrity((sightingsByIdentity.get(anchor.identity) ?? []).every(sighting => sighting.file === anchor.source.file && sighting.category === anchor.source.category &&
|
|
179
|
+
compareClaims(sighting.claimDescriptor, anchor.descriptor) !== undefined));
|
|
180
|
+
}
|
|
181
|
+
const path = convergeRunStatePath(gitCommonDir, state.target);
|
|
182
|
+
let legacy = recoveryLegacy;
|
|
183
|
+
if (state.migration !== undefined) {
|
|
184
|
+
const migration = state.migration;
|
|
185
|
+
requireIntegrity(!!migration && typeof migration === 'object' &&
|
|
186
|
+
/^[a-f0-9]{64}$/.test(migration.sourceSha256) &&
|
|
187
|
+
typeof migration.snapshotPath === 'string');
|
|
188
|
+
// Git resolves common directories physically; API callers may retain the
|
|
189
|
+
// same directory through an alias (for example Darwin /var). Keep the
|
|
190
|
+
// stored binding unchanged while requiring the expected physical file.
|
|
191
|
+
requireIntegrity(await realpath(migration.snapshotPath) === await realpath(`${path}.v1-${migration.sourceSha256}.snapshot`));
|
|
192
|
+
try {
|
|
193
|
+
const original = await readFile(migration.snapshotPath);
|
|
194
|
+
requireIntegrity(sha(original) === migration.sourceSha256);
|
|
195
|
+
legacy = decodeRecoveryDocument(original.toString());
|
|
196
|
+
}
|
|
197
|
+
catch (cause) {
|
|
198
|
+
throw new ConvergeRunStateError('Original v1 migration snapshot unavailable or invalid; refusing native mutation.', { cause });
|
|
199
|
+
}
|
|
200
|
+
requireIntegrity(!!legacy && legacy.version === 1 && legacy.target === state.target && Array.isArray(legacy.rounds) &&
|
|
201
|
+
!!legacy.findings && typeof legacy.findings === 'object' && !Array.isArray(legacy.findings));
|
|
202
|
+
for (const round of legacy.rounds) {
|
|
203
|
+
requireIntegrity(isDeepStrictEqual(state.rounds.find(r => r?.round === round.round), round));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const rounds = new Set();
|
|
207
|
+
for (const round of state.rounds) {
|
|
208
|
+
requireIntegrity(!!round && Number.isSafeInteger(round.round) && round.round > 0 && !rounds.has(round.round));
|
|
209
|
+
rounds.add(round.round);
|
|
210
|
+
const members = sightingsByRound.get(round.round) ?? [];
|
|
211
|
+
if (legacy?.rounds.some(r => r.round === round.round)) {
|
|
212
|
+
// Migrated legacy rounds deliberately have no semantic sightings. Their
|
|
213
|
+
// exact snapshot is the boundary, not an invented descriptor or ref.
|
|
214
|
+
requireIntegrity(members.length === 0);
|
|
215
|
+
if (round.reportBinding) {
|
|
216
|
+
requireIntegrity(bindingSchema.safeParse(round.reportBinding).success && round.runId === round.reportBinding.runId);
|
|
217
|
+
await verifyRoundBinding(round.reportBinding, state.target, round.round, round.runId);
|
|
218
|
+
}
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
requireIntegrity(bindingSchema.safeParse(round.reportBinding).success && round.runId === round.reportBinding?.runId);
|
|
222
|
+
const binding = round.reportBinding;
|
|
223
|
+
requireIntegrity(await realpath(binding.sourcePath) === await realpath(`${path}.evidence/${binding.reportSha256}.json`));
|
|
224
|
+
const report = await verifyRoundBinding(binding, state.target, round.round, round.runId);
|
|
225
|
+
// Later semantic rounds must agree with the native cycle (including none).
|
|
226
|
+
// Original legacy rounds remain bounded by their exact predecessor bytes.
|
|
227
|
+
const cycleRun = report.run;
|
|
228
|
+
requireIntegrity(object(cycleRun) && cycleRun.cycle_id === state.cycle?.id && (!state.cycle || object(cycleRun.target) &&
|
|
229
|
+
typeof cycleRun.target.repo === 'string' && cycleRun.target.repo.toLowerCase() === state.cycle.repo.toLowerCase() &&
|
|
230
|
+
cycleRun.target.pr_number === state.cycle.prNumber));
|
|
231
|
+
requireIntegrity(Array.isArray(report.findings) && (report.belowThresholdFindings === undefined || Array.isArray(report.belowThresholdFindings)));
|
|
232
|
+
const all = [...report.findings, ...(report.belowThresholdFindings ?? [])];
|
|
233
|
+
// A genuinely empty legacy report can start v2, but its descriptor-less
|
|
234
|
+
// appendix is not retroactively claimed as semantic evidence.
|
|
235
|
+
const described = all.some(f => f?.claimDescriptor !== undefined);
|
|
236
|
+
const original = described ? all : report.findings;
|
|
237
|
+
requireIntegrity(members.length === original.length);
|
|
238
|
+
const counts = { new: 0, repeat: 0, suppressed: 0, regating: 0 };
|
|
239
|
+
const severities = {};
|
|
240
|
+
const reportKeys = new Set();
|
|
241
|
+
for (let i = 0; i < members.length; i++) {
|
|
242
|
+
const sighting = members[i];
|
|
243
|
+
const finding = original[i];
|
|
244
|
+
requireIntegrity(!!finding && claimDescriptorSchema.safeParse(finding.claimDescriptor).success &&
|
|
245
|
+
typeof finding.identity === 'string' && finding.identity.startsWith(`report:${binding.runId}:`) && !reportKeys.has(finding.identity));
|
|
246
|
+
reportKeys.add(finding.identity);
|
|
247
|
+
const entry = state.findings[sighting.canonicalIdentity];
|
|
248
|
+
requireIntegrity(!!entry && entry.key === sighting.canonicalIdentity && claimDescriptorSchema.safeParse(entry.claimDescriptor).success &&
|
|
249
|
+
entry.file === finding.file && entry.category === finding.category &&
|
|
250
|
+
(isDeepStrictEqual(entry.claimDescriptor, finding.claimDescriptor) || compareClaims(entry.claimDescriptor, finding.claimDescriptor) !== undefined));
|
|
251
|
+
const expected = { runId: binding.runId, target: binding.target, round: binding.round, reportSha256: binding.reportSha256,
|
|
252
|
+
findingRef: `f${String(i + 1).padStart(3, '0')}`, reportKey: finding.identity, claimDescriptor: finding.claimDescriptor,
|
|
253
|
+
severity: finding.severity, gating: i < report.findings.length ? findingGatingReason(finding) : 'none',
|
|
254
|
+
belowThreshold: i >= report.findings.length, file: finding.file, category: finding.category, startLine: finding.startLine, endLine: finding.endLine };
|
|
255
|
+
for (const key of Object.keys(expected))
|
|
256
|
+
requireIntegrity(isDeepStrictEqual(sighting[key], expected[key]));
|
|
257
|
+
requireIntegrity(typeof finding.title === 'string');
|
|
258
|
+
originalTitles.set(sighting, finding.title);
|
|
259
|
+
requireIntegrity(sighting.endLine >= sighting.startLine);
|
|
260
|
+
requireIntegrity(sighting.pendingRound === null || (Number.isSafeInteger(sighting.pendingRound) && sighting.pendingRound <= sighting.round));
|
|
261
|
+
if (sighting.gating !== 'none' && (sighting.status === 'new' || sighting.status === 'regating')) {
|
|
262
|
+
requireIntegrity(sighting.pendingRound === sighting.round);
|
|
263
|
+
}
|
|
264
|
+
counts[sighting.status]++;
|
|
265
|
+
const prior = severities[sighting.canonicalIdentity];
|
|
266
|
+
if (prior === undefined || DEFAULT_SEVERITY_ORDER.indexOf(sighting.severity) < DEFAULT_SEVERITY_ORDER.indexOf(prior)) {
|
|
267
|
+
severities[sighting.canonicalIdentity] = sighting.severity;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
requireIntegrity(isDeepStrictEqual(counts, round.counts) && isDeepStrictEqual(severities, round.severities));
|
|
271
|
+
}
|
|
272
|
+
requireIntegrity(sightings.every(s => rounds.has(s.round)));
|
|
273
|
+
validateSightingClassification(state, sightingsByIdentityAndRound, requireIntegrity, recoveryDismissalsByRound);
|
|
274
|
+
if (legacy) {
|
|
275
|
+
// Semantic admission never replaces or removes migrated original entries.
|
|
276
|
+
requireIntegrity(Object.keys(legacy.findings).every(key => Object.hasOwn(state.findings, key)));
|
|
277
|
+
}
|
|
278
|
+
for (const [key, entry] of Object.entries(state.findings)) {
|
|
279
|
+
requireIntegrity(!!entry && typeof entry === 'object' && entry.key === key);
|
|
280
|
+
const legacyOriginal = legacy?.findings[key];
|
|
281
|
+
if (entry.verdict !== undefined) {
|
|
282
|
+
requireIntegrity((entry.verdict === 'fixed' || entry.verdict === 'dismissed') &&
|
|
283
|
+
Number.isSafeInteger(entry.verdictRound) && rounds.has(entry.verdictRound));
|
|
284
|
+
const reviewed = state.rounds.find(r => r.round === entry.verdictRound);
|
|
285
|
+
const unchangedImplicitLegacySeverity = legacyOriginal?.verdict !== undefined &&
|
|
286
|
+
legacyOriginal.verdict === entry.verdict && legacyOriginal.verdictRound === entry.verdictRound &&
|
|
287
|
+
legacyOriginal.verdictSeverity === undefined && entry.verdictSeverity === undefined;
|
|
288
|
+
requireIntegrity(reviewed.severities === undefined ||
|
|
289
|
+
(reviewed.severities[key] !== undefined &&
|
|
290
|
+
(entry.verdictSeverity === reviewed.severities[key] || unchangedImplicitLegacySeverity)));
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
requireIntegrity(entry.verdictRound === undefined && entry.verdictSeverity === undefined);
|
|
294
|
+
}
|
|
295
|
+
if (entry.claimDescriptor === undefined) {
|
|
296
|
+
const original = legacyOriginal;
|
|
297
|
+
requireIntegrity(!!original && original.claimDescriptor === undefined);
|
|
298
|
+
if (original.verdict !== undefined) {
|
|
299
|
+
requireIntegrity(entry.verdict !== undefined && entry.verdictRound >= (original.verdictRound ?? 0));
|
|
300
|
+
}
|
|
301
|
+
const identityFields = (value) => Object.fromEntries(Object.entries(value)
|
|
302
|
+
.filter(([field]) => !['pendingRound', 'verdict', 'verdictRound', 'verdictSeverity', 'verdictReason'].includes(field)));
|
|
303
|
+
requireIntegrity(isDeepStrictEqual(identityFields(entry), identityFields(original)));
|
|
304
|
+
const initialPending = recoveryLegacy ? original.pendingRound : migratedLegacyPendingRound(original, legacy);
|
|
305
|
+
const expectedPending = initialPending !== undefined &&
|
|
306
|
+
!(entry.verdict !== undefined && verdictClearsPending(state, key, initialPending, entry.verdictRound, entry.verdictSeverity)) ? initialPending : undefined;
|
|
307
|
+
requireIntegrity(entry.pendingRound === expectedPending);
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
const members = sightingsByIdentity.get(key) ?? [];
|
|
311
|
+
requireIntegrity(members.length > 0 && claimDescriptorSchema.safeParse(entry.claimDescriptor).success &&
|
|
312
|
+
entry.firstRound === Math.min(...members.map(s => s.round)) && entry.lastRound === Math.max(...members.map(s => s.round)) &&
|
|
313
|
+
members.some(s => s.round === entry.firstRound && isDeepStrictEqual(s.claimDescriptor, entry.claimDescriptor)));
|
|
314
|
+
requireIntegrity(semanticCacheMatches(entry, members, originalTitles));
|
|
315
|
+
let latestGatedRound = null;
|
|
316
|
+
let latestGatedRoundAfterVerdict = null;
|
|
317
|
+
const groups = sightingsByIdentityAndRound.get(key) ?? new Map();
|
|
318
|
+
for (const [round, group] of [...groups.entries()].sort(([a], [b]) => a - b)) {
|
|
319
|
+
if (group.some(s => s.gating !== 'none' && s.status !== 'suppressed')) {
|
|
320
|
+
latestGatedRound = round;
|
|
321
|
+
if (entry.verdict === undefined || !verdictClearsPending(state, key, round, entry.verdictRound, entry.verdictSeverity)) {
|
|
322
|
+
latestGatedRoundAfterVerdict = round;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
requireIntegrity(group.every(s => s.pendingRound === group[0].pendingRound));
|
|
326
|
+
const capturedPending = group[0].pendingRound;
|
|
327
|
+
requireIntegrity(capturedPending === null || members.some(s => s.round === capturedPending &&
|
|
328
|
+
s.gating !== 'none' && s.status !== 'suppressed'));
|
|
329
|
+
if (group.every(s => s.status === 'suppressed'))
|
|
330
|
+
continue;
|
|
331
|
+
const expectedCaptured = entry.verdict !== undefined && entry.verdictRound < round ?
|
|
332
|
+
latestGatedRoundAfterVerdict : latestGatedRound;
|
|
333
|
+
requireIntegrity(capturedPending === expectedCaptured);
|
|
334
|
+
}
|
|
335
|
+
const latestPending = sightingsByIdentityAndRound.get(key).get(entry.lastRound)[0].pendingRound;
|
|
336
|
+
const expectedPending = latestPending !== null &&
|
|
337
|
+
!(entry.verdict !== undefined && verdictClearsPending(state, key, latestPending, entry.verdictRound, entry.verdictSeverity)) ? latestPending : undefined;
|
|
338
|
+
requireIntegrity(entry.pendingRound === expectedPending);
|
|
339
|
+
}
|
|
340
|
+
const latest = Math.max(0, ...rounds);
|
|
341
|
+
if (latest && !legacy?.rounds.some(r => r.round === latest)) {
|
|
342
|
+
const expected = { round: latest, identities: (sightingsByRound.get(latest) ?? [])
|
|
343
|
+
.map(s => ({ identity: s.canonicalIdentity, status: s.status, gating: s.gating })) };
|
|
344
|
+
requireIntegrity(state.lastAnnotations !== undefined && state.lastAnnotations.round === expected.round &&
|
|
345
|
+
isDeepStrictEqual(state.lastAnnotations.identities, expected.identities));
|
|
346
|
+
const admission = state.rounds.find(row => row.round === latest)?.admission;
|
|
347
|
+
if (admission)
|
|
348
|
+
requireIntegrity(state.lastAnnotations.actionableBeforeTriage !== undefined &&
|
|
349
|
+
isDeepStrictEqual(admission.actionableIdentities, state.lastAnnotations.actionableBeforeTriage));
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
function validateSightingClassification(state, sightingsByIdentityAndRound, requireIntegrity, recoveryDismissalsByRound) {
|
|
353
|
+
if (state.version === 1)
|
|
354
|
+
return;
|
|
355
|
+
const anchored = new Set(state.version === 3 ? recoveryAnchors(state).map(anchor => anchor.identity) : []);
|
|
356
|
+
const recoveryDismissalsForRound = createRecoveredDismissalLookup(recoveryDismissalsByRound);
|
|
357
|
+
for (const [key, entry] of Object.entries(state.findings)) {
|
|
358
|
+
if (entry.claimDescriptor === undefined)
|
|
359
|
+
continue;
|
|
360
|
+
const byRound = sightingsByIdentityAndRound.get(key) ?? new Map();
|
|
361
|
+
if (byRound.size === 0)
|
|
362
|
+
continue;
|
|
363
|
+
const firstRound = Math.min(...byRound.keys());
|
|
364
|
+
for (const [round, group] of byRound) {
|
|
365
|
+
const ordinaryVerdict = (entry.verdictRound ?? Infinity) < round;
|
|
366
|
+
const verdictRecordedForThisSightingOrLater = entry.verdict !== undefined && entry.verdictRound >= round;
|
|
367
|
+
const recoveryDismissal = recoveryDismissalsForRound(round, key);
|
|
368
|
+
const overwrittenRecoveredEscalation = verdictRecordedForThisSightingOrLater &&
|
|
369
|
+
group.some(row => row.severity === 'critical') && recoveryDismissal !== undefined && recoveryDismissal !== 'critical';
|
|
370
|
+
const recoveredEscalation = entry.verdict === undefined && group.some(row => row.severity === 'critical') &&
|
|
371
|
+
recoveryDismissal !== undefined && recoveryDismissal !== 'critical';
|
|
372
|
+
const expectedStatus = state.version === 2 ? round === firstRound ? 'new' : undefined
|
|
373
|
+
: overwrittenRecoveredEscalation ? undefined
|
|
374
|
+
: recoveredEscalation ? 'regating' : round === firstRound
|
|
375
|
+
? anchored.has(key) ? 'repeat' : 'new'
|
|
376
|
+
: entry.verdict === undefined ? 'repeat'
|
|
377
|
+
: ordinaryVerdict ? entry.verdict === 'dismissed' && group.some(row => row.severity === 'critical') && entry.verdictSeverity !== 'critical'
|
|
378
|
+
? 'regating' : entry.verdict === 'dismissed' ? 'suppressed' : 'repeat'
|
|
379
|
+
: undefined;
|
|
380
|
+
if (expectedStatus !== undefined) {
|
|
381
|
+
requireIntegrity(group.every(row => row.status === expectedStatus));
|
|
382
|
+
if (expectedStatus === 'suppressed') {
|
|
383
|
+
const reason = `dismissed in round ${entry.verdictRound}${entry.verdictReason ? ` (${entry.verdictReason})` : ''} — matching semantic claim; escalation to critical re-gates`;
|
|
384
|
+
requireIntegrity(group.every(row => row.suppressReason === reason));
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
else {
|
|
388
|
+
// A later ordinary verdict overwrites the prior verdict's reason and
|
|
389
|
+
// severity. The retained schema cannot reconstruct that old decision,
|
|
390
|
+
// but it can still rule out a fresh classification.
|
|
391
|
+
requireIntegrity(group.every(row => row.status !== 'new'));
|
|
392
|
+
}
|
|
393
|
+
for (const sighting of group) {
|
|
394
|
+
// v2 retained the original producer's later match rationale without a
|
|
395
|
+
// replayable classification protocol. Its first semantic sighting is
|
|
396
|
+
// still immutable, while v3 replays every later descriptor match.
|
|
397
|
+
if (state.version === 2 && round > firstRound)
|
|
398
|
+
continue;
|
|
399
|
+
const match = compareClaims(sighting.claimDescriptor, entry.claimDescriptor);
|
|
400
|
+
if (round > firstRound || sighting.matchRationale === 'exact_descriptor' || sighting.matchRationale === 'supported_paraphrase') {
|
|
401
|
+
requireIntegrity(match === sighting.matchRationale);
|
|
402
|
+
}
|
|
403
|
+
else {
|
|
404
|
+
requireIntegrity(sighting.matchRationale === 'new_claim' || sighting.matchRationale === 'ambiguous');
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
function semanticRoundBinding(state, binding) {
|
|
411
|
+
const pending = new Set(effectivePendingIdentities(state));
|
|
412
|
+
const legacyPendingIdentities = Object.values(state.findings).filter(e => e.claimDescriptor === undefined && pending.has(e.key)).map(e => e.key).sort();
|
|
413
|
+
return { reportBinding: binding, classificationVersion: 1,
|
|
414
|
+
...(recoveryProjectionFreshness(state) ? { recoveryProjection: recoveryProjectionFreshness(state) } : {}),
|
|
415
|
+
...(legacyPendingIdentities.length ? { legacyPendingIdentities } : {}) };
|
|
416
|
+
}
|
|
417
|
+
function located(a, b, window) {
|
|
418
|
+
return a.file === b.file && a.category === b.category && a.startLine <= b.endLine + window && b.startLine <= a.endLine + window;
|
|
419
|
+
}
|
|
420
|
+
function highest(rows) {
|
|
421
|
+
return rows.map(f => f.severity).sort((a, b) => DEFAULT_SEVERITY_ORDER.indexOf(a) - DEFAULT_SEVERITY_ORDER.indexOf(b))[0];
|
|
422
|
+
}
|
|
423
|
+
/** @internal Linear first-seen index for admission candidates. */
|
|
424
|
+
export function indexSemanticPriorByFileCategory(prior) {
|
|
425
|
+
const result = new Map();
|
|
426
|
+
for (const entry of prior) {
|
|
427
|
+
const key = JSON.stringify([entry.file, entry.category]);
|
|
428
|
+
const bucket = result.get(key);
|
|
429
|
+
if (bucket)
|
|
430
|
+
bucket.push(entry);
|
|
431
|
+
else
|
|
432
|
+
result.set(key, [entry]);
|
|
433
|
+
}
|
|
434
|
+
return result;
|
|
435
|
+
}
|
|
436
|
+
/** Frozen batch matching: a component must be a clique and agree on one prior claim. */
|
|
437
|
+
export function processSemanticRound(options, binding, ownership) {
|
|
438
|
+
return withOwnedNativeOperation(ownership, options.gitCommonDir, options.target, operation => processSemanticRoundOwned(options, binding, operation));
|
|
439
|
+
}
|
|
440
|
+
/** @internal Bind a recovered report to the retained review cycle before any native mutation. */
|
|
441
|
+
function assertSemanticReportCycle(state, report) {
|
|
442
|
+
const reportRepo = report.run?.target?.repo;
|
|
443
|
+
const matches = state.cycle
|
|
444
|
+
? report.run?.cycle_id === state.cycle.id && typeof reportRepo === 'string' &&
|
|
445
|
+
reportRepo.toLowerCase() === state.cycle.repo.toLowerCase() && report.run?.target?.pr_number === state.cycle.prNumber
|
|
446
|
+
: report.run?.cycle_id === undefined;
|
|
447
|
+
if (!matches) {
|
|
448
|
+
throw new ConvergeRunStateError('Recovered-v3 report cycle, repository and pull request must match the retained native cycle.');
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
async function processSemanticRoundOwned(options, binding, ownership) {
|
|
452
|
+
const findings = options.findings;
|
|
453
|
+
if (findings.length > MAX_SEMANTIC_FINDINGS) {
|
|
454
|
+
throw new ConvergeRunStateError(`Semantic continuation supports at most ${MAX_SEMANTIC_FINDINGS} findings per report.`);
|
|
455
|
+
}
|
|
456
|
+
const keys = new Set();
|
|
457
|
+
for (const f of findings) {
|
|
458
|
+
if (!claimDescriptorSchema.safeParse(f.claimDescriptor).success || !f.identity || !f.identity.startsWith(`report:${binding.runId}:`) || keys.has(f.identity)) {
|
|
459
|
+
throw new ConvergeRunStateError('Every described sighting requires a valid descriptor and distinct original report-qualified key.');
|
|
460
|
+
}
|
|
461
|
+
if (!Number.isSafeInteger(f.startLine) || !Number.isSafeInteger(f.endLine) || f.startLine < 0 || f.endLine < f.startLine)
|
|
462
|
+
throw new ConvergeRunStateError('Invalid described sighting location.');
|
|
463
|
+
keys.add(f.identity);
|
|
464
|
+
}
|
|
465
|
+
const state = await loadConvergeRunState(options.gitCommonDir, binding.target);
|
|
466
|
+
if (state?.version !== 3)
|
|
467
|
+
throw new ConvergeRunStateError('Semantic continuation requires a validated recovered-v3 target.');
|
|
468
|
+
if (!Array.isArray(state.sightings))
|
|
469
|
+
throw new ConvergeRunStateError('Invalid v2 sighting ledger; refusing to reset it.');
|
|
470
|
+
const old = state.rounds.find(r => r.round === options.round);
|
|
471
|
+
if (old) {
|
|
472
|
+
if (old.reportBinding?.reportSha256 !== binding.reportSha256 || old.runId !== binding.runId)
|
|
473
|
+
throw new ConvergeRunStateError('Round is already bound to different immutable report bytes.');
|
|
474
|
+
await verifyRoundBinding(old.reportBinding, binding.target, options.round, binding.runId);
|
|
475
|
+
const sightings = state.sightings.filter(s => s.round === options.round);
|
|
476
|
+
if (sightings.length !== findings.length)
|
|
477
|
+
throw new ConvergeRunStateError('Incomplete immutable sighting ledger.');
|
|
478
|
+
return { ...semanticRoundBinding(state, binding), roundCap: state.roundCap, counts: old.counts, actionableIdentities: pending(state), findings: findings.map((finding, i) => {
|
|
479
|
+
const sighting = sightings[i];
|
|
480
|
+
return { identity: sighting.canonicalIdentity, status: sighting.status, suppressReason: sighting.suppressReason, finding, sighting };
|
|
481
|
+
}) };
|
|
482
|
+
}
|
|
483
|
+
const report = JSON.parse(options.evidence.reportJson);
|
|
484
|
+
const predecessor = report.run?.converge?.recovery_source;
|
|
485
|
+
if (!predecessor || !recoverySourceSchema.safeParse(predecessor).success) {
|
|
486
|
+
throw new ConvergeRunStateError('Recovered-v3 report requires its versioned native predecessor binding.');
|
|
487
|
+
}
|
|
488
|
+
assertSemanticReportCycle(state, report);
|
|
489
|
+
const current = await loadConvergeRunStateEvidence(options.gitCommonDir, binding.target);
|
|
490
|
+
if (current?.sha256 !== predecessor.native_sha256) {
|
|
491
|
+
await retainReportEvidence(options.evidence.reportJson, binding);
|
|
492
|
+
throw new ConvergeRunStateError('Native recovery state changed during review; original report retained without native admission.');
|
|
493
|
+
}
|
|
494
|
+
const predecessorJson = await readFile(convergeRunStatePath(options.gitCommonDir, binding.target), 'utf8');
|
|
495
|
+
if (sha(predecessorJson) !== predecessor.native_sha256) {
|
|
496
|
+
await retainReportEvidence(options.evidence.reportJson, binding);
|
|
497
|
+
throw new ConvergeRunStateError('Native recovery state changed during review; original report retained without native admission.');
|
|
498
|
+
}
|
|
499
|
+
if (options.maxRounds !== undefined)
|
|
500
|
+
state.roundCap = validateRoundCap(options.maxRounds);
|
|
501
|
+
if (options.round > state.roundCap || options.round > HARD_CONVERGE_ROUND_CAP)
|
|
502
|
+
throw new ConvergeRoundCapError(binding.target, options.round, state.roundCap);
|
|
503
|
+
const max = Math.max(0, ...state.rounds.map(r => r.round));
|
|
504
|
+
if (max && options.round !== max + 1)
|
|
505
|
+
throw new ConvergeRunStateError(`Round ${options.round} is out of order; next round is ${max + 1}.`);
|
|
506
|
+
const window = options.lineWindow ?? 5;
|
|
507
|
+
const anchors = recoveryAnchors(state);
|
|
508
|
+
const anchorsByIdentity = new Map();
|
|
509
|
+
for (const anchor of anchors) {
|
|
510
|
+
const bucket = anchorsByIdentity.get(anchor.identity);
|
|
511
|
+
if (bucket)
|
|
512
|
+
bucket.push(anchor);
|
|
513
|
+
else
|
|
514
|
+
anchorsByIdentity.set(anchor.identity, [anchor]);
|
|
515
|
+
}
|
|
516
|
+
const { byIdentity: sightingsByIdentity } = indexSemanticSightings(state.sightings);
|
|
517
|
+
const prior = [
|
|
518
|
+
...Object.values(state.findings).filter(e => e.claimDescriptor !== undefined),
|
|
519
|
+
...anchors.filter(anchor => !Object.hasOwn(state.findings, anchor.identity)).map(anchor => ({
|
|
520
|
+
key: anchor.identity, ...anchor.source, claimDescriptor: anchor.descriptor,
|
|
521
|
+
})),
|
|
522
|
+
];
|
|
523
|
+
const priorByFileCategory = indexSemanticPriorByFileCategory(prior);
|
|
524
|
+
// Never expand equivalence through a paraphrase bridge. Every previously
|
|
525
|
+
// associated descriptor must support the new sighting, not just the latest.
|
|
526
|
+
const candidates = findings.map(f => (priorByFileCategory.get(JSON.stringify([f.file, f.category])) ?? [])
|
|
527
|
+
.filter(e => located(f, e, window) && compareClaims(f.claimDescriptor, e.claimDescriptor) &&
|
|
528
|
+
(anchorsByIdentity.get(e.key) ?? []).every(anchor => compareClaims(f.claimDescriptor, anchor.descriptor)) &&
|
|
529
|
+
(sightingsByIdentity.get(e.key) ?? []).every(s => compareClaims(f.claimDescriptor, s.claimDescriptor)))
|
|
530
|
+
.map(e => e.key).sort());
|
|
531
|
+
const related = findings.map(a => findings.map(b => located(a, b, window) && compareClaims(a.claimDescriptor, b.claimDescriptor) !== undefined));
|
|
532
|
+
const visited = new Set();
|
|
533
|
+
const groups = [];
|
|
534
|
+
for (let i = 0; i < findings.length; i++) {
|
|
535
|
+
if (visited.has(i))
|
|
536
|
+
continue;
|
|
537
|
+
const component = [i];
|
|
538
|
+
visited.add(i);
|
|
539
|
+
for (let j = 0; j < component.length; j++)
|
|
540
|
+
for (let k = 0; k < findings.length; k++) {
|
|
541
|
+
if (!visited.has(k) && related[component[j]][k]) {
|
|
542
|
+
visited.add(k);
|
|
543
|
+
component.push(k);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
const clique = component.every(a => component.every(b => a === b || related[a][b]));
|
|
547
|
+
const sameCandidates = component.every(j => JSON.stringify(candidates[j]) === JSON.stringify(candidates[i]));
|
|
548
|
+
if (!clique || !sameCandidates) {
|
|
549
|
+
for (const j of component)
|
|
550
|
+
groups.push({ indices: [j], rationale: 'ambiguous' });
|
|
551
|
+
}
|
|
552
|
+
else {
|
|
553
|
+
const matches = candidates[i];
|
|
554
|
+
groups.push({ indices: component, ...(matches.length === 1 ? { matched: matches[0] } : {}),
|
|
555
|
+
rationale: matches.length > 1 ? 'ambiguous' : 'new_claim' });
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
// Disconnected current components may still share a broad prior candidate.
|
|
559
|
+
// A prior claim is not permission to bridge mutually unsupported sightings.
|
|
560
|
+
for (const key of new Set(groups.flatMap(g => g.matched ? [g.matched] : []))) {
|
|
561
|
+
const sharing = groups.filter(g => g.matched === key);
|
|
562
|
+
const members = sharing.flatMap(g => g.indices);
|
|
563
|
+
if (!members.every(a => members.every(b => a === b || related[a][b]))) {
|
|
564
|
+
for (const group of sharing) {
|
|
565
|
+
delete group.matched;
|
|
566
|
+
group.rationale = 'ambiguous';
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
// Canonical allocation order is independent of report order. Sighting refs
|
|
571
|
+
// below retain the untouched original kept/appendix positions.
|
|
572
|
+
const signature = (g) => JSON.stringify(g.indices.map(i => {
|
|
573
|
+
const f = findings[i];
|
|
574
|
+
return [semanticFindingKey(f.file, f.category, f.claimDescriptor), f.startLine, f.endLine];
|
|
575
|
+
}).sort());
|
|
576
|
+
const occupied = new Set([...Object.keys(state.findings), ...anchors.map(anchor => anchor.identity)]);
|
|
577
|
+
for (const group of [...groups].sort((a, b) => signature(a).localeCompare(signature(b)))) {
|
|
578
|
+
if (group.matched) {
|
|
579
|
+
group.key = group.matched;
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
const representatives = group.indices.map(i => findings[i]).sort((a, b) => semanticFindingKey(a.file, a.category, a.claimDescriptor).localeCompare(semanticFindingKey(b.file, b.category, b.claimDescriptor)));
|
|
583
|
+
const rep = representatives[0];
|
|
584
|
+
group.representative = rep;
|
|
585
|
+
const base = semanticFindingKey(rep.file, rep.category, rep.claimDescriptor);
|
|
586
|
+
let key = base;
|
|
587
|
+
let salt = 0;
|
|
588
|
+
while (occupied.has(key))
|
|
589
|
+
key = sha(JSON.stringify([base, signature(group), ++salt])).slice(0, 16);
|
|
590
|
+
occupied.add(key);
|
|
591
|
+
group.key = key;
|
|
592
|
+
}
|
|
593
|
+
const original = JSON.parse(options.evidence.reportJson);
|
|
594
|
+
const keptCount = original.findings.length;
|
|
595
|
+
const counts = { new: 0, repeat: 0, suppressed: 0, regating: 0 };
|
|
596
|
+
const severities = {};
|
|
597
|
+
const annotations = [];
|
|
598
|
+
const recoveredDismissals = findings.some(finding => finding.severity === 'critical')
|
|
599
|
+
? recoveredDismissalsBefore(await retainedRecoveredDismissalsByRound(state, options.gitCommonDir), options.round) : new Map();
|
|
600
|
+
for (const group of groups) {
|
|
601
|
+
const rows = group.indices.map(i => findings[i]);
|
|
602
|
+
const severity = highest(rows);
|
|
603
|
+
const key = group.key;
|
|
604
|
+
severities[key] = severity;
|
|
605
|
+
const representative = group.representative ?? rows.slice().sort((a, b) => JSON.stringify(a.claimDescriptor).localeCompare(JSON.stringify(b.claimDescriptor)))[0];
|
|
606
|
+
const previous = state.findings[key];
|
|
607
|
+
const recoveredEscalation = previous?.verdict === undefined && severity === 'critical' &&
|
|
608
|
+
recoveredDismissals.has(key) && recoveredDismissals.get(key) !== 'critical';
|
|
609
|
+
const status = recoveredEscalation ? 'regating' : !previous ? anchorsByIdentity.has(key) ? 'repeat' : 'new' : previous.verdict === 'dismissed'
|
|
610
|
+
? severity === 'critical' && (previous.verdictSeverity ?? previous.severity) !== 'critical' ? 'regating' : 'suppressed' : 'repeat';
|
|
611
|
+
const suppressReason = status === 'suppressed' ? `dismissed in round ${previous.verdictRound}${previous.verdictReason ? ` (${previous.verdictReason})` : ''} — matching semantic claim; escalation to critical re-gates` : undefined;
|
|
612
|
+
const entry = previous ?? { key, file: representative.file, category: representative.category, startLine: representative.startLine,
|
|
613
|
+
endLine: representative.endLine, title: representative.title, severity, models: [], firstRound: options.round, lastRound: options.round, claimDescriptor: representative.claimDescriptor };
|
|
614
|
+
if (entry.verdict && !entry.verdictSeverity)
|
|
615
|
+
entry.verdictSeverity = entry.severity;
|
|
616
|
+
entry.severity = severity;
|
|
617
|
+
entry.lastRound = options.round;
|
|
618
|
+
entry.startLine = Math.min(...rows.map(f => f.startLine));
|
|
619
|
+
entry.endLine = Math.max(...rows.map(f => f.endLine));
|
|
620
|
+
entry.models = [...new Set([...entry.models, ...rows.flatMap(f => f.consensus.models)])].sort();
|
|
621
|
+
const gated = group.indices.some(i => i < keptCount && findingGatingReason(findings[i]) !== 'none');
|
|
622
|
+
if (gated && status !== 'suppressed' && (entry.pendingRound !== undefined || status === 'new' || status === 'regating' ||
|
|
623
|
+
entry.verdict === undefined || !verdictClearsPending(state, key, options.round, entry.verdictRound, entry.verdictSeverity))) {
|
|
624
|
+
entry.pendingRound = options.round;
|
|
625
|
+
}
|
|
626
|
+
state.findings[key] = entry;
|
|
627
|
+
for (const i of group.indices) {
|
|
628
|
+
const finding = findings[i];
|
|
629
|
+
const rationale = group.matched ? compareClaims(finding.claimDescriptor, entry.claimDescriptor) : group.rationale;
|
|
630
|
+
const sighting = { runId: binding.runId, target: binding.target, round: options.round, reportSha256: binding.reportSha256,
|
|
631
|
+
findingRef: `f${String(i + 1).padStart(3, '0')}`, reportKey: finding.identity, canonicalIdentity: key, claimDescriptor: finding.claimDescriptor,
|
|
632
|
+
matchRationale: rationale, status, ...(suppressReason ? { suppressReason } : {}), severity: finding.severity,
|
|
633
|
+
pendingRound: entry.pendingRound ?? null,
|
|
634
|
+
gating: i < keptCount ? findingGatingReason(finding) : 'none', belowThreshold: i >= keptCount, file: finding.file, category: finding.category,
|
|
635
|
+
startLine: finding.startLine, endLine: finding.endLine };
|
|
636
|
+
counts[status]++;
|
|
637
|
+
annotations[i] = { identity: key, status, ...(suppressReason ? { suppressReason } : {}), finding, sighting };
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
state.sightings.push(...annotations.map(a => a.sighting));
|
|
641
|
+
await retainNativeRecoverySource(options.gitCommonDir, binding.target, predecessorJson);
|
|
642
|
+
const admission = { version: 1, recoveryOperationCount: state.recovery.operations.length,
|
|
643
|
+
sourceStateSha256: predecessor.native_sha256, actionableIdentities: pending(state) };
|
|
644
|
+
state.rounds.push({ round: options.round, counts, severities, runId: binding.runId, reportBinding: binding, admission });
|
|
645
|
+
state.lastAnnotations = { round: options.round,
|
|
646
|
+
identities: annotations.map(a => ({ identity: a.identity, status: a.status, gating: a.sighting.gating })),
|
|
647
|
+
actionableBeforeTriage: [...admission.actionableIdentities] };
|
|
648
|
+
state.updatedAt = new Date().toISOString();
|
|
649
|
+
await retainReportEvidence(options.evidence.reportJson, binding);
|
|
650
|
+
await writeStateIfUnchanged(options.gitCommonDir, predecessor.native_sha256, state, ownership);
|
|
651
|
+
return { ...semanticRoundBinding(state, binding), roundCap: state.roundCap, counts, findings: annotations, actionableIdentities: pending(state) };
|
|
652
|
+
}
|
|
653
|
+
/** Historical attribution may add an escalation obligation, never clear one.
|
|
654
|
+
* Each recovery operation is bound to the exact predecessor state that existed
|
|
655
|
+
* before it. A later refresh cannot rewrite the dismissal explaining an earlier
|
|
656
|
+
* semantic sighting. */
|
|
657
|
+
/** @internal Physical adapter for the shared retained-dismissal projection. */
|
|
658
|
+
export async function retainedRecoveredDismissalsByRound(state, gitCommonDir) {
|
|
659
|
+
const materials = await readNativeRecoveryMaterials(gitCommonDir, state);
|
|
660
|
+
const snapshots = new Map((await readNativeRecoverySourceJsons(gitCommonDir, state)).map(raw => [sha(raw), raw]));
|
|
661
|
+
return recoveredDismissalsByRound(state, materials, snapshots);
|
|
662
|
+
}
|
|
663
|
+
export function pending(state) {
|
|
664
|
+
return effectivePendingIdentities(state);
|
|
665
|
+
}
|
|
666
|
+
//# sourceMappingURL=semantic-state.js.map
|