@usefragments/core 1.10.2 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-MZ4SW3TP.js → chunk-3IOWHECM.js} +8 -1
- package/dist/{chunk-MZ4SW3TP.js.map → chunk-3IOWHECM.js.map} +1 -1
- package/dist/chunk-7ULGH74M.js +66 -0
- package/dist/chunk-7ULGH74M.js.map +1 -0
- package/dist/{chunk-XN3LSDPY.js → chunk-BMPYIUZE.js} +612 -344
- package/dist/chunk-BMPYIUZE.js.map +1 -0
- package/dist/{chunk-YF65VYRY.js → chunk-ML5S6QNU.js} +191 -169
- package/dist/chunk-ML5S6QNU.js.map +1 -0
- package/dist/{chunk-3LLRNCPX.js → chunk-MZ2FS7U4.js} +1 -1
- package/dist/chunk-MZ2FS7U4.js.map +1 -0
- package/dist/chunk-PWIJMOI4.js +202 -0
- package/dist/chunk-PWIJMOI4.js.map +1 -0
- package/dist/chunk-RYFULE43.js +578 -0
- package/dist/chunk-RYFULE43.js.map +1 -0
- package/dist/codes/index.d.ts +2 -2
- package/dist/codes/index.js +3 -2
- package/dist/compiled-types/index.d.ts +327 -2
- package/dist/compiled-types/index.js +1 -1
- package/dist/generate/index.d.ts +3 -2
- package/dist/{governance-CLk_wkP9.d.ts → governance-hOPXGbbs.d.ts} +474 -515
- package/dist/governance-telemetry.d.ts +6 -0
- package/dist/governance-telemetry.js +1 -1
- package/dist/{index-_sxhUNqx.d.ts → index-C8bcXVav.d.ts} +448 -448
- package/dist/index.d.ts +3280 -1305
- package/dist/index.js +2414 -350
- package/dist/index.js.map +1 -1
- package/dist/manifest.d.ts +228 -0
- package/dist/manifest.js +24 -0
- package/dist/manifest.js.map +1 -0
- package/dist/preview/index.js +45 -1
- package/dist/preview/index.js.map +1 -1
- package/dist/preview-runtime.d.ts +1 -2
- package/dist/preview-runtime.js +150 -14
- package/dist/preview-runtime.js.map +1 -1
- package/dist/react-types.d.ts +1 -2
- package/dist/registry.d.ts +1412 -203
- package/dist/registry.js +30 -3
- package/dist/schemas/index.d.ts +1 -1
- package/dist/schemas/index.js +3 -2
- package/dist/storyAdapter.d.ts +1 -2
- package/dist/storyAdapter.js +11 -49
- package/dist/storyAdapter.js.map +1 -1
- package/dist/test-utils.d.ts +3 -2
- package/dist/topology/index.d.ts +1 -1
- package/dist/topology/index.js +1 -1
- package/package.json +8 -2
- package/src/__tests__/contract-parser.test.ts +318 -277
- package/src/__tests__/preview-runtime-hook.test.tsx +315 -0
- package/src/__tests__/preview-runtime.test.tsx +30 -8
- package/src/__tests__/schema.test.ts +191 -14
- package/src/analysis-plan/analysis-plan-v1.test.ts +320 -0
- package/src/analysis-plan/coverage.ts +181 -0
- package/src/analysis-plan/digest.ts +141 -0
- package/src/analysis-plan/index.ts +34 -0
- package/src/analysis-plan/types.ts +207 -0
- package/src/approved-contract-tokens.test.ts +39 -0
- package/src/approved-contract-tokens.ts +18 -0
- package/src/codes/__tests__/codes.test.ts +13 -0
- package/src/codes/codes.ts +40 -0
- package/src/compiled-types/index.ts +640 -39
- package/src/compiled-types/parse.test.ts +145 -4
- package/src/component-contract.ts +95 -53
- package/src/composition.ts +7 -13
- package/src/constants.ts +3 -6
- package/src/contract/hash.test.ts +20 -0
- package/src/contract/hash.ts +66 -9
- package/src/contract/index.ts +24 -1
- package/src/contract/manifest.test.ts +94 -0
- package/src/contract/manifest.ts +68 -0
- package/src/contract/preimage.test.ts +219 -1
- package/src/contract/preimage.ts +326 -6
- package/src/contract/stamp.test.ts +3 -0
- package/src/contract/stamp.ts +1 -1
- package/src/contract-parser.ts +54 -30
- package/src/defineFragment.test.ts +476 -91
- package/src/defineFragment.ts +204 -114
- package/src/domain-ids.test.ts +35 -0
- package/src/domain-ids.ts +61 -0
- package/src/evaluation/evaluate.test.ts +522 -0
- package/src/evaluation/evaluate.ts +690 -0
- package/src/evaluation/evaluation-v2-receipt-v1.test.ts +772 -0
- package/src/evaluation/index.ts +58 -0
- package/src/evaluation/receipt.ts +753 -0
- package/src/evaluation/types.ts +406 -0
- package/src/facts/builders.ts +2 -0
- package/src/facts/compile.ts +29 -6
- package/src/facts/fact-index.ts +13 -3
- package/src/facts/fact-integrity-v1.test.ts +172 -0
- package/src/facts/facts.test.ts +15 -0
- package/src/facts/ids.ts +46 -3
- package/src/facts/index.ts +14 -1
- package/src/facts/integrity.ts +134 -0
- package/src/facts/types.ts +36 -0
- package/src/governance-integrity.test.ts +1 -0
- package/src/governance-integrity.ts +5 -3
- package/src/governance-telemetry.ts +8 -0
- package/src/governance.ts +70 -8
- package/src/index.ts +230 -37
- package/src/preview/validation.test.ts +62 -0
- package/src/preview/validation.ts +48 -2
- package/src/preview-runtime.tsx +227 -20
- package/src/registry-install-plan.ts +200 -109
- package/src/registry-shards.test.ts +263 -0
- package/src/registry.ts +237 -0
- package/src/repository-binding.test.ts +50 -0
- package/src/repository-binding.ts +96 -0
- package/src/rules/families.test.ts +36 -0
- package/src/rules/finding.ts +7 -2
- package/src/rules/index.ts +17 -1
- package/src/rules/rule-config.test.ts +66 -0
- package/src/rules/rule-config.ts +73 -0
- package/src/rules/rules.test.ts +26 -0
- package/src/rules/tokens-css-vars-must-be-defined.test.ts +51 -2
- package/src/rules/tokens-css-vars-must-be-defined.ts +34 -1
- package/src/schema.ts +293 -113
- package/src/schemas/index.ts +1 -1
- package/src/storyAdapter.test.ts +68 -12
- package/src/storyAdapter.ts +44 -75
- package/src/topology/resolve-area.ts +1 -1
- package/src/types.ts +258 -40
- package/dist/chunk-3LLRNCPX.js.map +0 -1
- package/dist/chunk-RANPUC6C.js +0 -72
- package/dist/chunk-RANPUC6C.js.map +0 -1
- package/dist/chunk-XN3LSDPY.js.map +0 -1
- package/dist/chunk-YF65VYRY.js.map +0 -1
- package/src/fragment-types.ts +0 -214
- package/src/react-create-element.test.ts +0 -22
- package/src/react-create-element.ts +0 -12
- package/src/storyFilters.test.ts +0 -350
- package/src/storyFilters.ts +0 -253
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pure governance evaluation kernel (architecture spec §§1–2).
|
|
3
|
+
*
|
|
4
|
+
* Verdict model, settled by the parity corpus (`@repo/parity`):
|
|
5
|
+
*
|
|
6
|
+
* 1. **Integrity failures cap the verdict at `indeterminate`** — an unpinned,
|
|
7
|
+
* stale, or identity-mismatched contract, an inert scan, zero-file
|
|
8
|
+
* coverage, or unknown policy inputs mean neither pass nor block is
|
|
9
|
+
* provable (`blocking-but-unpinned` is the corpus row that forces the cap:
|
|
10
|
+
* a fully-trusted blocking finding on an unpinned verdict is still
|
|
11
|
+
* indeterminate).
|
|
12
|
+
* 2. With integrity intact, **definitive gating evidence blocks**: a blocked
|
|
13
|
+
* finding, a proven adoption regression, or an expired waiver.
|
|
14
|
+
* 3. Otherwise **any unknown holds the verdict at `indeterminate`** — pending
|
|
15
|
+
* trust, unavailable adoption history. Local green can never become Cloud
|
|
16
|
+
* red (spec §2): a candidate with unknown trust is never silently green.
|
|
17
|
+
* 4. Nothing gating, nothing unknown → `pass`.
|
|
18
|
+
*
|
|
19
|
+
* Per-finding pipeline (duplicate fingerprints classified independently, the
|
|
20
|
+
* hardest genuinely-supported state kept — never field-merged):
|
|
21
|
+
* waiver → eligibility (severity/policy/evidence) → required trust facts.
|
|
22
|
+
* Any required fact definitively negative → `advisory`; any unknown →
|
|
23
|
+
* `pending_cloud_trust`; all affirmative → `blocked`.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { deriveCoverageSummaryV1 } from "../analysis-plan/index.js";
|
|
27
|
+
import {
|
|
28
|
+
contractHash,
|
|
29
|
+
canonicalPreimage,
|
|
30
|
+
compareCanonicalStrings,
|
|
31
|
+
sha256Hex,
|
|
32
|
+
} from "../contract/hash.js";
|
|
33
|
+
import { EVIDENCE_ORDER, canBlock, type EvidenceGrade } from "../evidence.js";
|
|
34
|
+
import { runRules } from "../rules/index.js";
|
|
35
|
+
import { configuredFindingSeverity, readRuleConfig } from "../rules/rule-config.js";
|
|
36
|
+
import { severityLevel } from "../severity.js";
|
|
37
|
+
import { tierFor } from "../rules/tiers.js";
|
|
38
|
+
import type {
|
|
39
|
+
EffectivePolicy,
|
|
40
|
+
EvaluatedFinding,
|
|
41
|
+
EvaluationFindingInput,
|
|
42
|
+
EvaluationInput,
|
|
43
|
+
EvaluationInputV2,
|
|
44
|
+
EvaluationReason,
|
|
45
|
+
EvaluationReasonV2,
|
|
46
|
+
EvaluationResult,
|
|
47
|
+
EvaluationResultV2,
|
|
48
|
+
EvaluationVerdict,
|
|
49
|
+
EvaluationWaiver,
|
|
50
|
+
} from "./types.js";
|
|
51
|
+
import { EVALUATOR_VERSION, EVALUATOR_VERSION_V2 } from "./types.js";
|
|
52
|
+
import { evaluationInputV2Schema, evaluationResultV2Schema } from "./receipt.js";
|
|
53
|
+
|
|
54
|
+
type TrustFactState = "affirmative" | "negative" | "unknown";
|
|
55
|
+
|
|
56
|
+
const INTEGRITY_REASONS: ReadonlySet<EvaluationReason> = new Set([
|
|
57
|
+
"zeroFileScan",
|
|
58
|
+
"governanceInert",
|
|
59
|
+
"contractUnpinned",
|
|
60
|
+
"contractStale",
|
|
61
|
+
"contractIdentityMismatch",
|
|
62
|
+
"inertConfigPresent",
|
|
63
|
+
"unknownPresetPresent",
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
const BLOCK_REASONS: ReadonlySet<EvaluationReason> = new Set([
|
|
67
|
+
"blockedFindings",
|
|
68
|
+
"adoptionRegression",
|
|
69
|
+
"expiredSuppression",
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
/** Stable reason ordering for deterministic results. */
|
|
73
|
+
const REASON_ORDER: readonly EvaluationReason[] = [
|
|
74
|
+
"zeroFileScan",
|
|
75
|
+
"governanceInert",
|
|
76
|
+
"contractUnpinned",
|
|
77
|
+
"contractStale",
|
|
78
|
+
"contractIdentityMismatch",
|
|
79
|
+
"inertConfigPresent",
|
|
80
|
+
"unknownPresetPresent",
|
|
81
|
+
"blockedFindings",
|
|
82
|
+
"adoptionRegression",
|
|
83
|
+
"expiredSuppression",
|
|
84
|
+
"pendingCloudTrust",
|
|
85
|
+
"adoptionBaselineUnavailable",
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
export function evaluate(input: EvaluationInputV2): EvaluationResultV2;
|
|
89
|
+
export function evaluate(input: EvaluationInput): EvaluationResult;
|
|
90
|
+
export function evaluate(
|
|
91
|
+
input: EvaluationInput | EvaluationInputV2
|
|
92
|
+
): EvaluationResult | EvaluationResultV2 {
|
|
93
|
+
return isEvaluationInputV2(input) ? evaluateV2(input) : evaluateV1(input);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isEvaluationInputV2(
|
|
97
|
+
input: EvaluationInput | EvaluationInputV2
|
|
98
|
+
): input is EvaluationInputV2 {
|
|
99
|
+
return "schemaVersion" in input && input.schemaVersion === 2;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function evaluateV1(input: EvaluationInput): EvaluationResult {
|
|
103
|
+
const reasons = new Set<EvaluationReason>();
|
|
104
|
+
|
|
105
|
+
// ---- Contract identity (the kernel recomputes the FCID itself) ----------
|
|
106
|
+
let fcidVerified: boolean | "unavailable" = "unavailable";
|
|
107
|
+
if (input.contract.state === "unpinned") {
|
|
108
|
+
reasons.add("contractUnpinned");
|
|
109
|
+
} else {
|
|
110
|
+
if (input.contract.stale) reasons.add("contractStale");
|
|
111
|
+
if (input.contract.preimage) {
|
|
112
|
+
fcidVerified = contractHash(input.contract.preimage) === input.contract.fcid;
|
|
113
|
+
if (!fcidVerified) reasons.add("contractIdentityMismatch");
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---- Coverage / run integrity -------------------------------------------
|
|
118
|
+
if (input.scope.kind === "full" && input.scope.coverage.filesScanned === 0) {
|
|
119
|
+
reasons.add("zeroFileScan");
|
|
120
|
+
}
|
|
121
|
+
if (input.integrity.governance === "inert") {
|
|
122
|
+
// An adapter may soften the RENDERING of an inert local run — never the
|
|
123
|
+
// verdict: inert coverage cannot prove anything on any surface.
|
|
124
|
+
reasons.add("governanceInert");
|
|
125
|
+
}
|
|
126
|
+
if (input.policy.failOnInert === true && (input.integrity.inertConfigDiagnostics ?? 0) > 0) {
|
|
127
|
+
reasons.add("inertConfigPresent");
|
|
128
|
+
}
|
|
129
|
+
if ((input.integrity.unknownPresets?.length ?? 0) > 0) {
|
|
130
|
+
reasons.add("unknownPresetPresent");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---- Adoption history ----------------------------------------------------
|
|
134
|
+
if (input.adoption === "unavailable") {
|
|
135
|
+
reasons.add("adoptionBaselineUnavailable");
|
|
136
|
+
} else if (input.adoption?.regressed === true) {
|
|
137
|
+
reasons.add("adoptionRegression");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---- Waivers -------------------------------------------------------------
|
|
141
|
+
// A non-finite expiry (Infinity, NaN) means "never expires" — the same
|
|
142
|
+
// reading `canonicalWaivers` digests it as, so the digest and the verdict
|
|
143
|
+
// can never disagree about a waiver's state.
|
|
144
|
+
const activeWaivers = new Set<string>();
|
|
145
|
+
for (const waiver of input.waivers ?? []) {
|
|
146
|
+
if (
|
|
147
|
+
waiver.expiresAt !== undefined &&
|
|
148
|
+
Number.isFinite(waiver.expiresAt) &&
|
|
149
|
+
waiver.expiresAt <= input.evaluatedAt
|
|
150
|
+
) {
|
|
151
|
+
// An expired waiver re-exposes the debt it hid — the finding it covered
|
|
152
|
+
// may not even be in this run's evidence, so this is a run-level gate,
|
|
153
|
+
// not merely the loss of a downgrade.
|
|
154
|
+
reasons.add("expiredSuppression");
|
|
155
|
+
} else {
|
|
156
|
+
activeWaivers.add(waiver.fingerprint);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---- Per-finding classification -----------------------------------------
|
|
161
|
+
// Duplicate fingerprints are classified INDEPENDENTLY and the hardest
|
|
162
|
+
// genuinely-supported outcome wins (blocked > pending > advisory). Nothing
|
|
163
|
+
// is merged field-by-field — a synthetic row could fabricate gating
|
|
164
|
+
// evidence no input row carries — and reordering never changes the result
|
|
165
|
+
// because rows are canonically sorted before classification.
|
|
166
|
+
const evidence = evidenceFindings(input);
|
|
167
|
+
const findings = [...groupByFingerprint(evidence).values()].map((rows) => {
|
|
168
|
+
const classified = rows.map((row) => classifyFinding(row, input, activeWaivers));
|
|
169
|
+
return classified.reduce((hardest, candidate) =>
|
|
170
|
+
STATE_RANK[candidate.state] < STATE_RANK[hardest.state] ? candidate : hardest
|
|
171
|
+
);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const counts = { blocked: 0, advisory: 0, pendingCloudTrust: 0 };
|
|
175
|
+
for (const finding of findings) {
|
|
176
|
+
if (finding.state === "blocked") counts.blocked += 1;
|
|
177
|
+
else if (finding.state === "pending_cloud_trust") counts.pendingCloudTrust += 1;
|
|
178
|
+
else counts.advisory += 1;
|
|
179
|
+
}
|
|
180
|
+
if (counts.blocked > 0) reasons.add("blockedFindings");
|
|
181
|
+
if (counts.pendingCloudTrust > 0) reasons.add("pendingCloudTrust");
|
|
182
|
+
|
|
183
|
+
// ---- Verdict -------------------------------------------------------------
|
|
184
|
+
const ordered = REASON_ORDER.filter((reason) => reasons.has(reason));
|
|
185
|
+
let verdict: EvaluationVerdict;
|
|
186
|
+
if (ordered.some((reason) => INTEGRITY_REASONS.has(reason))) {
|
|
187
|
+
verdict = "indeterminate";
|
|
188
|
+
} else if (ordered.some((reason) => BLOCK_REASONS.has(reason))) {
|
|
189
|
+
verdict = "block";
|
|
190
|
+
} else if (ordered.length > 0) {
|
|
191
|
+
verdict = "indeterminate";
|
|
192
|
+
} else {
|
|
193
|
+
verdict = "pass";
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const derived = {
|
|
197
|
+
warnPresent: evidence.some((finding) => finding.severity === "warn"),
|
|
198
|
+
gatingFindingsPresent: counts.blocked > 0,
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const inputDigest = sha256Hex(canonicalPreimage(normalizeInputForDigest(input)));
|
|
202
|
+
const partial = {
|
|
203
|
+
evaluatorVersion: EVALUATOR_VERSION,
|
|
204
|
+
verdict,
|
|
205
|
+
reasons: ordered,
|
|
206
|
+
findings,
|
|
207
|
+
counts,
|
|
208
|
+
derived,
|
|
209
|
+
integrity: { fcidVerified },
|
|
210
|
+
};
|
|
211
|
+
return {
|
|
212
|
+
...partial,
|
|
213
|
+
inputDigest,
|
|
214
|
+
resultDigest: sha256Hex(canonicalPreimage({ ...partial, inputDigest })),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
// Evaluation V2
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
const V2_REASON_ORDER: readonly EvaluationReasonV2[] = [
|
|
223
|
+
"analysisPlanIdentityMismatch",
|
|
224
|
+
"requiredCoverageMissing",
|
|
225
|
+
"requiredCoverageIncomplete",
|
|
226
|
+
"factConflict",
|
|
227
|
+
"unknownAnalysisProfile",
|
|
228
|
+
...REASON_ORDER,
|
|
229
|
+
];
|
|
230
|
+
|
|
231
|
+
const V2_INTEGRITY_REASONS = new Set<EvaluationReasonV2>([
|
|
232
|
+
...INTEGRITY_REASONS,
|
|
233
|
+
"analysisPlanIdentityMismatch",
|
|
234
|
+
"requiredCoverageMissing",
|
|
235
|
+
"requiredCoverageIncomplete",
|
|
236
|
+
"factConflict",
|
|
237
|
+
"unknownAnalysisProfile",
|
|
238
|
+
]);
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* V1 supports one exact first-party profile/fact/evaluator generation. Unknown
|
|
242
|
+
* generations are never interpreted optimistically: evaluator/fact schema
|
|
243
|
+
* mismatches reject the call, while an unknown analysis profile remains a
|
|
244
|
+
* typed indeterminate result so coverage can explain the unsupported source.
|
|
245
|
+
*/
|
|
246
|
+
const SUPPORTED_FACT_SCHEMA_VERSIONS_V2 = new Set(["facts:v1"]);
|
|
247
|
+
const SUPPORTED_REACT_WEB_PROFILE_VERSIONS_V1 = new Set(["1"]);
|
|
248
|
+
|
|
249
|
+
function evaluateV2(rawInput: EvaluationInputV2): EvaluationResultV2 {
|
|
250
|
+
if (rawInput.evaluatorVersion !== EVALUATOR_VERSION_V2) {
|
|
251
|
+
throw new Error(`Unsupported evaluator version: ${rawInput.evaluatorVersion}`);
|
|
252
|
+
}
|
|
253
|
+
if (!SUPPORTED_FACT_SCHEMA_VERSIONS_V2.has(rawInput.factSchemaVersion)) {
|
|
254
|
+
throw new Error(`Unsupported fact schema version: ${rawInput.factSchemaVersion}`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const input = evaluationInputV2Schema.parse(rawInput);
|
|
258
|
+
const coverageSummary = deriveCoverageSummaryV1(input.analysisPlan, input.coverage);
|
|
259
|
+
const profileKnown =
|
|
260
|
+
input.integrity.profile.state === "known" &&
|
|
261
|
+
input.analysisPlan.profile.id === "react-web-v1" &&
|
|
262
|
+
SUPPORTED_REACT_WEB_PROFILE_VERSIONS_V1.has(input.analysisPlan.profile.version);
|
|
263
|
+
|
|
264
|
+
const compatibilityInput: EvaluationInput = {
|
|
265
|
+
contract: {
|
|
266
|
+
state: "pinned",
|
|
267
|
+
fcid: input.contract.fcid,
|
|
268
|
+
preimage: input.contract.preimage,
|
|
269
|
+
...(!input.contract.active ? { stale: true } : {}),
|
|
270
|
+
},
|
|
271
|
+
scope: { kind: "full", coverage: { filesScanned: coverageSummary.analyzedFiles } },
|
|
272
|
+
evidence: { kind: "facts", facts: input.facts },
|
|
273
|
+
policy: input.policy,
|
|
274
|
+
...(input.baseline
|
|
275
|
+
? {
|
|
276
|
+
baseline: {
|
|
277
|
+
identityVerified: true,
|
|
278
|
+
findingStates: input.baseline.findingStates,
|
|
279
|
+
},
|
|
280
|
+
}
|
|
281
|
+
: {}),
|
|
282
|
+
...(input.adoption !== undefined ? { adoption: input.adoption } : {}),
|
|
283
|
+
...(input.trust !== undefined ? { trust: input.trust } : {}),
|
|
284
|
+
waivers: input.waivers.flatMap((waiver) =>
|
|
285
|
+
waiver.matchedFindingFingerprints.map((fingerprint) => ({
|
|
286
|
+
fingerprint,
|
|
287
|
+
...(waiver.expiresAt !== undefined ? { expiresAt: waiver.expiresAt } : {}),
|
|
288
|
+
}))
|
|
289
|
+
),
|
|
290
|
+
integrity: {
|
|
291
|
+
governance: input.integrity.governance,
|
|
292
|
+
...(input.integrity.inertConfigDiagnosticCount > 0
|
|
293
|
+
? { inertConfigDiagnostics: input.integrity.inertConfigDiagnosticCount }
|
|
294
|
+
: {}),
|
|
295
|
+
},
|
|
296
|
+
evaluatedAt: input.evaluatedAt,
|
|
297
|
+
};
|
|
298
|
+
const compatibilityResult = evaluateV1(compatibilityInput);
|
|
299
|
+
|
|
300
|
+
const requiredCoverage =
|
|
301
|
+
coverageSummary.requiredRegions === 0
|
|
302
|
+
? "no_required_regions"
|
|
303
|
+
: coverageSummary.requiredByState.analyzed === coverageSummary.requiredRegions
|
|
304
|
+
? "complete"
|
|
305
|
+
: "incomplete";
|
|
306
|
+
const integrity: EvaluationResultV2["integrity"] = {
|
|
307
|
+
fcidVerified: compatibilityResult.integrity.fcidVerified === true,
|
|
308
|
+
analysisPlanVerified: true,
|
|
309
|
+
factIntegrity: input.integrity.facts.state,
|
|
310
|
+
requiredCoverage,
|
|
311
|
+
governance: input.integrity.governance,
|
|
312
|
+
profile: profileKnown ? ("known" as const) : ("unknown" as const),
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
const reasons = new Set<EvaluationReasonV2>(compatibilityResult.reasons);
|
|
316
|
+
if (requiredCoverage === "no_required_regions") reasons.add("requiredCoverageMissing");
|
|
317
|
+
if (requiredCoverage === "incomplete") reasons.add("requiredCoverageIncomplete");
|
|
318
|
+
if (integrity.factIntegrity === "conflict") reasons.add("factConflict");
|
|
319
|
+
if (integrity.profile === "unknown") reasons.add("unknownAnalysisProfile");
|
|
320
|
+
const orderedReasons = V2_REASON_ORDER.filter((reason) => reasons.has(reason));
|
|
321
|
+
|
|
322
|
+
let verdict = compatibilityResult.verdict;
|
|
323
|
+
if (orderedReasons.some((reason) => V2_INTEGRITY_REASONS.has(reason))) {
|
|
324
|
+
verdict = "indeterminate";
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const inputDigest = evaluationInputDigestV2(input);
|
|
328
|
+
const partial = {
|
|
329
|
+
schemaVersion: 2 as const,
|
|
330
|
+
verdict,
|
|
331
|
+
reasons: orderedReasons,
|
|
332
|
+
findings: compatibilityResult.findings.map((finding) => ({
|
|
333
|
+
...finding,
|
|
334
|
+
stateReasons: [...finding.stateReasons],
|
|
335
|
+
})),
|
|
336
|
+
counts: { ...compatibilityResult.counts },
|
|
337
|
+
derived: { ...compatibilityResult.derived },
|
|
338
|
+
coverageSummary,
|
|
339
|
+
integrity,
|
|
340
|
+
};
|
|
341
|
+
const resultDigest = evaluationResultDigestV2({ ...partial, inputDigest });
|
|
342
|
+
return evaluationResultV2Schema.parse({ ...partial, inputDigest, resultDigest });
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Hash captured input identity without re-running the rule engine. */
|
|
346
|
+
export function evaluationInputDigestV2(input: EvaluationInputV2): string {
|
|
347
|
+
return sha256Hex(
|
|
348
|
+
canonicalPreimage(
|
|
349
|
+
normalizeInputV2ForDigest(input, deriveCoverageSummaryV1(input.analysisPlan, input.coverage))
|
|
350
|
+
)
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Validate stored result identity using the same algorithm as evaluation. */
|
|
355
|
+
export function evaluationResultDigestV2(result: Omit<EvaluationResultV2, "resultDigest">): string {
|
|
356
|
+
return sha256Hex(canonicalPreimage({ schema: "evaluation-result:v2", ...result }));
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function normalizeInputV2ForDigest(
|
|
360
|
+
input: EvaluationInputV2,
|
|
361
|
+
coverageSummary: ReturnType<typeof deriveCoverageSummaryV1>
|
|
362
|
+
) {
|
|
363
|
+
const {
|
|
364
|
+
analysisPlanId: _analysisPlanId,
|
|
365
|
+
createdAt: _createdAt,
|
|
366
|
+
digest: _planDigest,
|
|
367
|
+
obligationsDigest: _obligationsDigest,
|
|
368
|
+
...analysisPlanSemantic
|
|
369
|
+
} = input.analysisPlan;
|
|
370
|
+
return {
|
|
371
|
+
schema: "evaluation-input:v2",
|
|
372
|
+
binding: input.binding,
|
|
373
|
+
analysisPlan: {
|
|
374
|
+
...analysisPlanSemantic,
|
|
375
|
+
analysisPlanDigest: input.analysisPlan.digest,
|
|
376
|
+
obligationsDigest: input.analysisPlan.obligationsDigest,
|
|
377
|
+
},
|
|
378
|
+
contract: input.contract,
|
|
379
|
+
factSchemaVersion: input.factSchemaVersion,
|
|
380
|
+
factsDigest: input.factsDigest,
|
|
381
|
+
coverage: [...input.coverage]
|
|
382
|
+
.map((region) => ({
|
|
383
|
+
...region,
|
|
384
|
+
diagnosticIds: [...region.diagnosticIds].sort(compareCanonicalStrings),
|
|
385
|
+
}))
|
|
386
|
+
.sort((left, right) => compareCanonicalStrings(left.regionId, right.regionId)),
|
|
387
|
+
coverageSummary,
|
|
388
|
+
policy: input.policy,
|
|
389
|
+
baseline: input.baseline,
|
|
390
|
+
adoption: input.adoption,
|
|
391
|
+
trust: input.trust,
|
|
392
|
+
waivers: input.waivers
|
|
393
|
+
.map((waiver) => ({
|
|
394
|
+
...waiver,
|
|
395
|
+
matchedFindingFingerprints: [...waiver.matchedFindingFingerprints].sort(
|
|
396
|
+
compareCanonicalStrings
|
|
397
|
+
),
|
|
398
|
+
}))
|
|
399
|
+
.sort((left, right) => compareCanonicalStrings(left.waiverId, right.waiverId)),
|
|
400
|
+
integrity: input.integrity,
|
|
401
|
+
evaluatedAt: input.evaluatedAt,
|
|
402
|
+
evaluatorVersion: input.evaluatorVersion,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ---------------------------------------------------------------------------
|
|
407
|
+
// Evidence
|
|
408
|
+
// ---------------------------------------------------------------------------
|
|
409
|
+
|
|
410
|
+
function evidenceFindings(input: EvaluationInput): EvaluationFindingInput[] {
|
|
411
|
+
if (input.evidence.kind === "findings") return [...input.evidence.findings];
|
|
412
|
+
// Facts mode: run the core rules engine inside the kernel (spec §1 — the
|
|
413
|
+
// kernel extends the `runRules(FactIndex)` seed). `runRules` is pure and
|
|
414
|
+
// its output ordering is already deterministic.
|
|
415
|
+
// The configured severity (`govern.rules[id].severity`, a `governance_rule_config`
|
|
416
|
+
// fact) is applied here exactly as the engine applies it to the findings it
|
|
417
|
+
// prints — otherwise a rule the config demoted to `info` gates the verdict.
|
|
418
|
+
const ruleConfig = readRuleConfig(input.evidence.facts);
|
|
419
|
+
return runRules(input.evidence.facts).map((finding) => ({
|
|
420
|
+
fingerprint: finding.fingerprint,
|
|
421
|
+
ruleId: finding.ruleId,
|
|
422
|
+
severity: severityLevel(configuredFindingSeverity(finding, ruleConfig)),
|
|
423
|
+
advisory: finding.attributes?.advisory === true,
|
|
424
|
+
evidenceGrade: finding.evidenceGrade,
|
|
425
|
+
}));
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Hardest state first: an earned block outranks a pending hold outranks an
|
|
429
|
+
* advisory downgrade. */
|
|
430
|
+
const STATE_RANK = { blocked: 0, pending_cloud_trust: 1, advisory: 2 } as const;
|
|
431
|
+
|
|
432
|
+
const SEVERITY_RANK = { error: 0, warn: 1, info: 2 } as const;
|
|
433
|
+
|
|
434
|
+
/** Canonical row shape: the evidence grade is normalized (absent reads as
|
|
435
|
+
* source-backed) so semantically identical rows compare — and digest —
|
|
436
|
+
* identically. */
|
|
437
|
+
function canonicalRow(finding: EvaluationFindingInput): EvaluationFindingInput {
|
|
438
|
+
return {
|
|
439
|
+
fingerprint: finding.fingerprint,
|
|
440
|
+
ruleId: finding.ruleId,
|
|
441
|
+
severity: finding.severity,
|
|
442
|
+
advisory: finding.advisory === true,
|
|
443
|
+
evidenceGrade: coerceEvidenceGrade(finding.evidenceGrade),
|
|
444
|
+
waived: finding.waived === true,
|
|
445
|
+
...(finding.sourceTrust !== undefined ? { sourceTrust: finding.sourceTrust } : {}),
|
|
446
|
+
...(finding.mappingTrust !== undefined ? { mappingTrust: finding.mappingTrust } : {}),
|
|
447
|
+
...(finding.baselineState !== undefined ? { baselineState: finding.baselineState } : {}),
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function compareStrings(a: string, b: string): number {
|
|
452
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function compareRows(a: EvaluationFindingInput, b: EvaluationFindingInput): number {
|
|
456
|
+
return (
|
|
457
|
+
compareStrings(a.fingerprint, b.fingerprint) ||
|
|
458
|
+
SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity] ||
|
|
459
|
+
compareStrings(a.ruleId, b.ruleId) ||
|
|
460
|
+
Number(a.advisory ?? false) - Number(b.advisory ?? false) ||
|
|
461
|
+
compareStrings(a.evidenceGrade ?? "", b.evidenceGrade ?? "") ||
|
|
462
|
+
Number(a.waived ?? false) - Number(b.waived ?? false) ||
|
|
463
|
+
compareStrings(a.sourceTrust ?? "", b.sourceTrust ?? "") ||
|
|
464
|
+
compareStrings(a.mappingTrust ?? "", b.mappingTrust ?? "") ||
|
|
465
|
+
compareStrings(a.baselineState ?? "", b.baselineState ?? "")
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** Canonicalized, fully-ordered, exact-duplicate-free rows — input ordering
|
|
470
|
+
* can never reach the classification or the digests. */
|
|
471
|
+
function canonicalRows(findings: readonly EvaluationFindingInput[]): EvaluationFindingInput[] {
|
|
472
|
+
const sorted = findings.map(canonicalRow).sort(compareRows);
|
|
473
|
+
return sorted.filter((row, index) => index === 0 || compareRows(row, sorted[index - 1]!) !== 0);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function groupByFingerprint(
|
|
477
|
+
findings: readonly EvaluationFindingInput[]
|
|
478
|
+
): Map<string, EvaluationFindingInput[]> {
|
|
479
|
+
const groups = new Map<string, EvaluationFindingInput[]>();
|
|
480
|
+
for (const row of canonicalRows(findings)) {
|
|
481
|
+
const group = groups.get(row.fingerprint);
|
|
482
|
+
if (group) group.push(row);
|
|
483
|
+
else groups.set(row.fingerprint, [row]);
|
|
484
|
+
}
|
|
485
|
+
return groups;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// ---------------------------------------------------------------------------
|
|
489
|
+
// Per-finding classification
|
|
490
|
+
// ---------------------------------------------------------------------------
|
|
491
|
+
|
|
492
|
+
function classifyFinding(
|
|
493
|
+
finding: EvaluationFindingInput,
|
|
494
|
+
input: EvaluationInput,
|
|
495
|
+
activeWaivers: ReadonlySet<string>
|
|
496
|
+
): EvaluatedFinding {
|
|
497
|
+
const base = {
|
|
498
|
+
fingerprint: finding.fingerprint,
|
|
499
|
+
ruleId: finding.ruleId,
|
|
500
|
+
severity: finding.severity,
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
if (activeWaivers.has(finding.fingerprint) || finding.waived === true) {
|
|
504
|
+
return { ...base, state: "advisory", stateReasons: ["waived"] };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const eligibility = eligibilityFor(finding, input.policy);
|
|
508
|
+
if (eligibility !== null) {
|
|
509
|
+
return { ...base, state: "advisory", stateReasons: [eligibility] };
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Candidate. A verified baseline that records this exact finding as existing
|
|
513
|
+
// debt downgrades it regardless of the rule's trust requirements — existing
|
|
514
|
+
// debt may be green (spec §2), and Cloud can never promote existing back to
|
|
515
|
+
// introduced.
|
|
516
|
+
const introduced = introducedFactFor(finding, input);
|
|
517
|
+
if (introduced.state === "negative") {
|
|
518
|
+
return { ...base, state: "advisory", stateReasons: ["baseline:existing"] };
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// A policy override relaxes which UNKNOWN facts hold a finding — it never
|
|
522
|
+
// silences a DEFINITIVE negative the rule's intrinsic (default) requirement
|
|
523
|
+
// knows about: spec §3 never enforces a proven non-reproduction, and a
|
|
524
|
+
// contract-tier rule with an authoritatively unconfirmed mapping has no
|
|
525
|
+
// named fix to offer, override or not.
|
|
526
|
+
const requirement =
|
|
527
|
+
input.policy.rules?.[finding.ruleId]?.trust ?? defaultTrustRequirement(finding.ruleId);
|
|
528
|
+
const mappingBinds =
|
|
529
|
+
requirement === "canonical" || defaultTrustRequirement(finding.ruleId) === "canonical";
|
|
530
|
+
if (requirement === "none") {
|
|
531
|
+
const definitives: Array<[string, TrustFactState]> = [sourceFact(finding, input)];
|
|
532
|
+
if (mappingBinds) definitives.push(mappingFact(finding, input));
|
|
533
|
+
const definitiveNegative = definitives.find(([, state]) => state === "negative");
|
|
534
|
+
if (definitiveNegative) {
|
|
535
|
+
return { ...base, state: "advisory", stateReasons: [`trust:${definitiveNegative[0]}`] };
|
|
536
|
+
}
|
|
537
|
+
return { ...base, state: "blocked", stateReasons: [`eligible:${finding.severity}`] };
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const facts: Array<[string, TrustFactState]> = [
|
|
541
|
+
[`introduced:${introduced.detail}`, introduced.state],
|
|
542
|
+
sourceFact(finding, input),
|
|
543
|
+
];
|
|
544
|
+
if (requirement === "canonical") {
|
|
545
|
+
facts.push(mappingFact(finding, input));
|
|
546
|
+
} else if (mappingBinds) {
|
|
547
|
+
const mapping = mappingFact(finding, input);
|
|
548
|
+
if (mapping[1] === "negative") facts.push(mapping);
|
|
549
|
+
}
|
|
550
|
+
const negative = facts.find(([, state]) => state === "negative");
|
|
551
|
+
if (negative) {
|
|
552
|
+
return { ...base, state: "advisory", stateReasons: [`trust:${negative[0]}`] };
|
|
553
|
+
}
|
|
554
|
+
const unknowns = facts.filter(([, state]) => state === "unknown");
|
|
555
|
+
if (unknowns.length > 0) {
|
|
556
|
+
return {
|
|
557
|
+
...base,
|
|
558
|
+
state: "pending_cloud_trust",
|
|
559
|
+
stateReasons: unknowns.map(([label]) => `trust:${label}`),
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
return { ...base, state: "blocked", stateReasons: [`eligible:${finding.severity}`, "trust:all"] };
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** Non-null = the advisory state reason; null = the finding is a candidate. */
|
|
566
|
+
function eligibilityFor(finding: EvaluationFindingInput, policy: EffectivePolicy): string | null {
|
|
567
|
+
if (finding.advisory === true) return "ineligible:advisory-tier";
|
|
568
|
+
if (!canBlock(coerceEvidenceGrade(finding.evidenceGrade))) return "ineligible:evidence-grade";
|
|
569
|
+
if (finding.severity === "error") return null;
|
|
570
|
+
if (finding.severity === "warn") {
|
|
571
|
+
return policy.failOnWarnings ? null : "ineligible:warn-not-gated";
|
|
572
|
+
}
|
|
573
|
+
return "ineligible:info";
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** Missing grades read as source-backed (compat with older reports); unknown
|
|
577
|
+
* grade strings read as non-blocking — an unclassifiable claim never gates. */
|
|
578
|
+
function coerceEvidenceGrade(grade: string | undefined): EvidenceGrade {
|
|
579
|
+
if (grade === undefined) return "source_backed";
|
|
580
|
+
return (EVIDENCE_ORDER as readonly string[]).includes(grade) ? (grade as EvidenceGrade) : "none";
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function defaultTrustRequirement(ruleId: string): "canonical" | "evidence" | "none" {
|
|
584
|
+
// RULE_TIER is the SSOT: contract-tier rules judge the declared canonical
|
|
585
|
+
// vocabulary and need the full canonical trust facts. EVERY other rule —
|
|
586
|
+
// hygiene and unknown ids alike — still requires source + introduced (spec
|
|
587
|
+
// §2: a candidate with unknown trust is never silently green, and spec §3
|
|
588
|
+
// never enforces unverified evidence), but a canonical mapping a rule
|
|
589
|
+
// cannot have (registry drift, style rules) is not demanded of it. `none`
|
|
590
|
+
// exists only as an explicit per-rule policy override.
|
|
591
|
+
return tierFor(ruleId) === "contract" ? "canonical" : "evidence";
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function introducedFactFor(
|
|
595
|
+
finding: EvaluationFindingInput,
|
|
596
|
+
input: EvaluationInput
|
|
597
|
+
): { state: TrustFactState; detail: string } {
|
|
598
|
+
const baseline = input.baseline;
|
|
599
|
+
if (!baseline) return { state: "unknown", detail: "no-baseline" };
|
|
600
|
+
if (!baseline.identityVerified) return { state: "unknown", detail: "identity-unverified" };
|
|
601
|
+
// Verified baseline: an absent entry means the finding is not in the
|
|
602
|
+
// baseline — introduced. A record-level judgment overrides the map.
|
|
603
|
+
const recorded =
|
|
604
|
+
finding.baselineState ?? baseline.findingStates?.[finding.fingerprint] ?? "introduced";
|
|
605
|
+
if (recorded === "existing") return { state: "negative", detail: "existing" };
|
|
606
|
+
if (recorded === "introduced") return { state: "affirmative", detail: "introduced" };
|
|
607
|
+
return { state: "unknown", detail: "unknown" };
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function sourceFact(
|
|
611
|
+
finding: EvaluationFindingInput,
|
|
612
|
+
input: EvaluationInput
|
|
613
|
+
): [string, TrustFactState] {
|
|
614
|
+
const source =
|
|
615
|
+
finding.sourceTrust ??
|
|
616
|
+
input.trust?.sources?.[finding.fingerprint] ??
|
|
617
|
+
input.trust?.source ??
|
|
618
|
+
"unknown";
|
|
619
|
+
if (source === "verified") return ["source:verified", "affirmative"];
|
|
620
|
+
if (source === "unverified") return ["source:non-reproduction", "negative"];
|
|
621
|
+
return ["source:unknown", "unknown"];
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function mappingFact(
|
|
625
|
+
finding: EvaluationFindingInput,
|
|
626
|
+
input: EvaluationInput
|
|
627
|
+
): [string, TrustFactState] {
|
|
628
|
+
const mapping = finding.mappingTrust ?? input.trust?.mappings[finding.fingerprint] ?? "unknown";
|
|
629
|
+
if (mapping === "confirmed") return ["mapping:confirmed", "affirmative"];
|
|
630
|
+
if (mapping === "unconfirmed") return ["mapping:unconfirmed", "negative"];
|
|
631
|
+
return ["mapping:unknown", "unknown"];
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// ---------------------------------------------------------------------------
|
|
635
|
+
// Digests
|
|
636
|
+
// ---------------------------------------------------------------------------
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Digest over the judgment-relevant input. Facts-mode evidence digests the
|
|
640
|
+
* DERIVED findings (a `FactIndex` is a class instance, not a canonical value);
|
|
641
|
+
* two inputs that judge identically digest identically.
|
|
642
|
+
*/
|
|
643
|
+
function normalizeInputForDigest(input: EvaluationInput) {
|
|
644
|
+
return {
|
|
645
|
+
schema: "evaluation-input:v1",
|
|
646
|
+
contract: input.contract,
|
|
647
|
+
scope: input.scope,
|
|
648
|
+
evidence: canonicalRows(evidenceFindings(input)),
|
|
649
|
+
policy: input.policy,
|
|
650
|
+
baseline: input.baseline,
|
|
651
|
+
adoption: input.adoption,
|
|
652
|
+
trust: input.trust,
|
|
653
|
+
waivers: canonicalWaivers(input.waivers ?? []),
|
|
654
|
+
integrity: {
|
|
655
|
+
...input.integrity,
|
|
656
|
+
...(input.integrity.unknownPresets
|
|
657
|
+
? { unknownPresets: [...new Set(input.integrity.unknownPresets)].sort() }
|
|
658
|
+
: {}),
|
|
659
|
+
},
|
|
660
|
+
evaluatedAt: input.evaluatedAt,
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/** Totally ordered (fingerprint, then expiry) and exact-duplicate-free.
|
|
665
|
+
* Absent, `Infinity`, and `NaN` expiries all mean "never expires" against
|
|
666
|
+
* `expiresAt <= evaluatedAt`, so they canonicalize identically (no expiry). */
|
|
667
|
+
function canonicalWaivers(waivers: readonly EvaluationWaiver[]) {
|
|
668
|
+
const expiryOf = (waiver: EvaluationWaiver) =>
|
|
669
|
+
waiver.expiresAt === undefined || Number.isNaN(waiver.expiresAt)
|
|
670
|
+
? Number.POSITIVE_INFINITY
|
|
671
|
+
: waiver.expiresAt;
|
|
672
|
+
const canonical = waivers.map((waiver) => {
|
|
673
|
+
const expiresAt = expiryOf(waiver);
|
|
674
|
+
return expiresAt === Number.POSITIVE_INFINITY
|
|
675
|
+
? { fingerprint: waiver.fingerprint }
|
|
676
|
+
: { fingerprint: waiver.fingerprint, expiresAt };
|
|
677
|
+
});
|
|
678
|
+
const sorted = canonical.sort((a, b) => {
|
|
679
|
+
if (a.fingerprint !== b.fingerprint) return a.fingerprint < b.fingerprint ? -1 : 1;
|
|
680
|
+
const ea = expiryOf(a);
|
|
681
|
+
const eb = expiryOf(b);
|
|
682
|
+
return ea === eb ? 0 : ea < eb ? -1 : 1;
|
|
683
|
+
});
|
|
684
|
+
return sorted.filter(
|
|
685
|
+
(waiver, index) =>
|
|
686
|
+
index === 0 ||
|
|
687
|
+
waiver.fingerprint !== sorted[index - 1]!.fingerprint ||
|
|
688
|
+
expiryOf(waiver) !== expiryOf(sorted[index - 1]!)
|
|
689
|
+
);
|
|
690
|
+
}
|