@mikeargento/bitgraph-audit 0.7.0 → 0.9.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.
Files changed (49) hide show
  1. package/README.md +4 -0
  2. package/dist/audit.d.ts +8 -6
  3. package/dist/audit.d.ts.map +1 -1
  4. package/dist/audit.js +28 -8
  5. package/dist/audit.js.map +1 -1
  6. package/dist/ceilings.d.ts +25 -0
  7. package/dist/ceilings.d.ts.map +1 -1
  8. package/dist/ceilings.js +159 -31
  9. package/dist/ceilings.js.map +1 -1
  10. package/dist/cli.js +40 -3
  11. package/dist/cli.js.map +1 -1
  12. package/dist/exports.d.ts +39 -0
  13. package/dist/exports.d.ts.map +1 -0
  14. package/dist/exports.js +371 -0
  15. package/dist/exports.js.map +1 -0
  16. package/dist/index.d.ts +5 -1
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +4 -0
  19. package/dist/index.js.map +1 -1
  20. package/dist/ingest.d.ts.map +1 -1
  21. package/dist/ingest.js +179 -19
  22. package/dist/ingest.js.map +1 -1
  23. package/dist/report-json.d.ts.map +1 -1
  24. package/dist/report-json.js +15 -0
  25. package/dist/report-json.js.map +1 -1
  26. package/dist/report-md.d.ts.map +1 -1
  27. package/dist/report-md.js +225 -0
  28. package/dist/report-md.js.map +1 -1
  29. package/dist/settlement-blobs.d.ts +131 -0
  30. package/dist/settlement-blobs.d.ts.map +1 -0
  31. package/dist/settlement-blobs.js +682 -0
  32. package/dist/settlement-blobs.js.map +1 -0
  33. package/dist/types.d.ts +254 -3
  34. package/dist/types.d.ts.map +1 -1
  35. package/package.json +5 -3
  36. package/src/__tests__/fixtures/settlement/0x018efc046e94610e2530344caf1574e507a3182e1a975fb25b032ec1434a3f5c.bin +0 -0
  37. package/src/__tests__/fixtures/settlement/ceiling-51979918.json +101 -0
  38. package/src/__tests__/fixtures/settlement/pointer-51979918.json +69 -0
  39. package/src/__tests__/settlement.test.ts +254 -0
  40. package/src/audit.ts +30 -8
  41. package/src/ceilings.ts +167 -33
  42. package/src/cli.ts +39 -3
  43. package/src/exports.ts +478 -0
  44. package/src/index.ts +16 -0
  45. package/src/ingest.ts +192 -18
  46. package/src/report-json.ts +15 -0
  47. package/src/report-md.ts +248 -0
  48. package/src/settlement-blobs.ts +689 -0
  49. package/src/types.ts +234 -2
package/src/ingest.ts CHANGED
@@ -14,9 +14,16 @@
14
14
  * with the stable code "unsupported-version", counted, listed, and excluded
15
15
  * from verification, chain reconstruction, and anomaly analysis.
16
16
  *
17
+ * An export (bitgraph-export/1, found by its format field) is evidence,
18
+ * never an artifact: it is listed for the export stage (exports.ts), and the
19
+ * proof it carries is recorded as an observed proof from the export's path,
20
+ * exactly like a proof file. A file that declares another bitgraph-export
21
+ * format, or lacks an export's structure, is listed as rejected.
22
+ *
17
23
  * Memory: archives are never unpacked to disk and never loaded whole.
18
24
  * Every entry is hashed incrementally as it streams. Only small JSON
19
- * candidates (at most MAX_CANDIDATE_JSON_BYTES) are buffered for parsing;
25
+ * candidates (at most MAX_CANDIDATE_JSON_BYTES) are buffered for parsing,
26
+ * and entries that open an export object (at most maxExportJsonBytes);
20
27
  * artifact bytes are hashed and dropped. What ingest retains scales with
21
28
  * the number of entries and the total size of the proof JSONs, not with
22
29
  * artifact payload sizes.
@@ -37,7 +44,7 @@ import { open, readdir, readFile, stat } from "node:fs/promises";
37
44
  import { join } from "node:path";
38
45
  import { createGunzip } from "node:zlib";
39
46
  import { sha256 } from "@noble/hashes/sha256";
