@gmickel/gno 1.33.0 → 1.34.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,678 @@
1
+ /** Real read-only workspace snapshot adapter shared by CLI and MCP. */
2
+
3
+ // node:path has no Bun equivalent.
4
+ import { join } from "node:path";
5
+
6
+ import type { Collection, Config } from "../config/types";
7
+ import type { SqliteAdapter } from "../store/sqlite/adapter";
8
+ import type { DocumentRow } from "../store/types";
9
+ import type {
10
+ AuditCategory,
11
+ AuditFingerprints,
12
+ AuditRunResult,
13
+ AuditScope,
14
+ } from "./audit";
15
+ import type {
16
+ AuditFreshnessDocument,
17
+ AuditFreshnessOptions,
18
+ } from "./audit-freshness";
19
+ import type { AuditProvenanceDocument } from "./audit-provenance";
20
+
21
+ import {
22
+ captureAuditLinkSnapshot,
23
+ type AuditLinkSnapshot,
24
+ } from "../store/sqlite/graph-link-resolver";
25
+ import {
26
+ AUDIT_RULE_SET_VERSION,
27
+ canonicalAuditJson,
28
+ hashAuditCanonical,
29
+ runAudit,
30
+ } from "./audit";
31
+ import { evaluateFreshnessAudit } from "./audit-freshness";
32
+ import { evaluateLinkAudit } from "./audit-links";
33
+ import { evaluateProvenanceAudit } from "./audit-provenance";
34
+ import {
35
+ extractCaptureSourceFromFrontmatter,
36
+ hasDeclaredCaptureSource,
37
+ } from "./capture";
38
+ import { MARKDOWN_SOURCE_EXTENSIONS } from "./document-capabilities";
39
+ import { normalizeTag } from "./tags";
40
+ import { normalizeCollectionName } from "./validation";
41
+
42
+ export const AUDIT_WORKSPACE_MAX_DOCUMENTS = 10_000;
43
+ const AUDIT_PATH_FILTER_MAX_CHARS = 2048;
44
+ const AUDIT_SOURCE_CONCURRENCY = 16;
45
+ const AUDIT_FRONTMATTER_BYTES = 64 * 1024;
46
+ const FRONTMATTER_OPEN = /^---\r?\n/;
47
+ const FRONTMATTER_COMPLETE = /^---\r?\n[\s\S]*?(?:\r?\n)?---(?:\r?\n|$)/;
48
+
49
+ export interface WorkspaceAuditOptions {
50
+ store: SqliteAdapter;
51
+ config: Config;
52
+ collections: readonly Collection[];
53
+ indexName: string;
54
+ categories: readonly AuditCategory[];
55
+ collectionFilters?: readonly string[];
56
+ pathFilters?: readonly string[];
57
+ tagFilters?: readonly string[];
58
+ maxFindings?: number;
59
+ agePolicy?: AuditFreshnessOptions["agePolicy"];
60
+ orphanRoots?: readonly string[];
61
+ orphanIgnorePrefixes?: readonly string[];
62
+ signal?: AbortSignal;
63
+ now?: Date;
64
+ onProgress?: (progress: WorkspaceAuditProgress) => void | Promise<void>;
65
+ }
66
+
67
+ export interface WorkspaceAuditProgress {
68
+ phase: "snapshot" | "rules" | "complete";
69
+ completed: number;
70
+ total: number;
71
+ }
72
+
73
+ interface WorkspaceDocumentSnapshot {
74
+ document: DocumentRow;
75
+ provenance: AuditProvenanceDocument;
76
+ freshness: AuditFreshnessDocument;
77
+ }
78
+
79
+ interface WorkspaceSnapshot {
80
+ documents: WorkspaceDocumentSnapshot[];
81
+ links: AuditLinkSnapshot;
82
+ truncated: boolean;
83
+ }
84
+
85
+ const normalizeValues = (values: readonly string[] | undefined): string[] =>
86
+ [...new Set((values ?? []).map((value) => value.normalize("NFC").trim()))]
87
+ .filter(Boolean)
88
+ .sort();
89
+
90
+ const collectionPathMap = (
91
+ collections: readonly Collection[]
92
+ ): Map<string, string> =>
93
+ new Map(collections.map((collection) => [collection.name, collection.path]));
94
+
95
+ const selectDocuments = async (
96
+ store: SqliteAdapter,
97
+ options: {
98
+ collections: readonly string[];
99
+ paths: readonly string[];
100
+ tags: readonly string[];
101
+ }
102
+ ): Promise<{ documents: DocumentRow[]; total: number; truncated: boolean }> => {
103
+ const listed = await store.listDocumentsForAudit({
104
+ collections: options.collections,
105
+ pathPrefixes: options.paths,
106
+ tags: options.tags,
107
+ limit: AUDIT_WORKSPACE_MAX_DOCUMENTS,
108
+ });
109
+ if (!listed.ok) throw new Error(listed.error.message);
110
+ return {
111
+ documents: listed.value.documents,
112
+ total: listed.value.total,
113
+ truncated: listed.value.total > AUDIT_WORKSPACE_MAX_DOCUMENTS,
114
+ };
115
+ };
116
+
117
+ const hashBlob = async (file: Blob, signal?: AbortSignal): Promise<string> => {
118
+ const hasher = new Bun.CryptoHasher("sha256");
119
+ signal?.throwIfAborted();
120
+ for await (const chunk of file.stream()) {
121
+ signal?.throwIfAborted();
122
+ hasher.update(chunk);
123
+ }
124
+ return hasher.digest("hex");
125
+ };
126
+
127
+ const mapConcurrent = async <T, R>(
128
+ values: readonly T[],
129
+ mapper: (value: T) => Promise<R>
130
+ ): Promise<R[]> => {
131
+ const output = Array.from({ length: values.length }, () => undefined as R);
132
+ let cursor = 0;
133
+ const worker = async (): Promise<void> => {
134
+ while (cursor < values.length) {
135
+ const index = cursor;
136
+ cursor += 1;
137
+ const value = values[index];
138
+ if (value !== undefined) output[index] = await mapper(value);
139
+ }
140
+ };
141
+ await Promise.all(
142
+ Array.from(
143
+ { length: Math.min(AUDIT_SOURCE_CONCURRENCY, values.length) },
144
+ worker
145
+ )
146
+ );
147
+ return output;
148
+ };
149
+
150
+ export interface AuditPhysicalSourceDescriptor {
151
+ key: string;
152
+ collection: string;
153
+ relPath: string;
154
+ markdownSource: boolean;
155
+ }
156
+
157
+ /** Collapse logical records that share one physical export/container. */
158
+ export const groupAuditPhysicalSources = (
159
+ documents: readonly DocumentRow[]
160
+ ): AuditPhysicalSourceDescriptor[] => {
161
+ const grouped = new Map<string, AuditPhysicalSourceDescriptor>();
162
+ for (const document of documents) {
163
+ const recordSourcePath = document.recordSourcePath?.trim();
164
+ const relPath =
165
+ document.recordKey != null && recordSourcePath
166
+ ? recordSourcePath
167
+ : document.relPath;
168
+ const key = JSON.stringify([document.collection, relPath]);
169
+ const markdownSource = MARKDOWN_SOURCE_EXTENSIONS.has(
170
+ document.sourceExt.toLowerCase()
171
+ );
172
+ const existing = grouped.get(key);
173
+ if (existing) {
174
+ existing.markdownSource ||= markdownSource;
175
+ } else {
176
+ grouped.set(key, {
177
+ key,
178
+ collection: document.collection,
179
+ relPath,
180
+ markdownSource,
181
+ });
182
+ }
183
+ }
184
+ return [...grouped.values()].sort((left, right) =>
185
+ left.key < right.key ? -1 : left.key > right.key ? 1 : 0
186
+ );
187
+ };
188
+
189
+ const observeDocument = async (
190
+ document: DocumentRow,
191
+ roots: ReadonlyMap<string, string>,
192
+ readFrontmatter: boolean,
193
+ inspectFreshness: boolean,
194
+ signal?: AbortSignal
195
+ ): Promise<WorkspaceDocumentSnapshot> => {
196
+ const markdownSource = MARKDOWN_SOURCE_EXTENSIONS.has(
197
+ document.sourceExt.toLowerCase()
198
+ );
199
+ const root = roots.get(document.collection);
200
+ if (!root) {
201
+ return {
202
+ document,
203
+ provenance: {
204
+ uri: document.uri,
205
+ relPath: document.relPath,
206
+ sourceState: "unreadable",
207
+ captureSourceSupported: markdownSource,
208
+ captureSourceDeclared: false,
209
+ record: document,
210
+ },
211
+ freshness: {
212
+ uri: document.uri,
213
+ relPath: document.relPath,
214
+ contentType: document.contentType ?? null,
215
+ indexedSourceHash: document.sourceHash,
216
+ indexedSourceMtime: document.sourceMtime,
217
+ indexedAt: document.indexedAt ?? null,
218
+ lastErrorCode: document.lastErrorCode,
219
+ source: { state: "unreadable", hash: null, mtime: null },
220
+ },
221
+ };
222
+ }
223
+ const recordSourcePath = document.recordSourcePath?.trim();
224
+ const logicalRecord =
225
+ document.recordKey != null &&
226
+ recordSourcePath !== undefined &&
227
+ recordSourcePath.length > 0;
228
+ const file = Bun.file(
229
+ join(root, logicalRecord ? recordSourcePath : document.relPath)
230
+ );
231
+ try {
232
+ if (!(await file.exists())) {
233
+ return {
234
+ document,
235
+ provenance: {
236
+ uri: document.uri,
237
+ relPath: document.relPath,
238
+ sourceState: "missing",
239
+ captureSourceSupported: markdownSource,
240
+ captureSourceDeclared: false,
241
+ record: document,
242
+ },
243
+ freshness: {
244
+ uri: document.uri,
245
+ relPath: document.relPath,
246
+ contentType: document.contentType ?? null,
247
+ indexedSourceHash: document.sourceHash,
248
+ indexedSourceMtime: document.sourceMtime,
249
+ indexedAt: document.indexedAt ?? null,
250
+ lastErrorCode: document.lastErrorCode,
251
+ source: { state: "missing", hash: null, mtime: null },
252
+ },
253
+ };
254
+ }
255
+ const beforeMtime = file.lastModified;
256
+ const beforeSize = file.size;
257
+ // Freshness must hash readable bytes even when size/mtime still match the
258
+ // indexed metadata — metadata-preserving restores can drift without a
259
+ // stat change.
260
+ const observedHash =
261
+ inspectFreshness && !logicalRecord
262
+ ? await hashBlob(file, signal)
263
+ : inspectFreshness
264
+ ? null
265
+ : document.sourceHash;
266
+ const frontmatter =
267
+ readFrontmatter && markdownSource
268
+ ? await file.slice(0, AUDIT_FRONTMATTER_BYTES).text()
269
+ : "";
270
+ const incompleteFrontmatter =
271
+ readFrontmatter &&
272
+ markdownSource &&
273
+ FRONTMATTER_OPEN.test(frontmatter) &&
274
+ !FRONTMATTER_COMPLETE.test(frontmatter);
275
+ const afterMtime = file.lastModified;
276
+ const afterSize = file.size;
277
+ return {
278
+ document,
279
+ provenance: {
280
+ uri: document.uri,
281
+ relPath: document.relPath,
282
+ sourceState: incompleteFrontmatter ? "unreadable" : "readable",
283
+ captureSourceSupported: markdownSource,
284
+ captureSourceDeclared:
285
+ !incompleteFrontmatter && hasDeclaredCaptureSource(frontmatter),
286
+ captureSource: incompleteFrontmatter
287
+ ? undefined
288
+ : extractCaptureSourceFromFrontmatter(frontmatter),
289
+ record: document,
290
+ },
291
+ freshness: {
292
+ uri: document.uri,
293
+ relPath: document.relPath,
294
+ contentType: document.contentType ?? null,
295
+ indexedSourceHash: document.sourceHash,
296
+ indexedSourceMtime: document.sourceMtime,
297
+ indexedAt: document.indexedAt ?? null,
298
+ lastErrorCode: document.lastErrorCode,
299
+ source: {
300
+ state: "readable",
301
+ hash: observedHash,
302
+ mtime: new Date(afterMtime).toISOString(),
303
+ byteComparable: !logicalRecord,
304
+ changedDuringRead:
305
+ beforeMtime !== afterMtime || beforeSize !== afterSize,
306
+ },
307
+ },
308
+ };
309
+ } catch (cause) {
310
+ if (signal?.aborted) throw cause;
311
+ return {
312
+ document,
313
+ provenance: {
314
+ uri: document.uri,
315
+ relPath: document.relPath,
316
+ sourceState: "unreadable",
317
+ captureSourceSupported: markdownSource,
318
+ captureSourceDeclared: false,
319
+ record: document,
320
+ },
321
+ freshness: {
322
+ uri: document.uri,
323
+ relPath: document.relPath,
324
+ contentType: document.contentType ?? null,
325
+ indexedSourceHash: document.sourceHash,
326
+ indexedSourceMtime: document.sourceMtime,
327
+ indexedAt: document.indexedAt ?? null,
328
+ lastErrorCode: document.lastErrorCode,
329
+ source: { state: "unreadable", hash: null, mtime: null },
330
+ },
331
+ };
332
+ }
333
+ };
334
+
335
+ const filterLinkSnapshot = (
336
+ snapshot: AuditLinkSnapshot,
337
+ selectedIds: ReadonlySet<number>,
338
+ selectedDocuments: readonly DocumentRow[],
339
+ selectionTruncated: boolean
340
+ ): AuditLinkSnapshot => {
341
+ const links = snapshot.links.filter(
342
+ (link) =>
343
+ selectedIds.has(link.sourceId) ||
344
+ (link.resolved !== null && selectedIds.has(link.resolved.targetId))
345
+ );
346
+ const outgoingTotal = snapshot.links.filter((link) =>
347
+ selectedIds.has(link.sourceId)
348
+ ).length;
349
+ return {
350
+ ...snapshot,
351
+ // Preserve graph-wide documents as duplicate-mirror evidence while the
352
+ // explicit id set prevents findings outside the requested audit scope.
353
+ auditedDocumentIds: [...selectedIds],
354
+ links,
355
+ totals: { documents: selectedDocuments.length, links: outgoingTotal },
356
+ truncated: {
357
+ documents: selectionTruncated || snapshot.truncated.documents,
358
+ links: snapshot.truncated.links,
359
+ },
360
+ };
361
+ };
362
+
363
+ const emptyLinkSnapshot = (): AuditLinkSnapshot => ({
364
+ documents: [],
365
+ links: [],
366
+ totals: { documents: 0, links: 0 },
367
+ truncated: { documents: false, links: false },
368
+ metrics: {
369
+ documentRowsExamined: 0,
370
+ linkRowsExamined: 0,
371
+ uniqueTargetsResolved: 0,
372
+ batchedResolution: true,
373
+ },
374
+ });
375
+
376
+ const loadWorkspaceSnapshot = async (
377
+ options: WorkspaceAuditOptions,
378
+ filters: { collections: string[]; paths: string[]; tags: string[] }
379
+ ): Promise<WorkspaceSnapshot> => {
380
+ const selected = await selectDocuments(options.store, filters);
381
+ const roots = collectionPathMap(options.collections);
382
+ const needsSourceObservation =
383
+ options.categories.includes("provenance") ||
384
+ options.categories.includes("freshness");
385
+ const observed = needsSourceObservation
386
+ ? await mapConcurrent(selected.documents, (document) =>
387
+ observeDocument(
388
+ document,
389
+ roots,
390
+ options.categories.includes("provenance"),
391
+ options.categories.includes("freshness"),
392
+ options.signal
393
+ )
394
+ )
395
+ : [];
396
+ await options.onProgress?.({
397
+ phase: "snapshot",
398
+ completed: selected.documents.length,
399
+ total: selected.total,
400
+ });
401
+ // Capture the bounded graph before narrowing link scope so incoming edges
402
+ // from outside a filter still prevent false orphans. Source-only audits do
403
+ // not touch the unrelated graph.
404
+ const rawLinks =
405
+ options.categories.includes("links") && selected.documents.length > 0
406
+ ? captureAuditLinkSnapshot(options.store.getRawDb())
407
+ : emptyLinkSnapshot();
408
+ const selectedIds = new Set(selected.documents.map(({ id }) => id));
409
+ return {
410
+ documents: observed,
411
+ links: filterLinkSnapshot(
412
+ rawLinks,
413
+ selectedIds,
414
+ selected.documents,
415
+ selected.truncated
416
+ ),
417
+ truncated: selected.truncated,
418
+ };
419
+ };
420
+
421
+ const captureWorkspaceFingerprints = async (
422
+ options: WorkspaceAuditOptions,
423
+ filters: { collections: string[]; paths: string[]; tags: string[] }
424
+ ): Promise<AuditFingerprints> => {
425
+ options.signal?.throwIfAborted();
426
+ const selected = await selectDocuments(options.store, filters);
427
+ const needsSourceFingerprint =
428
+ options.categories.includes("provenance") ||
429
+ options.categories.includes("freshness");
430
+ const inspectFreshness = options.categories.includes("freshness");
431
+ const inspectProvenance = options.categories.includes("provenance");
432
+ const roots = collectionPathMap(options.collections);
433
+ const sourceStats = needsSourceFingerprint
434
+ ? await mapConcurrent(
435
+ groupAuditPhysicalSources(selected.documents),
436
+ async (source) => {
437
+ const root = roots.get(source.collection);
438
+ if (!root)
439
+ return {
440
+ collection: source.collection,
441
+ path: source.relPath,
442
+ state: "unavailable",
443
+ };
444
+ const file = Bun.file(join(root, source.relPath));
445
+ try {
446
+ const exists = await file.exists();
447
+ if (!exists)
448
+ return {
449
+ collection: source.collection,
450
+ path: source.relPath,
451
+ state: "missing",
452
+ };
453
+ const hash = inspectFreshness
454
+ ? await hashBlob(file, options.signal)
455
+ : inspectProvenance && source.markdownSource
456
+ ? await hashBlob(
457
+ file.slice(0, AUDIT_FRONTMATTER_BYTES),
458
+ options.signal
459
+ )
460
+ : null;
461
+ return {
462
+ collection: source.collection,
463
+ path: source.relPath,
464
+ state: "readable",
465
+ size: file.size,
466
+ mtime: file.lastModified,
467
+ hash,
468
+ };
469
+ } catch (cause) {
470
+ if (options.signal?.aborted) throw cause;
471
+ return {
472
+ collection: source.collection,
473
+ path: source.relPath,
474
+ state: "unavailable",
475
+ };
476
+ }
477
+ }
478
+ )
479
+ : [];
480
+ // Link audits fingerprint the same unscoped bounded graph capture the rules
481
+ // use, so concurrent doc_links / resolution changes retry or report
482
+ // changed_during_audit even when document revision fields are unchanged.
483
+ const linkGraph =
484
+ options.categories.includes("links") && selected.documents.length > 0
485
+ ? captureAuditLinkSnapshot(options.store.getRawDb())
486
+ : null;
487
+ return {
488
+ config: hashAuditCanonical({
489
+ collections: options.collections.map(({ name, path, pattern }) => ({
490
+ name,
491
+ path,
492
+ pattern,
493
+ })),
494
+ filters,
495
+ }),
496
+ source: hashAuditCanonical({ sourceStats, total: selected.total }),
497
+ index: hashAuditCanonical({
498
+ documents: selected.documents.map((document) => ({
499
+ uri: document.uri,
500
+ relPath: document.relPath,
501
+ sourceExt: document.sourceExt,
502
+ sourceHash: document.sourceHash,
503
+ sourceMtime: document.sourceMtime,
504
+ contentType: document.contentType ?? null,
505
+ indexedAt: document.indexedAt ?? null,
506
+ lastErrorCode: document.lastErrorCode,
507
+ converterId: document.converterId,
508
+ converterVersion: document.converterVersion,
509
+ recordKey: document.recordKey ?? null,
510
+ recordSourcePath: document.recordSourcePath ?? null,
511
+ recordSourceLocator: document.recordSourceLocator ?? null,
512
+ recordAdapterFingerprint: document.recordAdapterFingerprint ?? null,
513
+ recordMetadata: document.recordMetadata ?? null,
514
+ recordAnchors: document.recordAnchors ?? null,
515
+ })),
516
+ total: selected.total,
517
+ linkGraph,
518
+ }),
519
+ rules: hashAuditCanonical({
520
+ ruleSet: AUDIT_RULE_SET_VERSION,
521
+ categories: options.categories,
522
+ agePolicy: options.agePolicy ?? null,
523
+ orphanRoots: normalizeValues(options.orphanRoots),
524
+ orphanIgnorePrefixes: normalizeValues(options.orphanIgnorePrefixes),
525
+ }),
526
+ };
527
+ };
528
+
529
+ export const runWorkspaceAudit = async (
530
+ options: WorkspaceAuditOptions
531
+ ): Promise<AuditRunResult> => {
532
+ const rawPaths = options.pathFilters ?? [];
533
+ const normalizedPathInputs = rawPaths.map((path) =>
534
+ path.normalize("NFC").trim()
535
+ );
536
+ if (normalizedPathInputs.some((path) => path.length === 0)) {
537
+ return {
538
+ ok: false,
539
+ exit: "invalid",
540
+ error: "path filters must not be empty or whitespace-only",
541
+ };
542
+ }
543
+ if (
544
+ normalizedPathInputs.some(
545
+ (path) => path.length > AUDIT_PATH_FILTER_MAX_CHARS
546
+ )
547
+ ) {
548
+ return {
549
+ ok: false,
550
+ exit: "invalid",
551
+ error: `path filters must be at most ${AUDIT_PATH_FILTER_MAX_CHARS} characters`,
552
+ };
553
+ }
554
+ const normalizedPaths = normalizeValues(normalizedPathInputs).map((path) =>
555
+ path.replace(/^\/+|\/+$/g, "")
556
+ );
557
+ const filters = {
558
+ collections: normalizeValues(options.collectionFilters).map(
559
+ normalizeCollectionName
560
+ ),
561
+ // A root-like prefix means the whole selected collection. Remove it from
562
+ // both selection and reported scope instead of producing an empty match.
563
+ paths: normalizedPaths.includes("") ? [] : normalizedPaths,
564
+ tags: normalizeValues(options.tagFilters).map(normalizeTag),
565
+ };
566
+ const scope: AuditScope = {
567
+ categories: [...options.categories],
568
+ collections: filters.collections,
569
+ paths: filters.paths,
570
+ tags: filters.tags,
571
+ indexName: options.indexName,
572
+ };
573
+ await options.onProgress?.({ phase: "snapshot", completed: 0, total: 1 });
574
+ const snapshots = new Map<number, Promise<WorkspaceSnapshot>>();
575
+ const snapshotFor = (attempt: number): Promise<WorkspaceSnapshot> => {
576
+ const existing = snapshots.get(attempt);
577
+ if (existing) return existing;
578
+ const pending = loadWorkspaceSnapshot(options, filters);
579
+ snapshots.set(attempt, pending);
580
+ return pending;
581
+ };
582
+ const result = await runAudit({
583
+ scope,
584
+ capabilities: {
585
+ indexReadable: true,
586
+ sourcesReadable: true,
587
+ linksGraphAvailable: true,
588
+ provenanceSchemaAvailable: true,
589
+ offline: true,
590
+ llmDisabled: true,
591
+ },
592
+ captureFingerprints: () => captureWorkspaceFingerprints(options, filters),
593
+ signal: options.signal,
594
+ maxFindings: options.maxFindings,
595
+ rules: [
596
+ async ({ attempt }) => {
597
+ if (options.signal?.aborted) {
598
+ return {
599
+ ruleId: "audit.cancelled",
600
+ category: options.categories[0] ?? "links",
601
+ status: "inconclusive",
602
+ message: "Audit was cancelled",
603
+ findings: [],
604
+ findingCount: 0,
605
+ skipReason: "cancelled",
606
+ };
607
+ }
608
+ const snapshot = await snapshotFor(attempt);
609
+ if (options.signal?.aborted) {
610
+ return {
611
+ ruleId: "audit.cancelled",
612
+ category: options.categories[0] ?? "links",
613
+ status: "inconclusive",
614
+ message: "Audit was cancelled",
615
+ findings: [],
616
+ findingCount: 0,
617
+ skipReason: "cancelled",
618
+ };
619
+ }
620
+ const contributions = [];
621
+ await options.onProgress?.({
622
+ phase: "rules",
623
+ completed: 0,
624
+ total: options.categories.length,
625
+ });
626
+ if (options.categories.includes("links")) {
627
+ contributions.push(
628
+ ...evaluateLinkAudit(snapshot.links, {
629
+ rootUris: options.orphanRoots ?? [],
630
+ ignorePathPrefixes: options.orphanIgnorePrefixes ?? [],
631
+ })
632
+ );
633
+ await options.onProgress?.({
634
+ phase: "rules",
635
+ completed: 1,
636
+ total: options.categories.length,
637
+ });
638
+ }
639
+ if (options.categories.includes("provenance")) {
640
+ contributions.push(
641
+ ...evaluateProvenanceAudit(
642
+ snapshot.documents.map(({ provenance }) => provenance),
643
+ { truncated: snapshot.truncated }
644
+ )
645
+ );
646
+ await options.onProgress?.({
647
+ phase: "rules",
648
+ completed: Number(options.categories.includes("links")) + 1,
649
+ total: options.categories.length,
650
+ });
651
+ }
652
+ if (options.categories.includes("freshness")) {
653
+ contributions.push(
654
+ ...evaluateFreshnessAudit(
655
+ snapshot.documents.map(({ freshness }) => freshness),
656
+ {
657
+ now: options.now ?? new Date(),
658
+ agePolicy: options.agePolicy,
659
+ truncated: snapshot.truncated,
660
+ }
661
+ )
662
+ );
663
+ await options.onProgress?.({
664
+ phase: "rules",
665
+ completed: options.categories.length,
666
+ total: options.categories.length,
667
+ });
668
+ }
669
+ return contributions;
670
+ },
671
+ ],
672
+ });
673
+ await options.onProgress?.({ phase: "complete", completed: 1, total: 1 });
674
+ return result;
675
+ };
676
+
677
+ export const auditReportBytes = (result: AuditRunResult): number =>
678
+ new TextEncoder().encode(canonicalAuditJson(result)).byteLength;