@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,222 @@
1
+ /** Deterministic read-only link-integrity audit rules. */
2
+
3
+ import type {
4
+ AuditLinkSnapshot,
5
+ AuditLinkSnapshotDocument,
6
+ } from "../store/sqlite/graph-link-resolver";
7
+ import type { AuditFindingDraft, AuditRuleContribution } from "./audit";
8
+
9
+ import { compareAuditCodeUnits, compareAuditFindingDrafts } from "./audit";
10
+
11
+ export const LINK_AUDIT_RULE_VERSION = "1.0" as const;
12
+ export const LINK_AUDIT_MAX_FINDINGS_PER_RULE = 1000;
13
+
14
+ export interface AuditOrphanPolicy {
15
+ rootUris: readonly string[];
16
+ ignorePathPrefixes: readonly string[];
17
+ /** Mirrored duplicate rows are excluded from orphan claims by default. */
18
+ ignoreMirrorDuplicates?: boolean;
19
+ }
20
+
21
+ const boundedFindings = (
22
+ findings: readonly AuditFindingDraft[]
23
+ ): AuditFindingDraft[] =>
24
+ [...findings]
25
+ .sort(compareAuditFindingDrafts)
26
+ .slice(0, LINK_AUDIT_MAX_FINDINGS_PER_RULE);
27
+
28
+ const lineLocation = (line: number, column: number): string =>
29
+ `L${line}:C${column}`;
30
+
31
+ const linkFinding = (
32
+ link: AuditLinkSnapshot["links"][number]
33
+ ): AuditFindingDraft => {
34
+ const ambiguous = (link.resolved?.matchCount ?? 0) > 1;
35
+ const target = `${link.targetCollection}:${link.targetRef}`;
36
+ return {
37
+ subject: link.sourceUri,
38
+ location: lineLocation(link.startLine, link.startCol),
39
+ severity: "warning",
40
+ message: ambiguous
41
+ ? `Link target is ambiguous: ${target}`
42
+ : `${link.linkType === "markdown" ? "Broken" : "Unresolved"} local link: ${target}`,
43
+ evidence: [
44
+ {
45
+ kind: ambiguous ? "ambiguous-target" : "unresolved-target",
46
+ summary: target,
47
+ uri: link.sourceUri,
48
+ path: link.sourceRelPath,
49
+ detail: JSON.stringify({
50
+ anchor: link.targetAnchor,
51
+ endColumn: link.endCol,
52
+ endLine: link.endLine,
53
+ linkType: link.linkType,
54
+ matchCount: link.resolved?.matchCount ?? 0,
55
+ matchRank: link.resolved?.matchRank ?? null,
56
+ normalizedTarget: link.targetRefNorm,
57
+ }),
58
+ },
59
+ ],
60
+ guidance: ambiguous
61
+ ? ["Use an explicit collection-relative target path"]
62
+ : ["Create the target or correct the local link"],
63
+ };
64
+ };
65
+
66
+ const normalizedPrefixes = (values: readonly string[]): string[] =>
67
+ [...new Set(values.map((value) => value.normalize("NFC").trim()))]
68
+ .filter(Boolean)
69
+ .sort(compareAuditCodeUnits);
70
+
71
+ const isIgnoredDocument = (
72
+ document: AuditLinkSnapshotDocument,
73
+ roots: Set<string>,
74
+ ignorePrefixes: readonly string[],
75
+ mirroredIds: Set<number>
76
+ ): boolean =>
77
+ roots.has(document.uri) ||
78
+ mirroredIds.has(document.id) ||
79
+ ignorePrefixes.some((prefix) => {
80
+ const visiblePath = document.recordSourcePath ?? document.relPath;
81
+ return visiblePath === prefix || visiblePath.startsWith(`${prefix}/`);
82
+ });
83
+
84
+ const duplicateMirrorIds = (
85
+ documents: readonly AuditLinkSnapshotDocument[],
86
+ ignoreMirrorDuplicates: boolean
87
+ ): Set<number> => {
88
+ if (!ignoreMirrorDuplicates) return new Set<number>();
89
+ const byMirror = new Map<string, number[]>();
90
+ for (const document of documents) {
91
+ if (!document.mirrorHash) continue;
92
+ const ids = byMirror.get(document.mirrorHash) ?? [];
93
+ ids.push(document.id);
94
+ byMirror.set(document.mirrorHash, ids);
95
+ }
96
+ const duplicates = new Set<number>();
97
+ for (const ids of byMirror.values()) {
98
+ if (ids.length < 2) continue;
99
+ for (const id of ids) duplicates.add(id);
100
+ }
101
+ return duplicates;
102
+ };
103
+
104
+ /** Evaluate one already captured set-oriented snapshot; never touches storage. */
105
+ export const evaluateLinkAudit = (
106
+ snapshot: AuditLinkSnapshot,
107
+ policy: AuditOrphanPolicy
108
+ ): AuditRuleContribution[] => {
109
+ const unresolved: AuditFindingDraft[] = [];
110
+ const ambiguous: AuditFindingDraft[] = [];
111
+ const connected = new Set<number>();
112
+ const auditedDocumentIds = new Set(
113
+ snapshot.auditedDocumentIds ?? snapshot.documents.map(({ id }) => id)
114
+ );
115
+ for (const link of snapshot.links) {
116
+ const sourceAudited = auditedDocumentIds.has(link.sourceId);
117
+ if (!link.resolved) {
118
+ if (sourceAudited) unresolved.push(linkFinding(link));
119
+ continue;
120
+ }
121
+ if (sourceAudited) connected.add(link.sourceId);
122
+ if (auditedDocumentIds.has(link.resolved.targetId)) {
123
+ connected.add(link.resolved.targetId);
124
+ }
125
+ if (sourceAudited && link.resolved.matchCount > 1) {
126
+ ambiguous.push(linkFinding(link));
127
+ }
128
+ }
129
+ const roots = new Set(normalizedPrefixes(policy.rootUris));
130
+ const ignorePrefixes = normalizedPrefixes(policy.ignorePathPrefixes);
131
+ const mirroredIds = duplicateMirrorIds(
132
+ snapshot.documents,
133
+ policy.ignoreMirrorDuplicates ?? true
134
+ );
135
+ const orphanFindings = snapshot.documents
136
+ .filter(
137
+ (document) =>
138
+ auditedDocumentIds.has(document.id) &&
139
+ !connected.has(document.id) &&
140
+ !isIgnoredDocument(document, roots, ignorePrefixes, mirroredIds)
141
+ )
142
+ .sort((left, right) => compareAuditCodeUnits(left.uri, right.uri))
143
+ .map<AuditFindingDraft>((document) => ({
144
+ subject: document.uri,
145
+ location: null,
146
+ severity: "info",
147
+ message: "Document is isolated under the configured orphan policy",
148
+ evidence: [
149
+ {
150
+ kind: "orphan-policy",
151
+ summary: "No resolved incoming or outgoing local links",
152
+ uri: document.uri,
153
+ path: document.recordSourcePath ?? document.relPath,
154
+ detail: JSON.stringify({ root: false, ignored: false }),
155
+ },
156
+ ],
157
+ guidance: [
158
+ "Link this document or add it to the explicit root/ignore policy",
159
+ ],
160
+ }));
161
+ const partial = snapshot.truncated.documents || snapshot.truncated.links;
162
+ const statusFor = (findings: readonly AuditFindingDraft[]) =>
163
+ partial
164
+ ? ("inconclusive" as const)
165
+ : findings.length > 0
166
+ ? ("fail" as const)
167
+ : ("pass" as const);
168
+ const common = {
169
+ examinedCount:
170
+ snapshot.metrics.documentRowsExamined + snapshot.metrics.linkRowsExamined,
171
+ durationMs: 0,
172
+ };
173
+ return [
174
+ {
175
+ ...common,
176
+ ruleId: "links.local-targets",
177
+ category: "links",
178
+ status: statusFor(unresolved),
179
+ message: partial
180
+ ? "Local target scan was truncated"
181
+ : `${unresolved.length} unresolved or broken local links`,
182
+ findings: boundedFindings(unresolved),
183
+ findingCount: unresolved.length,
184
+ skipReason: partial ? "snapshot_truncated" : null,
185
+ },
186
+ {
187
+ ...common,
188
+ ruleId: "links.ambiguous-targets",
189
+ category: "links",
190
+ status: statusFor(ambiguous),
191
+ message: partial
192
+ ? "Ambiguous target scan was truncated"
193
+ : `${ambiguous.length} ambiguous local links`,
194
+ findings: boundedFindings(ambiguous),
195
+ findingCount: ambiguous.length,
196
+ skipReason: partial ? "snapshot_truncated" : null,
197
+ },
198
+ {
199
+ ...common,
200
+ ruleId: "links.orphans",
201
+ category: "links",
202
+ status: statusFor(orphanFindings),
203
+ message: partial
204
+ ? "Orphan scan was truncated"
205
+ : `${orphanFindings.length} policy-defined orphan documents`,
206
+ findings: boundedFindings(orphanFindings),
207
+ findingCount: orphanFindings.length,
208
+ skipReason: partial ? "snapshot_truncated" : null,
209
+ },
210
+ {
211
+ ...common,
212
+ ruleId: "links.parser-boundary",
213
+ category: "links",
214
+ status: "pass",
215
+ message:
216
+ "External URLs and parser-excluded malformed references are outside the local target graph",
217
+ findings: [],
218
+ findingCount: 0,
219
+ skipReason: null,
220
+ },
221
+ ];
222
+ };
@@ -0,0 +1,154 @@
1
+ /** Declared-contract provenance completeness audit rules. */
2
+
3
+ import type { AuditFindingDraft, AuditRuleContribution } from "./audit";
4
+ import type { CaptureSource } from "./capture";
5
+
6
+ import { compareAuditFindingDrafts } from "./audit";
7
+ import { validateDeclaredCaptureProvenance } from "./capture";
8
+ import {
9
+ hasDeclaredRecordProvenance,
10
+ validateDeclaredRecordProvenance,
11
+ } from "./record-metadata";
12
+
13
+ export const PROVENANCE_AUDIT_MAX_FINDINGS_PER_RULE = 1000;
14
+
15
+ export interface AuditProvenanceDocument {
16
+ uri: string;
17
+ relPath: string;
18
+ sourceState?: "readable" | "missing" | "unreadable";
19
+ /** Whether this source format can declare CaptureSource frontmatter. */
20
+ captureSourceSupported?: boolean;
21
+ captureSource?: Partial<CaptureSource>;
22
+ captureSourceDeclared: boolean;
23
+ record: {
24
+ recordKey?: string | null;
25
+ recordSourceLocator?: string | null;
26
+ converterId?: string | null;
27
+ converterVersion?: string | null;
28
+ recordAdapterFingerprint?: string | null;
29
+ };
30
+ }
31
+
32
+ const issueFinding = (input: {
33
+ document: AuditProvenanceDocument;
34
+ field: string;
35
+ reason: "missing" | "invalid";
36
+ contract: string;
37
+ }): AuditFindingDraft => ({
38
+ subject: input.document.uri,
39
+ location: input.field,
40
+ severity: "warning",
41
+ message: `${input.contract} provenance field is ${input.reason}: ${input.field}`,
42
+ evidence: [
43
+ {
44
+ kind: "declared-provenance-requirement",
45
+ summary: `${input.contract}:${input.field}:${input.reason}`,
46
+ uri: input.document.uri,
47
+ path: input.document.relPath,
48
+ },
49
+ ],
50
+ guidance: [
51
+ `Supply a valid ${input.field} value or remove the declaring provenance block`,
52
+ ],
53
+ });
54
+
55
+ /** Missing provenance is completeness evidence, never a truth judgment. */
56
+ export const evaluateProvenanceAudit = (
57
+ documents: readonly AuditProvenanceDocument[],
58
+ options: { truncated?: boolean } = {}
59
+ ): AuditRuleContribution[] => {
60
+ const captureFindings: AuditFindingDraft[] = [];
61
+ const recordFindings: AuditFindingDraft[] = [];
62
+ let declaredCaptureDocuments = 0;
63
+ let declaredRecordDocuments = 0;
64
+ let unavailableCaptureSources = 0;
65
+ for (const document of documents) {
66
+ if (
67
+ document.captureSourceSupported !== false &&
68
+ document.sourceState !== undefined &&
69
+ document.sourceState !== "readable"
70
+ ) {
71
+ unavailableCaptureSources += 1;
72
+ }
73
+ if (document.captureSourceDeclared) {
74
+ declaredCaptureDocuments += 1;
75
+ for (const issue of validateDeclaredCaptureProvenance(
76
+ document.captureSource ?? {}
77
+ )) {
78
+ captureFindings.push(
79
+ issueFinding({
80
+ document,
81
+ field: issue.field,
82
+ reason: issue.reason,
83
+ contract: "capture",
84
+ })
85
+ );
86
+ }
87
+ }
88
+ const recordIssues = validateDeclaredRecordProvenance(document.record);
89
+ if (hasDeclaredRecordProvenance(document.record)) {
90
+ declaredRecordDocuments += 1;
91
+ }
92
+ for (const issue of recordIssues) {
93
+ recordFindings.push(
94
+ issueFinding({
95
+ document,
96
+ field: issue.field,
97
+ reason: issue.reason,
98
+ contract: "logical-record",
99
+ })
100
+ );
101
+ }
102
+ }
103
+ const truncated = options.truncated === true;
104
+ const result = (
105
+ ruleId: string,
106
+ findings: AuditFindingDraft[],
107
+ declaredDocuments: number,
108
+ unavailableDocuments = 0
109
+ ): AuditRuleContribution => ({
110
+ ruleId,
111
+ category: "provenance",
112
+ status: truncated
113
+ ? "inconclusive"
114
+ : unavailableDocuments > 0
115
+ ? "unavailable"
116
+ : findings.length > 0
117
+ ? "fail"
118
+ : declaredDocuments === 0
119
+ ? "skip"
120
+ : "pass",
121
+ message: truncated
122
+ ? "Provenance scan was truncated"
123
+ : unavailableDocuments > 0
124
+ ? `${unavailableDocuments} source files could not be inspected for declared capture provenance`
125
+ : declaredDocuments === 0
126
+ ? "No documents declared this provenance contract"
127
+ : `${findings.length} declared provenance completeness issues`,
128
+ findings: [...findings]
129
+ .sort(compareAuditFindingDrafts)
130
+ .slice(0, PROVENANCE_AUDIT_MAX_FINDINGS_PER_RULE),
131
+ findingCount: findings.length,
132
+ examinedCount: documents.length,
133
+ skipReason: truncated
134
+ ? "snapshot_truncated"
135
+ : unavailableDocuments > 0
136
+ ? "source_unavailable"
137
+ : declaredDocuments === 0
138
+ ? "contract_not_declared"
139
+ : null,
140
+ });
141
+ return [
142
+ result(
143
+ "provenance.capture-source",
144
+ captureFindings,
145
+ declaredCaptureDocuments,
146
+ unavailableCaptureSources
147
+ ),
148
+ result(
149
+ "provenance.logical-record",
150
+ recordFindings,
151
+ declaredRecordDocuments
152
+ ),
153
+ ];
154
+ };
@@ -0,0 +1,318 @@
1
+ /** Canonical finding identity, ordering, counts, and report serialization. */
2
+
3
+ import type {
4
+ AuditCategory,
5
+ AuditCounts,
6
+ AuditEvidence,
7
+ AuditExitKind,
8
+ AuditFinding,
9
+ AuditFindingDraft,
10
+ AuditReport,
11
+ AuditReportStatus,
12
+ AuditRuleResult,
13
+ } from "./audit-contract";
14
+
15
+ import {
16
+ AUDIT_CATEGORIES,
17
+ AUDIT_EXIT_CODES,
18
+ AUDIT_MAX_EVIDENCE_DETAIL_CHARS,
19
+ AUDIT_MAX_EVIDENCE_PER_FINDING,
20
+ AUDIT_MAX_GUIDANCE_CHARS,
21
+ AUDIT_MAX_GUIDANCE_PER_FINDING,
22
+ AUDIT_MAX_CODE_CHARS,
23
+ AUDIT_MAX_IDENTIFIER_CHARS,
24
+ AUDIT_MAX_MESSAGE_CHARS,
25
+ } from "./audit-contract";
26
+
27
+ // ─────────────────────────────────────────────────────────────────────────────
28
+ // Canonicalization & stable IDs
29
+ // ─────────────────────────────────────────────────────────────────────────────
30
+
31
+ export const compareAuditCodeUnits = (left: string, right: string): number =>
32
+ left < right ? -1 : left > right ? 1 : 0;
33
+
34
+ export const compareAuditFindingDrafts = (
35
+ left: AuditFindingDraft,
36
+ right: AuditFindingDraft
37
+ ): number =>
38
+ compareAuditCodeUnits(left.subject, right.subject) ||
39
+ compareAuditCodeUnits(left.location ?? "", right.location ?? "") ||
40
+ compareAuditCodeUnits(left.message, right.message) ||
41
+ compareAuditCodeUnits(
42
+ JSON.stringify(left.evidence),
43
+ JSON.stringify(right.evidence)
44
+ );
45
+
46
+ type CanonicalJson =
47
+ | boolean
48
+ | null
49
+ | number
50
+ | string
51
+ | CanonicalJson[]
52
+ | { [key: string]: CanonicalJson };
53
+
54
+ const canonicalizeJsonValue = (value: unknown): CanonicalJson => {
55
+ if (
56
+ value === null ||
57
+ typeof value === "boolean" ||
58
+ typeof value === "string"
59
+ ) {
60
+ return value;
61
+ }
62
+ if (typeof value === "number") {
63
+ if (!Number.isFinite(value)) {
64
+ throw new Error("Canonical JSON rejects non-finite numbers");
65
+ }
66
+ return value;
67
+ }
68
+ if (Array.isArray(value)) {
69
+ return value.map((item) => canonicalizeJsonValue(item));
70
+ }
71
+ if (typeof value === "object") {
72
+ const sorted: Record<string, CanonicalJson> = {};
73
+ for (const key of Object.keys(value).sort(compareAuditCodeUnits)) {
74
+ const child = (value as Record<string, unknown>)[key];
75
+ if (child === undefined) {
76
+ throw new Error(`Canonical JSON rejects undefined at ${key}`);
77
+ }
78
+ sorted[key] = canonicalizeJsonValue(child);
79
+ }
80
+ return sorted;
81
+ }
82
+ throw new TypeError(`Unsupported canonical JSON value: ${typeof value}`);
83
+ };
84
+
85
+ /** Key-sorted JSON used for identity hashes and semantic equality. */
86
+ export const canonicalAuditJson = (value: unknown): string =>
87
+ JSON.stringify(canonicalizeJsonValue(value));
88
+
89
+ export const hashAuditCanonical = (value: unknown): string =>
90
+ new Bun.CryptoHasher("sha256")
91
+ .update(canonicalAuditJson(value))
92
+ .digest("hex");
93
+
94
+ export const normalizeAuditText = (value: string): string =>
95
+ value.normalize("NFC").trim();
96
+
97
+ export const boundAuditText = (value: string, maxChars: number): string => {
98
+ const normalized = normalizeAuditText(value);
99
+ const characters = Array.from(normalized);
100
+ if (characters.length <= maxChars) return normalized;
101
+ return characters.slice(0, maxChars).join("");
102
+ };
103
+
104
+ const boundEvidence = (evidence: readonly AuditEvidence[]): AuditEvidence[] => {
105
+ const bounded: AuditEvidence[] = [];
106
+ for (const item of evidence.slice(0, AUDIT_MAX_EVIDENCE_PER_FINDING)) {
107
+ const next: AuditEvidence = {
108
+ kind: boundAuditText(item.kind, AUDIT_MAX_CODE_CHARS),
109
+ summary: boundAuditText(item.summary, AUDIT_MAX_MESSAGE_CHARS),
110
+ };
111
+ if (item.uri !== undefined) {
112
+ next.uri = boundAuditText(item.uri, AUDIT_MAX_IDENTIFIER_CHARS);
113
+ }
114
+ if (item.path !== undefined) {
115
+ next.path = boundAuditText(item.path, AUDIT_MAX_IDENTIFIER_CHARS);
116
+ }
117
+ if (item.detail !== undefined) {
118
+ next.detail = boundAuditText(
119
+ item.detail,
120
+ AUDIT_MAX_EVIDENCE_DETAIL_CHARS
121
+ );
122
+ }
123
+ bounded.push(next);
124
+ }
125
+ return bounded.sort((left, right) => {
126
+ const byKind = compareAuditCodeUnits(left.kind, right.kind);
127
+ if (byKind !== 0) return byKind;
128
+ const bySummary = compareAuditCodeUnits(left.summary, right.summary);
129
+ if (bySummary !== 0) return bySummary;
130
+ return compareAuditCodeUnits(
131
+ canonicalAuditJson(left),
132
+ canonicalAuditJson(right)
133
+ );
134
+ });
135
+ };
136
+
137
+ const boundGuidance = (guidance: readonly string[] | undefined): string[] => {
138
+ if (!guidance || guidance.length === 0) return [];
139
+ return guidance
140
+ .slice(0, AUDIT_MAX_GUIDANCE_PER_FINDING)
141
+ .map((item) => boundAuditText(item, AUDIT_MAX_GUIDANCE_CHARS))
142
+ .sort(compareAuditCodeUnits);
143
+ };
144
+
145
+ export const fingerprintAuditEvidence = (
146
+ evidence: readonly AuditEvidence[]
147
+ ): string => hashAuditCanonical(boundEvidence(evidence));
148
+
149
+ /**
150
+ * Stable finding identity from rule + normalized subject/location + evidence.
151
+ * Wall-clock timing is intentionally excluded.
152
+ */
153
+ export const buildAuditFindingId = (input: {
154
+ ruleId: string;
155
+ subject: string;
156
+ location: string | null;
157
+ evidenceFingerprint: string;
158
+ }): string =>
159
+ hashAuditCanonical({
160
+ evidenceFingerprint: input.evidenceFingerprint,
161
+ location: input.location,
162
+ ruleId: normalizeAuditText(input.ruleId),
163
+ subject: normalizeAuditText(input.subject),
164
+ });
165
+
166
+ export const materializeAuditFinding = (
167
+ ruleId: string,
168
+ category: AuditCategory,
169
+ draft: AuditFindingDraft
170
+ ): AuditFinding => {
171
+ const subject = boundAuditText(draft.subject, AUDIT_MAX_IDENTIFIER_CHARS);
172
+ const location =
173
+ draft.location === undefined || draft.location === null
174
+ ? null
175
+ : boundAuditText(draft.location, AUDIT_MAX_IDENTIFIER_CHARS);
176
+ const evidence = boundEvidence(draft.evidence);
177
+ const evidenceFingerprint = fingerprintAuditEvidence(evidence);
178
+ return {
179
+ id: buildAuditFindingId({
180
+ ruleId,
181
+ subject,
182
+ location,
183
+ evidenceFingerprint,
184
+ }),
185
+ ruleId: boundAuditText(ruleId, AUDIT_MAX_CODE_CHARS),
186
+ category,
187
+ severity: draft.severity,
188
+ subject,
189
+ location,
190
+ message: boundAuditText(draft.message, AUDIT_MAX_MESSAGE_CHARS),
191
+ evidence,
192
+ guidance: boundGuidance(draft.guidance),
193
+ evidenceFingerprint,
194
+ };
195
+ };
196
+
197
+ // ─────────────────────────────────────────────────────────────────────────────
198
+ // Ordering, counts, exit taxonomy
199
+ // ─────────────────────────────────────────────────────────────────────────────
200
+
201
+ export const auditCategoryRank = (category: AuditCategory): number =>
202
+ AUDIT_CATEGORIES.indexOf(category);
203
+
204
+ export const compareAuditFindings = (
205
+ left: AuditFinding,
206
+ right: AuditFinding
207
+ ): number => {
208
+ const byCategory =
209
+ auditCategoryRank(left.category) - auditCategoryRank(right.category);
210
+ if (byCategory !== 0) return byCategory;
211
+ const byRule = compareAuditCodeUnits(left.ruleId, right.ruleId);
212
+ if (byRule !== 0) return byRule;
213
+ const bySubject = compareAuditCodeUnits(left.subject, right.subject);
214
+ if (bySubject !== 0) return bySubject;
215
+ const leftLocation = left.location ?? "";
216
+ const rightLocation = right.location ?? "";
217
+ const byLocation = compareAuditCodeUnits(leftLocation, rightLocation);
218
+ if (byLocation !== 0) return byLocation;
219
+ return compareAuditCodeUnits(left.id, right.id);
220
+ };
221
+
222
+ export const compareAuditRules = (
223
+ left: AuditRuleResult,
224
+ right: AuditRuleResult
225
+ ): number => {
226
+ const byCategory =
227
+ auditCategoryRank(left.category) - auditCategoryRank(right.category);
228
+ if (byCategory !== 0) return byCategory;
229
+ const byRule = compareAuditCodeUnits(left.ruleId, right.ruleId);
230
+ if (byRule !== 0) return byRule;
231
+ const semanticRule = ({
232
+ durationMs: _durationMs,
233
+ ...rule
234
+ }: AuditRuleResult) => canonicalAuditJson(rule);
235
+ return compareAuditCodeUnits(semanticRule(left), semanticRule(right));
236
+ };
237
+
238
+ const emptyRuleCounts = (): AuditCounts["rules"] => ({
239
+ pass: 0,
240
+ fail: 0,
241
+ skip: 0,
242
+ unavailable: 0,
243
+ inconclusive: 0,
244
+ total: 0,
245
+ });
246
+
247
+ export const tallyAuditRuleCounts = (
248
+ rules: readonly AuditRuleResult[]
249
+ ): AuditCounts["rules"] => {
250
+ const counts = emptyRuleCounts();
251
+ for (const rule of rules) {
252
+ counts[rule.status] += 1;
253
+ counts.total += 1;
254
+ }
255
+ return counts;
256
+ };
257
+
258
+ /**
259
+ * Derive report status from rule outcomes and snapshot consistency.
260
+ * Unavailable/inconclusive evidence can never yield a clean complete report.
261
+ */
262
+ export const deriveAuditReportStatus = (input: {
263
+ rules: readonly AuditRuleResult[];
264
+ snapshotChanged: boolean;
265
+ failed?: boolean;
266
+ }): AuditReportStatus => {
267
+ if (input.failed) return "failed";
268
+ if (input.snapshotChanged) return "changed_during_audit";
269
+ const hasHonestGap = input.rules.some(
270
+ (rule) => rule.status === "unavailable" || rule.status === "inconclusive"
271
+ );
272
+ if (hasHonestGap) return "partial";
273
+ return "complete";
274
+ };
275
+
276
+ /**
277
+ * Map a finished report to the frozen exit taxonomy.
278
+ * Clean requires complete status and zero fail findings.
279
+ */
280
+ export const deriveAuditExitKind = (report: AuditReport): AuditExitKind => {
281
+ if (report.status === "failed") return "runtime";
282
+ if (report.status === "partial" || report.status === "changed_during_audit") {
283
+ return "partial";
284
+ }
285
+ const hasFailFindings = report.findings.some(
286
+ (finding) => finding.severity === "error" || finding.severity === "warning"
287
+ );
288
+ const hasFailRules = report.rules.some((rule) => rule.status === "fail");
289
+ if (hasFailFindings || hasFailRules) return "findings";
290
+ return "clean";
291
+ };
292
+
293
+ export const auditExitCode = (kind: AuditExitKind): number =>
294
+ AUDIT_EXIT_CODES[kind];
295
+
296
+ /**
297
+ * Semantic projection for equality: identical snapshots must match regardless
298
+ * of wall-clock timing and per-rule duration noise.
299
+ */
300
+ export const auditSemanticProjection = (report: AuditReport): CanonicalJson => {
301
+ const {
302
+ startedAt: _s,
303
+ completedAt: _c,
304
+ durationMs: _d,
305
+ timing: _t,
306
+ ...rest
307
+ } = report;
308
+ return canonicalizeJsonValue({
309
+ ...rest,
310
+ rules: report.rules.map(({ durationMs: _durationMs, ...rule }) => rule),
311
+ });
312
+ };
313
+
314
+ export const serializeAuditReportSemantic = (report: AuditReport): string =>
315
+ canonicalAuditJson(auditSemanticProjection(report));
316
+
317
+ export const serializeAuditReportCanonical = (report: AuditReport): string =>
318
+ canonicalAuditJson(report);