40
- import { computeProofHash, computeChainHash } from "@mikeargento/bitgraph-verify";
47
+ import { computeProofHash, computeChainHash, EXPORT_FORMAT, parseExport } from "@mikeargento/bitgraph-verify";
41
48
  import type { BitGraphProof } from "@mikeargento/bitgraph-verify";
42
49
  import { readTarEntries } from "./tar.js";
43
50
  import { combineEntryDigests } from "./contents-hash.js";
@@ -49,6 +56,7 @@ import type {
49
56
  BundleManifest,
50
57
  ContainerKind,
51
58
  EmbeddedProofHashStatus,
59
+ ExportFile,
52
60
  IngestCounts,
53
61
  IngestLimits,
54
62
  IngestResult,
@@ -67,6 +75,20 @@ import type {
67
75
  */
68
76
  const MAX_CANDIDATE_JSON_BYTES = 8 * 1024 * 1024;
69
77
 
78
+ /**
79
+ * An export's JSON may run past MAX_CANDIDATE_JSON_BYTES: the owner's export
80
+ * of a large tree lists every leaf (65 bytes each, base64) and a name per
81
+ * leaf, up to 1,000,000 leaves. An entry whose opening bytes open an export
82
+ * object ({"format": "bitgraph-export/...) is buffered up to this cap
83
+ * instead (IngestLimits.maxExportJsonBytes); a larger one is reported as an
84
+ * export too large to check, never silently taken for an ordinary file.
85
+ */
86
+ const MAX_EXPORT_JSON_BYTES = 192 * 1024 * 1024;
87
+ /** How many opening bytes decide whether an entry opens an export object. */
88
+ const EXPORT_SNIFF_BYTES = 256;
89
+ /** Producers write format first (buildExport); whitespace and one BOM are tolerated. */
90
+ const EXPORT_OPENING = /^\s*\{\s*"format"\s*:\s*"(bitgraph-export\/[^"\\]{0,64})"/;
91
+
70
92
  /**
71
93
  * Default resource caps for untrusted .tar / .tar.gz ingest. Chosen well
72
94
  * above any legitimate bundle (the 50k-proof benchmark bundle decompresses
@@ -77,6 +99,7 @@ export const DEFAULT_INGEST_LIMITS: IngestLimits = {
77
99
  maxTotalBytes: 2 * 1024 * 1024 * 1024, // 2 GiB decompressed
78
100
  maxEntryCount: 1_000_000,
79
101
  maxMetadataEntryBytes: 8 * 1024 * 1024, // 8 MiB per PAX/GNU metadata entry
102
+ maxExportJsonBytes: MAX_EXPORT_JSON_BYTES, // 192 MiB for one export's JSON, every container
80
103
  };
81
104
 
82
105
  const NUL = new Uint8Array([0]);
@@ -85,6 +108,8 @@ const BUNDLE_VERSION = "bitgraph-bundle/1";
85
108
  const WITNESS_VERSION = "bitgraph-anchor-witness/1";
86
109
  const CEILING_VERSION_TAG = "bitgraph-ceiling/1";
87
110
  const CEILING_STATUS_VERSION_TAG = "bitgraph-ceiling-status/1";
111
+ /** Every export format starts with this; only EXPORT_FORMAT ("bitgraph-export/1") is checked. */
112
+ const EXPORT_FORMAT_PREFIX = "bitgraph-export/";
88
113
 
89
114
  // ---------------------------------------------------------------------------
90
115
  // Public API
@@ -111,6 +136,7 @@ export async function ingestBundle(
111
136
  const findings: AuditFinding[] = [];
112
137
  let skippedUnsafePaths = 0;
113
138
  let entriesScanned = 0;
139
+ const exportCap = limits.maxExportJsonBytes ?? MAX_EXPORT_JSON_BYTES;
114
140
 
115
141
  // ---------------------------------------------------------------------
116
142
  // Scan: stream every entry once, hashing incrementally.
@@ -122,7 +148,7 @@ export async function ingestBundle(
122
148
  for await (const file of walkDirectory(bundlePath)) {
123
149
  entriesScanned++;
124
150
  const stream = createReadStream(file.absPath);
125
- const hashed = await hashEntryStream(file.relPath, undefined, undefined, stream);
151
+ const hashed = await hashEntryStream(file.relPath, undefined, undefined, stream, exportCap);
126
152
  scanned.push(makeScannedEntry(file.relPath, hashed));
127
153
  }
128
154
  } else {
@@ -147,7 +173,7 @@ export async function ingestBundle(
147
173
  }
148
174
  const slash = normalized.path.indexOf("/");
149
175
  const strippedVariant = slash === -1 ? undefined : normalized.path.slice(slash + 1);
150
- const hashed = await hashEntryStream(normalized.path, strippedVariant, entry.size, entry.body);
176
+ const hashed = await hashEntryStream(normalized.path, strippedVariant, entry.size, entry.body, exportCap);
151
177
  scanned.push(makeScannedEntry(normalized.path, hashed));
152
178
  }
153
179
  } finally {
@@ -235,7 +261,8 @@ export async function ingestEntries(
235
261
  normalized.path,
236
262
  undefined,
237
263
  undefined,
238
- openAsChunks(entry.open())
264
+ openAsChunks(entry.open()),
265
+ MAX_EXPORT_JSON_BYTES
239
266
  );
240
267
  scanned.push(makeScannedEntry(normalized.path, hashed));
241
268
  sources.set(normalized.path, entry);
@@ -319,6 +346,7 @@ function finalizeIngest(params: FinalizeParams): IngestResult {
319
346
  byteLength: entry.byteLength,
320
347
  entryDigest,
321
348
  ...(entry.json !== undefined ? { json: entry.json } : {}),
349
+ ...(entry.exportTooLarge !== undefined ? { exportTooLarge: entry.exportTooLarge } : {}),
322
350
  });
323
351
  }
