bmad-method-test-architecture-enterprise 3.0.2 → 3.1.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.
@@ -0,0 +1,699 @@
1
+ /**
2
+ * Exploratory candidates: the reader, the canonical writer, the identity and the draft promotion of a record under
3
+ * `discovery/<campaignId>/C-NNN.candidate.json`.
4
+ *
5
+ * A candidate is an edit a person or a generator proposes before any native oracle can demonstrate the behavior it
6
+ * changes. It is evidence for a review. The record sits in `discovery/`, a directory of the evaluation folder that no
7
+ * scored artifact reads: `evaluation.json` and the mutation and probe schemas are closed to it, `tea-evaluate check`
8
+ * refuses a `*.candidate.json` file anywhere else in the folder, the corpus digest covers `corpus/`, `probes/` and
9
+ * `mutations/` only, and no state here is an eval-quality outcome. Promotion produces a draft mutation that goes
10
+ * through check, compile, seal, qualification and scoring like any authored one.
11
+ *
12
+ * Reading trusts nothing the file says about itself. `identity` is recomputed from the fields that define the
13
+ * candidate, with the length-prefixed digest every other harness input uses. A review or an accepted risk names the
14
+ * identity, the determination (disposition and independent evidence) and the observations it rested on, and the reader
15
+ * refuses it once any of them differs from the record. The text is read as strict UTF-8, a repeated key or a lone
16
+ * surrogate is refused, and a file is read only from its own path, `discovery/<campaignId>/<candidateId>.candidate.json`,
17
+ * through no link.
18
+ */
19
+
20
+ 'use strict';
21
+
22
+ const fs = require('node:fs');
23
+ const path = require('node:path');
24
+ const { createHash } = require('node:crypto');
25
+
26
+ const AjvModule = require('ajv/dist/2020');
27
+
28
+ const { digest } = require('../digest');
29
+ const { addFormats } = require('../formats');
30
+
31
+ const Ajv = AjvModule.default ?? AjvModule;
32
+
33
+ const SCHEMA = JSON.parse(fs.readFileSync(path.join(__dirname, 'candidate.schema.json'), 'utf8'));
34
+ const MUTATION_SCHEMA = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'schemas', 'mutation.schema.json'), 'utf8'));
35
+ const MUTATION_SCHEMA_VERSION = MUTATION_SCHEMA.properties.schemaVersion.const;
36
+ const ajv = new Ajv({ allErrors: true });
37
+ addFormats(ajv);
38
+ const validateSchema = ajv.compile(SCHEMA);
39
+ const validateMutation = ajv.compile(MUTATION_SCHEMA);
40
+
41
+ /** The record's format version, read from its schema so the two cannot disagree. */
42
+ const CANDIDATE_SCHEMA_VERSION = SCHEMA.properties.schemaVersion.const;
43
+ const RECORD_KIND = SCHEMA.properties.recordKind.const;
44
+ /** Where candidates sit in the evaluation folder, outside every directory the scored path reads. */
45
+ const DISCOVERY_DIRECTORY = 'discovery';
46
+ /** Where a campaign keeps the snapshots its independent evidence cites, below its own directory. */
47
+ const EVIDENCE_DIRECTORY = 'evidence';
48
+ const CANDIDATE_PATH = /^discovery\/([a-z0-9]+(?:-[a-z0-9]+)*)\/(C-[0-9]{3,})\.candidate\.json$/;
49
+ /** The fields of a qualified probe `qualifiedProbe` writes; the accepted baseline's copy of the probe holds all of them. */
50
+ const QUALIFIED_PROBE_FIELDS = Object.freeze([
51
+ 'schemaVersion',
52
+ 'parentDigest',
53
+ 'revisionCount',
54
+ 'probeId',
55
+ 'probeClass',
56
+ 'behaviorId',
57
+ 'systemId',
58
+ 'implementationDigest',
59
+ 'artifactDigest',
60
+ 'commitDigest',
61
+ 'rationale',
62
+ 'qualification',
63
+ 'expectedClean',
64
+ 'defects',
65
+ 'defectSignature',
66
+ ]);
67
+ /** The fields of the controlled-mutation qualification inside it. */
68
+ const QUALIFICATION_FIELDS = Object.freeze([
69
+ 'route',
70
+ 'mutationSource',
71
+ 'mutationOperator',
72
+ 'targetArtifact',
73
+ 'expectedObservableFailure',
74
+ 'baselinePassEvidence',
75
+ 'mutatedFailEvidence',
76
+ 'rollbackVerified',
77
+ ]);
78
+ const SHA256 = /^sha256:[0-9a-f]{64}$/;
79
+ /** The native receipt a promotion links: the mutated arm's evidence, which a qualification writes under `baseline/qualification/<probeId>/`. */
80
+ const RECEIPT_PATH = /^baseline\/qualification\/(P-[0-9]{3,})\/mutated-fail\.json$/;
81
+ const STATES = Object.freeze([...SCHEMA.properties.state.enum]);
82
+ const DISPOSITIONS = Object.freeze([...SCHEMA.$defs.disposition.enum]);
83
+ const REVIEW_DISPOSITIONS = Object.freeze([...SCHEMA.$defs.reviewDisposition.enum]);
84
+
85
+ /** A candidate that cannot be read; `problems` lists every reason, each naming the field it found it in. */
86
+ class CandidateError extends Error {
87
+ constructor(problems) {
88
+ super(`not a valid exploratory candidate: ${problems.join('; ')}`);
89
+ this.name = 'CandidateError';
90
+ this.problems = problems;
91
+ }
92
+ }
93
+
94
+ /** `value` with every object's keys in code-point order, so equal records serialize to equal bytes. */
95
+ function sortKeys(value) {
96
+ if (Array.isArray(value)) return value.map(sortKeys);
97
+ if (value === null || typeof value !== 'object') return value;
98
+ return Object.fromEntries(
99
+ Object.keys(value)
100
+ .sort()
101
+ .map((key) => [key, sortKeys(value[key])]),
102
+ );
103
+ }
104
+
105
+ /** The canonical bytes of a record's JSON: sorted keys, two-space indent, one trailing newline. */
106
+ function canonicalJson(value) {
107
+ return `${JSON.stringify(sortKeys(value), null, 2)}\n`;
108
+ }
109
+
110
+ /** eval-quality's `digestBytes`: sha256 over the bytes, spelled `sha256:<hex>`. */
111
+ function bytesDigest(bytes) {
112
+ return `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
113
+ }
114
+
115
+ /**
116
+ * The digest of every field that defines a candidate: what it edits, where, why, against which requirement and from
117
+ * which generator. The state, observations, review and promotion are left out, since they describe what happened to the
118
+ * candidate and change as it moves. Each part is length-prefixed by `digest`, so moving text between two neighbouring
119
+ * fields cannot produce the same identity.
120
+ *
121
+ * @param {object} record a candidate with the fields below
122
+ * @returns {string} `sha256:<hex>`
123
+ */
124
+ function candidateIdentity(record) {
125
+ return digest([
126
+ `tea-evaluate-candidate/${CANDIDATE_SCHEMA_VERSION}`,
127
+ record.candidateId,
128
+ record.campaignId,
129
+ record.partition,
130
+ record.generator.name,
131
+ record.generator.version,
132
+ record.generator.seed,
133
+ record.intent,
134
+ record.hypothesis,
135
+ record.requirement.path,
136
+ record.requirement.digest,
137
+ record.target.artifact,
138
+ record.target.artifactDigest,
139
+ record.edit.find,
140
+ record.edit.replace,
141
+ String(record.edit.occurrences),
142
+ ]);
143
+ }
144
+
145
+ /** The digest of one observation as recorded: its arm, evidence and trial counts. */
146
+ function observationDigest(observation) {
147
+ return digest(canonicalJson(observation));
148
+ }
149
+
150
+ /** The digest of what a reviewer concluded and rested the conclusion on: the disposition and the independent evidence. */
151
+ function determinationDigest(record) {
152
+ return digest(canonicalJson({ disposition: record.disposition, independentEvidence: record.independentEvidence }));
153
+ }
154
+
155
+ /**
156
+ * The scope a decision on `record` binds to as it stands: its identity, its determination and every observation it
157
+ * holds. A reviewer or a tool writing a review or an accepted risk copies this.
158
+ *
159
+ * @param {object} record
160
+ * @returns {{identity: string, determination: string, observations: Array<{observationId: string, evidence: string}>}}
161
+ */
162
+ function decisionScope(record) {
163
+ return {
164
+ identity: candidateIdentity(record),
165
+ determination: determinationDigest(record),
166
+ observations: record.observations.map((observation) => ({
167
+ observationId: observation.observationId,
168
+ digest: observationDigest(observation),
169
+ })),
170
+ };
171
+ }
172
+
173
+ /** The positions of every string in `value` that is not well-formed UTF-16 (a lone surrogate). */
174
+ function illFormedStrings(value, at = '(root)') {
175
+ if (typeof value === 'string') return value.isWellFormed() ? [] : [at];
176
+ if (Array.isArray(value)) return value.flatMap((item, index) => illFormedStrings(item, `${at}/${index}`));
177
+ if (value !== null && typeof value === 'object') {
178
+ return Object.entries(value).flatMap(([key, child]) => [
179
+ ...illFormedStrings(key, `${at}/(key)`),
180
+ ...illFormedStrings(child, `${at}/${key}`),
181
+ ]);
182
+ }
183
+ return [];
184
+ }
185
+
186
+ /**
187
+ * Every key that a JSON object of `text` states twice. `JSON.parse` keeps the last, so a reader of the raw file and the
188
+ * record the reader holds would disagree. The text has already parsed, so the scan only has to follow strings and the
189
+ * container brackets.
190
+ */
191
+ function repeatedKeys(text) {
192
+ const repeated = [];
193
+ const stack = [];
194
+ for (let at = 0; at < text.length; at += 1) {
195
+ const character = text[at];
196
+ switch (character) {
197
+ case '"': {
198
+ let end = at + 1;
199
+ while (text[end] !== '"') end += text[end] === '\\' ? 2 : 1;
200
+ const frame = stack.at(-1);
201
+ if (frame?.keys !== undefined && frame.expectKey) {
202
+ const key = JSON.parse(text.slice(at, end + 1));
203
+ if (frame.keys.has(key)) repeated.push(key);
204
+ frame.keys.add(key);
205
+ frame.expectKey = false;
206
+ }
207
+ at = end;
208
+
209
+ break;
210
+ }
211
+ case '{': {
212
+ stack.push({ keys: new Set(), expectKey: true });
213
+ break;
214
+ }
215
+ case '[': {
216
+ stack.push({});
217
+ break;
218
+ }
219
+ case '}':
220
+ case ']': {
221
+ stack.pop();
222
+ break;
223
+ }
224
+ default: {
225
+ if (character === ',' && stack.at(-1)?.keys !== undefined) stack.at(-1).expectKey = true;
226
+ }
227
+ }
228
+ }
229
+ return repeated;
230
+ }
231
+
232
+ /** What the schema cannot say: stored digests that must equal recomputed ones, decisions that must still fit, counts that add up. */
233
+ function semanticProblems(record) {
234
+ const problems = [];
235
+ const identity = candidateIdentity(record);
236
+ if (record.identity !== identity) problems.push(`identity is ${record.identity}; the candidate's fields digest to ${identity}`);
237
+
238
+ const seen = new Map();
239
+ for (const [index, observation] of record.observations.entries()) {
240
+ if (seen.has(observation.observationId)) problems.push(`observations[${index}].observationId ${observation.observationId} repeats`);
241
+ seen.set(observation.observationId, observationDigest(observation));
242
+ const { planned, completed, errors, missing } = observation.trials;
243
+ if (completed + errors + missing > planned) {
244
+ problems.push(`observations[${index}].trials accounts for ${completed + errors + missing} trials of ${planned} planned`);
245
+ }
246
+ }
247
+
248
+ const determination = determinationDigest(record);
249
+ const staleScope = (label, scope) => {
250
+ if (scope.identity !== identity) problems.push(`${label}.scope.identity ${scope.identity} is stale; the candidate is now ${identity}`);
251
+ if (scope.determination !== determination) {
252
+ problems.push(
253
+ `${label}.scope.determination ${scope.determination} is stale; the disposition and independent evidence now digest to ${determination}`,
254
+ );
255
+ }
256
+ for (const reviewed of scope.observations) {
257
+ if (seen.get(reviewed.observationId) !== reviewed.digest) {
258
+ problems.push(
259
+ `${label}.scope.observations names ${reviewed.observationId} as ${reviewed.digest}, which the record no longer holds`,
260
+ );
261
+ }
262
+ }
263
+ };
264
+ if (record.review !== null) {
265
+ staleScope('review', record.review.scope);
266
+ if (String(record.disposition).startsWith('confirmed-undesirable')) {
267
+ const cited = new Set(record.review.scope.observations.map((reviewed) => reviewed.observationId));
268
+ for (const arm of ['baseline', 'candidate']) {
269
+ const rests = record.observations.some(
270
+ (observation) => observation.arm === arm && observation.trials.completed >= 1 && cited.has(observation.observationId),
271
+ );
272
+ if (!rests) {
273
+ problems.push(
274
+ `review.scope.observations cites no ${arm} observation with a completed trial, which disposition ${record.disposition} rests on`,
275
+ );
276
+ }
277
+ }
278
+ }
279
+ }
280
+ if (record.acceptedRisk !== null) staleScope('acceptedRisk', record.acceptedRisk.scope);
281
+
282
+ if (String(record.disposition).startsWith('confirmed-undesirable')) {
283
+ for (const arm of ['baseline', 'candidate']) {
284
+ if (!record.observations.some((observation) => observation.arm === arm && observation.trials.completed >= 1)) {
285
+ problems.push(`disposition ${record.disposition} needs a ${arm} observation with at least one completed trial`);
286
+ }
287
+ }
288
+ }
289
+ const observed = new Set(record.observations.map((observation) => observation.evidence));
290
+ for (const [index, evidence] of record.independentEvidence.entries()) {
291
+ if (evidence.digest === record.requirement.digest) {
292
+ problems.push(`independentEvidence[${index}].digest is the requirement's digest, which the candidate is judged against`);
293
+ }
294
+ if (evidence.digest === record.target.artifactDigest) {
295
+ problems.push(`independentEvidence[${index}].digest is the target's own digest, which the edit changes`);
296
+ }
297
+ if (observed.has(evidence.digest))
298
+ problems.push(`independentEvidence[${index}].digest is an observation's evidence, which the evaluated system produced`);
299
+ }
300
+ if (record.edit.find === record.edit.replace) problems.push('edit.find equals edit.replace, so the edit changes nothing');
301
+ return problems;
302
+ }
303
+
304
+ /** The Ajv errors of a failed schema validation, each as a path and a message. */
305
+ function schemaProblems(errors) {
306
+ return errors.map(
307
+ (error) =>
308
+ `${error.instancePath || '(root)'} ${error.message}${error.params?.additionalProperty ? ` (${JSON.stringify(error.params.additionalProperty)})` : ''}`,
309
+ );
310
+ }
311
+
312
+ /**
313
+ * Validates a parsed candidate.
314
+ *
315
+ * @param {unknown} value
316
+ * @returns {object} the same value, once it is a candidate
317
+ * @throws {CandidateError}
318
+ */
319
+ function validateCandidate(value) {
320
+ const illFormed = illFormedStrings(value);
321
+ if (illFormed.length > 0) throw new CandidateError(illFormed.map((at) => `${at} holds a lone surrogate, which no UTF-8 file can state`));
322
+ if (!validateSchema(value)) throw new CandidateError(schemaProblems(validateSchema.errors));
323
+ const problems = semanticProblems(value);
324
+ if (problems.length > 0) throw new CandidateError(problems);
325
+ return value;
326
+ }
327
+
328
+ /**
329
+ * Reads a candidate from its JSON text.
330
+ *
331
+ * @param {string} text
332
+ * @returns {object}
333
+ * @throws {CandidateError}
334
+ */
335
+ function parseCandidate(text) {
336
+ let value;
337
+ try {
338
+ value = JSON.parse(text);
339
+ } catch (error) {
340
+ throw new CandidateError([`is not JSON: ${error.message}`]);
341
+ }
342
+ const repeated = repeatedKeys(text);
343
+ if (repeated.length > 0) throw new CandidateError(repeated.map((key) => `key ${JSON.stringify(key)} appears twice in one object`));
344
+ return validateCandidate(value);
345
+ }
346
+
347
+ /**
348
+ * The canonical bytes of a valid candidate. Parsing them returns an equal record, and serializing that record returns
349
+ * the same bytes.
350
+ *
351
+ * @param {object} record
352
+ * @returns {string}
353
+ * @throws {CandidateError} when the record is not a valid candidate
354
+ */
355
+ function serializeCandidate(record) {
356
+ return canonicalJson(validateCandidate(record));
357
+ }
358
+
359
+ /** The relative path a candidate is stored at. */
360
+ function candidatePath(campaignId, candidateId) {
361
+ return `${DISCOVERY_DIRECTORY}/${campaignId}/${candidateId}.candidate.json`;
362
+ }
363
+
364
+ /** The bytes of a regular file under `folder`, reached through no link and read through one descriptor. */
365
+ function readHeldFile(folder, relative, label) {
366
+ let at = folder;
367
+ const segments = relative.split('/');
368
+ for (const [index, segment] of segments.entries()) {
369
+ at = path.join(at, segment);
370
+ let stats;
371
+ try {
372
+ stats = fs.lstatSync(at);
373
+ } catch {
374
+ throw new CandidateError([`${label} ${relative} does not exist in the evaluation folder`]);
375
+ }
376
+ if (stats.isSymbolicLink()) throw new CandidateError([`${label} ${relative} is reached through a link`]);
377
+ if (index < segments.length - 1 && !stats.isDirectory())
378
+ throw new CandidateError([`${label} ${relative} is under something that is not a directory`]);
379
+ if (index === segments.length - 1 && !stats.isFile()) throw new CandidateError([`${label} ${relative} is not a regular file`]);
380
+ }
381
+ let descriptor;
382
+ try {
383
+ descriptor = fs.openSync(at, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0));
384
+ } catch (error) {
385
+ throw new CandidateError([`${label} ${relative} cannot be opened: ${error.code ?? error.message}`]);
386
+ }
387
+ try {
388
+ if (!fs.fstatSync(descriptor).isFile()) throw new CandidateError([`${label} ${relative} is not a regular file`]);
389
+ return fs.readFileSync(descriptor);
390
+ } finally {
391
+ fs.closeSync(descriptor);
392
+ }
393
+ }
394
+
395
+ /** The JSON object `bytes` state; `problems` gets the reason when they state anything else. */
396
+ function jsonObject(bytes, relative, label, problems) {
397
+ let value;
398
+ try {
399
+ const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
400
+ value = JSON.parse(text);
401
+ if (repeatedKeys(text).length > 0) {
402
+ problems.push(`${label} ${relative} states a key twice in one object`);
403
+ return;
404
+ }
405
+ } catch {
406
+ problems.push(`${label} ${relative} is not JSON text`);
407
+ return;
408
+ }
409
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
410
+ problems.push(`${label} ${relative} is not a JSON object`);
411
+ return;
412
+ }
413
+ return value;
414
+ }
415
+
416
+ /** The bytes of a regular file `folder` holds; `problems` gets the reason when it cannot be read. */
417
+ function heldBytes(folder, relative, label, problems) {
418
+ try {
419
+ return readHeldFile(folder, relative, label);
420
+ } catch (error) {
421
+ problems.push(...error.problems);
422
+ }
423
+ }
424
+
425
+ /** The parsed JSON of a regular file `folder` holds; `problems` gets every reason it cannot be read as an object. */
426
+ function heldJson(folder, relative, label, problems) {
427
+ let bytes;
428
+ try {
429
+ bytes = readHeldFile(folder, relative, label);
430
+ } catch (error) {
431
+ problems.push(...error.problems);
432
+ return;
433
+ }
434
+ return jsonObject(bytes, relative, label, problems);
435
+ }
436
+
437
+ /** Pushes a problem for each `[field, expected]` pair the document does not state. */
438
+ function expectFields(problems, relative, document, expected) {
439
+ for (const [field, value] of Object.entries(expected)) {
440
+ if (document[field] !== value) {
441
+ problems.push(`${relative} states ${field} ${JSON.stringify(document[field])}; the promotion needs ${JSON.stringify(value)}`);
442
+ }
443
+ }
444
+ }
445
+
446
+ /** An arm's evidence backs its verdict: it holds oracle results, and they decide the verdict the way a qualification decides it. */
447
+ function oracleProblems(problems, relative, document, verdict) {
448
+ const oracles = Array.isArray(document.oracles) ? document.oracles : [];
449
+ const decided =
450
+ verdict === 'held'
451
+ ? oracles.every((oracle) => oracle?.disposition === 'held')
452
+ : oracles.some((oracle) => oracle?.disposition === 'violated');
453
+ if (oracles.length === 0 || !decided) {
454
+ problems.push(`${relative} holds oracle results that do not back the verdict ${verdict}`);
455
+ }
456
+ }
457
+
458
+ /**
459
+ * Holds a promotion's linked receipt to the native qualification of the draft: the probe names the draft mutation through
460
+ * the controlled-mutation route, the mutation file of the folder states the candidate's own edit, and the qualification's
461
+ * clean arm held, mutated arm violated an oracle and rollback restored the target the record digests, each stating the
462
+ * probe, the mutation and the target.
463
+ */
464
+ function qualificationProblems(folder, record, receiptBytes, problems) {
465
+ const { draftMutationId: mutationId, qualificationReceipt: receipt } = record.promotion;
466
+ const probeId = RECEIPT_PATH.exec(receipt.path)[1];
467
+ const directory = `baseline/qualification/${probeId}`;
468
+ const target = record.target.artifact;
469
+ const draft = draftMutation(record);
470
+
471
+ const probe = heldJson(folder, `probes/${probeId}.probe.json`, 'qualified probe', problems);
472
+ if (probe !== undefined) {
473
+ if (probe.probeId !== probeId) problems.push(`probes/${probeId}.probe.json is the probe ${probe.probeId}`);
474
+ if (probe.qualification?.route !== 'controlled-mutation') {
475
+ problems.push(`probes/${probeId}.probe.json takes the ${probe.qualification?.route} route; a promotion needs controlled-mutation`);
476
+ }
477
+ if (probe.qualification?.mutation !== mutationId) {
478
+ problems.push(`${receipt.path} qualifies ${probeId}, whose mutation is ${probe.qualification?.mutation}; the draft is ${mutationId}`);
479
+ }
480
+ }
481
+
482
+ const mutationFile = `mutations/${mutationId}.mutation.json`;
483
+ const mutation = heldJson(folder, mutationFile, 'draft mutation', problems);
484
+ if (mutation !== undefined) {
485
+ if (validateMutation(mutation)) {
486
+ expectFields(problems, mutationFile, mutation, { mutationId, targetArtifact: target });
487
+ for (const field of ['kind', 'find', 'replace', 'occurrences']) {
488
+ if (mutation.operator[field] !== draft.operator[field]) {
489
+ problems.push(
490
+ `${mutationFile} states operator.${field} ${JSON.stringify(mutation.operator[field])}; the candidate's edit is ${JSON.stringify(draft.operator[field])}`,
491
+ );
492
+ }
493
+ }
494
+ } else {
495
+ problems.push(...schemaProblems(validateMutation.errors).map((problem) => `${mutationFile} ${problem}`));
496
+ }
497
+ }
498
+
499
+ const arms = { probeId, mutationId, targetArtifact: target };
500
+ const baselineBytes = heldBytes(folder, `${directory}/baseline-pass.json`, 'qualification evidence', problems);
501
+ const baseline =
502
+ baselineBytes === undefined
503
+ ? undefined
504
+ : jsonObject(baselineBytes, `${directory}/baseline-pass.json`, 'qualification evidence', problems);
505
+ if (baseline !== undefined) {
506
+ expectFields(problems, `${directory}/baseline-pass.json`, baseline, {
507
+ ...arms,
508
+ phase: 'baseline-pass',
509
+ verdict: 'held',
510
+ targetArtifactDigest: record.target.artifactDigest,
511
+ });
512
+ oracleProblems(problems, `${directory}/baseline-pass.json`, baseline, 'held');
513
+ }
514
+ const mutated = receiptBytes === undefined ? undefined : jsonObject(receiptBytes, receipt.path, 'qualification receipt', problems);
515
+ if (mutated !== undefined) {
516
+ expectFields(problems, receipt.path, mutated, { ...arms, phase: 'mutated-fail', verdict: 'violated' });
517
+ oracleProblems(problems, receipt.path, mutated, 'violated');
518
+ if (mutated.targetArtifactDigest === record.target.artifactDigest || !SHA256.test(mutated.targetArtifactDigest ?? '')) {
519
+ problems.push(
520
+ `${receipt.path} states targetArtifactDigest ${JSON.stringify(mutated.targetArtifactDigest)}; the mutated bytes digest to another value than the target's ${record.target.artifactDigest}`,
521
+ );
522
+ }
523
+ }
524
+ const rollback = heldJson(folder, `${directory}/rollback.json`, 'qualification evidence', problems);
525
+ if (rollback !== undefined) {
526
+ expectFields(problems, `${directory}/rollback.json`, rollback, {
527
+ ...arms,
528
+ preDigest: record.target.artifactDigest,
529
+ restoredDigest: record.target.artifactDigest,
530
+ ...(mutated === undefined ? {} : { mutatedDigest: mutated.targetArtifactDigest }),
531
+ });
532
+ if (rollback.rollbackVerified !== true) problems.push(`${directory}/rollback.json does not state that the rollback was verified`);
533
+ const lastRePass = Array.isArray(rollback.rePasses) ? rollback.rePasses.at(-1) : undefined;
534
+ if (lastRePass?.verdict === 'held') {
535
+ oracleProblems(problems, `${directory}/rollback.json`, lastRePass, 'held');
536
+ } else {
537
+ problems.push(`${directory}/rollback.json ends with no restored arm that held, which a verified rollback rests on`);
538
+ }
539
+ }
540
+ const fault = `${directory}/fault.json`;
541
+ try {
542
+ fs.lstatSync(path.join(folder, ...fault.split('/')));
543
+ problems.push(`${fault} records a qualification that stopped before it finished`);
544
+ } catch {
545
+ // No fault file: the qualification finished.
546
+ }
547
+
548
+ // The accepted baseline's qualified probe binds the operator and the two arms' evidence digests; the authored mutation file does not.
549
+ const accepted = heldJson(folder, `baseline/probes/${probeId}.probe.json`, 'accepted baseline probe', problems);
550
+ if (accepted !== undefined) {
551
+ const qualification = accepted.qualification ?? {};
552
+ const acceptedFile = `baseline/probes/${probeId}.probe.json`;
553
+ const absent = QUALIFIED_PROBE_FIELDS.filter((field) => !Object.hasOwn(accepted, field));
554
+ if (absent.length > 0) problems.push(`${acceptedFile} is not a qualified probe; it lacks ${absent.join(', ')}`);
555
+ const unqualified = QUALIFICATION_FIELDS.filter((field) => !Object.hasOwn(qualification, field));
556
+ if (unqualified.length > 0)
557
+ problems.push(`${acceptedFile} holds no controlled-mutation qualification; it lacks ${unqualified.join(', ')}`);
558
+ expectFields(problems, acceptedFile, accepted, { probeId, expectedClean: false, artifactDigest: record.target.artifactDigest });
559
+ if (!Number.isInteger(accepted.schemaVersion) || accepted.schemaVersion < 1) {
560
+ problems.push(`${acceptedFile} states schemaVersion ${JSON.stringify(accepted.schemaVersion)}, which is not a probe schema version`);
561
+ }
562
+ for (const field of ['implementationDigest', 'commitDigest']) {
563
+ if (!SHA256.test(accepted[field] ?? ''))
564
+ problems.push(`${acceptedFile} states ${field} ${JSON.stringify(accepted[field])}, which is not a digest`);
565
+ }
566
+ const operator = `replace-exact: ${JSON.stringify(record.edit.find)} -> ${JSON.stringify(record.edit.replace)} (occurrences ${record.edit.occurrences})`;
567
+ if (qualification.route !== 'controlled-mutation') problems.push(`${acceptedFile} takes the ${qualification.route} route`);
568
+ if (qualification.mutationOperator !== operator) {
569
+ problems.push(
570
+ `${acceptedFile} qualified ${JSON.stringify(qualification.mutationOperator)}; the candidate's edit is ${JSON.stringify(operator)}`,
571
+ );
572
+ }
573
+ if (qualification.targetArtifact?.digest !== record.target.artifactDigest) {
574
+ problems.push(
575
+ `${acceptedFile} qualified a target digesting to ${qualification.targetArtifact?.digest}; the record names ${record.target.artifactDigest}`,
576
+ );
577
+ }
578
+ if (qualification.rollbackVerified !== true) problems.push(`${acceptedFile} does not state that the rollback was verified`);
579
+ const arm = (field, bytes, name) => {
580
+ if (bytes !== undefined && qualification[field]?.digest !== bytesDigest(bytes)) {
581
+ problems.push(
582
+ `${acceptedFile} records ${qualification[field]?.digest} for its ${name} evidence; ${directory}/${name}.json digests to ${bytesDigest(bytes)}`,
583
+ );
584
+ }
585
+ };
586
+ arm('baselinePassEvidence', baselineBytes, 'baseline-pass');
587
+ arm('mutatedFailEvidence', receiptBytes, 'mutated-fail');
588
+ }
589
+ }
590
+
591
+ /**
592
+ * Reads the candidate an evaluation folder holds at `relative`, which must be `discovery/<campaignId>/C-NNN.candidate.json`,
593
+ * and holds it to the folder: the record names that campaign and that candidate, the requirements file digests to the
594
+ * record's `requirement.digest`, each independent evidence is a snapshot under `discovery/<campaignId>/evidence/` that
595
+ * digests to the record's value, and a linked qualification receipt is the native qualification of the draft mutation
596
+ * (see `qualificationProblems`).
597
+ *
598
+ * @param {string} folder the evaluation folder
599
+ * @param {string} relative
600
+ * @returns {object}
601
+ * @throws {CandidateError}
602
+ */
603
+ function readCandidate(folder, relative) {
604
+ const match = CANDIDATE_PATH.exec(relative);
605
+ if (match === null) throw new CandidateError([`${relative} is not discovery/<campaignId>/C-NNN.candidate.json`]);
606
+ let text;
607
+ try {
608
+ text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(readHeldFile(folder, relative, 'candidate'));
609
+ } catch (error) {
610
+ if (error instanceof CandidateError) throw error;
611
+ throw new CandidateError([`${relative} is not UTF-8 text`]);
612
+ }
613
+ const record = parseCandidate(text);
614
+ const problems = [];
615
+ if (record.campaignId !== match[1]) problems.push(`campaignId ${record.campaignId} differs from the directory ${match[1]}`);
616
+ if (record.candidateId !== match[2]) problems.push(`candidateId ${record.candidateId} differs from the file name ${match[2]}`);
617
+ const held = (label, file, expected) => {
618
+ try {
619
+ const bytes = readHeldFile(folder, file, label);
620
+ const actual = bytesDigest(bytes);
621
+ if (actual !== expected) problems.push(`${file} digests to ${actual}; the record names ${expected}`);
622
+ return bytes;
623
+ } catch (error) {
624
+ problems.push(
625
+ ...(error instanceof CandidateError ? error.problems : [`${label} ${file} cannot be read: ${error.code ?? error.message}`]),
626
+ );
627
+ return;
628
+ }
629
+ };
630
+ held('requirement', record.requirement.path, record.requirement.digest);
631
+ const evidenceDirectory = `${DISCOVERY_DIRECTORY}/${record.campaignId}/${EVIDENCE_DIRECTORY}/`;
632
+ for (const [index, evidence] of record.independentEvidence.entries()) {
633
+ if (!evidence.reference.startsWith(evidenceDirectory)) {
634
+ problems.push(`independentEvidence[${index}].reference ${evidence.reference} is not a snapshot under ${evidenceDirectory}`);
635
+ continue;
636
+ }
637
+ const bytes = held('independent evidence', evidence.reference, evidence.digest);
638
+ if (bytes?.length === 0) problems.push(`independent evidence ${evidence.reference} is empty, so it holds nothing a reviewer saw`);
639
+ }
640
+ if (record.promotion?.qualificationReceipt) {
641
+ const receiptBytes = held(
642
+ 'qualification receipt',
643
+ record.promotion.qualificationReceipt.path,
644
+ record.promotion.qualificationReceipt.digest,
645
+ );
646
+ qualificationProblems(folder, record, receiptBytes, problems);
647
+ }
648
+ if (problems.length > 0) throw new CandidateError(problems);
649
+ return record;
650
+ }
651
+
652
+ /**
653
+ * The draft mutation a promoted candidate stands for: the native `replace-exact` shape, ready for `tea-evaluate check`.
654
+ * It is a file to review and qualify, and carries no claim that the candidate is a qualified probe.
655
+ *
656
+ * @param {object} record a promoted candidate
657
+ * @returns {object} the content of `mutations/<draftMutationId>.mutation.json`
658
+ * @throws {CandidateError} when the candidate is invalid or not promoted
659
+ */
660
+ function draftMutation(record) {
661
+ validateCandidate(record);
662
+ if (record.promotion === null)
663
+ throw new CandidateError([`candidate ${record.candidateId} is ${record.state}; only a promoted candidate has a draft`]);
664
+ return {
665
+ schemaVersion: MUTATION_SCHEMA_VERSION,
666
+ mutationId: record.promotion.draftMutationId,
667
+ mutationSource: record.target.artifact,
668
+ targetArtifact: record.target.artifact,
669
+ operator: { kind: 'replace-exact', find: record.edit.find, replace: record.edit.replace, occurrences: record.edit.occurrences },
670
+ expectedObservableFailure: record.hypothesis,
671
+ };
672
+ }
673
+
674
+ module.exports = {
675
+ CANDIDATE_PATH,
676
+ CANDIDATE_SCHEMA_VERSION,
677
+ CandidateError,
678
+ DISCOVERY_DIRECTORY,
679
+ DISPOSITIONS,
680
+ EVIDENCE_DIRECTORY,
681
+ QUALIFICATION_FIELDS,
682
+ QUALIFIED_PROBE_FIELDS,
683
+ RECORD_KIND,
684
+ REVIEW_DISPOSITIONS,
685
+ STATES,
686
+ bytesDigest,
687
+ candidateIdentity,
688
+ candidatePath,
689
+ canonicalJson,
690
+ decisionScope,
691
+ determinationDigest,
692
+ draftMutation,
693
+ parseCandidate,
694
+ readCandidate,
695
+ readHeldFile,
696
+ repeatedKeys,
697
+ serializeCandidate,
698
+ validateCandidate,
699
+ };