@pi-unipi/background-tasks 2.16.0 → 2.17.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/README.md +21 -27
- package/package.json +3 -4
- package/src/cards.ts +76 -0
- package/src/child-process.ts +1 -1
- package/src/config.ts +0 -42
- package/src/context-visible-conversation-v2.ts +1 -1
- package/src/delegate/artifacts.ts +1 -1
- package/src/delegate/launch.ts +17 -30
- package/src/delegate/result-package.ts +1 -1
- package/src/delegate/runner.ts +1 -20
- package/src/delegate/seed.ts +1 -1
- package/src/delegate-extension.ts +16 -168
- package/src/index.ts +53 -25
- package/src/json-utils.ts +56 -0
- package/src/package-assets.ts +51 -0
- package/src/registry.ts +8 -459
- package/src/task-manager.ts +13 -2
- package/src/tools.ts +4 -189
- package/src/types.ts +17 -70
- package/extensions/anthropic-attribution.ts +0 -1
- package/extensions/fusion-child.ts +0 -1
- package/src/anthropic-attribution-path.ts +0 -21
- package/src/anthropic-attribution.ts +0 -1983
- package/src/attested-pi-run.ts +0 -612
- package/src/fixtures/fusion-golden-bytes.json +0 -310
- package/src/fixtures/fusion-validate-golden-bytes.json +0 -282
- package/src/fusion/artifacts.ts +0 -967
- package/src/fusion/budget.ts +0 -1162
- package/src/fusion/child-protocol.ts +0 -305
- package/src/fusion/claude-cache.ts +0 -207
- package/src/fusion/clean-context.ts +0 -91
- package/src/fusion/config.ts +0 -449
- package/src/fusion/context.ts +0 -265
- package/src/fusion/evaluation.ts +0 -800
- package/src/fusion/orchestrator.ts +0 -1288
- package/src/fusion/output-contract.ts +0 -34
- package/src/fusion/pi-child.ts +0 -2373
- package/src/fusion/prompts.ts +0 -345
- package/src/fusion/result-package.ts +0 -959
- package/src/fusion/source-policy.ts +0 -257
- package/src/fusion/types.ts +0 -1139
- package/src/fusion/web-fetch.ts +0 -1060
- package/src/fusion/workflows.ts +0 -184
- package/src/fusion-child-extension.ts +0 -1052
- package/src/fusion-extension.ts +0 -1293
- package/src/ui/fusion-model-selector.ts +0 -322
package/src/fusion/evaluation.ts
DELETED
|
@@ -1,800 +0,0 @@
|
|
|
1
|
-
import { parseJsonText, type JsonObject } from '../types.js';
|
|
2
|
-
import {
|
|
3
|
-
FUSION_CANDIDATE_IDS,
|
|
4
|
-
FUSION_EVALUATION_SCHEMA_VERSION,
|
|
5
|
-
FUSION_VALIDATE_CANDIDATE_SCHEMA_VERSION,
|
|
6
|
-
FusionError,
|
|
7
|
-
type CandidateAssessment,
|
|
8
|
-
type FusionCandidateId,
|
|
9
|
-
type FusionConflict,
|
|
10
|
-
type FusionConflictPosition,
|
|
11
|
-
type FusionEvaluationV1,
|
|
12
|
-
type FusionSynthesisContribution,
|
|
13
|
-
type FusionSynthesisPlan,
|
|
14
|
-
type FusionValidationFindingAccounting,
|
|
15
|
-
type FusionValidationFindingDecision,
|
|
16
|
-
type FusionValidationFindingGroup,
|
|
17
|
-
type FusionValidationFindingRecord,
|
|
18
|
-
type FusionValidationSeverity,
|
|
19
|
-
} from './types.js';
|
|
20
|
-
|
|
21
|
-
const MAX_REPAIR_ERROR_CHARS = 500;
|
|
22
|
-
const MAX_REPAIR_ERROR_COUNT = 24;
|
|
23
|
-
const MAX_REPAIR_ERROR_TOTAL_CHARS = 4000;
|
|
24
|
-
|
|
25
|
-
export type FusionEvaluationValidationResult =
|
|
26
|
-
| { ok: true; value: FusionEvaluationV1 }
|
|
27
|
-
| { ok: false; errors: readonly string[] };
|
|
28
|
-
|
|
29
|
-
function isRecord(value: unknown): value is JsonObject {
|
|
30
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function errorText(error: unknown): string {
|
|
34
|
-
return error instanceof Error ? error.message : String(error);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function closed(
|
|
38
|
-
record: JsonObject,
|
|
39
|
-
keys: readonly string[],
|
|
40
|
-
label: string,
|
|
41
|
-
errors: string[],
|
|
42
|
-
): void {
|
|
43
|
-
const expected = new Set(keys);
|
|
44
|
-
for (const key of Object.keys(record)) {
|
|
45
|
-
if (!expected.has(key)) errors.push(`${label} contains unknown key ${key}`);
|
|
46
|
-
}
|
|
47
|
-
for (const key of keys) {
|
|
48
|
-
if (!Object.prototype.hasOwnProperty.call(record, key))
|
|
49
|
-
errors.push(`${label} is missing key ${key}`);
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function nonBlankString(value: unknown, label: string, errors: string[]): string | undefined {
|
|
54
|
-
if (typeof value !== 'string') {
|
|
55
|
-
errors.push(`${label} must be a string`);
|
|
56
|
-
return undefined;
|
|
57
|
-
}
|
|
58
|
-
if (value.trim().length === 0) {
|
|
59
|
-
errors.push(`${label} must be non-blank`);
|
|
60
|
-
return undefined;
|
|
61
|
-
}
|
|
62
|
-
return value;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function stringList(
|
|
66
|
-
value: unknown,
|
|
67
|
-
label: string,
|
|
68
|
-
errors: string[],
|
|
69
|
-
): readonly string[] | undefined {
|
|
70
|
-
if (!Array.isArray(value)) {
|
|
71
|
-
errors.push(`${label} must be an array`);
|
|
72
|
-
return undefined;
|
|
73
|
-
}
|
|
74
|
-
const out: string[] = [];
|
|
75
|
-
for (const [index, item] of value.entries()) {
|
|
76
|
-
const parsed = nonBlankString(item, `${label}[${String(index)}]`, errors);
|
|
77
|
-
if (parsed !== undefined) out.push(parsed);
|
|
78
|
-
}
|
|
79
|
-
return out;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function candidateId(
|
|
83
|
-
value: unknown,
|
|
84
|
-
label: string,
|
|
85
|
-
errors: string[],
|
|
86
|
-
): FusionCandidateId | undefined {
|
|
87
|
-
if (value === 'A' || value === 'B' || value === 'C') return value;
|
|
88
|
-
errors.push(`${label} must be A, B, or C`);
|
|
89
|
-
return undefined;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function tuple3<T>(
|
|
93
|
-
items: readonly T[],
|
|
94
|
-
label: string,
|
|
95
|
-
errors: string[],
|
|
96
|
-
): readonly [T, T, T] | undefined {
|
|
97
|
-
if (items.length !== 3) {
|
|
98
|
-
errors.push(`${label} must contain exactly three entries`);
|
|
99
|
-
return undefined;
|
|
100
|
-
}
|
|
101
|
-
const first = items[0];
|
|
102
|
-
const second = items[1];
|
|
103
|
-
const third = items[2];
|
|
104
|
-
if (first === undefined || second === undefined || third === undefined) {
|
|
105
|
-
errors.push(`${label} must not contain empty positions`);
|
|
106
|
-
return undefined;
|
|
107
|
-
}
|
|
108
|
-
return [first, second, third];
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function parseAssessment(
|
|
112
|
-
value: unknown,
|
|
113
|
-
label: string,
|
|
114
|
-
errors: string[],
|
|
115
|
-
): CandidateAssessment | undefined {
|
|
116
|
-
if (!isRecord(value)) {
|
|
117
|
-
errors.push(`${label} must be an object`);
|
|
118
|
-
return undefined;
|
|
119
|
-
}
|
|
120
|
-
closed(
|
|
121
|
-
value,
|
|
122
|
-
['candidate_id', 'summary', 'strengths', 'limitations', 'useful_contributions', 'risks'],
|
|
123
|
-
label,
|
|
124
|
-
errors,
|
|
125
|
-
);
|
|
126
|
-
const id = candidateId(value['candidate_id'], `${label}.candidate_id`, errors);
|
|
127
|
-
const summary = nonBlankString(value['summary'], `${label}.summary`, errors);
|
|
128
|
-
const strengths = stringList(value['strengths'], `${label}.strengths`, errors);
|
|
129
|
-
const limitations = stringList(value['limitations'], `${label}.limitations`, errors);
|
|
130
|
-
const useful = stringList(value['useful_contributions'], `${label}.useful_contributions`, errors);
|
|
131
|
-
const risks = stringList(value['risks'], `${label}.risks`, errors);
|
|
132
|
-
if (
|
|
133
|
-
id === undefined ||
|
|
134
|
-
summary === undefined ||
|
|
135
|
-
strengths === undefined ||
|
|
136
|
-
limitations === undefined ||
|
|
137
|
-
useful === undefined ||
|
|
138
|
-
risks === undefined
|
|
139
|
-
) {
|
|
140
|
-
return undefined;
|
|
141
|
-
}
|
|
142
|
-
return { candidate_id: id, summary, strengths, limitations, useful_contributions: useful, risks };
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function parsePosition(
|
|
146
|
-
value: unknown,
|
|
147
|
-
label: string,
|
|
148
|
-
errors: string[],
|
|
149
|
-
): FusionConflictPosition | undefined {
|
|
150
|
-
if (!isRecord(value)) {
|
|
151
|
-
errors.push(`${label} must be an object`);
|
|
152
|
-
return undefined;
|
|
153
|
-
}
|
|
154
|
-
closed(value, ['candidate_id', 'position'], label, errors);
|
|
155
|
-
const id = candidateId(value['candidate_id'], `${label}.candidate_id`, errors);
|
|
156
|
-
const position = nonBlankString(value['position'], `${label}.position`, errors);
|
|
157
|
-
if (id === undefined || position === undefined) return undefined;
|
|
158
|
-
return { candidate_id: id, position };
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
function parseConflict(
|
|
162
|
-
value: unknown,
|
|
163
|
-
label: string,
|
|
164
|
-
errors: string[],
|
|
165
|
-
): FusionConflict | undefined {
|
|
166
|
-
if (!isRecord(value)) {
|
|
167
|
-
errors.push(`${label} must be an object`);
|
|
168
|
-
return undefined;
|
|
169
|
-
}
|
|
170
|
-
closed(value, ['topic', 'positions', 'resolution'], label, errors);
|
|
171
|
-
const topic = nonBlankString(value['topic'], `${label}.topic`, errors);
|
|
172
|
-
const positionsRaw = value['positions'];
|
|
173
|
-
const positions: FusionConflictPosition[] = [];
|
|
174
|
-
if (!Array.isArray(positionsRaw)) {
|
|
175
|
-
errors.push(`${label}.positions must be an array`);
|
|
176
|
-
} else {
|
|
177
|
-
for (const [index, item] of positionsRaw.entries()) {
|
|
178
|
-
const parsed = parsePosition(item, `${label}.positions[${String(index)}]`, errors);
|
|
179
|
-
if (parsed !== undefined) positions.push(parsed);
|
|
180
|
-
}
|
|
181
|
-
const distinctIds = new Set(positions.map((position) => position.candidate_id));
|
|
182
|
-
if (distinctIds.size < 2)
|
|
183
|
-
errors.push(`${label}.positions must include at least two distinct candidates`);
|
|
184
|
-
if (distinctIds.size !== positions.length)
|
|
185
|
-
errors.push(`${label}.positions candidate_id values must be unique`);
|
|
186
|
-
}
|
|
187
|
-
const resolution = nonBlankString(value['resolution'], `${label}.resolution`, errors);
|
|
188
|
-
if (topic === undefined || resolution === undefined || !Array.isArray(positionsRaw))
|
|
189
|
-
return undefined;
|
|
190
|
-
return { topic, positions, resolution };
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function parseContribution(
|
|
194
|
-
value: unknown,
|
|
195
|
-
label: string,
|
|
196
|
-
errors: string[],
|
|
197
|
-
): FusionSynthesisContribution | undefined {
|
|
198
|
-
if (!isRecord(value)) {
|
|
199
|
-
errors.push(`${label} must be an object`);
|
|
200
|
-
return undefined;
|
|
201
|
-
}
|
|
202
|
-
closed(value, ['candidate_id', 'contribution'], label, errors);
|
|
203
|
-
const id = candidateId(value['candidate_id'], `${label}.candidate_id`, errors);
|
|
204
|
-
const contribution = nonBlankString(value['contribution'], `${label}.contribution`, errors);
|
|
205
|
-
if (id === undefined || contribution === undefined) return undefined;
|
|
206
|
-
return { candidate_id: id, contribution };
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
function parseContributionList(
|
|
210
|
-
value: unknown,
|
|
211
|
-
label: string,
|
|
212
|
-
errors: string[],
|
|
213
|
-
): readonly FusionSynthesisContribution[] | undefined {
|
|
214
|
-
if (!Array.isArray(value)) {
|
|
215
|
-
errors.push(`${label} must be an array`);
|
|
216
|
-
return undefined;
|
|
217
|
-
}
|
|
218
|
-
const out: FusionSynthesisContribution[] = [];
|
|
219
|
-
for (const [index, item] of value.entries()) {
|
|
220
|
-
const parsed = parseContribution(item, `${label}[${String(index)}]`, errors);
|
|
221
|
-
if (parsed !== undefined) out.push(parsed);
|
|
222
|
-
}
|
|
223
|
-
return out;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
function parseSynthesisPlan(
|
|
227
|
-
value: unknown,
|
|
228
|
-
label: string,
|
|
229
|
-
errors: string[],
|
|
230
|
-
): FusionSynthesisPlan | undefined {
|
|
231
|
-
if (!isRecord(value)) {
|
|
232
|
-
errors.push(`${label} must be an object`);
|
|
233
|
-
return undefined;
|
|
234
|
-
}
|
|
235
|
-
closed(value, ['must_include', 'must_resolve', 'must_avoid'], label, errors);
|
|
236
|
-
const include = parseContributionList(value['must_include'], `${label}.must_include`, errors);
|
|
237
|
-
const resolve = stringList(value['must_resolve'], `${label}.must_resolve`, errors);
|
|
238
|
-
const avoid = stringList(value['must_avoid'], `${label}.must_avoid`, errors);
|
|
239
|
-
if (include === undefined || resolve === undefined || avoid === undefined) return undefined;
|
|
240
|
-
return { must_include: include, must_resolve: resolve, must_avoid: avoid };
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
function parseAssessmentList(
|
|
244
|
-
value: unknown,
|
|
245
|
-
label: string,
|
|
246
|
-
errors: string[],
|
|
247
|
-
): readonly [CandidateAssessment, CandidateAssessment, CandidateAssessment] | undefined {
|
|
248
|
-
if (!Array.isArray(value)) {
|
|
249
|
-
errors.push(`${label} must be an array`);
|
|
250
|
-
return undefined;
|
|
251
|
-
}
|
|
252
|
-
const parsed: CandidateAssessment[] = [];
|
|
253
|
-
for (const [index, item] of value.entries()) {
|
|
254
|
-
const assessment = parseAssessment(item, `${label}[${String(index)}]`, errors);
|
|
255
|
-
if (assessment !== undefined) parsed.push(assessment);
|
|
256
|
-
}
|
|
257
|
-
const ids = new Set(parsed.map((assessment) => assessment.candidate_id));
|
|
258
|
-
for (const id of FUSION_CANDIDATE_IDS) {
|
|
259
|
-
if (!ids.has(id)) errors.push(`${label} must contain candidate ${id}`);
|
|
260
|
-
}
|
|
261
|
-
if (ids.size !== parsed.length) errors.push(`${label} candidate_id values must be unique`);
|
|
262
|
-
return tuple3(parsed, label, errors);
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
function parseConflictList(
|
|
266
|
-
value: unknown,
|
|
267
|
-
label: string,
|
|
268
|
-
errors: string[],
|
|
269
|
-
): readonly FusionConflict[] | undefined {
|
|
270
|
-
if (!Array.isArray(value)) {
|
|
271
|
-
errors.push(`${label} must be an array`);
|
|
272
|
-
return undefined;
|
|
273
|
-
}
|
|
274
|
-
const out: FusionConflict[] = [];
|
|
275
|
-
for (const [index, item] of value.entries()) {
|
|
276
|
-
const parsed = parseConflict(item, `${label}[${String(index)}]`, errors);
|
|
277
|
-
if (parsed !== undefined) out.push(parsed);
|
|
278
|
-
}
|
|
279
|
-
return out;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
function parseValidationGroups(
|
|
283
|
-
value: unknown,
|
|
284
|
-
label: string,
|
|
285
|
-
errors: string[],
|
|
286
|
-
): readonly FusionValidationFindingGroup[] {
|
|
287
|
-
if (!Array.isArray(value)) {
|
|
288
|
-
errors.push(`${label} must be an array`);
|
|
289
|
-
return [];
|
|
290
|
-
}
|
|
291
|
-
const groups: FusionValidationFindingGroup[] = [];
|
|
292
|
-
for (const [index, item] of value.entries()) {
|
|
293
|
-
const itemLabel = `${label}[${String(index)}]`;
|
|
294
|
-
if (!isRecord(item)) {
|
|
295
|
-
errors.push(`${itemLabel} must be an object`);
|
|
296
|
-
continue;
|
|
297
|
-
}
|
|
298
|
-
closed(
|
|
299
|
-
item,
|
|
300
|
-
['group_id', 'source_ids', 'severity', 'location', 'evidence', 'impact', 'summary', 'rationale'],
|
|
301
|
-
itemLabel,
|
|
302
|
-
errors,
|
|
303
|
-
);
|
|
304
|
-
const groupId = nonBlankString(item['group_id'], `${itemLabel}.group_id`, errors);
|
|
305
|
-
const sourceIds = stringList(item['source_ids'], `${itemLabel}.source_ids`, errors);
|
|
306
|
-
const severity = nonBlankString(item['severity'], `${itemLabel}.severity`, errors) as
|
|
307
|
-
| FusionValidationSeverity
|
|
308
|
-
| undefined;
|
|
309
|
-
const location = nonBlankString(item['location'], `${itemLabel}.location`, errors);
|
|
310
|
-
const evidence = nonBlankString(item['evidence'], `${itemLabel}.evidence`, errors);
|
|
311
|
-
const impact = nonBlankString(item['impact'], `${itemLabel}.impact`, errors);
|
|
312
|
-
const summary = nonBlankString(item['summary'], `${itemLabel}.summary`, errors);
|
|
313
|
-
const rationale = nonBlankString(item['rationale'], `${itemLabel}.rationale`, errors);
|
|
314
|
-
if (sourceIds !== undefined && sourceIds.length === 0) {
|
|
315
|
-
errors.push(`${itemLabel}.source_ids must not be empty`);
|
|
316
|
-
}
|
|
317
|
-
if (severity !== undefined && !['critical', 'high', 'minor'].includes(severity)) {
|
|
318
|
-
errors.push(`${itemLabel}.severity invalid`);
|
|
319
|
-
}
|
|
320
|
-
if (
|
|
321
|
-
groupId !== undefined &&
|
|
322
|
-
sourceIds !== undefined &&
|
|
323
|
-
sourceIds.length > 0 &&
|
|
324
|
-
severity !== undefined &&
|
|
325
|
-
location !== undefined &&
|
|
326
|
-
evidence !== undefined &&
|
|
327
|
-
impact !== undefined &&
|
|
328
|
-
summary !== undefined &&
|
|
329
|
-
rationale !== undefined
|
|
330
|
-
) {
|
|
331
|
-
groups.push({
|
|
332
|
-
group_id: groupId,
|
|
333
|
-
source_ids: sourceIds,
|
|
334
|
-
severity,
|
|
335
|
-
location,
|
|
336
|
-
evidence,
|
|
337
|
-
impact,
|
|
338
|
-
summary,
|
|
339
|
-
rationale,
|
|
340
|
-
});
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
return groups;
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
function parseValidationAccounting(value: unknown, label: string, errors: string[]): FusionValidationFindingAccounting | undefined {
|
|
347
|
-
if (!isRecord(value)) {
|
|
348
|
-
errors.push(`${label} must be an object`);
|
|
349
|
-
return undefined;
|
|
350
|
-
}
|
|
351
|
-
closed(value, ['findings', 'decisions', 'groups'], label, errors);
|
|
352
|
-
const findingsRaw = value['findings'];
|
|
353
|
-
const decisionsRaw = value['decisions'];
|
|
354
|
-
const groups = parseValidationGroups(value['groups'], `${label}.groups`, errors);
|
|
355
|
-
const findings: FusionValidationFindingRecord[] = [];
|
|
356
|
-
if (!Array.isArray(findingsRaw)) errors.push(`${label}.findings must be an array`);
|
|
357
|
-
else {
|
|
358
|
-
for (const [index, item] of findingsRaw.entries()) {
|
|
359
|
-
const itemLabel = `${label}.findings[${String(index)}]`;
|
|
360
|
-
if (!isRecord(item)) {
|
|
361
|
-
errors.push(`${itemLabel} must be an object`);
|
|
362
|
-
continue;
|
|
363
|
-
}
|
|
364
|
-
closed(item, ['id', 'candidate_id', 'severity', 'location', 'evidence', 'impact', 'summary'], itemLabel, errors);
|
|
365
|
-
const id = nonBlankString(item['id'], `${itemLabel}.id`, errors);
|
|
366
|
-
const candidate = candidateId(item['candidate_id'], `${itemLabel}.candidate_id`, errors);
|
|
367
|
-
const severity = nonBlankString(item['severity'], `${itemLabel}.severity`, errors) as FusionValidationSeverity | undefined;
|
|
368
|
-
const location = nonBlankString(item['location'], `${itemLabel}.location`, errors);
|
|
369
|
-
const evidence = nonBlankString(item['evidence'], `${itemLabel}.evidence`, errors);
|
|
370
|
-
const impact = nonBlankString(item['impact'], `${itemLabel}.impact`, errors);
|
|
371
|
-
const summary = nonBlankString(item['summary'], `${itemLabel}.summary`, errors);
|
|
372
|
-
if (severity !== undefined && !['critical', 'high', 'minor'].includes(severity)) errors.push(`${itemLabel}.severity invalid`);
|
|
373
|
-
if (id !== undefined && candidate !== undefined && severity !== undefined && location !== undefined && evidence !== undefined && impact !== undefined && summary !== undefined) {
|
|
374
|
-
findings.push({ id, candidate_id: candidate, severity, location, evidence, impact, summary });
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
const decisions: FusionValidationFindingDecision[] = [];
|
|
379
|
-
if (!Array.isArray(decisionsRaw)) errors.push(`${label}.decisions must be an array`);
|
|
380
|
-
else {
|
|
381
|
-
for (const [index, item] of decisionsRaw.entries()) {
|
|
382
|
-
const itemLabel = `${label}.decisions[${String(index)}]`;
|
|
383
|
-
if (!isRecord(item)) {
|
|
384
|
-
errors.push(`${itemLabel} must be an object`);
|
|
385
|
-
continue;
|
|
386
|
-
}
|
|
387
|
-
const allowedDecisionKeys = new Set(['source_id', 'disposition', 'rationale', 'group_id']);
|
|
388
|
-
for (const key of Object.keys(item)) {
|
|
389
|
-
if (!allowedDecisionKeys.has(key)) errors.push(`${itemLabel} contains unknown key ${key}`);
|
|
390
|
-
}
|
|
391
|
-
for (const key of ['source_id', 'disposition', 'rationale'] as const) {
|
|
392
|
-
if (!Object.prototype.hasOwnProperty.call(item, key)) errors.push(`${itemLabel} is missing key ${key}`);
|
|
393
|
-
}
|
|
394
|
-
const sourceId = nonBlankString(item['source_id'], `${itemLabel}.source_id`, errors);
|
|
395
|
-
const disposition = nonBlankString(item['disposition'], `${itemLabel}.disposition`, errors);
|
|
396
|
-
const rationale = nonBlankString(item['rationale'], `${itemLabel}.rationale`, errors);
|
|
397
|
-
const group = item['group_id'] === undefined ? undefined : nonBlankString(item['group_id'], `${itemLabel}.group_id`, errors);
|
|
398
|
-
if (disposition !== undefined && disposition !== 'include' && disposition !== 'exclude') errors.push(`${itemLabel}.disposition invalid`);
|
|
399
|
-
if (sourceId !== undefined && (disposition === 'include' || disposition === 'exclude') && rationale !== undefined) {
|
|
400
|
-
const decision: FusionValidationFindingDecision = { source_id: sourceId, disposition, rationale };
|
|
401
|
-
if (group !== undefined) decision.group_id = group;
|
|
402
|
-
decisions.push(decision);
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
const accounting: FusionValidationFindingAccounting = { findings, decisions, groups };
|
|
407
|
-
errors.push(...validateFusionFindingAccounting(accounting));
|
|
408
|
-
return accounting;
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
export function validateFusionEvaluation(value: unknown): FusionEvaluationValidationResult {
|
|
412
|
-
const errors: string[] = [];
|
|
413
|
-
if (!isRecord(value)) return { ok: false, errors: ['evaluation must be a JSON object'] };
|
|
414
|
-
const evaluationAllowed = new Set(['schema_version', 'candidate_assessments', 'agreements', 'conflicts', 'synthesis_plan', 'validation_accounting']);
|
|
415
|
-
for (const key of Object.keys(value)) {
|
|
416
|
-
if (!evaluationAllowed.has(key)) errors.push(`evaluation contains unknown key ${key}`);
|
|
417
|
-
}
|
|
418
|
-
for (const key of ['schema_version', 'candidate_assessments', 'agreements', 'conflicts', 'synthesis_plan'] as const) {
|
|
419
|
-
if (!Object.prototype.hasOwnProperty.call(value, key)) errors.push(`evaluation is missing key ${key}`);
|
|
420
|
-
}
|
|
421
|
-
if (value['schema_version'] !== FUSION_EVALUATION_SCHEMA_VERSION) {
|
|
422
|
-
errors.push('evaluation.schema_version mismatch');
|
|
423
|
-
}
|
|
424
|
-
const assessments = parseAssessmentList(
|
|
425
|
-
value['candidate_assessments'],
|
|
426
|
-
'evaluation.candidate_assessments',
|
|
427
|
-
errors,
|
|
428
|
-
);
|
|
429
|
-
const agreements = stringList(value['agreements'], 'evaluation.agreements', errors);
|
|
430
|
-
const conflicts = parseConflictList(value['conflicts'], 'evaluation.conflicts', errors);
|
|
431
|
-
const plan = parseSynthesisPlan(value['synthesis_plan'], 'evaluation.synthesis_plan', errors);
|
|
432
|
-
const validationAccounting = Object.prototype.hasOwnProperty.call(value, 'validation_accounting')
|
|
433
|
-
? parseValidationAccounting(value['validation_accounting'], 'evaluation.validation_accounting', errors)
|
|
434
|
-
: undefined;
|
|
435
|
-
if (
|
|
436
|
-
errors.length > 0 ||
|
|
437
|
-
assessments === undefined ||
|
|
438
|
-
agreements === undefined ||
|
|
439
|
-
conflicts === undefined ||
|
|
440
|
-
plan === undefined
|
|
441
|
-
) {
|
|
442
|
-
return { ok: false, errors };
|
|
443
|
-
}
|
|
444
|
-
const parsedValue: FusionEvaluationV1 = {
|
|
445
|
-
schema_version: FUSION_EVALUATION_SCHEMA_VERSION,
|
|
446
|
-
candidate_assessments: assessments,
|
|
447
|
-
agreements,
|
|
448
|
-
conflicts,
|
|
449
|
-
synthesis_plan: plan,
|
|
450
|
-
};
|
|
451
|
-
if (validationAccounting !== undefined) parsedValue.validation_accounting = validationAccounting;
|
|
452
|
-
return { ok: true, value: parsedValue };
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
export function parseFusionEvaluation(text: string): FusionEvaluationV1 {
|
|
456
|
-
let parsed: unknown;
|
|
457
|
-
try {
|
|
458
|
-
parsed = parseJsonText(text);
|
|
459
|
-
} catch (error) {
|
|
460
|
-
throw new FusionError(
|
|
461
|
-
`evaluation output must be JSON only: ${error instanceof Error ? error.message : String(error)}`,
|
|
462
|
-
{
|
|
463
|
-
code: 'evaluation_invalid',
|
|
464
|
-
stage: 'evaluation',
|
|
465
|
-
},
|
|
466
|
-
);
|
|
467
|
-
}
|
|
468
|
-
const result = validateFusionEvaluation(parsed);
|
|
469
|
-
if (!result.ok) {
|
|
470
|
-
throw new FusionError(
|
|
471
|
-
`evaluation output failed schema validation: ${formatEvaluationErrors(result.errors)}`,
|
|
472
|
-
{
|
|
473
|
-
code: 'evaluation_invalid',
|
|
474
|
-
stage: 'evaluation',
|
|
475
|
-
},
|
|
476
|
-
);
|
|
477
|
-
}
|
|
478
|
-
return result.value;
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
export function boundedEvaluationErrors(errors: readonly string[]): readonly string[] {
|
|
482
|
-
const bounded: string[] = [];
|
|
483
|
-
let total = 0;
|
|
484
|
-
for (const error of errors) {
|
|
485
|
-
if (bounded.length >= MAX_REPAIR_ERROR_COUNT) break;
|
|
486
|
-
const perError =
|
|
487
|
-
error.length <= MAX_REPAIR_ERROR_CHARS
|
|
488
|
-
? error
|
|
489
|
-
: `${error.slice(0, MAX_REPAIR_ERROR_CHARS - 1)}…`;
|
|
490
|
-
const remaining = MAX_REPAIR_ERROR_TOTAL_CHARS - total;
|
|
491
|
-
if (remaining <= 0) break;
|
|
492
|
-
const next =
|
|
493
|
-
perError.length <= remaining ? perError : `${perError.slice(0, Math.max(0, remaining - 1))}…`;
|
|
494
|
-
bounded.push(next);
|
|
495
|
-
total += next.length;
|
|
496
|
-
}
|
|
497
|
-
if (errors.length > bounded.length)
|
|
498
|
-
bounded.push(`… ${String(errors.length - bounded.length)} more validation errors omitted`);
|
|
499
|
-
return bounded;
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
export function formatEvaluationErrors(errors: readonly string[]): string {
|
|
503
|
-
return boundedEvaluationErrors(errors).join('; ');
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
function parseValidationCandidateFinding(value: unknown, label: string, errors: string[]): Omit<FusionValidationFindingRecord, 'id' | 'candidate_id'> | undefined {
|
|
507
|
-
if (!isRecord(value)) {
|
|
508
|
-
errors.push(`${label} must be an object`);
|
|
509
|
-
return undefined;
|
|
510
|
-
}
|
|
511
|
-
closed(value, ['severity', 'location', 'evidence', 'impact', 'summary'], label, errors);
|
|
512
|
-
const severity = nonBlankString(value['severity'], `${label}.severity`, errors) as FusionValidationSeverity | undefined;
|
|
513
|
-
const location = nonBlankString(value['location'], `${label}.location`, errors);
|
|
514
|
-
const evidence = nonBlankString(value['evidence'], `${label}.evidence`, errors);
|
|
515
|
-
const impact = nonBlankString(value['impact'], `${label}.impact`, errors);
|
|
516
|
-
const summary = nonBlankString(value['summary'], `${label}.summary`, errors);
|
|
517
|
-
if (severity !== undefined && !['critical', 'high', 'minor'].includes(severity)) errors.push(`${label}.severity invalid`);
|
|
518
|
-
if (severity === undefined || location === undefined || evidence === undefined || impact === undefined || summary === undefined) return undefined;
|
|
519
|
-
return { severity, location, evidence, impact, summary };
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
export interface ParsedFusionValidationCandidateReport {
|
|
523
|
-
findings: readonly FusionValidationFindingRecord[];
|
|
524
|
-
verified: readonly string[];
|
|
525
|
-
limitations: readonly string[];
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
export type FusionValidationCandidateNormalization =
|
|
529
|
-
| 'markdown_json_fence'
|
|
530
|
-
| 'prose_then_markdown_json_fence';
|
|
531
|
-
|
|
532
|
-
export interface RecoveredFusionValidationCandidateReport {
|
|
533
|
-
report: ParsedFusionValidationCandidateReport;
|
|
534
|
-
/** Bare JSON forwarded to the evaluator after explicit, audited recovery. */
|
|
535
|
-
response: string;
|
|
536
|
-
normalization: FusionValidationCandidateNormalization;
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
/**
|
|
540
|
-
* Recognize exactly one complete Markdown JSON fence, optionally preceded by a
|
|
541
|
-
* short prose preamble. This is deliberately narrower than generic substring
|
|
542
|
-
* extraction: trailing prose, nested fences, unlabelled fences, and oversized
|
|
543
|
-
* preambles remain contract failures.
|
|
544
|
-
*/
|
|
545
|
-
function fencedValidationCandidateJson(text: string): {
|
|
546
|
-
payload: string;
|
|
547
|
-
normalization: FusionValidationCandidateNormalization;
|
|
548
|
-
} | undefined {
|
|
549
|
-
const trimmed = text.trim();
|
|
550
|
-
const openingPattern = /```json[ \t]*\r?\n/giu;
|
|
551
|
-
const openings = [...trimmed.matchAll(openingPattern)];
|
|
552
|
-
if (openings.length !== 1) return undefined;
|
|
553
|
-
const opening = openings[0];
|
|
554
|
-
if (opening === undefined) return undefined;
|
|
555
|
-
const headerEnd = opening.index + opening[0].length;
|
|
556
|
-
const closing = trimmed.indexOf('```', headerEnd);
|
|
557
|
-
if (closing < 0 || trimmed.slice(closing + 3).includes('```')) return undefined;
|
|
558
|
-
if (trimmed.slice(closing + 3).trim().length > 0) return undefined;
|
|
559
|
-
const preamble = trimmed.slice(0, opening.index).trim();
|
|
560
|
-
if (Buffer.byteLength(preamble, 'utf8') > 2_000 || preamble.includes('```')) return undefined;
|
|
561
|
-
const payload = trimmed.slice(headerEnd, closing).trim();
|
|
562
|
-
if (payload.length === 0 || payload.includes('```')) return undefined;
|
|
563
|
-
return {
|
|
564
|
-
payload,
|
|
565
|
-
normalization: preamble.length === 0
|
|
566
|
-
? 'markdown_json_fence'
|
|
567
|
-
: 'prose_then_markdown_json_fence',
|
|
568
|
-
};
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
/**
|
|
572
|
-
* Defensive recovery for the one observed contract violation shape. Callers
|
|
573
|
-
* must persist/surface the returned normalization; this function intentionally
|
|
574
|
-
* does not make the strict parser permissive.
|
|
575
|
-
*/
|
|
576
|
-
export function recoverFencedFusionValidationCandidateReport(
|
|
577
|
-
text: string,
|
|
578
|
-
candidateId: FusionCandidateId,
|
|
579
|
-
): RecoveredFusionValidationCandidateReport | undefined {
|
|
580
|
-
const recovered = fencedValidationCandidateJson(text);
|
|
581
|
-
if (recovered === undefined) return undefined;
|
|
582
|
-
return {
|
|
583
|
-
report: parseFusionValidationCandidateReport(recovered.payload, candidateId),
|
|
584
|
-
response: recovered.payload,
|
|
585
|
-
normalization: recovered.normalization,
|
|
586
|
-
};
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
export function parseFusionValidationCandidateReport(text: string, candidateId: FusionCandidateId): ParsedFusionValidationCandidateReport {
|
|
590
|
-
let parsed: unknown;
|
|
591
|
-
try {
|
|
592
|
-
parsed = parseJsonText(text);
|
|
593
|
-
} catch (error) {
|
|
594
|
-
throw new FusionError(
|
|
595
|
-
`validation candidate ${candidateId} output must be structured JSON only: ${errorText(error)}`,
|
|
596
|
-
{ code: 'evaluation_invalid', stage: 'candidate' },
|
|
597
|
-
);
|
|
598
|
-
}
|
|
599
|
-
const errors: string[] = [];
|
|
600
|
-
if (!isRecord(parsed)) {
|
|
601
|
-
errors.push('validation candidate report must be an object');
|
|
602
|
-
} else {
|
|
603
|
-
closed(parsed, ['schema_version', 'findings', 'verified', 'limitations'], 'validation candidate report', errors);
|
|
604
|
-
}
|
|
605
|
-
if (!isRecord(parsed)) {
|
|
606
|
-
throw new FusionError(`validation candidate ${candidateId} output failed schema validation: ${formatEvaluationErrors(errors)}`, {
|
|
607
|
-
code: 'evaluation_invalid',
|
|
608
|
-
stage: 'candidate',
|
|
609
|
-
});
|
|
610
|
-
}
|
|
611
|
-
if (parsed['schema_version'] !== FUSION_VALIDATE_CANDIDATE_SCHEMA_VERSION) errors.push('validation candidate report.schema_version mismatch');
|
|
612
|
-
const rawFindings = parsed['findings'];
|
|
613
|
-
const findings: Array<Omit<FusionValidationFindingRecord, 'id' | 'candidate_id'>> = [];
|
|
614
|
-
if (!Array.isArray(rawFindings)) errors.push('validation candidate report.findings must be an array');
|
|
615
|
-
else {
|
|
616
|
-
for (const [index, item] of rawFindings.entries()) {
|
|
617
|
-
const finding = parseValidationCandidateFinding(item, `validation candidate report.findings[${String(index)}]`, errors);
|
|
618
|
-
if (finding !== undefined) findings.push(finding);
|
|
619
|
-
}
|
|
620
|
-
}
|
|
621
|
-
const verified = stringList(parsed['verified'], 'validation candidate report.verified', errors);
|
|
622
|
-
const limitations = stringList(parsed['limitations'], 'validation candidate report.limitations', errors);
|
|
623
|
-
if (errors.length > 0) {
|
|
624
|
-
throw new FusionError(`validation candidate ${candidateId} output failed schema validation: ${formatEvaluationErrors(errors)}`, {
|
|
625
|
-
code: 'evaluation_invalid',
|
|
626
|
-
stage: 'candidate',
|
|
627
|
-
});
|
|
628
|
-
}
|
|
629
|
-
return {
|
|
630
|
-
findings: findings.map((finding, index) => ({
|
|
631
|
-
id: stableFusionFindingId(candidateId, index + 1),
|
|
632
|
-
candidate_id: candidateId,
|
|
633
|
-
...finding,
|
|
634
|
-
})),
|
|
635
|
-
verified: verified ?? [],
|
|
636
|
-
limitations: limitations ?? [],
|
|
637
|
-
};
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
export function stableFusionFindingId(candidateId: FusionCandidateId, ordinal: number): string {
|
|
641
|
-
if (!Number.isSafeInteger(ordinal) || ordinal <= 0) {
|
|
642
|
-
throw new FusionError('validation finding ordinal must be a positive integer', {
|
|
643
|
-
code: 'evaluation_invalid',
|
|
644
|
-
stage: 'evaluation',
|
|
645
|
-
});
|
|
646
|
-
}
|
|
647
|
-
return `${candidateId}-F${String(ordinal).padStart(3, '0')}`;
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
export function validateFusionFindingAccounting(
|
|
651
|
-
accounting: FusionValidationFindingAccounting,
|
|
652
|
-
): readonly string[] {
|
|
653
|
-
const errors: string[] = [];
|
|
654
|
-
const sourceIds = new Set<string>();
|
|
655
|
-
const perCandidateOrdinal: Record<FusionCandidateId, number> = { A: 0, B: 0, C: 0 };
|
|
656
|
-
for (const [index, finding] of accounting.findings.entries()) {
|
|
657
|
-
const label = `finding[${String(index)}]`;
|
|
658
|
-
perCandidateOrdinal[finding.candidate_id] += 1;
|
|
659
|
-
if (finding.id !== stableFusionFindingId(finding.candidate_id, perCandidateOrdinal[finding.candidate_id])) {
|
|
660
|
-
errors.push(`${label}.id must be the stable host id for its candidate and ordinal`);
|
|
661
|
-
}
|
|
662
|
-
if (!['critical', 'high', 'minor'].includes(finding.severity)) errors.push(`${label}.severity invalid`);
|
|
663
|
-
for (const key of ['location', 'evidence', 'impact', 'summary'] as const) {
|
|
664
|
-
if (finding[key].trim().length === 0) errors.push(`${label}.${key} must be non-blank`);
|
|
665
|
-
}
|
|
666
|
-
if (sourceIds.has(finding.id)) errors.push(`${label}.id duplicate`);
|
|
667
|
-
sourceIds.add(finding.id);
|
|
668
|
-
}
|
|
669
|
-
const accounted = new Set<string>();
|
|
670
|
-
for (const [index, decision] of accounting.decisions.entries()) {
|
|
671
|
-
const label = `decision[${String(index)}]`;
|
|
672
|
-
if (!sourceIds.has(decision.source_id)) errors.push(`${label}.source_id does not name a candidate finding`);
|
|
673
|
-
if (accounted.has(decision.source_id)) errors.push(`${label}.source_id accounted more than once`);
|
|
674
|
-
accounted.add(decision.source_id);
|
|
675
|
-
if (decision.disposition !== 'include' && decision.disposition !== 'exclude') errors.push(`${label}.disposition invalid`);
|
|
676
|
-
if (decision.rationale.trim().length === 0) errors.push(`${label}.rationale must be non-blank`);
|
|
677
|
-
if (decision.disposition === 'include' && (decision.group_id === undefined || decision.group_id.trim().length === 0)) {
|
|
678
|
-
errors.push(`${label}.group_id required for included findings`);
|
|
679
|
-
}
|
|
680
|
-
if (decision.disposition === 'exclude' && decision.group_id !== undefined) {
|
|
681
|
-
errors.push(`${label}.group_id must be omitted for excluded findings`);
|
|
682
|
-
}
|
|
683
|
-
}
|
|
684
|
-
for (const id of sourceIds) {
|
|
685
|
-
if (!accounted.has(id)) errors.push(`source finding ${id} was not accounted exactly once`);
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
const groupsById = new Map<string, FusionValidationFindingGroup>();
|
|
689
|
-
for (const [index, group] of accounting.groups.entries()) {
|
|
690
|
-
const label = `group[${String(index)}]`;
|
|
691
|
-
if (groupsById.has(group.group_id)) errors.push(`${label}.group_id duplicate`);
|
|
692
|
-
groupsById.set(group.group_id, group);
|
|
693
|
-
if (!['critical', 'high', 'minor'].includes(group.severity)) errors.push(`${label}.severity invalid`);
|
|
694
|
-
for (const key of ['location', 'evidence', 'impact', 'summary', 'rationale'] as const) {
|
|
695
|
-
if (group[key].trim().length === 0) errors.push(`${label}.${key} must be non-blank`);
|
|
696
|
-
}
|
|
697
|
-
if (group.source_ids.length === 0) errors.push(`${label}.source_ids must not be empty`);
|
|
698
|
-
const groupSourceIds = new Set<string>();
|
|
699
|
-
for (const sourceId of group.source_ids) {
|
|
700
|
-
if (!sourceIds.has(sourceId)) errors.push(`${label}.source_ids contains unknown finding ${sourceId}`);
|
|
701
|
-
if (groupSourceIds.has(sourceId)) errors.push(`${label}.source_ids contains duplicate ${sourceId}`);
|
|
702
|
-
groupSourceIds.add(sourceId);
|
|
703
|
-
}
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
const includedByGroup = new Map<string, Set<string>>();
|
|
707
|
-
for (const decision of accounting.decisions) {
|
|
708
|
-
if (decision.disposition !== 'include' || decision.group_id === undefined) continue;
|
|
709
|
-
const group = groupsById.get(decision.group_id);
|
|
710
|
-
if (group === undefined) {
|
|
711
|
-
errors.push(`included source finding ${decision.source_id} references unknown group ${decision.group_id}`);
|
|
712
|
-
continue;
|
|
713
|
-
}
|
|
714
|
-
const members = includedByGroup.get(decision.group_id) ?? new Set<string>();
|
|
715
|
-
members.add(decision.source_id);
|
|
716
|
-
includedByGroup.set(decision.group_id, members);
|
|
717
|
-
}
|
|
718
|
-
for (const group of accounting.groups) {
|
|
719
|
-
const expected = [...group.source_ids].sort();
|
|
720
|
-
const actual = [...(includedByGroup.get(group.group_id) ?? new Set<string>())].sort();
|
|
721
|
-
if (expected.length !== actual.length || expected.some((id, index) => id !== actual[index])) {
|
|
722
|
-
errors.push(`group ${group.group_id} source_ids must exactly match included decisions assigned to that group`);
|
|
723
|
-
}
|
|
724
|
-
}
|
|
725
|
-
return errors;
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
function sanitizeValidationRationale(value: string): string {
|
|
729
|
-
return value
|
|
730
|
-
.replace(/\b[ABC]-F\d{3}\b/gu, 'source finding')
|
|
731
|
-
.replace(/\bcandidate [ABC]\b/giu, 'one reviewer')
|
|
732
|
-
.replace(/\b[ABC]:\s*/gu, '');
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
export function renderValidatedFusionValidationReport(
|
|
736
|
-
accounting: FusionValidationFindingAccounting,
|
|
737
|
-
coverage?: { verified: readonly string[]; limitations: readonly string[] } | undefined,
|
|
738
|
-
): string {
|
|
739
|
-
const errors = validateFusionFindingAccounting(accounting);
|
|
740
|
-
if (errors.length > 0) {
|
|
741
|
-
throw new FusionError(`validation accounting invalid before render: ${formatEvaluationErrors(errors)}`, {
|
|
742
|
-
code: 'evaluation_invalid',
|
|
743
|
-
stage: 'merge',
|
|
744
|
-
});
|
|
745
|
-
}
|
|
746
|
-
const severityOrder = { critical: 0, high: 1, minor: 2 } as const;
|
|
747
|
-
const renderedFindings = [...accounting.groups].sort((left, right) =>
|
|
748
|
-
severityOrder[left.severity] - severityOrder[right.severity] ||
|
|
749
|
-
left.location.localeCompare(right.location) ||
|
|
750
|
-
left.group_id.localeCompare(right.group_id),
|
|
751
|
-
);
|
|
752
|
-
const lines: string[] = ['# Validation report', ''];
|
|
753
|
-
if (renderedFindings.length === 0) {
|
|
754
|
-
lines.push('No included findings were identified by the validated accounting.', '');
|
|
755
|
-
} else {
|
|
756
|
-
lines.push('## Findings', '');
|
|
757
|
-
for (const finding of renderedFindings) {
|
|
758
|
-
lines.push(`### ${finding.severity}: ${finding.summary}`, '');
|
|
759
|
-
lines.push(`- Location: ${finding.location}`);
|
|
760
|
-
lines.push(`- Evidence: ${finding.evidence}`);
|
|
761
|
-
lines.push(`- Impact: ${finding.impact}`);
|
|
762
|
-
lines.push(`- Inclusion rationale: ${sanitizeValidationRationale(finding.rationale)}`, '');
|
|
763
|
-
}
|
|
764
|
-
}
|
|
765
|
-
const exclusions = accounting.decisions
|
|
766
|
-
.filter((decision) => decision.disposition === 'exclude')
|
|
767
|
-
.sort((left, right) => left.source_id.localeCompare(right.source_id));
|
|
768
|
-
if (exclusions.length > 0) {
|
|
769
|
-
lines.push('## Excluded source findings', '');
|
|
770
|
-
for (const decision of exclusions) lines.push(`- ${sanitizeValidationRationale(decision.rationale)}`);
|
|
771
|
-
lines.push('');
|
|
772
|
-
}
|
|
773
|
-
if (coverage !== undefined) {
|
|
774
|
-
lines.push('## Verified', '');
|
|
775
|
-
if (coverage.verified.length === 0) lines.push('- No verification statements were provided.');
|
|
776
|
-
else for (const item of coverage.verified) lines.push(`- ${item}`);
|
|
777
|
-
lines.push('', '## Limitations', '');
|
|
778
|
-
if (coverage.limitations.length === 0) lines.push('- No limitations were provided.');
|
|
779
|
-
else for (const item of coverage.limitations) lines.push(`- ${item}`);
|
|
780
|
-
lines.push('');
|
|
781
|
-
}
|
|
782
|
-
return lines.join('\n').trimEnd();
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
export function assertMergerFindingCoverage(
|
|
786
|
-
accounting: FusionValidationFindingAccounting,
|
|
787
|
-
renderedGroupIds: readonly string[],
|
|
788
|
-
): void {
|
|
789
|
-
const errors = [...validateFusionFindingAccounting(accounting)];
|
|
790
|
-
const included = new Set(accounting.groups.map((group) => group.group_id));
|
|
791
|
-
const rendered = new Set(renderedGroupIds);
|
|
792
|
-
for (const id of included) if (!rendered.has(id)) errors.push(`merger dropped included group ${id}`);
|
|
793
|
-
for (const id of rendered) if (!included.has(id)) errors.push(`merger invented or revived group ${id}`);
|
|
794
|
-
if (errors.length > 0) {
|
|
795
|
-
throw new FusionError(`validation finding preservation failed: ${formatEvaluationErrors(errors)}`, {
|
|
796
|
-
code: 'evaluation_invalid',
|
|
797
|
-
stage: 'merge',
|
|
798
|
-
});
|
|
799
|
-
}
|
|
800
|
-
}
|