324
352
  const finalEntries = Array.from(finalByPath.values());
@@ -341,6 +369,7 @@ function finalizeIngest(params: FinalizeParams): IngestResult {
341
369
  const witnesses: AnchorWitnessFile[] = [];
342
370
  const ceilings: CeilingFile[] = [];
343
371
  const ceilingStatuses: CeilingFile[] = [];
372
+ const exportFiles: ExportFile[] = [];
344
373
  const artifactsByHex = new Map<string, ArtifactRecord>();
345
374
  let manifest: ManifestReport | undefined;
346
375
  let proofFiles = 0;
@@ -353,6 +382,19 @@ function finalizeIngest(params: FinalizeParams): IngestResult {
353
382
  continue;
354
383
  }
355
384
 
385
+ // An export too large to parse is still an export: reported, never an
386
+ // artifact, and never silently checked as nothing.
387
+ if (entry.exportTooLarge !== undefined) {
388
+ exportFiles.push({ path: entry.path, fileSha256Hex: entry.sha256Hex, format: entry.exportTooLarge, status: "too-large", json: {} });
389
+ findings.push({
390
+ code: "export-too-large",
391
+ path: entry.path,
392
+ message: `the file opens an export (${entry.exportTooLarge}) but is ${entry.byteLength} bytes, past the audit's cap for one export's JSON; not checked`,
393
+ details: { byteLength: entry.byteLength },
394
+ });
395
+ continue;
396
+ }
397
+
356
398
  // A Frame (profile bitgraph-fuse/1) is the shipped carrier of a fused
357
399
  // recording: { type: "bitgraph-fuse/1", manifest, fusePayload?, proof }.
358
400
  // The nested proof is the member; the wrapper is never an artifact.
@@ -386,6 +428,45 @@ function finalizeIngest(params: FinalizeParams): IngestResult {
386
428
  continue;
387
429
  }
388
430
 
431
+ // An export (bitgraph-export/1) is one JSON file that, with the file it
432
+ // covers, checks a tree/1 BitGraph: { format, spec, proof, tree, floor,
433
+ // ceiling, settlement }. Found by its format field, never by name. It is
434
+ // evidence, never an artifact; the proof it carries joins the proof
435
+ // analysis like any other (counters, chain links, partitions, attestation).
436
+ const exportFormat = exportFormatOf(parsed);
437
+ if (exportFormat !== undefined) {
438
+ const record = classifyExport(entry, parsed as Record<string, unknown>, exportFormat, findings);
439
+ exportFiles.push(record);
440
+ const carried = record.status === "ok" ? (parsed as Record<string, unknown>)["proof"] : undefined;
441
+ if (carried !== undefined && isProofShaped(carried)) {
442
+ const inner = carried as Record<string, unknown>;
443
+ const version = inner["version"] as string;
444
+ if (version === "bitgraph/1") {
445
+ proofFiles++;
446
+ const outcome = recordMemberProof(entry, inner, proofsByHash, proofs, findings);
447
+ if (outcome === "exact-duplicate") exactDuplicates++;
448
+ if (outcome === "semantic-duplicate") semanticDuplicates++;
449
+ record.proofHash = computeProofHash(inner);
450
+ } else {
451
+ // The same version policy as any proof-shaped input; the export
452
+ // itself is still checked (its proof claim fails).
453
+ unsupportedVersions.push({
454
+ code: "unsupported-version",
455
+ path: entry.path,
456
+ version,
457
+ fileSha256Hex: entry.sha256Hex,
458
+ });
459
+ findings.push({
460
+ code: "unsupported-version",
461
+ path: entry.path,
462
+ message: `the proof inside this export is rejected: version "${version}" is not "bitgraph/1"`,
463
+ details: { version },
464
+ });
465
+ }
466
+ }
467
+ continue;
468
+ }
469
+
389
470
  if (
390
471
  parsed !== undefined &&
391
472
  (parsed as Record<string, unknown>)["version"] === WITNESS_VERSION
@@ -437,6 +518,7 @@ function finalizeIngest(params: FinalizeParams): IngestResult {
437
518
  artifacts: artifacts.length,
438
519
  witnesses: witnesses.length,
439
520
  ceilings: ceilings.length + ceilingStatuses.length,
521
+ exports: exportFiles.length,
440
522
  skippedUnsafePaths,
441
523
  };
442
524
 
@@ -451,6 +533,7 @@ function finalizeIngest(params: FinalizeParams): IngestResult {
451
533
  witnesses,
452
534
  ceilings,
453
535
  ceilingStatuses,
536
+ exports: exportFiles,
454
537
  ...(manifest !== undefined ? { manifest } : {}),
455
538
  computedContentsHashB64,
456
539
  findings,
@@ -668,6 +751,8 @@ interface ScannedEntry {
668
751
  entryDigestFull: Uint8Array;
669
752
  entryDigestStripped?: Uint8Array;
670
753
  json?: unknown;
754
+ /** The format an entry opening an export declared, when it ran past the export JSON cap. */
755
+ exportTooLarge?: string;
671
756
  }
672
757
 
673
758
  interface FinalEntry {
@@ -676,6 +761,7 @@ interface FinalEntry {
676
761
  byteLength: number;
677
762
  entryDigest: Uint8Array;
678
763
  json?: unknown;
764
+ exportTooLarge?: string;
679
765
  }
680
766
 
681
767
  interface HashedEntry {
@@ -684,6 +770,7 @@ interface HashedEntry {
684
770
  entryDigestFull: Uint8Array;
685
771
  entryDigestStripped?: Uint8Array;
686
772
  buffered?: Uint8Array;
773
+ exportTooLarge?: string;
687
774
  }
688
775
 
689
776
  /**
@@ -693,13 +780,14 @@ interface HashedEntry {
693
780
  * - for tar entries below a potentially stripped root, the same entry
694
781
  * digest under the stripped path variant,
695
782
  * while buffering the content only if it stays within the JSON candidacy
696
- * cap.
783
+ * cap, or, for an entry that opens an export object, within the export cap.
697
784
  */
698
785
  async function hashEntryStream(
699
786
  path: string,
700
787
  strippedPathVariant: string | undefined,
701
788
  declaredSize: number | undefined,
702
- chunks: AsyncIterable<Uint8Array>
789
+ chunks: AsyncIterable<Uint8Array>,
790
+ exportCap: number
703
791
  ): Promise<HashedEntry> {
704
792
  const encoder = new TextEncoder();
705
793
  const contentHasher = sha256.create();
@@ -712,36 +800,72 @@ async function hashEntryStream(
712
800
  strippedHasher.update(NUL);
713
801
  }
714
802
 
715
- let buffering = declaredSize === undefined || declaredSize <= MAX_CANDIDATE_JSON_BYTES;
803
+ // json: buffer within the ordinary cap (past it, keep going only if the
804
+ // entry opens an export). sniff: declared past the ordinary cap, so only an
805
+ // export is worth buffering; decided on the opening bytes. export: buffer
806
+ // within the export cap. off: hash only.
807
+ let mode: "json" | "sniff" | "export" | "off" =
808
+ declaredSize === undefined || declaredSize <= MAX_CANDIDATE_JSON_BYTES ? "json" : "sniff";
809
+ let exportTooLarge: string | undefined;
810
+ const head = new Uint8Array(EXPORT_SNIFF_BYTES);
811
+ let headLength = 0;
716
812
  const buffered: Uint8Array[] = [];
717
813
  let total = 0;
814
+ const stop = (): "off" => {
815
+ buffered.length = 0;
816
+ return "off";
817
+ };
818
+ const tooLarge = (): "off" => {
819
+ exportTooLarge = exportOpening(head.subarray(0, headLength)) ?? "bitgraph-export/";
820
+ return stop();
821
+ };
718
822
 
719
823
  for await (const chunk of chunks) {
720
824
  contentHasher.update(chunk);
721
825
  fullHasher.update(chunk);
722
826
  strippedHasher?.update(chunk);
723
827
  total += chunk.length;
724
- if (buffering) {
725
- if (total > MAX_CANDIDATE_JSON_BYTES) {
726
- buffered.length = 0;
727
- buffering = false;
728
- } else {
729
- // Copy: tar body chunks are views into the container's stream
730
- // buffers and are not stable after iteration continues.
731
- buffered.push(chunk.slice());
732
- }
828
+ if (headLength < head.length) {
829
+ const take = Math.min(head.length - headLength, chunk.length);
830
+ head.set(chunk.subarray(0, take), headLength);
831
+ headLength += take;
832
+ }
833
+ if (mode === "off") continue;
834
+ if (mode === "sniff" && headLength === head.length) {
835
+ mode = exportOpening(head) === undefined ? stop() : declaredSize !== undefined && declaredSize > exportCap ? tooLarge() : "export";
836
+ if (mode === "off") continue;
733
837
  }
838
+ // Copy: tar body chunks are views into the container's stream
839
+ // buffers and are not stable after iteration continues.
840
+ buffered.push(chunk.slice());
841
+ if (mode === "json" && total > MAX_CANDIDATE_JSON_BYTES) {
842
+ mode = exportOpening(head.subarray(0, headLength)) === undefined ? stop() : "export";
843
+ }
844
+ if (mode === "export" && total > exportCap) mode = tooLarge();
845
+ }
846
+ if (mode === "sniff") {
847
+ // Shorter than the sniff window although declared past the ordinary cap.
848
+ mode = exportOpening(head.subarray(0, headLength)) === undefined || total > exportCap ? stop() : "export";
734
849
  }
735
850
 
851
+ const keep = mode === "json" || mode === "export";
736
852
  return {
737
853
  sha256Hex: toHex(contentHasher.digest()),
738
854
  byteLength: total,
739
855
  entryDigestFull: fullHasher.digest(),
740
856
  ...(strippedHasher !== undefined ? { entryDigestStripped: strippedHasher.digest() } : {}),
741
- ...(buffering ? { buffered: concatBytes(buffered, total) } : {}),
857
+ ...(keep ? { buffered: concatBytes(buffered, total) } : {}),
858
+ ...(exportTooLarge !== undefined ? { exportTooLarge } : {}),
742
859
  };
743
860
  }
744
861
 
862
+ /** The export format an entry's opening bytes declare ({"format": "bitgraph-export/..."), or undefined. */
863
+ function exportOpening(head: Uint8Array): string | undefined {
864
+ const start = head.length >= 3 && head[0] === 0xef && head[1] === 0xbb && head[2] === 0xbf ? 3 : 0;
865
+ const match = EXPORT_OPENING.exec(Buffer.from(head.subarray(start)).toString("latin1"));
866
+ return match?.[1];
867
+ }
868
+
745
869
  function makeScannedEntry(rawPath: string, hashed: HashedEntry): ScannedEntry {
746
870
  const json = hashed.buffered === undefined ? undefined : tryParseJsonObject(hashed.buffered);
747
871
  return {
@@ -753,6 +877,7 @@ function makeScannedEntry(rawPath: string, hashed: HashedEntry): ScannedEntry {
753
877
  ? { entryDigestStripped: hashed.entryDigestStripped }
754
878
  : {}),
755
879
  ...(json !== undefined ? { json } : {}),
880
+ ...(hashed.exportTooLarge !== undefined ? { exportTooLarge: hashed.exportTooLarge } : {}),
756
881
  };
757
882
  }
758
883
 
@@ -873,6 +998,55 @@ function isProofShaped(value: unknown): boolean {
873
998
  );
874
999
  }
875
1000
 
1001
+ // ---------------------------------------------------------------------------
1002
+ // Export classification (bitgraph-export/1)
1003
+ // ---------------------------------------------------------------------------
1004
+
1005
+ /** The format string of an export-shaped value ("bitgraph-export/..."), or undefined. */
1006
+ function exportFormatOf(value: unknown): string | undefined {
1007
+ if (!isPlainObject(value)) return undefined;
1008
+ const format = value["format"];
1009
+ return typeof format === "string" && format.startsWith(EXPORT_FORMAT_PREFIX) ? format : undefined;
1010
+ }
1011
+
1012
+ /**
1013
+ * Classify one export-shaped file. Structure is decided by bitgraph-verify's
1014
+ * parseExport, so the audit and the verifier agree on what an export is. A
1015
+ * file that declares an export format but is not one is recorded with a
1016
+ * finding and is never an artifact.
1017
+ */
1018
+ function classifyExport(
1019
+ entry: FinalEntry,
1020
+ parsed: Record<string, unknown>,
1021
+ format: string,
1022
+ findings: AuditFinding[]
1023
+ ): ExportFile {
1024
+ const base = { path: entry.path, fileSha256Hex: entry.sha256Hex, format, json: parsed };
1025
+ if (format !== EXPORT_FORMAT) {
1026
+ findings.push({
1027
+ code: "export-unsupported-format",
1028
+ path: entry.path,
1029
+ message: `export-shaped file not checked: format "${format}" is not "${EXPORT_FORMAT}"`,
1030
+ details: { format },
1031
+ });
1032
+ return { ...base, status: "unsupported-format" };
1033
+ }
1034
+ if (parseExport(parsed) === null) {
1035
+ const tree = parsed["tree"];
1036
+ const missing = !isPlainObject(parsed["proof"])
1037
+ ? "a proof object"
1038
+ : "tree.rootDocument (a string)";
1039
+ findings.push({
1040
+ code: "export-malformed",
1041
+ path: entry.path,
1042
+ message: `declares ${EXPORT_FORMAT} but lacks ${missing}; not checked`,
1043
+ details: { hasProof: isPlainObject(parsed["proof"]), hasTree: isPlainObject(tree) },
1044
+ });
1045
+ return { ...base, status: "malformed" };
1046
+ }
1047
+ return { ...base, status: "ok" };
1048
+ }
1049
+
876
1050
  type DuplicateKind = "new" | "exact-duplicate" | "semantic-duplicate";
877
1051
 
878
1052
  function recordMemberProof(
@@ -112,6 +112,7 @@ export function buildJsonReport(result: AuditResult): AuditJsonReport {
112
112
  ...(result.ceilings && (result.ceilings.checks.length > 0 || result.ceilings.statuses.length > 0)
113
113
  ? { ceilings: result.ceilings }
114
114
  : {}),
115
+ ...(result.exports && result.exports.checks.length > 0 ? { exports: result.exports } : {}),
115
116
  attestations: {
116
117
  records: result.attestations.records,
117
118
  counts: result.attestations.counts,
@@ -291,6 +292,8 @@ function buildSummary(
291
292
 
292
293
  const partitionsIntact = partitions.filter((p) => p.intact).length;
293
294
  const exit = computeExitFlags(result);
295
+ const exportChecks = result.exports?.checks ?? [];
296
+ const checkedExports = exportChecks.filter((e) => e.status === "checked");
294
297
 
295
298
  return {
296
299
  proofsObserved: result.ingest.counts.observed,
@@ -331,6 +334,18 @@ function buildSummary(
331
334
  segmentsUpperBounded: segmentCount("upper-bounded"),
332
335
  segmentsUnanchored: segmentCount("ordered-but-unanchored"),
333
336
  },
337
+ ...(exportChecks.length > 0
338
+ ? {
339
+ exports: {
340
+ files: exportChecks.length,
341
+ verdictTrue: checkedExports.filter((e) => e.verdict === "TRUE").length,
342
+ verdictFalse: checkedExports.filter((e) => e.verdict === "FALSE").length,
343
+ verdictUndetermined: checkedExports.filter((e) => e.verdict === "UNDETERMINED").length,
344
+ rejected: exportChecks.length - checkedExports.length,
345
+ coveredFilesChecked: checkedExports.reduce((n, e) => n + e.runs.filter((r) => r.file !== null).length, 0),
346
+ },
347
+ }
348
+ : {}),
334
349
  exit,
335
350
  };
336
351
  }