@affiant/core 0.1.0-alpha.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/LICENSE +202 -0
- package/README.md +354 -0
- package/dist/context.d.ts +136 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +30 -0
- package/dist/context.js.map +1 -0
- package/dist/docket/entry.d.ts +421 -0
- package/dist/docket/entry.d.ts.map +1 -0
- package/dist/docket/entry.js +155 -0
- package/dist/docket/entry.js.map +1 -0
- package/dist/docket/expiry.d.ts +82 -0
- package/dist/docket/expiry.d.ts.map +1 -0
- package/dist/docket/expiry.js +106 -0
- package/dist/docket/expiry.js.map +1 -0
- package/dist/docket/memory.d.ts +163 -0
- package/dist/docket/memory.d.ts.map +1 -0
- package/dist/docket/memory.js +528 -0
- package/dist/docket/memory.js.map +1 -0
- package/dist/docket/store.d.ts +387 -0
- package/dist/docket/store.d.ts.map +1 -0
- package/dist/docket/store.js +51 -0
- package/dist/docket/store.js.map +1 -0
- package/dist/errors.d.ts +153 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +164 -0
- package/dist/errors.js.map +1 -0
- package/dist/gate/coverage.d.ts +152 -0
- package/dist/gate/coverage.d.ts.map +1 -0
- package/dist/gate/coverage.js +114 -0
- package/dist/gate/coverage.js.map +1 -0
- package/dist/gate/decide.d.ts +207 -0
- package/dist/gate/decide.d.ts.map +1 -0
- package/dist/gate/decide.js +559 -0
- package/dist/gate/decide.js.map +1 -0
- package/dist/gate/gate.d.ts +212 -0
- package/dist/gate/gate.d.ts.map +1 -0
- package/dist/gate/gate.js +175 -0
- package/dist/gate/gate.js.map +1 -0
- package/dist/gate/pipeline.d.ts +285 -0
- package/dist/gate/pipeline.d.ts.map +1 -0
- package/dist/gate/pipeline.js +515 -0
- package/dist/gate/pipeline.js.map +1 -0
- package/dist/gate/policy.d.ts +272 -0
- package/dist/gate/policy.d.ts.map +1 -0
- package/dist/gate/policy.js +396 -0
- package/dist/gate/policy.js.map +1 -0
- package/dist/gate/wrap.d.ts +107 -0
- package/dist/gate/wrap.d.ts.map +1 -0
- package/dist/gate/wrap.js +164 -0
- package/dist/gate/wrap.js.map +1 -0
- package/dist/index.d.ts +95 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +111 -0
- package/dist/index.js.map +1 -0
- package/dist/model/affidavit.d.ts +354 -0
- package/dist/model/affidavit.d.ts.map +1 -0
- package/dist/model/affidavit.js +417 -0
- package/dist/model/affidavit.js.map +1 -0
- package/dist/model/amendments.d.ts +160 -0
- package/dist/model/amendments.d.ts.map +1 -0
- package/dist/model/amendments.js +183 -0
- package/dist/model/amendments.js.map +1 -0
- package/dist/model/canonical.d.ts +311 -0
- package/dist/model/canonical.d.ts.map +1 -0
- package/dist/model/canonical.js +665 -0
- package/dist/model/canonical.js.map +1 -0
- package/dist/model/money.d.ts +127 -0
- package/dist/model/money.d.ts.map +1 -0
- package/dist/model/money.js +177 -0
- package/dist/model/money.js.map +1 -0
- package/dist/model/provenance.d.ts +315 -0
- package/dist/model/provenance.d.ts.map +1 -0
- package/dist/model/provenance.js +223 -0
- package/dist/model/provenance.js.map +1 -0
- package/dist/ports.d.ts +269 -0
- package/dist/ports.d.ts.map +1 -0
- package/dist/ports.js +34 -0
- package/dist/ports.js.map +1 -0
- package/dist/store-memory.d.ts +21 -0
- package/dist/store-memory.d.ts.map +1 -0
- package/dist/store-memory.js +20 -0
- package/dist/store-memory.js.map +1 -0
- package/dist/telemetry-keys.d.ts +65 -0
- package/dist/telemetry-keys.d.ts.map +1 -0
- package/dist/telemetry-keys.js +72 -0
- package/dist/telemetry-keys.js.map +1 -0
- package/dist/telemetry.d.ts +77 -0
- package/dist/telemetry.d.ts.map +1 -0
- package/dist/telemetry.js +43 -0
- package/dist/telemetry.js.map +1 -0
- package/dist/testing.d.ts +574 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +1291 -0
- package/dist/testing.js.map +1 -0
- package/package.json +75 -0
- package/telemetry-keys.json +92 -0
package/dist/testing.js
ADDED
|
@@ -0,0 +1,1291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@affiant/core/testing` — the declarative fixture format, the runner that
|
|
3
|
+
* executes it against a real gate, and the stub ports a fixture is wired from.
|
|
4
|
+
*
|
|
5
|
+
* ## What a fixture is
|
|
6
|
+
*
|
|
7
|
+
* One JSON document describing **a wiring, a sequence of acts, and what must be
|
|
8
|
+
* true afterwards**:
|
|
9
|
+
*
|
|
10
|
+
* ```jsonc
|
|
11
|
+
* {
|
|
12
|
+
* "id": "gate/standing-order-by-the-book",
|
|
13
|
+
* "rules": ["GT-5", "AZ-1"],
|
|
14
|
+
* "title": "A Standing Order with no threshold fires on the verdict alone",
|
|
15
|
+
* "given": {
|
|
16
|
+
* "clock": "2026-09-04T09:00:00.000Z",
|
|
17
|
+
* "gate": { "defaultTtlMs": 1800000, "authorization": { "allow": ["*"] } },
|
|
18
|
+
* "ctx": { "tenantId": "tenant-a", "conversationId": "conv-1", "channel": "chat" },
|
|
19
|
+
* "prior": [],
|
|
20
|
+
* "step": { "kind": "file", "toolName": "capture", "operation": { … } }
|
|
21
|
+
* },
|
|
22
|
+
* "expect": { "entry": { "status": "approved" } }
|
|
23
|
+
* }
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* ## The runner is strict about the document, not only about the gate
|
|
27
|
+
*
|
|
28
|
+
* A fixture is checked against the format **before** it is run, and a fixture that
|
|
29
|
+
* fails that check is not run at all: an unknown key at any level fails the fixture
|
|
30
|
+
* naming its path, and an `expect` clause that states no fact — `{}`,
|
|
31
|
+
* `{ entry: {} }`, `{ telemetryAbsent: [] }` — fails as vacuous. Both are the same
|
|
32
|
+
* defect wearing different clothes: a fixture that asserts nothing, or asserts it in
|
|
33
|
+
* a key nothing reads, is passed by every implementation including a broken one, and
|
|
34
|
+
* the rulebook's negative-oracle clause exists to forbid exactly that. Since this
|
|
35
|
+
* runner is what a conformance driver executes against a second implementation and
|
|
36
|
+
* what its parity manifest is derived from, a silently-ignored key would be a rule
|
|
37
|
+
* silently unchecked in both implementations at once.
|
|
38
|
+
*
|
|
39
|
+
* **One shape for every rule.** The gate's whole surface is reachable from
|
|
40
|
+
* {@link FixtureStep} — `wrap-execute`, `file`, `decide`, `resubmit`,
|
|
41
|
+
* `markExecuted`, `expireDue`, `get`, `rehydrate` — so a fixture about a decision
|
|
42
|
+
* and a fixture about a filing differ in their steps, not in their format. A single
|
|
43
|
+
* format is what lets one runner serve three consumers: this package's own suites,
|
|
44
|
+
* a host checking its ports against the reference behaviour, and the conformance
|
|
45
|
+
* driver that will run the same documents against a second implementation.
|
|
46
|
+
*
|
|
47
|
+
* ## What the expectations are, and what they are not
|
|
48
|
+
*
|
|
49
|
+
* Every expectation is a **partial** matcher: a fixture states the facts its rule is
|
|
50
|
+
* about and says nothing about the rest, so an unrelated addition to `DocketEntry`
|
|
51
|
+
* does not break thirty documents. What a fixture may not do is state something the
|
|
52
|
+
* implementation computed for it — every value in a fixture is authored, and the
|
|
53
|
+
* generator that mirrors these files into a TypeScript module computes nothing.
|
|
54
|
+
*
|
|
55
|
+
* ## Reading the results
|
|
56
|
+
*
|
|
57
|
+
* {@link runFixture} never throws for a failed expectation: it returns a
|
|
58
|
+
* {@link FixtureResult} carrying every failure it found, each with the path it was
|
|
59
|
+
* found at and the two values. A suite turns that into one assertion; a conformance
|
|
60
|
+
* driver turns the same structure into a parity manifest naming what an
|
|
61
|
+
* implementation does not yet pass. A runner that threw on the first mismatch could
|
|
62
|
+
* do neither.
|
|
63
|
+
*
|
|
64
|
+
* @packageDocumentation
|
|
65
|
+
*/
|
|
66
|
+
import { readStatus } from "./docket/entry.js";
|
|
67
|
+
import { InMemoryDocketStore, InMemorySessionStore } from "./docket/memory.js";
|
|
68
|
+
import { isAffiantError } from "./errors.js";
|
|
69
|
+
import { createGate } from "./gate/gate.js";
|
|
70
|
+
import { canonicalHash, canonicalHashEntry, swornAffidavitOf } from "./model/canonical.js";
|
|
71
|
+
import { chainOf, mintTag } from "./model/provenance.js";
|
|
72
|
+
import { TELEMETRY_KEYS } from "./telemetry-keys.js";
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// The fixture schema
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
/**
|
|
77
|
+
* Every key the fixture format defines, level by level.
|
|
78
|
+
*
|
|
79
|
+
* **Why a runtime schema when the format is already an interface.** A fixture is a
|
|
80
|
+
* JSON document read off disk or off a wire; the interfaces above type the *authors*
|
|
81
|
+
* of one and not the documents themselves. Without this table a misspelled
|
|
82
|
+
* expectation key — `statuz` for `status`, `valu` for `value` — is a key the checker
|
|
83
|
+
* never reads, so the fixture asserts nothing about that fact and passes. That is
|
|
84
|
+
* the shape the rulebook's negative-oracle clause exists to forbid: *a fixture a
|
|
85
|
+
* broken implementation passes is not a test*. This is the oracle a conformance
|
|
86
|
+
* driver runs against a second implementation and the parity manifest is derived
|
|
87
|
+
* from, so a silently-ignored key is a rule silently unchecked in both.
|
|
88
|
+
*
|
|
89
|
+
* The lists are exhaustive and the check is exact: an unknown key anywhere in a
|
|
90
|
+
* fixture fails that fixture, naming the path. Adding a clause to the format means
|
|
91
|
+
* adding it here in the same commit — which is the point.
|
|
92
|
+
*/
|
|
93
|
+
const FIXTURE_KEYS = {
|
|
94
|
+
fixture: ["id", "rules", "title", "given", "expect"],
|
|
95
|
+
given: ["gate", "store", "clock", "ctx", "prior", "step"],
|
|
96
|
+
gate: [
|
|
97
|
+
"policies",
|
|
98
|
+
"riskScorer",
|
|
99
|
+
"interceptors",
|
|
100
|
+
"defaultTtlMs",
|
|
101
|
+
"authorization",
|
|
102
|
+
"inference",
|
|
103
|
+
"entities",
|
|
104
|
+
"uncovered",
|
|
105
|
+
"sessions",
|
|
106
|
+
],
|
|
107
|
+
ctx: ["tenantId", "conversationId", "channel", "principal", "utterance", "messageId"],
|
|
108
|
+
step: ["kind", "as", "at", "principal", "tenantId", "conversationId", "entry", "refusal"],
|
|
109
|
+
expect: [
|
|
110
|
+
"error",
|
|
111
|
+
"entry",
|
|
112
|
+
"card",
|
|
113
|
+
"superseded",
|
|
114
|
+
"telemetry",
|
|
115
|
+
"telemetryAbsent",
|
|
116
|
+
"store",
|
|
117
|
+
"expired",
|
|
118
|
+
"page",
|
|
119
|
+
"found",
|
|
120
|
+
"canonicalHash",
|
|
121
|
+
],
|
|
122
|
+
error: ["code", "messageContains"],
|
|
123
|
+
entry: [
|
|
124
|
+
"status",
|
|
125
|
+
"execution",
|
|
126
|
+
"executionDetail",
|
|
127
|
+
"requirement",
|
|
128
|
+
"blocked",
|
|
129
|
+
"toolName",
|
|
130
|
+
"channel",
|
|
131
|
+
"tenantId",
|
|
132
|
+
"conversationId",
|
|
133
|
+
"attestation",
|
|
134
|
+
"decision",
|
|
135
|
+
"amendments",
|
|
136
|
+
"preservedAmendments",
|
|
137
|
+
"lineage",
|
|
138
|
+
"expiresAtOffsetMs",
|
|
139
|
+
"affidavit",
|
|
140
|
+
"amendedAffidavit",
|
|
141
|
+
"canonicalDiffersFromProposal",
|
|
142
|
+
],
|
|
143
|
+
lineage: ["supersedes", "supersededBy"],
|
|
144
|
+
affidavit: [
|
|
145
|
+
"operationType",
|
|
146
|
+
"entityType",
|
|
147
|
+
"entityId",
|
|
148
|
+
"aggregateConfidence",
|
|
149
|
+
"populatedConfidence",
|
|
150
|
+
"emptyFieldCount",
|
|
151
|
+
"fields",
|
|
152
|
+
],
|
|
153
|
+
affidavitField: [
|
|
154
|
+
"name",
|
|
155
|
+
"value",
|
|
156
|
+
"previousValue",
|
|
157
|
+
"kind",
|
|
158
|
+
"isMandatory",
|
|
159
|
+
"source",
|
|
160
|
+
"bound",
|
|
161
|
+
"bindingKind",
|
|
162
|
+
"confidence",
|
|
163
|
+
"priorSources",
|
|
164
|
+
],
|
|
165
|
+
card: [
|
|
166
|
+
"requiresConfirmation",
|
|
167
|
+
"warningsContain",
|
|
168
|
+
"priorAmendments",
|
|
169
|
+
"blocked",
|
|
170
|
+
"protocolVersion",
|
|
171
|
+
"aggregateConfidence",
|
|
172
|
+
"populatedConfidence",
|
|
173
|
+
"emptyFieldCount",
|
|
174
|
+
"fields",
|
|
175
|
+
"presentation",
|
|
176
|
+
],
|
|
177
|
+
cardField: ["name", "kind", "value", "isMandatory"],
|
|
178
|
+
cardPresentation: ["name", "kind", "allowedValues", "pattern"],
|
|
179
|
+
store: ["count", "pending", "approvedUnexecuted"],
|
|
180
|
+
expired: ["count", "more"],
|
|
181
|
+
page: ["count", "more", "statuses"],
|
|
182
|
+
};
|
|
183
|
+
/** The keys each kind of step adds to {@link FIXTURE_KEYS.step}. */
|
|
184
|
+
const STEP_KEYS = {
|
|
185
|
+
"wrap-execute": ["tool", "args"],
|
|
186
|
+
file: ["toolName", "operation", "schema", "preparedFields", "args", "operationLabel"],
|
|
187
|
+
decide: ["decision"],
|
|
188
|
+
resubmit: [],
|
|
189
|
+
markExecuted: ["outcome", "detail"],
|
|
190
|
+
expireDue: ["limit", "scope"],
|
|
191
|
+
get: [],
|
|
192
|
+
rehydrate: ["page", "scope"],
|
|
193
|
+
};
|
|
194
|
+
/** The clauses {@link wireUpRefusal} can answer. Everything else is unanswerable there. */
|
|
195
|
+
const WIRE_UP_ANSWERABLE = ["error", "telemetry", "telemetryAbsent", "store"];
|
|
196
|
+
/** Whether `value` is a plain object a key check applies to. */
|
|
197
|
+
function isRecord(value) {
|
|
198
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
199
|
+
}
|
|
200
|
+
/** Push a failure for every key of `value` the format does not define. */
|
|
201
|
+
function checkKeys(value, allowed, path, failures) {
|
|
202
|
+
if (!isRecord(value))
|
|
203
|
+
return;
|
|
204
|
+
for (const key of Object.keys(value)) {
|
|
205
|
+
if (allowed.includes(key))
|
|
206
|
+
continue;
|
|
207
|
+
failures.push({
|
|
208
|
+
at: `${path}.${key}`,
|
|
209
|
+
expected: `one of: ${[...allowed].sort().join(", ")}`,
|
|
210
|
+
actual: "an unknown key",
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/** Push a failure for every key `required` names that `value` does not carry. */
|
|
215
|
+
function checkRequired(value, required, path, failures) {
|
|
216
|
+
if (!isRecord(value)) {
|
|
217
|
+
failures.push({ at: path, expected: "an object", actual: value });
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
for (const key of required) {
|
|
221
|
+
if (value[key] === undefined) {
|
|
222
|
+
failures.push({ at: `${path}.${key}`, expected: "stated", actual: "absent" });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
/** Every telemetry key the registry names (TL-1). A fixture may not assert on another. */
|
|
227
|
+
const REGISTERED_TELEMETRY = TELEMETRY_KEYS.map((entry) => entry.key);
|
|
228
|
+
/** Check one telemetry clause: an array of keys the registry knows. */
|
|
229
|
+
function checkTelemetryKeys(value, path, failures) {
|
|
230
|
+
if (value === undefined || value === null)
|
|
231
|
+
return;
|
|
232
|
+
if (!Array.isArray(value)) {
|
|
233
|
+
failures.push({ at: path, expected: "an array of telemetry keys", actual: value });
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
for (const [index, key] of value.entries()) {
|
|
237
|
+
if (typeof key !== "string" || !REGISTERED_TELEMETRY.includes(key)) {
|
|
238
|
+
failures.push({
|
|
239
|
+
at: `${path}[${String(index)}]`,
|
|
240
|
+
expected: `one of: ${[...REGISTERED_TELEMETRY].sort().join(", ")}`,
|
|
241
|
+
actual: key,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
/** Check a row matcher and everything under it. */
|
|
247
|
+
function checkRowKeys(value, path, failures) {
|
|
248
|
+
if (!isRecord(value))
|
|
249
|
+
return;
|
|
250
|
+
checkKeys(value, FIXTURE_KEYS.entry, path, failures);
|
|
251
|
+
checkKeys(value["lineage"], FIXTURE_KEYS.lineage, `${path}.lineage`, failures);
|
|
252
|
+
for (const which of ["affidavit", "amendedAffidavit"]) {
|
|
253
|
+
const affidavit = value[which];
|
|
254
|
+
if (!isRecord(affidavit))
|
|
255
|
+
continue;
|
|
256
|
+
checkKeys(affidavit, FIXTURE_KEYS.affidavit, `${path}.${which}`, failures);
|
|
257
|
+
const fields = affidavit["fields"];
|
|
258
|
+
if (!Array.isArray(fields))
|
|
259
|
+
continue;
|
|
260
|
+
for (const [index, field] of fields.entries()) {
|
|
261
|
+
checkKeys(field, FIXTURE_KEYS.affidavitField, `${path}.${which}.fields[${String(index)}]`, failures);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Check a fixture document against the format before running it.
|
|
267
|
+
*
|
|
268
|
+
* Two families of defect, both of which pass silently without this: a key the format
|
|
269
|
+
* does not define (which the checker never reads, so the fact goes unasserted), and
|
|
270
|
+
* an `expect` clause that states no fact at all (`{}`, `{ entry: {} }`,
|
|
271
|
+
* `{ telemetryAbsent: [] }` — each of which any implementation passes, including one
|
|
272
|
+
* that does nothing).
|
|
273
|
+
*/
|
|
274
|
+
function validateFixture(fixture) {
|
|
275
|
+
const failures = [];
|
|
276
|
+
const document = fixture;
|
|
277
|
+
checkRequired(document, FIXTURE_KEYS.fixture, "fixture", failures);
|
|
278
|
+
checkKeys(document, FIXTURE_KEYS.fixture, "fixture", failures);
|
|
279
|
+
const given = document["given"];
|
|
280
|
+
if (isRecord(given)) {
|
|
281
|
+
checkRequired(given, ["gate", "clock", "ctx", "step"], "given", failures);
|
|
282
|
+
checkKeys(given, FIXTURE_KEYS.given, "given", failures);
|
|
283
|
+
checkKeys(given["gate"], FIXTURE_KEYS.gate, "given.gate", failures);
|
|
284
|
+
checkKeys(given["ctx"], FIXTURE_KEYS.ctx, "given.ctx", failures);
|
|
285
|
+
const prior = given["prior"];
|
|
286
|
+
if (Array.isArray(prior)) {
|
|
287
|
+
for (const [index, step] of prior.entries()) {
|
|
288
|
+
checkStepKeys(step, `given.prior[${String(index)}]`, failures);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
checkStepKeys(given["step"], "given.step", failures);
|
|
292
|
+
}
|
|
293
|
+
const expectation = document["expect"];
|
|
294
|
+
if (isRecord(expectation)) {
|
|
295
|
+
checkKeys(expectation, FIXTURE_KEYS.expect, "expect", failures);
|
|
296
|
+
checkKeys(expectation["error"], FIXTURE_KEYS.error, "expect.error", failures);
|
|
297
|
+
checkRowKeys(expectation["entry"], "expect.entry", failures);
|
|
298
|
+
checkRowKeys(expectation["superseded"], "expect.superseded", failures);
|
|
299
|
+
checkKeys(expectation["store"], FIXTURE_KEYS.store, "expect.store", failures);
|
|
300
|
+
checkKeys(expectation["expired"], FIXTURE_KEYS.expired, "expect.expired", failures);
|
|
301
|
+
checkKeys(expectation["page"], FIXTURE_KEYS.page, "expect.page", failures);
|
|
302
|
+
checkTelemetryKeys(expectation["telemetry"], "expect.telemetry", failures);
|
|
303
|
+
checkTelemetryKeys(expectation["telemetryAbsent"], "expect.telemetryAbsent", failures);
|
|
304
|
+
const card = expectation["card"];
|
|
305
|
+
if (isRecord(card)) {
|
|
306
|
+
checkKeys(card, FIXTURE_KEYS.card, "expect.card", failures);
|
|
307
|
+
const fields = card["fields"];
|
|
308
|
+
if (Array.isArray(fields)) {
|
|
309
|
+
for (const [index, field] of fields.entries()) {
|
|
310
|
+
checkKeys(field, FIXTURE_KEYS.cardField, `expect.card.fields[${String(index)}]`, failures);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const presentation = card["presentation"];
|
|
314
|
+
if (Array.isArray(presentation)) {
|
|
315
|
+
for (const [index, hint] of presentation.entries()) {
|
|
316
|
+
checkKeys(hint, FIXTURE_KEYS.cardPresentation, `expect.card.presentation[${String(index)}]`, failures);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if (countAssertions(expectation) === 0) {
|
|
321
|
+
failures.push({
|
|
322
|
+
at: "expect",
|
|
323
|
+
expected: "at least one stated fact",
|
|
324
|
+
actual: "a fixture that asserts nothing",
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return failures;
|
|
329
|
+
}
|
|
330
|
+
/** Check one step: the common keys plus the ones its `kind` adds. */
|
|
331
|
+
function checkStepKeys(step, path, failures) {
|
|
332
|
+
if (!isRecord(step)) {
|
|
333
|
+
failures.push({ at: path, expected: "a step object", actual: step });
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
const kind = step["kind"];
|
|
337
|
+
if (typeof kind !== "string" || !Object.hasOwn(STEP_KEYS, kind)) {
|
|
338
|
+
failures.push({
|
|
339
|
+
at: `${path}.kind`,
|
|
340
|
+
expected: `one of: ${Object.keys(STEP_KEYS).sort().join(", ")}`,
|
|
341
|
+
actual: kind,
|
|
342
|
+
});
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
const own = STEP_KEYS[kind];
|
|
346
|
+
checkKeys(step, [...FIXTURE_KEYS.step, ...own], path, failures);
|
|
347
|
+
}
|
|
348
|
+
/** How many leaf facts a clause states, counting only keys the format defines. */
|
|
349
|
+
function countStated(value, keys) {
|
|
350
|
+
if (!isRecord(value))
|
|
351
|
+
return 0;
|
|
352
|
+
return keys.filter((key) => value[key] !== undefined).length;
|
|
353
|
+
}
|
|
354
|
+
/** How many leaf facts an Affidavit matcher states. `null` states one: "there is none". */
|
|
355
|
+
function countAffidavit(value) {
|
|
356
|
+
if (value === null)
|
|
357
|
+
return 1;
|
|
358
|
+
if (!isRecord(value))
|
|
359
|
+
return 0;
|
|
360
|
+
let total = countStated(value, FIXTURE_KEYS.affidavit.filter((key) => key !== "fields"));
|
|
361
|
+
const fields = value["fields"];
|
|
362
|
+
if (Array.isArray(fields)) {
|
|
363
|
+
// Stating the list asserts the field names exactly (AF-1); each further key on a
|
|
364
|
+
// field is one more fact.
|
|
365
|
+
total += 1;
|
|
366
|
+
for (const field of fields) {
|
|
367
|
+
total += countStated(field, FIXTURE_KEYS.affidavitField.filter((key) => key !== "name"));
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return total;
|
|
371
|
+
}
|
|
372
|
+
/** How many leaf facts a row matcher states. */
|
|
373
|
+
function countRow(value) {
|
|
374
|
+
if (!isRecord(value))
|
|
375
|
+
return 0;
|
|
376
|
+
const scalars = FIXTURE_KEYS.entry.filter((key) => key !== "lineage" && key !== "affidavit" && key !== "amendedAffidavit");
|
|
377
|
+
return (countStated(value, scalars) +
|
|
378
|
+
countStated(value["lineage"], FIXTURE_KEYS.lineage) +
|
|
379
|
+
countAffidavit(value["affidavit"]) +
|
|
380
|
+
(Object.hasOwn(value, "amendedAffidavit") ? countAffidavit(value["amendedAffidavit"]) : 0));
|
|
381
|
+
}
|
|
382
|
+
/** How many leaf facts a card matcher states. */
|
|
383
|
+
function countCard(value) {
|
|
384
|
+
if (!isRecord(value))
|
|
385
|
+
return 0;
|
|
386
|
+
let total = countStated(value, FIXTURE_KEYS.card.filter((key) => key !== "fields" && key !== "presentation" && key !== "warningsContain"));
|
|
387
|
+
const warnings = value["warningsContain"];
|
|
388
|
+
if (Array.isArray(warnings))
|
|
389
|
+
total += warnings.length;
|
|
390
|
+
const fields = value["fields"];
|
|
391
|
+
if (Array.isArray(fields)) {
|
|
392
|
+
total += 1;
|
|
393
|
+
for (const field of fields) {
|
|
394
|
+
total += countStated(field, FIXTURE_KEYS.cardField.filter((key) => key !== "name"));
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
const presentation = value["presentation"];
|
|
398
|
+
if (Array.isArray(presentation)) {
|
|
399
|
+
// Stating the list asserts the hinted names exactly; each further key on a hint
|
|
400
|
+
// is one more fact.
|
|
401
|
+
total += 1;
|
|
402
|
+
for (const hint of presentation) {
|
|
403
|
+
total += countStated(hint, FIXTURE_KEYS.cardPresentation.filter((key) => key !== "name"));
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return total;
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* How many facts a fixture's `expect` clause actually states.
|
|
410
|
+
*
|
|
411
|
+
* Zero means the fixture asserts nothing an implementation could fail — which every
|
|
412
|
+
* implementation passes, including one that files nothing at all. `expect: {}`,
|
|
413
|
+
* `{ entry: {} }` and `{ telemetryAbsent: [] }` are the three shapes this catches;
|
|
414
|
+
* they are counted here rather than by tallying the comparisons a run performed,
|
|
415
|
+
* because a run also performs the card invariants that hold for every filing, and a
|
|
416
|
+
* vacuous fixture with a filing step would clear that bar without stating a thing.
|
|
417
|
+
*/
|
|
418
|
+
function countAssertions(expectation) {
|
|
419
|
+
const arrayLength = (value) => (Array.isArray(value) ? value.length : 0);
|
|
420
|
+
return (countStated(expectation["error"], FIXTURE_KEYS.error) +
|
|
421
|
+
(expectation["found"] === undefined ? 0 : 1) +
|
|
422
|
+
(expectation["canonicalHash"] === undefined ? 0 : 1) +
|
|
423
|
+
arrayLength(expectation["telemetry"]) +
|
|
424
|
+
arrayLength(expectation["telemetryAbsent"]) +
|
|
425
|
+
countStated(expectation["store"], FIXTURE_KEYS.store) +
|
|
426
|
+
countStated(expectation["expired"], FIXTURE_KEYS.expired) +
|
|
427
|
+
countStated(expectation["page"], FIXTURE_KEYS.page) +
|
|
428
|
+
countRow(expectation["entry"]) +
|
|
429
|
+
countRow(expectation["superseded"]) +
|
|
430
|
+
countCard(expectation["card"]));
|
|
431
|
+
}
|
|
432
|
+
/** A {@link Clock} that reads `start` until a step moves it. */
|
|
433
|
+
export function fixedClock(start) {
|
|
434
|
+
let current = start;
|
|
435
|
+
return {
|
|
436
|
+
now: () => current,
|
|
437
|
+
set: (instant) => {
|
|
438
|
+
current = instant;
|
|
439
|
+
},
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* An {@link InferencePort} that reports exactly what the fixture scripted, for every
|
|
444
|
+
* turn.
|
|
445
|
+
*
|
|
446
|
+
* Scripted rather than computed: the gate's contract is that it asks the host for
|
|
447
|
+
* values and tags whatever it gets, so a fixture that also decided *how* the values
|
|
448
|
+
* were found would be testing a model the framework does not ship.
|
|
449
|
+
*/
|
|
450
|
+
export function scriptedInference(fields) {
|
|
451
|
+
const scripted = {};
|
|
452
|
+
for (const [name, field] of Object.entries(fields ?? {})) {
|
|
453
|
+
scripted[name] = {
|
|
454
|
+
value: field.value,
|
|
455
|
+
confidence: field.confidence,
|
|
456
|
+
presence: field.presence,
|
|
457
|
+
utteranceSpan: field.utteranceSpan ?? null,
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
return {
|
|
461
|
+
async infer() {
|
|
462
|
+
return { fields: scripted };
|
|
463
|
+
},
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* A {@link ProjectionPort} reading the fixture's entity table (AF-3).
|
|
468
|
+
*
|
|
469
|
+
* An entity the table does not name does not exist, and the port answers `null` —
|
|
470
|
+
* which the pipeline treats as "nothing to project", not as "every field was empty".
|
|
471
|
+
*/
|
|
472
|
+
export function entityProjection(entities) {
|
|
473
|
+
const table = entities ?? {};
|
|
474
|
+
return {
|
|
475
|
+
async previousValues(op) {
|
|
476
|
+
if (op.kind !== "update")
|
|
477
|
+
return null;
|
|
478
|
+
const row = table[`${op.entityType}/${op.entityId}`];
|
|
479
|
+
if (row === undefined)
|
|
480
|
+
return null;
|
|
481
|
+
const previous = {};
|
|
482
|
+
for (const name of op.fields) {
|
|
483
|
+
if (Object.hasOwn(row, name))
|
|
484
|
+
previous[name] = row[name];
|
|
485
|
+
}
|
|
486
|
+
return previous;
|
|
487
|
+
},
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* An {@link AuthorizationPort} over an allowlist of principal ids (AZ-2).
|
|
492
|
+
*
|
|
493
|
+
* `"*"` admits everyone. A port configured to throw is the AZ-2 case that matters
|
|
494
|
+
* most: the gate must read a port that fell over as a refusal, never as an approval.
|
|
495
|
+
*/
|
|
496
|
+
export function allowlistAuthorization(config) {
|
|
497
|
+
const allowed = new Set(config.allow);
|
|
498
|
+
return {
|
|
499
|
+
async mayDecide(principal) {
|
|
500
|
+
if (config.throws === true)
|
|
501
|
+
throw new Error("the host's directory is unavailable");
|
|
502
|
+
return allowed.has("*") || allowed.has(principal.id);
|
|
503
|
+
},
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
/** A {@link RiskScorer} that always returns `score` (GT-5). */
|
|
507
|
+
export function fixedRiskScorer(score) {
|
|
508
|
+
return {
|
|
509
|
+
async score() {
|
|
510
|
+
return score;
|
|
511
|
+
},
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Run `fixture` against a real gate and report what it did.
|
|
516
|
+
*
|
|
517
|
+
* Never throws for a failed expectation — see the module note. It **does** propagate
|
|
518
|
+
* a `RangeError` or a `TypeError`, because those are programming errors in the
|
|
519
|
+
* fixture or in a port, not behaviours a rule is about, and swallowing them into a
|
|
520
|
+
* failure list would hide a broken document behind a red test.
|
|
521
|
+
*/
|
|
522
|
+
export async function runFixture(fixture, ports = {}) {
|
|
523
|
+
// The document before the gate. A fixture the format does not recognise, or one
|
|
524
|
+
// that states no fact, is not run at all: running it would report a pass, and a
|
|
525
|
+
// pass is the one answer it must never give.
|
|
526
|
+
const structural = validateFixture(fixture);
|
|
527
|
+
if (structural.length > 0) {
|
|
528
|
+
return {
|
|
529
|
+
id: fixture.id,
|
|
530
|
+
rules: fixture.rules,
|
|
531
|
+
title: fixture.title,
|
|
532
|
+
pass: false,
|
|
533
|
+
failures: structural,
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
const failures = [];
|
|
537
|
+
const given = fixture.given;
|
|
538
|
+
const clock = (ports.clock ?? ((f) => fixedClock(f.given.clock)))(fixture);
|
|
539
|
+
const store = (ports.store ?? ((c) => new InMemoryDocketStore({ clock: c })))(clock);
|
|
540
|
+
const events = [];
|
|
541
|
+
const telemetry = {
|
|
542
|
+
emit(event) {
|
|
543
|
+
events.push(event);
|
|
544
|
+
},
|
|
545
|
+
};
|
|
546
|
+
// A wiring the gate refuses is a rule (CV-1), so it has to be expressible as a
|
|
547
|
+
// fixture: the refusal is reported exactly as a refusal from the step would be,
|
|
548
|
+
// and nothing after it runs, because there is no gate to run it on.
|
|
549
|
+
let gate;
|
|
550
|
+
try {
|
|
551
|
+
gate = buildGate(fixture, { clock, store, telemetry, ports });
|
|
552
|
+
}
|
|
553
|
+
catch (error) {
|
|
554
|
+
if (!isAffiantError(error))
|
|
555
|
+
throw error;
|
|
556
|
+
return wireUpRefusal(fixture, error.code, error.message, events);
|
|
557
|
+
}
|
|
558
|
+
const labelled = new Map();
|
|
559
|
+
let lastFiled = null;
|
|
560
|
+
const supersededIds = [];
|
|
561
|
+
const runOne = async (step, where) => {
|
|
562
|
+
if (step.at !== undefined)
|
|
563
|
+
clock.set(step.at);
|
|
564
|
+
const target = step.entry === undefined ? lastFiled : (labelled.get(step.entry) ?? step.entry);
|
|
565
|
+
const outcome = await performStep(step, {
|
|
566
|
+
gate,
|
|
567
|
+
store,
|
|
568
|
+
fixture,
|
|
569
|
+
clock,
|
|
570
|
+
target,
|
|
571
|
+
});
|
|
572
|
+
if (outcome.filed !== null) {
|
|
573
|
+
lastFiled = outcome.filed.entry.entryId;
|
|
574
|
+
if (step.as !== undefined)
|
|
575
|
+
labelled.set(step.as, lastFiled);
|
|
576
|
+
const supersedes = outcome.filed.entry.lineage.supersedes;
|
|
577
|
+
if (supersedes !== null)
|
|
578
|
+
supersededIds.push(supersedes);
|
|
579
|
+
}
|
|
580
|
+
else if (outcome.entryId !== null && step.as !== undefined) {
|
|
581
|
+
labelled.set(step.as, outcome.entryId);
|
|
582
|
+
}
|
|
583
|
+
// A declared refusal is compared wherever it is declared — on a prior step and
|
|
584
|
+
// on the step under test alike. Skipping the final one let a fixture claim its
|
|
585
|
+
// own act was refused and pass when it was not; the step under test may still
|
|
586
|
+
// leave `refusal` off and state its refusal in `expect.error`, which is where
|
|
587
|
+
// the format asks for it and which is compared below.
|
|
588
|
+
if (step.refusal !== undefined || where !== "step") {
|
|
589
|
+
const expectedRefusal = step.refusal ?? null;
|
|
590
|
+
if (expectedRefusal !== outcome.code) {
|
|
591
|
+
failures.push({ at: `${where}.refusal`, expected: expectedRefusal, actual: outcome.code });
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
return outcome;
|
|
595
|
+
};
|
|
596
|
+
const priors = given.prior ?? [];
|
|
597
|
+
for (const [index, step] of priors.entries()) {
|
|
598
|
+
await runOne(step, `prior[${String(index)}]`);
|
|
599
|
+
}
|
|
600
|
+
const final = await runOne(given.step, "step");
|
|
601
|
+
// ---- the refusal (or the absence of one) --------------------------------
|
|
602
|
+
const expectedError = fixture.expect.error ?? null;
|
|
603
|
+
if (expectedError === null) {
|
|
604
|
+
if (final.code !== null) {
|
|
605
|
+
failures.push({ at: "error", expected: null, actual: `${final.code}: ${final.message}` });
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
else {
|
|
609
|
+
if (final.code !== expectedError.code) {
|
|
610
|
+
failures.push({
|
|
611
|
+
at: "error.code",
|
|
612
|
+
expected: expectedError.code,
|
|
613
|
+
actual: final.code === null ? "no refusal" : `${final.code}: ${final.message}`,
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
const phrase = expectedError.messageContains;
|
|
617
|
+
if (phrase !== undefined && !(final.message ?? "").includes(phrase)) {
|
|
618
|
+
failures.push({ at: "error.message", expected: phrase, actual: final.message });
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
// ---- the row ------------------------------------------------------------
|
|
622
|
+
const scope = { tenantId: given.ctx.tenantId };
|
|
623
|
+
const targetId = final.filed?.entry.entryId ?? final.entryId ?? lastFiled;
|
|
624
|
+
const row = final.read ?? (targetId === null ? null : await store.get(targetId, scope));
|
|
625
|
+
if (fixture.expect.found !== undefined) {
|
|
626
|
+
if ((row !== null) !== fixture.expect.found) {
|
|
627
|
+
failures.push({ at: "found", expected: fixture.expect.found, actual: row !== null });
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
const entryExpectation = fixture.expect.entry ?? null;
|
|
631
|
+
if (entryExpectation !== null) {
|
|
632
|
+
if (row === null) {
|
|
633
|
+
failures.push({ at: "entry", expected: "a Docket row", actual: null });
|
|
634
|
+
}
|
|
635
|
+
else {
|
|
636
|
+
await checkEntry(row, entryExpectation, clock.now(), failures, "entry");
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
// SR-1: the exact bytes a host's execution grant binds to, through the package's
|
|
640
|
+
// own exported helper rather than re-derived here — an oracle that re-derived the
|
|
641
|
+
// binding could not catch an implementation whose exported helper disagreed with
|
|
642
|
+
// it, which is precisely the substitution SR-1 exists to prevent.
|
|
643
|
+
if (fixture.expect.canonicalHash !== undefined) {
|
|
644
|
+
if (row === null) {
|
|
645
|
+
failures.push({ at: "canonicalHash", expected: fixture.expect.canonicalHash, actual: null });
|
|
646
|
+
}
|
|
647
|
+
else {
|
|
648
|
+
compare("canonicalHash", fixture.expect.canonicalHash, await canonicalHashEntry(row), failures);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
const supersededExpectation = fixture.expect.superseded ?? null;
|
|
652
|
+
if (supersededExpectation !== null) {
|
|
653
|
+
const supersededId = supersededIds[supersededIds.length - 1];
|
|
654
|
+
const superseded = supersededId === undefined ? null : await store.get(supersededId, scope);
|
|
655
|
+
if (superseded === null) {
|
|
656
|
+
failures.push({ at: "superseded", expected: "a superseded Docket row", actual: null });
|
|
657
|
+
}
|
|
658
|
+
else {
|
|
659
|
+
await checkEntry(superseded, supersededExpectation, clock.now(), failures, "superseded");
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
// ---- the card -----------------------------------------------------------
|
|
663
|
+
// Checked on every filing, stated or not: these hold for every card the gate ever
|
|
664
|
+
// produces, and a fixture that had to repeat them would be a fixture that stops
|
|
665
|
+
// being about its own rule.
|
|
666
|
+
if (final.filed !== null)
|
|
667
|
+
checkCardInvariants(final.filed, failures);
|
|
668
|
+
const cardExpectation = fixture.expect.card ?? null;
|
|
669
|
+
if (cardExpectation !== null) {
|
|
670
|
+
if (final.filed === null) {
|
|
671
|
+
failures.push({ at: "card", expected: "an Evidence Card", actual: null });
|
|
672
|
+
}
|
|
673
|
+
else {
|
|
674
|
+
checkCard(final.filed.card, cardExpectation, failures);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
// ---- telemetry, the store, the sweep, the page --------------------------
|
|
678
|
+
const emitted = events.map((event) => String(event.key));
|
|
679
|
+
for (const key of fixture.expect.telemetry ?? []) {
|
|
680
|
+
if (!emitted.includes(key)) {
|
|
681
|
+
failures.push({ at: `telemetry.${key}`, expected: "emitted", actual: emitted });
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
for (const key of fixture.expect.telemetryAbsent ?? []) {
|
|
685
|
+
if (emitted.includes(key)) {
|
|
686
|
+
failures.push({ at: `telemetryAbsent.${key}`, expected: "never emitted", actual: emitted });
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
const storeExpectation = fixture.expect.store ?? null;
|
|
690
|
+
if (storeExpectation !== null) {
|
|
691
|
+
const all = await drain(store, scope);
|
|
692
|
+
compare("store.count", storeExpectation.count, all.length, failures);
|
|
693
|
+
const now = clock.now();
|
|
694
|
+
compare("store.pending", storeExpectation.pending, all.filter((entry) => readStatus(entry, now) === "pending").length, failures);
|
|
695
|
+
compare("store.approvedUnexecuted", storeExpectation.approvedUnexecuted, all.filter((entry) => readStatus(entry, now) === "approved" && entry.execution === "unexecuted").length, failures);
|
|
696
|
+
}
|
|
697
|
+
const expiredExpectation = fixture.expect.expired ?? null;
|
|
698
|
+
if (expiredExpectation !== null) {
|
|
699
|
+
compare("expired.count", expiredExpectation.count, final.expired?.expired.length, failures);
|
|
700
|
+
compare("expired.more", expiredExpectation.more, final.expired?.more, failures);
|
|
701
|
+
}
|
|
702
|
+
const pageExpectation = fixture.expect.page ?? null;
|
|
703
|
+
if (pageExpectation !== null) {
|
|
704
|
+
compare("page.count", pageExpectation.count, final.page?.items.length, failures);
|
|
705
|
+
compare("page.more", pageExpectation.more, final.page?.more, failures);
|
|
706
|
+
if (pageExpectation.statuses !== undefined) {
|
|
707
|
+
const now = clock.now();
|
|
708
|
+
compare("page.statuses", pageExpectation.statuses, (final.page?.items ?? []).map((entry) => readStatus(entry, now)), failures);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
return {
|
|
712
|
+
id: fixture.id,
|
|
713
|
+
rules: fixture.rules,
|
|
714
|
+
title: fixture.title,
|
|
715
|
+
pass: failures.length === 0,
|
|
716
|
+
failures,
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* The result for a fixture whose *wiring* was refused (CV-1).
|
|
721
|
+
*
|
|
722
|
+
* Reported through the same `expect.error` clause a step's refusal is, because to a
|
|
723
|
+
* host the two are the same event with different timing — and the timing is the
|
|
724
|
+
* point of CV-1: a misconfiguration the framework can detect fails when the gate is
|
|
725
|
+
* built, not on the unlucky request that first reaches the broken branch.
|
|
726
|
+
*/
|
|
727
|
+
function wireUpRefusal(fixture, code, message, events) {
|
|
728
|
+
const failures = [];
|
|
729
|
+
const expected = fixture.expect.error ?? null;
|
|
730
|
+
if (expected === null) {
|
|
731
|
+
failures.push({ at: "error", expected: null, actual: `${code}: ${message}` });
|
|
732
|
+
}
|
|
733
|
+
else {
|
|
734
|
+
compare("error.code", expected.code, code, failures);
|
|
735
|
+
if (expected.messageContains !== undefined && !message.includes(expected.messageContains)) {
|
|
736
|
+
failures.push({ at: "error.message", expected: expected.messageContains, actual: message });
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
const emitted = events.map((event) => String(event.key));
|
|
740
|
+
for (const key of fixture.expect.telemetry ?? []) {
|
|
741
|
+
if (!emitted.includes(key)) {
|
|
742
|
+
failures.push({ at: `telemetry.${key}`, expected: "emitted", actual: emitted });
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
for (const key of fixture.expect.telemetryAbsent ?? []) {
|
|
746
|
+
if (emitted.includes(key)) {
|
|
747
|
+
failures.push({ at: `telemetryAbsent.${key}`, expected: "never emitted", actual: emitted });
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
// Nothing was filed, so every count is zero and the store clause is answerable in
|
|
751
|
+
// full rather than in its first key only.
|
|
752
|
+
const storeExpectation = fixture.expect.store ?? null;
|
|
753
|
+
if (storeExpectation !== null) {
|
|
754
|
+
compare("store.count", storeExpectation.count, 0, failures);
|
|
755
|
+
compare("store.pending", storeExpectation.pending, 0, failures);
|
|
756
|
+
compare("store.approvedUnexecuted", storeExpectation.approvedUnexecuted, 0, failures);
|
|
757
|
+
}
|
|
758
|
+
// A clause this path cannot answer is a failure, not a silent pass: there is no
|
|
759
|
+
// row, no card and no page when the gate was never built, so a fixture that states
|
|
760
|
+
// one is a fixture stating something nobody will check (the same defect family as
|
|
761
|
+
// an unknown key).
|
|
762
|
+
for (const clause of Object.keys(fixture.expect)) {
|
|
763
|
+
if (!WIRE_UP_ANSWERABLE.includes(clause)) {
|
|
764
|
+
failures.push({
|
|
765
|
+
at: `${clause}`,
|
|
766
|
+
expected: `one of: ${[...WIRE_UP_ANSWERABLE].sort().join(", ")}`,
|
|
767
|
+
actual: "stated on a fixture whose wiring was refused, where nothing was filed",
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
return {
|
|
772
|
+
id: fixture.id,
|
|
773
|
+
rules: fixture.rules,
|
|
774
|
+
title: fixture.title,
|
|
775
|
+
pass: failures.length === 0,
|
|
776
|
+
failures,
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
/**
|
|
780
|
+
* Run every fixture and summarise.
|
|
781
|
+
*
|
|
782
|
+
* Takes the documents rather than a directory: this module runs inside workerd and
|
|
783
|
+
* under Bun as well as on Node, and none of those three read a directory the same
|
|
784
|
+
* way. A caller that has files loads them and passes them here.
|
|
785
|
+
*/
|
|
786
|
+
export async function runFixtureDir(fixtures, ports = {}) {
|
|
787
|
+
const results = [];
|
|
788
|
+
for (const fixture of fixtures) {
|
|
789
|
+
results.push(await runFixture(fixture, ports));
|
|
790
|
+
}
|
|
791
|
+
const failed = results.filter((result) => !result.pass);
|
|
792
|
+
return {
|
|
793
|
+
total: results.length,
|
|
794
|
+
passed: results.length - failed.length,
|
|
795
|
+
failed: failed.length,
|
|
796
|
+
results,
|
|
797
|
+
failedIds: failed.map((result) => result.id),
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
// ---------------------------------------------------------------------------
|
|
801
|
+
// Wiring
|
|
802
|
+
// ---------------------------------------------------------------------------
|
|
803
|
+
/** Build the gate a fixture describes. Refusals at wire-up are the caller's to catch. */
|
|
804
|
+
function buildGate(fixture, deps) {
|
|
805
|
+
const given = fixture.given.gate;
|
|
806
|
+
const options = {
|
|
807
|
+
store: deps.store,
|
|
808
|
+
inference: (deps.ports.inference ?? ((f) => scriptedInference(f.given.gate.inference ?? null)))(fixture),
|
|
809
|
+
projection: (deps.ports.projection ?? ((f) => entityProjection(f.given.gate.entities)))(fixture),
|
|
810
|
+
authorization: (deps.ports.authorization ??
|
|
811
|
+
((f) => allowlistAuthorization(f.given.gate.authorization)))(fixture),
|
|
812
|
+
policies: (given.policies ?? []).map(policyOf),
|
|
813
|
+
interceptors: (given.interceptors ?? []).map(interceptorOf),
|
|
814
|
+
clock: deps.clock,
|
|
815
|
+
telemetry: deps.telemetry,
|
|
816
|
+
defaultTtlMs: given.defaultTtlMs,
|
|
817
|
+
...(given.sessions === false ? {} : { sessions: new InMemorySessionStore(deps.store) }),
|
|
818
|
+
...(given.riskScorer === undefined || given.riskScorer === null
|
|
819
|
+
? {}
|
|
820
|
+
: { riskScorer: fixedRiskScorer(given.riskScorer) }),
|
|
821
|
+
};
|
|
822
|
+
const gate = createGate(options);
|
|
823
|
+
for (const declaration of given.uncovered ?? []) {
|
|
824
|
+
gate.declareUncovered({ name: declaration.tool }, declaration.category);
|
|
825
|
+
}
|
|
826
|
+
return gate;
|
|
827
|
+
}
|
|
828
|
+
/** An {@link ApprovalPolicy} that always returns the fixture's verdict. */
|
|
829
|
+
function policyOf(fixture) {
|
|
830
|
+
return {
|
|
831
|
+
id: fixture.id,
|
|
832
|
+
version: fixture.version,
|
|
833
|
+
declaredInputs: fixture.declaredInputs ?? [],
|
|
834
|
+
...(fixture.declaresThreshold === undefined
|
|
835
|
+
? {}
|
|
836
|
+
: { declaresThreshold: fixture.declaresThreshold }),
|
|
837
|
+
...(fixture.defaultTtlMs === undefined || fixture.defaultTtlMs === null
|
|
838
|
+
? {}
|
|
839
|
+
: { defaultTtlMs: fixture.defaultTtlMs }),
|
|
840
|
+
async evaluate() {
|
|
841
|
+
return fixture.verdict;
|
|
842
|
+
},
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
/** A {@link FieldInterceptor} that resolves the fixture's fields for every operation. */
|
|
846
|
+
function interceptorOf(fixture) {
|
|
847
|
+
return {
|
|
848
|
+
name: fixture.name,
|
|
849
|
+
resolve() {
|
|
850
|
+
const resolved = {};
|
|
851
|
+
for (const [name, field] of Object.entries(fixture.fields)) {
|
|
852
|
+
resolved[name] = {
|
|
853
|
+
value: field.value,
|
|
854
|
+
source: field.source,
|
|
855
|
+
binding: field.binding,
|
|
856
|
+
confidence: field.confidence,
|
|
857
|
+
evidence: field.evidence ?? null,
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
return resolved;
|
|
861
|
+
},
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
/** The turn a step runs in (GT-2): explicit in every property, nothing ambient. */
|
|
865
|
+
function contextOf(fixture, step, at) {
|
|
866
|
+
const ctx = fixture.given.ctx;
|
|
867
|
+
const principal = step.principal === undefined ? (ctx.principal ?? null) : step.principal;
|
|
868
|
+
return {
|
|
869
|
+
tenantId: step.tenantId ?? ctx.tenantId,
|
|
870
|
+
conversationId: step.conversationId ?? ctx.conversationId,
|
|
871
|
+
channel: ctx.channel,
|
|
872
|
+
principal,
|
|
873
|
+
turn: {
|
|
874
|
+
utterance: ctx.utterance ?? "",
|
|
875
|
+
messageId: ctx.messageId ?? "msg-1",
|
|
876
|
+
at,
|
|
877
|
+
},
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
/** The prepared fields a `file` step describes, with their tags minted at `at`. */
|
|
881
|
+
function preparedOf(fields, at) {
|
|
882
|
+
return fields.map((field) => ({
|
|
883
|
+
name: field.name,
|
|
884
|
+
kind: field.kind,
|
|
885
|
+
value: field.value,
|
|
886
|
+
...(field.isMandatory === undefined ? {} : { isMandatory: field.isMandatory }),
|
|
887
|
+
...(field.provenance === undefined || field.provenance === null
|
|
888
|
+
? {}
|
|
889
|
+
: {
|
|
890
|
+
provenance: chainOf(mintTag({
|
|
891
|
+
source: field.provenance.source,
|
|
892
|
+
confidence: field.provenance.confidence,
|
|
893
|
+
at,
|
|
894
|
+
note: field.provenance.note ?? null,
|
|
895
|
+
binding: field.provenance.binding ?? null,
|
|
896
|
+
})),
|
|
897
|
+
}),
|
|
898
|
+
}));
|
|
899
|
+
}
|
|
900
|
+
/** The field schema a tool or a `file` step declares. */
|
|
901
|
+
function schemaOf(entityType, fields) {
|
|
902
|
+
return {
|
|
903
|
+
entityType,
|
|
904
|
+
fields: fields.map((field) => ({
|
|
905
|
+
name: field.name,
|
|
906
|
+
kind: field.kind,
|
|
907
|
+
description: field.description ?? null,
|
|
908
|
+
required: field.required ?? false,
|
|
909
|
+
allowedValues: field.allowedValues ?? null,
|
|
910
|
+
pattern: field.pattern ?? null,
|
|
911
|
+
})),
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
/** The empty outcome every step starts from. */
|
|
915
|
+
const NOTHING = {
|
|
916
|
+
code: null,
|
|
917
|
+
message: null,
|
|
918
|
+
filed: null,
|
|
919
|
+
entryId: null,
|
|
920
|
+
read: null,
|
|
921
|
+
expired: null,
|
|
922
|
+
page: null,
|
|
923
|
+
};
|
|
924
|
+
/** Perform one step, turning a refusal into a code rather than letting it escape. */
|
|
925
|
+
async function performStep(step, deps) {
|
|
926
|
+
const at = deps.clock.now();
|
|
927
|
+
const ctx = contextOf(deps.fixture, step, at);
|
|
928
|
+
try {
|
|
929
|
+
switch (step.kind) {
|
|
930
|
+
case "wrap-execute": {
|
|
931
|
+
const tool = step.tool;
|
|
932
|
+
const entityId = tool.entityId === undefined ? null : tool.entityId;
|
|
933
|
+
const wrapped = deps.gate.wrap({
|
|
934
|
+
name: tool.name,
|
|
935
|
+
description: tool.description ?? tool.name,
|
|
936
|
+
inputSchema: schemaOf(tool.entityType, tool.fields),
|
|
937
|
+
writeCapable: tool.writeCapable ?? true,
|
|
938
|
+
...(tool.omitExecute === true
|
|
939
|
+
? {}
|
|
940
|
+
: {
|
|
941
|
+
execute: () => {
|
|
942
|
+
throw new Error("GT-6: the gate called a write tool's own execute");
|
|
943
|
+
},
|
|
944
|
+
}),
|
|
945
|
+
...(tool.executedBy === undefined ? {} : { executedBy: tool.executedBy }),
|
|
946
|
+
...(tool.hostedMcp === undefined ? {} : { hostedMcp: tool.hostedMcp }),
|
|
947
|
+
...(tool.operationLabel === undefined ? {} : { operationLabel: tool.operationLabel }),
|
|
948
|
+
operation: (args) => entityId === null
|
|
949
|
+
? {
|
|
950
|
+
kind: "create",
|
|
951
|
+
entityType: tool.entityType,
|
|
952
|
+
entityId: null,
|
|
953
|
+
fields: Object.keys(args),
|
|
954
|
+
}
|
|
955
|
+
: {
|
|
956
|
+
kind: "update",
|
|
957
|
+
entityType: tool.entityType,
|
|
958
|
+
entityId,
|
|
959
|
+
fields: Object.keys(args),
|
|
960
|
+
},
|
|
961
|
+
}, ctx);
|
|
962
|
+
const result = await wrapped.execute(step.args);
|
|
963
|
+
if (result.kind === "error") {
|
|
964
|
+
return { ...NOTHING, code: result.code, message: result.message };
|
|
965
|
+
}
|
|
966
|
+
if (result.kind === "read")
|
|
967
|
+
return NOTHING;
|
|
968
|
+
const filed = await deps.store.get(result.entryId, { tenantId: ctx.tenantId });
|
|
969
|
+
return {
|
|
970
|
+
...NOTHING,
|
|
971
|
+
entryId: result.entryId,
|
|
972
|
+
read: filed,
|
|
973
|
+
filed: filed === null ? null : { entry: filed, created: true, card: result.card },
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
case "file": {
|
|
977
|
+
const proposal = {
|
|
978
|
+
operation: step.operation,
|
|
979
|
+
toolName: step.toolName,
|
|
980
|
+
...(step.args === undefined ? {} : { args: step.args }),
|
|
981
|
+
...(step.preparedFields === undefined || step.preparedFields === null
|
|
982
|
+
? {}
|
|
983
|
+
: { fields: preparedOf(step.preparedFields, at) }),
|
|
984
|
+
...(step.schema === undefined || step.schema === null
|
|
985
|
+
? {}
|
|
986
|
+
: { schema: schemaOf(step.operation.entityType, step.schema) }),
|
|
987
|
+
...(step.operationLabel === undefined || step.operationLabel === null
|
|
988
|
+
? {}
|
|
989
|
+
: { operationLabel: step.operationLabel }),
|
|
990
|
+
};
|
|
991
|
+
const filed = await deps.gate.file(proposal, ctx);
|
|
992
|
+
return { ...NOTHING, filed, entryId: filed.entry.entryId };
|
|
993
|
+
}
|
|
994
|
+
case "decide": {
|
|
995
|
+
const id = requireTarget(deps.target, step.kind);
|
|
996
|
+
const stated = step.decision;
|
|
997
|
+
const decision = stated.kind === "reject"
|
|
998
|
+
? { kind: "reject", reason: stated.reason ?? "" }
|
|
999
|
+
: {
|
|
1000
|
+
kind: "approve",
|
|
1001
|
+
...(stated.amendments === undefined || stated.amendments === null
|
|
1002
|
+
? {}
|
|
1003
|
+
: { amendments: stated.amendments }),
|
|
1004
|
+
...(stated.reason === undefined || stated.reason === null
|
|
1005
|
+
? {}
|
|
1006
|
+
: { reason: stated.reason }),
|
|
1007
|
+
};
|
|
1008
|
+
const entry = await deps.gate.decide(id, decision, ctx);
|
|
1009
|
+
return { ...NOTHING, entryId: id, read: entry };
|
|
1010
|
+
}
|
|
1011
|
+
case "resubmit": {
|
|
1012
|
+
const id = requireTarget(deps.target, step.kind);
|
|
1013
|
+
const filed = await deps.gate.resubmit(id, ctx);
|
|
1014
|
+
return { ...NOTHING, filed, entryId: filed.entry.entryId };
|
|
1015
|
+
}
|
|
1016
|
+
case "markExecuted": {
|
|
1017
|
+
const id = requireTarget(deps.target, step.kind);
|
|
1018
|
+
const entry = await deps.gate.markExecuted(id, step.outcome, step.detail ?? null, ctx);
|
|
1019
|
+
return { ...NOTHING, entryId: id, read: entry };
|
|
1020
|
+
}
|
|
1021
|
+
case "expireDue": {
|
|
1022
|
+
const scope = {
|
|
1023
|
+
tenantId: step.scope?.tenantId ?? ctx.tenantId,
|
|
1024
|
+
...(step.scope?.conversationId === undefined
|
|
1025
|
+
? {}
|
|
1026
|
+
: { conversationId: step.scope.conversationId }),
|
|
1027
|
+
};
|
|
1028
|
+
const expired = await deps.gate.expireDue(at, scope, step.limit);
|
|
1029
|
+
return { ...NOTHING, expired };
|
|
1030
|
+
}
|
|
1031
|
+
case "get": {
|
|
1032
|
+
const id = requireTarget(deps.target, step.kind);
|
|
1033
|
+
const entry = await deps.gate.get(id, ctx);
|
|
1034
|
+
return { ...NOTHING, entryId: id, read: entry };
|
|
1035
|
+
}
|
|
1036
|
+
case "rehydrate": {
|
|
1037
|
+
const scope = {
|
|
1038
|
+
tenantId: step.scope?.tenantId ?? ctx.tenantId,
|
|
1039
|
+
...(step.scope?.conversationId === undefined
|
|
1040
|
+
? {}
|
|
1041
|
+
: { conversationId: step.scope.conversationId }),
|
|
1042
|
+
};
|
|
1043
|
+
const page = {
|
|
1044
|
+
limit: step.page.limit,
|
|
1045
|
+
...(step.page.cursor === undefined || step.page.cursor === null
|
|
1046
|
+
? {}
|
|
1047
|
+
: { cursor: step.page.cursor }),
|
|
1048
|
+
};
|
|
1049
|
+
const result = await deps.gate.rehydrate(scope, page);
|
|
1050
|
+
return { ...NOTHING, page: { items: result.items, more: result.more } };
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
catch (error) {
|
|
1055
|
+
if (isAffiantError(error)) {
|
|
1056
|
+
return { ...NOTHING, code: error.code, message: error.message };
|
|
1057
|
+
}
|
|
1058
|
+
throw error;
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
/** The entry a step acts on, or a fixture bug. */
|
|
1062
|
+
function requireTarget(target, kind) {
|
|
1063
|
+
if (target !== null)
|
|
1064
|
+
return target;
|
|
1065
|
+
throw new RangeError(`fixture step ${JSON.stringify(kind)} names no entry and nothing has been filed; ` +
|
|
1066
|
+
`give the step an \`entry\` label or file something in \`prior\` first`);
|
|
1067
|
+
}
|
|
1068
|
+
/** Everything the Docket holds in `scope`, in filing order. */
|
|
1069
|
+
async function drain(store, scope) {
|
|
1070
|
+
const all = [];
|
|
1071
|
+
for await (const entry of store.export(scope))
|
|
1072
|
+
all.push(entry);
|
|
1073
|
+
return all;
|
|
1074
|
+
}
|
|
1075
|
+
// ---------------------------------------------------------------------------
|
|
1076
|
+
// Checking
|
|
1077
|
+
// ---------------------------------------------------------------------------
|
|
1078
|
+
/** Record a failure when `expected` is stated and differs from `actual`. */
|
|
1079
|
+
function compare(at, expected, actual, failures) {
|
|
1080
|
+
if (expected === undefined)
|
|
1081
|
+
return;
|
|
1082
|
+
if (!deepEqual(expected, actual))
|
|
1083
|
+
failures.push({ at, expected, actual });
|
|
1084
|
+
}
|
|
1085
|
+
/** Structural equality over the JSON values a fixture can state. */
|
|
1086
|
+
function deepEqual(left, right) {
|
|
1087
|
+
if (Object.is(left, right))
|
|
1088
|
+
return true;
|
|
1089
|
+
if (typeof left !== "object" || typeof right !== "object")
|
|
1090
|
+
return false;
|
|
1091
|
+
if (left === null || right === null)
|
|
1092
|
+
return false;
|
|
1093
|
+
if (Array.isArray(left) !== Array.isArray(right))
|
|
1094
|
+
return false;
|
|
1095
|
+
if (Array.isArray(left) && Array.isArray(right)) {
|
|
1096
|
+
return left.length === right.length && left.every((item, i) => deepEqual(item, right[i]));
|
|
1097
|
+
}
|
|
1098
|
+
const leftKeys = Object.keys(left);
|
|
1099
|
+
const rightKeys = Object.keys(right);
|
|
1100
|
+
if (leftKeys.length !== rightKeys.length)
|
|
1101
|
+
return false;
|
|
1102
|
+
return leftKeys.every((key) => Object.hasOwn(right, key) &&
|
|
1103
|
+
deepEqual(left[key], right[key]));
|
|
1104
|
+
}
|
|
1105
|
+
/** Check a row against its partial matcher. */
|
|
1106
|
+
async function checkEntry(entry, expected, now, failures, at) {
|
|
1107
|
+
// The status a row *reads* (DK-1), not the one it says: a row past its deadline
|
|
1108
|
+
// reads expired whether or not a sweep has run, and every fixture about expiry is
|
|
1109
|
+
// about the read.
|
|
1110
|
+
compare(`${at}.status`, expected.status, readStatus(entry, now), failures);
|
|
1111
|
+
compare(`${at}.execution`, expected.execution, entry.execution, failures);
|
|
1112
|
+
compare(`${at}.executionDetail`, expected.executionDetail, entry.executionDetail, failures);
|
|
1113
|
+
compare(`${at}.requirement`, expected.requirement, entry.requirement, failures);
|
|
1114
|
+
compare(`${at}.blocked`, expected.blocked, entry.blocked, failures);
|
|
1115
|
+
compare(`${at}.toolName`, expected.toolName, entry.toolName, failures);
|
|
1116
|
+
compare(`${at}.channel`, expected.channel, entry.channel, failures);
|
|
1117
|
+
compare(`${at}.tenantId`, expected.tenantId, entry.tenantId, failures);
|
|
1118
|
+
compare(`${at}.conversationId`, expected.conversationId, entry.conversationId, failures);
|
|
1119
|
+
compare(`${at}.amendments`, expected.amendments, entry.amendments, failures);
|
|
1120
|
+
compare(`${at}.preservedAmendments`, expected.preservedAmendments, entry.preservedAmendments, failures);
|
|
1121
|
+
compare(`${at}.attestation`, expected.attestation, entry.attestation?.by ?? null, failures);
|
|
1122
|
+
if (expected.attestation != null && entry.attestation !== null) {
|
|
1123
|
+
// AZ-1: an attestation names the entry it attests to, or it is not evidence.
|
|
1124
|
+
compare(`${at}.attestation.entryId`, entry.entryId, entry.attestation.entryId, failures);
|
|
1125
|
+
}
|
|
1126
|
+
if (expected.decision !== undefined) {
|
|
1127
|
+
compare(`${at}.decision`, expected.decision, entry.decision === null ? null : { kind: entry.decision.kind, reason: entry.decision.reason }, failures);
|
|
1128
|
+
}
|
|
1129
|
+
if (expected.lineage !== undefined) {
|
|
1130
|
+
checkLink(`${at}.lineage.supersedes`, expected.lineage.supersedes, entry.lineage.supersedes, failures);
|
|
1131
|
+
checkLink(`${at}.lineage.supersededBy`, expected.lineage.supersededBy, entry.lineage.supersededBy, failures);
|
|
1132
|
+
}
|
|
1133
|
+
if (expected.expiresAtOffsetMs !== undefined) {
|
|
1134
|
+
const wanted = new Date(Date.parse(entry.filedAt) + expected.expiresAtOffsetMs).toISOString();
|
|
1135
|
+
compare(`${at}.expiresAt`, wanted, entry.expiresAt, failures);
|
|
1136
|
+
}
|
|
1137
|
+
if (expected.affidavit !== undefined) {
|
|
1138
|
+
checkAffidavit(entry.affidavit, expected.affidavit, failures, `${at}.affidavit`);
|
|
1139
|
+
}
|
|
1140
|
+
if (expected.amendedAffidavit !== undefined) {
|
|
1141
|
+
if (expected.amendedAffidavit === null) {
|
|
1142
|
+
compare(`${at}.amendedAffidavit`, null, entry.amendedAffidavit, failures);
|
|
1143
|
+
}
|
|
1144
|
+
else if (entry.amendedAffidavit === null) {
|
|
1145
|
+
failures.push({
|
|
1146
|
+
at: `${at}.amendedAffidavit`,
|
|
1147
|
+
expected: "the accepted state",
|
|
1148
|
+
actual: null,
|
|
1149
|
+
});
|
|
1150
|
+
}
|
|
1151
|
+
else {
|
|
1152
|
+
checkAffidavit(entry.amendedAffidavit, expected.amendedAffidavit, failures, `${at}.amendedAffidavit`);
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
if (expected.canonicalDiffersFromProposal !== undefined) {
|
|
1156
|
+
// Through the exported helper, not re-derived: `canonicalHashEntry` is what a
|
|
1157
|
+
// host's grant hashes over, so it is what an oracle has to exercise (SR-1).
|
|
1158
|
+
const sworn = await canonicalHashEntry(entry);
|
|
1159
|
+
const proposal = await canonicalHash(entry.affidavit);
|
|
1160
|
+
compare(`${at}.canonicalDiffersFromProposal`, expected.canonicalDiffersFromProposal, sworn !== proposal, failures);
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
/**
|
|
1164
|
+
* Check one lineage link (DK-1).
|
|
1165
|
+
*
|
|
1166
|
+
* A fixture cannot state a derived entry id — the id is a hash of the proposal — so
|
|
1167
|
+
* `"@some"` asserts only that the link is present, which is the fact the rule is
|
|
1168
|
+
* about: a resubmission names what it replaces, and the replaced row names it back.
|
|
1169
|
+
*/
|
|
1170
|
+
function checkLink(at, expected, actual, failures) {
|
|
1171
|
+
if (expected === undefined)
|
|
1172
|
+
return;
|
|
1173
|
+
if (expected === "@some") {
|
|
1174
|
+
if (actual === null)
|
|
1175
|
+
failures.push({ at, expected: "an entry id", actual: null });
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
1178
|
+
compare(at, expected, actual, failures);
|
|
1179
|
+
}
|
|
1180
|
+
/** Check an Affidavit against its partial matcher. */
|
|
1181
|
+
function checkAffidavit(affidavit, expected, failures, at) {
|
|
1182
|
+
compare(`${at}.operationType`, expected.operationType, affidavit.operationType, failures);
|
|
1183
|
+
compare(`${at}.entityType`, expected.entityType, affidavit.entityType, failures);
|
|
1184
|
+
compare(`${at}.entityId`, expected.entityId, affidavit.entityId, failures);
|
|
1185
|
+
compare(`${at}.aggregateConfidence`, expected.aggregateConfidence, affidavit.aggregateConfidence, failures);
|
|
1186
|
+
compare(`${at}.populatedConfidence`, expected.populatedConfidence, affidavit.populatedConfidence, failures);
|
|
1187
|
+
compare(`${at}.emptyFieldCount`, expected.emptyFieldCount, affidavit.emptyFieldCount, failures);
|
|
1188
|
+
if (expected.fields === undefined)
|
|
1189
|
+
return;
|
|
1190
|
+
compare(`${at}.fields`, expected.fields.map((field) => field.name), affidavit.fields.map((field) => field.name), failures);
|
|
1191
|
+
for (const [index, wanted] of expected.fields.entries()) {
|
|
1192
|
+
const field = affidavit.fields.find((candidate) => candidate.name === wanted.name);
|
|
1193
|
+
if (field === undefined)
|
|
1194
|
+
continue;
|
|
1195
|
+
const path = `${at}.fields[${String(index)}]`;
|
|
1196
|
+
compare(`${path}.value`, wanted.value, field.value, failures);
|
|
1197
|
+
compare(`${path}.previousValue`, wanted.previousValue, field.previousValue, failures);
|
|
1198
|
+
compare(`${path}.kind`, wanted.kind, field.kind, failures);
|
|
1199
|
+
compare(`${path}.isMandatory`, wanted.isMandatory, field.isMandatory, failures);
|
|
1200
|
+
compare(`${path}.source`, wanted.source, field.provenance.current.source, failures);
|
|
1201
|
+
compare(`${path}.confidence`, wanted.confidence, field.provenance.current.confidence, failures);
|
|
1202
|
+
compare(`${path}.bound`, wanted.bound, field.provenance.current.binding != null, failures);
|
|
1203
|
+
compare(`${path}.bindingKind`, wanted.bindingKind, field.provenance.current.binding?.kind ?? null, failures);
|
|
1204
|
+
compare(`${path}.priorSources`, wanted.priorSources, field.provenance.prior.map((tag) => tag.source), failures);
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
/**
|
|
1208
|
+
* The card facts that hold for every filing, whatever the fixture is about.
|
|
1209
|
+
*
|
|
1210
|
+
* - **SR-4**: the card names the protocol version the row is pinned to, and points
|
|
1211
|
+
* at the row it was built from with the row's own deadline (GT-4).
|
|
1212
|
+
* - **AF-2**: all three confidence numbers, and all three are the *record's* — a
|
|
1213
|
+
* card whose numbers were recomputed for display could disagree with the row.
|
|
1214
|
+
* - **AZ-4 / CV-4**: a blocked row says so on the card and never asks for a
|
|
1215
|
+
* confirmation no decision path will accept.
|
|
1216
|
+
*/
|
|
1217
|
+
function checkCardInvariants(filed, failures) {
|
|
1218
|
+
const { entry, card } = filed;
|
|
1219
|
+
// The exported helper, not the same expression written out again: the card's three
|
|
1220
|
+
// numbers must be the *sworn* record's — the state an approval accepted where
|
|
1221
|
+
// there is one, the proposal otherwise — and what "sworn" means is whatever
|
|
1222
|
+
// `swornAffidavitOf` says it is, because that is the function a host's execution
|
|
1223
|
+
// grant is taken over (SR-1).
|
|
1224
|
+
const sworn = swornAffidavitOf(entry);
|
|
1225
|
+
compare("card.docketId", entry.entryId, card.docketId, failures);
|
|
1226
|
+
compare("card.requiredBy", entry.expiresAt, card.requiredBy, failures);
|
|
1227
|
+
compare("card.protocolVersion", entry.protocolVersion, card.protocolVersion, failures);
|
|
1228
|
+
compare("card.affidavit.aggregateConfidence", sworn.aggregateConfidence, card.affidavit.aggregateConfidence, failures);
|
|
1229
|
+
compare("card.populatedConfidence", sworn.populatedConfidence, card.populatedConfidence, failures);
|
|
1230
|
+
compare("card.emptyFieldCount", sworn.emptyFieldCount, card.emptyFieldCount, failures);
|
|
1231
|
+
compare("card.blocked", entry.blocked, card.blocked, failures);
|
|
1232
|
+
if (entry.blocked !== null) {
|
|
1233
|
+
compare("card.requiresConfirmation", false, card.requiresConfirmation, failures);
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
/** Check an Evidence Card against its partial matcher. */
|
|
1237
|
+
function checkCard(card, expected, failures) {
|
|
1238
|
+
compare("card.requiresConfirmation", expected.requiresConfirmation, card.requiresConfirmation, failures);
|
|
1239
|
+
compare("card.priorAmendments", expected.priorAmendments, card.priorAmendments, failures);
|
|
1240
|
+
compare("card.blocked", expected.blocked, card.blocked, failures);
|
|
1241
|
+
compare("card.protocolVersion", expected.protocolVersion, card.protocolVersion, failures);
|
|
1242
|
+
compare("card.aggregateConfidence", expected.aggregateConfidence, card.affidavit.aggregateConfidence, failures);
|
|
1243
|
+
compare("card.populatedConfidence", expected.populatedConfidence, card.populatedConfidence, failures);
|
|
1244
|
+
compare("card.emptyFieldCount", expected.emptyFieldCount, card.emptyFieldCount, failures);
|
|
1245
|
+
// The warnings moved onto the envelope in v0.1 for the same reason the hints did:
|
|
1246
|
+
// a sentence a reviewer reads is presentation, and nothing swears to it. A card
|
|
1247
|
+
// with none omits the property.
|
|
1248
|
+
const stated = card.warnings ?? [];
|
|
1249
|
+
const warnings = stated.join(" ");
|
|
1250
|
+
for (const [index, phrase] of (expected.warningsContain ?? []).entries()) {
|
|
1251
|
+
if (!warnings.includes(phrase)) {
|
|
1252
|
+
failures.push({
|
|
1253
|
+
at: `card.warningsContain[${String(index)}]`,
|
|
1254
|
+
expected: phrase,
|
|
1255
|
+
actual: stated,
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
// The rendering hints are the envelope's own array from v0.1, not a per-field key:
|
|
1260
|
+
// a closed value set and an input mask are how a surface should show a field, and
|
|
1261
|
+
// the canonical form a host's execution grant binds to is the Affidavit and its
|
|
1262
|
+
// accepted amendments alone (SR-1). A fixture that states `presentation` states
|
|
1263
|
+
// the WHOLE array, in the order the card carries it — a card with an extra hint
|
|
1264
|
+
// nobody asked for is a card a reviewer surface renders differently.
|
|
1265
|
+
if (expected.presentation !== undefined) {
|
|
1266
|
+
const carried = card.presentation ?? [];
|
|
1267
|
+
compare("card.presentation", expected.presentation.map((hint) => hint.name), carried.map((hint) => hint.name), failures);
|
|
1268
|
+
for (const [index, wanted] of expected.presentation.entries()) {
|
|
1269
|
+
const hint = carried.find((candidate) => candidate.name === wanted.name);
|
|
1270
|
+
if (hint === undefined)
|
|
1271
|
+
continue;
|
|
1272
|
+
const path = `card.presentation[${String(index)}]`;
|
|
1273
|
+
compare(`${path}.kind`, wanted.kind, hint.kind, failures);
|
|
1274
|
+
compare(`${path}.allowedValues`, wanted.allowedValues, hint.allowedValues, failures);
|
|
1275
|
+
compare(`${path}.pattern`, wanted.pattern, hint.pattern, failures);
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
if (expected.fields === undefined)
|
|
1279
|
+
return;
|
|
1280
|
+
compare("card.fields", expected.fields.map((field) => field.name), card.affidavit.fields.map((field) => field.name), failures);
|
|
1281
|
+
for (const [index, wanted] of expected.fields.entries()) {
|
|
1282
|
+
const field = card.affidavit.fields.find((candidate) => candidate.name === wanted.name);
|
|
1283
|
+
if (field === undefined)
|
|
1284
|
+
continue;
|
|
1285
|
+
const path = `card.fields[${String(index)}]`;
|
|
1286
|
+
compare(`${path}.kind`, wanted.kind, field.kind, failures);
|
|
1287
|
+
compare(`${path}.value`, wanted.value, field.value, failures);
|
|
1288
|
+
compare(`${path}.isMandatory`, wanted.isMandatory, field.isMandatory, failures);
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
//# sourceMappingURL=testing.js.map
|