eval-quality 0.1.0 → 0.2.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/README.md +142 -67
- package/corpus/dev/README.md +17 -11
- package/corpus/dev/compile-seal-example/brief.json +1 -1
- package/corpus/dev/compile-seal-example/contract.json +1 -1
- package/corpus/dev/contracts/absent-collection-locations.json +1 -1
- package/corpus/dev/contracts/absent-sibling-groups.json +1 -1
- package/corpus/dev/contracts/absent-success-indicator.json +1 -1
- package/corpus/dev/contracts/empty-channel-roles.json +1 -1
- package/corpus/dev/contracts/empty-collection-locations.json +1 -1
- package/corpus/dev/contracts/empty-request-shapes.json +1 -1
- package/corpus/dev/contracts/empty-sibling-groups.json +1 -1
- package/corpus/dev/contracts/no-collection-quantifier.json +1 -1
- package/corpus/dev/contracts/no-operation-inventory.json +1 -1
- package/corpus/dev/contracts/no-read-back-relation.json +1 -1
- package/corpus/dev/contracts/no-state-change-marker.json +1 -1
- package/corpus/dev/contracts/no-type-violating-step.json +1 -1
- package/corpus/dev/contracts/per-key-split-oracles.json +1 -1
- package/corpus/dev/contracts/satisfied-declarations.json +1 -1
- package/corpus/dev/contracts/single-required-response-key.json +1 -1
- package/corpus/dev/contracts/split-indicator-oracle.json +1 -1
- package/corpus/dev/contracts/unaddressed-parameter-sibling.json +1 -1
- package/corpus/dev/contracts/unnamed-reference-set.json +1 -1
- package/corpus/dev/contracts/wrong-cardinality-form.json +1 -1
- package/corpus/dev/index.json +1 -1
- package/dist/application/index.d.ts +2 -0
- package/dist/application/index.js +1 -0
- package/dist/application/score.d.ts +48 -0
- package/dist/application/score.js +186 -0
- package/dist/cli/arguments.d.ts +7 -3
- package/dist/cli/arguments.js +73 -5
- package/dist/cli/exit-codes.d.ts +6 -2
- package/dist/cli/exit-codes.js +8 -9
- package/dist/cli/main.js +9 -0
- package/dist/cli/render.d.ts +1 -1
- package/dist/cli/render.js +6 -4
- package/dist/cli/run.d.ts +13 -3
- package/dist/cli/run.js +136 -16
- package/dist/core/canonical/canonicalize.d.ts +12 -0
- package/dist/core/canonical/canonicalize.js +18 -4
- package/dist/core/compile/bindings.d.ts +70 -0
- package/dist/core/compile/bindings.js +317 -0
- package/dist/core/compile/compile.d.ts +5 -0
- package/dist/core/compile/compile.js +9 -0
- package/dist/core/compile/expression-legality.d.ts +49 -0
- package/dist/core/compile/expression-legality.js +105 -31
- package/dist/core/compile/forbidden-inputs.d.ts +8 -1
- package/dist/core/compile/forbidden-inputs.js +16 -3
- package/dist/core/compile/interface-inventory.d.ts +23 -1
- package/dist/core/compile/interface-inventory.js +32 -3
- package/dist/core/compile/reachability.d.ts +18 -1
- package/dist/core/compile/reachability.js +46 -1
- package/dist/core/compile/sensitivity-witness.d.ts +1 -1
- package/dist/core/compile/sensitivity-witness.js +1 -1
- package/dist/core/emit/emit.d.ts +9 -0
- package/dist/core/emit/emit.js +214 -0
- package/dist/core/emit/private-artifact-digest.d.ts +11 -0
- package/dist/core/emit/private-artifact-digest.js +31 -0
- package/dist/core/evaluate/evidence-resolution.d.ts +11 -1
- package/dist/core/evaluate/evidence-resolution.js +4 -1
- package/dist/core/failure-codes.d.ts +2 -2
- package/dist/core/failure-codes.js +4 -2
- package/dist/core/ingest/conditions.d.ts +201 -0
- package/dist/core/ingest/conditions.js +48 -0
- package/dist/core/ingest/index.d.ts +13 -0
- package/dist/core/ingest/index.js +11 -0
- package/dist/core/ingest/ingest.d.ts +68 -0
- package/dist/core/ingest/ingest.js +310 -0
- package/dist/core/lineage/stage-table.d.ts +21 -4
- package/dist/core/lineage/stage-table.js +35 -3
- package/dist/core/preflight/witness-evidence.js +10 -0
- package/dist/core/schemas/artifact.d.ts +385 -14
- package/dist/core/schemas/constraint-ledger.js +40 -0
- package/dist/core/schemas/defect-signature.d.ts +273 -0
- package/dist/core/schemas/defect-signature.js +127 -0
- package/dist/core/schemas/eval-contract.d.ts +33 -0
- package/dist/core/schemas/eval-contract.js +14 -0
- package/dist/core/schemas/evidence-artifact.d.ts +99 -19
- package/dist/core/schemas/evidence-artifact.js +64 -8
- package/dist/core/schemas/plan.d.ts +112 -0
- package/dist/core/schemas/plan.js +67 -2
- package/dist/core/schemas/probe-qualification.d.ts +168 -0
- package/dist/core/schemas/probe-qualification.js +106 -0
- package/dist/core/schemas/probe.d.ts +312 -0
- package/dist/core/schemas/probe.js +6 -2
- package/dist/core/schemas/scoring-policy.d.ts +1 -0
- package/dist/core/schemas/scoring-policy.js +6 -1
- package/dist/core/schemas/sealed-evaluator-brief.d.ts +1 -0
- package/dist/core/schemas/sealed-evaluator-brief.js +4 -1
- package/dist/core/schemas/sealed-run-record.d.ts +30 -3
- package/dist/core/schemas/sealed-run-record.js +37 -5
- package/dist/core/score/binding-order.d.ts +31 -0
- package/dist/core/score/binding-order.js +88 -0
- package/dist/core/score/bindings.d.ts +93 -0
- package/dist/core/score/bindings.js +297 -0
- package/dist/core/score/ladder-table.d.ts +23 -0
- package/dist/core/score/ladder-table.js +109 -0
- package/dist/core/score/ladder.d.ts +229 -0
- package/dist/core/score/ladder.js +464 -0
- package/dist/core/score/mode-agreement.d.ts +30 -0
- package/dist/core/score/mode-agreement.js +16 -0
- package/dist/core/score/outcome-table.d.ts +17 -0
- package/dist/core/score/outcome-table.js +172 -0
- package/dist/core/score/outcome.d.ts +466 -0
- package/dist/core/score/outcome.js +562 -0
- package/dist/core/score/qualification.d.ts +83 -0
- package/dist/core/score/qualification.js +615 -0
- package/dist/core/score/quotation.d.ts +79 -0
- package/dist/core/score/quotation.js +201 -0
- package/dist/core/score/reduce-trials.d.ts +80 -0
- package/dist/core/score/reduce-trials.js +90 -0
- package/dist/core/score/score.d.ts +72 -0
- package/dist/core/score/score.js +581 -0
- package/dist/core/score/selection.d.ts +80 -0
- package/dist/core/score/selection.js +98 -0
- package/dist/core/score/strength.d.ts +49 -0
- package/dist/core/score/strength.js +193 -0
- package/dist/core/score/witness.d.ts +117 -0
- package/dist/core/score/witness.js +315 -0
- package/dist/core/seal/derived-reference.d.ts +1 -1
- package/dist/core/seal/derived-reference.js +236 -28
- package/dist/core/seal/seal.js +8 -3
- package/dist/core/stage-contracts.d.ts +62 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/testing/index.d.ts +2 -0
- package/dist/testing/index.js +1 -0
- package/package.json +12 -3
- package/schemas/eval-contract.schema.json +100 -2
- package/schemas/evidence-artifact.schema.json +96 -9
- package/schemas/probe.schema.json +508 -3
- package/schemas/scoring-policy.schema.json +8 -1
- package/schemas/sealed-evaluator-brief.schema.json +11 -1
- package/schemas/sealed-run-record.schema.json +19 -2
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AD-9's corpus gate: whether one probe earned its ground truth, and which
|
|
3
|
+
* probes may enter a sealed set.
|
|
4
|
+
*
|
|
5
|
+
* Pure and total, like the rest of `core/score`: every verdict and every reason
|
|
6
|
+
* comes back as data, nothing throws on a badly-shaped probe, and no AD-6
|
|
7
|
+
* outcome state is assigned. The failure vocabulary is closed and separate from
|
|
8
|
+
* AD-5's, because AD-5 is compile-time over contracts and `compile` never sees
|
|
9
|
+
* a probe.
|
|
10
|
+
*
|
|
11
|
+
* The gate is what keeps the witness match's ordinary path total. `resolveCheck`
|
|
12
|
+
* throws in seven places; four of them are reachable only through operand
|
|
13
|
+
* classes rejected here, `{ referenceSet }` above all, which resolves absent
|
|
14
|
+
* against the probe side's empty reference-set map and then throws a plain
|
|
15
|
+
* `Error`.
|
|
16
|
+
*/
|
|
17
|
+
import { checkExpressionOperandLegality, checkExpressionQuantifierNesting, checkExpressionQuantifierOverNonCollection, checkExpressionRegexConstructs, walkExpression, } from '../compile/expression-legality.js';
|
|
18
|
+
import { operationSignature } from '../compile/interface-inventory.js';
|
|
19
|
+
import { checkExpressionBoundElementScope, checkExpressionEvidenceReachability, forEachExpressionPointer, } from '../compile/reachability.js';
|
|
20
|
+
import { StructuralFailure } from '../failure-codes.js';
|
|
21
|
+
import { OBSERVED_STEP_ID, } from '../schemas/defect-signature.js';
|
|
22
|
+
import { TRANSPORT_CHANNELS, } from '../schemas/pointer.js';
|
|
23
|
+
import { parseEvidenceTarget } from '../seal/plan-index.js';
|
|
24
|
+
/**
|
|
25
|
+
* The closed reason set. Nothing outside it can come back from `qualifyProbe`,
|
|
26
|
+
* so a caller routing a rejection to a rung has a finite table to write.
|
|
27
|
+
*/
|
|
28
|
+
export const QUALIFICATION_FAILURES = [
|
|
29
|
+
'qualification-route-incompatible',
|
|
30
|
+
'qualification-defect-sources-mixed',
|
|
31
|
+
'qualification-evidence-unverified',
|
|
32
|
+
'signature-absent',
|
|
33
|
+
'signature-present-on-canary',
|
|
34
|
+
'signature-interface-kind-unsupported',
|
|
35
|
+
'signature-observable-channel-not-response-side',
|
|
36
|
+
'condition-channels-underspecified',
|
|
37
|
+
'condition-disjunct-without-response-channel',
|
|
38
|
+
'condition-selector-key-undeclared',
|
|
39
|
+
'condition-pointer-not-observation-rooted',
|
|
40
|
+
'condition-pointer-unwritable',
|
|
41
|
+
'condition-text-channel-on-api',
|
|
42
|
+
'condition-reference-set-operand',
|
|
43
|
+
'condition-operand-illegal',
|
|
44
|
+
'condition-regex-illegal',
|
|
45
|
+
'condition-quantifier-nesting',
|
|
46
|
+
'condition-quantifier-over-non-collection',
|
|
47
|
+
'condition-bound-element-outside-quantifier',
|
|
48
|
+
];
|
|
49
|
+
/**
|
|
50
|
+
* Resolves a signature's home operation against a contract's operation
|
|
51
|
+
* inventory, comparing method plus path template with parameter names erased
|
|
52
|
+
* first, so a corpus signature on `/notes/{id}` binds a contract declaring
|
|
53
|
+
* `/notes/{noteId}`. A post-erasure collision inside one contract has already
|
|
54
|
+
* failed compilation under `duplicate-operation-signature`, so the first match
|
|
55
|
+
* is the only match for any contract that compiled.
|
|
56
|
+
*/
|
|
57
|
+
export function resolveHomeOperation(signature, interfaces) {
|
|
58
|
+
const wanted = operationSignature(signature);
|
|
59
|
+
for (const iface of interfaces) {
|
|
60
|
+
for (const operation of iface.operations) {
|
|
61
|
+
if (operationSignature(operation) === wanted)
|
|
62
|
+
return operation;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
// AD-26's channels that carry what came back, as opposed to what was sent. The
|
|
68
|
+
// channel rule below needs at least one of these, or a condition naming two
|
|
69
|
+
// channels could name `call-inputs` twice and pass.
|
|
70
|
+
const RESPONSE_SIDE_CHANNELS = new Set([
|
|
71
|
+
'response-body',
|
|
72
|
+
'response-headers',
|
|
73
|
+
'response-status',
|
|
74
|
+
'stdout',
|
|
75
|
+
'stderr',
|
|
76
|
+
'exit-code',
|
|
77
|
+
]);
|
|
78
|
+
// The three channels an `api` signature can never manifest in. The contract
|
|
79
|
+
// side cannot decide this: reachability rejects a tailed `stdout` pointer and
|
|
80
|
+
// returns reachable for a bare one unconditionally, because an operation
|
|
81
|
+
// carries no interface kind. The signature's own declared kind is what makes
|
|
82
|
+
// the rule decidable, so it is spent here rather than left as prose.
|
|
83
|
+
const TEXT_CHANNELS = new Set([
|
|
84
|
+
'stdout',
|
|
85
|
+
'stderr',
|
|
86
|
+
'exit-code',
|
|
87
|
+
]);
|
|
88
|
+
const probePath = (probe, tail) => `Probe[probeId=${probe.probeId}]${tail}`;
|
|
89
|
+
/**
|
|
90
|
+
* Which routes this probe's class and control status admit. An empty list is
|
|
91
|
+
* the illegal cell: a pairing no route can satisfy, which the schema admits so
|
|
92
|
+
* this gate can name it.
|
|
93
|
+
*
|
|
94
|
+
* `expectedClean` is read first, because it is the schema's own discriminator,
|
|
95
|
+
* which is what makes a canary clean control an illegal cell rather than a
|
|
96
|
+
* probe owing two routes.
|
|
97
|
+
*/
|
|
98
|
+
function admissibleRoutes(probe) {
|
|
99
|
+
if (probe.expectedClean) {
|
|
100
|
+
return probe.probeClass === 'zero-action' ? ['clean-control'] : [];
|
|
101
|
+
}
|
|
102
|
+
switch (probe.probeClass) {
|
|
103
|
+
case 'gameability':
|
|
104
|
+
return ['gameability'];
|
|
105
|
+
case 'canary':
|
|
106
|
+
return ['canary'];
|
|
107
|
+
default: {
|
|
108
|
+
// A class that seeds admits no route when it seeds nothing. Both
|
|
109
|
+
// source rules read "every seeded source is X", which is vacuously
|
|
110
|
+
// true of an empty array both ways, so a literal reading hands a
|
|
111
|
+
// probe with no defect BOTH routes and lets a corpus author attach
|
|
112
|
+
// either route's evidence to a defect that does not exist. AD-9
|
|
113
|
+
// qualifies a seeded defect, so with none there is nothing for the
|
|
114
|
+
// evidence to be about.
|
|
115
|
+
if (probe.defects.length === 0)
|
|
116
|
+
return [];
|
|
117
|
+
const sources = new Set(probe.defects.map((defect) => defect.source));
|
|
118
|
+
const routes = [];
|
|
119
|
+
if (!sources.has('controlled-mutation'))
|
|
120
|
+
routes.push('historical');
|
|
121
|
+
if (!sources.has('natural'))
|
|
122
|
+
routes.push('controlled-mutation');
|
|
123
|
+
return routes;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* The route rules: a route the pairing admits, and a `defects` array whose
|
|
129
|
+
* sources agree with a single route.
|
|
130
|
+
*
|
|
131
|
+
* A mixed array is its own failure rather than being resolved by majority or by
|
|
132
|
+
* first entry: a probe seeding one mined defect and one introduced mutation has
|
|
133
|
+
* two qualification arguments and AD-9 gives it one record.
|
|
134
|
+
*/
|
|
135
|
+
function checkRoute(probe, failures) {
|
|
136
|
+
const path = probePath(probe, '.qualification.route');
|
|
137
|
+
if (!probe.expectedClean &&
|
|
138
|
+
probe.probeClass !== 'gameability' &&
|
|
139
|
+
probe.probeClass !== 'canary') {
|
|
140
|
+
const sources = new Set(probe.defects.map((defect) => defect.source));
|
|
141
|
+
if (sources.size > 1) {
|
|
142
|
+
failures.push({
|
|
143
|
+
code: 'qualification-defect-sources-mixed',
|
|
144
|
+
artifactPath: probePath(probe, '.defects'),
|
|
145
|
+
detail: 'seeds both a natural defect and a controlled mutation, which no single AD-9 route qualifies (AD-9)',
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const admissible = admissibleRoutes(probe);
|
|
150
|
+
if (!admissible.includes(probe.qualification.route)) {
|
|
151
|
+
failures.push({
|
|
152
|
+
code: 'qualification-route-incompatible',
|
|
153
|
+
artifactPath: path,
|
|
154
|
+
// Three conditions empty the admissible list and they send a corpus
|
|
155
|
+
// author to three different fields, so the detail names which one
|
|
156
|
+
// fired rather than blaming the class pairing for all of them.
|
|
157
|
+
detail: admissible.length > 0
|
|
158
|
+
? `route "${probe.qualification.route}" is not one of ${admissible.map((route) => `"${route}"`).join(', ')} for probeClass "${probe.probeClass}" with expectedClean ${probe.expectedClean} (AD-9)`
|
|
159
|
+
: emptyRouteReason(probe),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/** Why no route is admissible, in the terms of the field that caused it. */
|
|
164
|
+
function emptyRouteReason(probe) {
|
|
165
|
+
if (!probe.expectedClean && probe.defects.length === 0) {
|
|
166
|
+
return `probeClass "${probe.probeClass}" seeds a defect and this probe's defects array is empty, so no AD-9 route has a seeded defect to qualify (AD-9)`;
|
|
167
|
+
}
|
|
168
|
+
if (new Set(probe.defects.map((defect) => defect.source)).size > 1) {
|
|
169
|
+
return 'the defects array seeds both a natural defect and a controlled mutation, so no single AD-9 route qualifies this probe (AD-9)';
|
|
170
|
+
}
|
|
171
|
+
return `probeClass "${probe.probeClass}" with expectedClean ${probe.expectedClean} admits no AD-9 route at all, so no qualification record can be written for it (AD-9)`;
|
|
172
|
+
}
|
|
173
|
+
/** The two routes whose evidence carries its own verified/not-verified flag. */
|
|
174
|
+
function checkRouteEvidence(probe, failures) {
|
|
175
|
+
const { qualification } = probe;
|
|
176
|
+
if (qualification.route === 'historical' &&
|
|
177
|
+
!qualification.oracleStableAcrossRevisions) {
|
|
178
|
+
failures.push({
|
|
179
|
+
code: 'qualification-evidence-unverified',
|
|
180
|
+
artifactPath: probePath(probe, '.qualification.oracleStableAcrossRevisions'),
|
|
181
|
+
detail: 'AD-9 qualifies a historical probe only with the oracle stable across both revisions, and this record states it was not',
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
if (qualification.route === 'controlled-mutation' &&
|
|
185
|
+
!qualification.rollbackVerified) {
|
|
186
|
+
failures.push({
|
|
187
|
+
code: 'qualification-evidence-unverified',
|
|
188
|
+
artifactPath: probePath(probe, '.qualification.rollbackVerified'),
|
|
189
|
+
detail: 'AD-9 qualifies a controlled mutation only with verified rollback or cleanup, and this record states it was not verified',
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* The predicate's own operands: no reference set, every fully-rooted pointer at
|
|
195
|
+
* the reserved step identifier, and no text channel on an `api` signature.
|
|
196
|
+
* Returns the distinct channels the fully-rooted pointers name, which is what
|
|
197
|
+
* the channel rule counts.
|
|
198
|
+
*/
|
|
199
|
+
function checkOperandsAndCollectChannels(probe, signature, failures) {
|
|
200
|
+
const conditionPath = probePath(probe, '.defectSignature.condition.predicate');
|
|
201
|
+
const channels = new Set();
|
|
202
|
+
walkExpression(signature.condition.predicate, 0, '', {
|
|
203
|
+
onOperand: (operand, _op, _position, path) => {
|
|
204
|
+
if ('referenceSet' in operand) {
|
|
205
|
+
failures.push({
|
|
206
|
+
code: 'condition-reference-set-operand',
|
|
207
|
+
artifactPath: `${conditionPath}${path}`,
|
|
208
|
+
detail: `names referenceSet "${operand.referenceSet}"; a corpus signature declares no reference sets, so the operand resolves absent and faults the evaluator rather than discriminating (AD-4, AD-26)`,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
onSetOperand: (setOperand, path) => {
|
|
213
|
+
if ('referenceSet' in setOperand) {
|
|
214
|
+
failures.push({
|
|
215
|
+
code: 'condition-reference-set-operand',
|
|
216
|
+
artifactPath: `${conditionPath}${path}`,
|
|
217
|
+
detail: `names referenceSet "${setOperand.referenceSet}"; a corpus signature declares no reference sets (AD-4, AD-26)`,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
forEachExpressionPointer(signature.condition.predicate, (pointer, path) => {
|
|
223
|
+
// A bound-element pointer is relative to whatever a quantifier bound and
|
|
224
|
+
// roots at no step identifier at all.
|
|
225
|
+
if (pointer.startsWith('@'))
|
|
226
|
+
return;
|
|
227
|
+
const target = parseEvidenceTarget(pointer);
|
|
228
|
+
if (target.stepId !== OBSERVED_STEP_ID) {
|
|
229
|
+
failures.push({
|
|
230
|
+
code: 'condition-pointer-not-observation-rooted',
|
|
231
|
+
artifactPath: `${conditionPath}${path}`,
|
|
232
|
+
detail: `"${pointer}" roots at step "${target.stepId}"; a signature is authored against a corpus and roots every pointer at "${OBSERVED_STEP_ID}", since a step identifier is contract-relative and resolves nothing against a second contract (AD-40)`,
|
|
233
|
+
});
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
channels.add(target.channel);
|
|
237
|
+
if (signature.interfaceKind === 'api' &&
|
|
238
|
+
TEXT_CHANNELS.has(target.channel)) {
|
|
239
|
+
failures.push({
|
|
240
|
+
code: 'condition-text-channel-on-api',
|
|
241
|
+
artifactPath: `${conditionPath}${path}`,
|
|
242
|
+
detail: `"${pointer}" addresses ${target.channel}, which an api interface never produces (AD-19, AD-26)`,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
return channels;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* The selector's own keys against the home operation's declared request shape.
|
|
250
|
+
*
|
|
251
|
+
* A selector binding a key the operation declares in neither `requiredKeys`
|
|
252
|
+
* nor `permittedKeys` matches no observation at all, so every candidate is
|
|
253
|
+
* filtered out, the probe reports `not-triggered`, and a typo becomes a
|
|
254
|
+
* silently passing run. Same shape as the pointer writability check, reached
|
|
255
|
+
* through the selector.
|
|
256
|
+
*
|
|
257
|
+
* Stricter than the contract side deliberately. `compile/bindings.ts` leaves the
|
|
258
|
+
* equivalent key rule to `undeclared-mandatory-input`, which AD-4 makes
|
|
259
|
+
* strict-only, because a contract may be compiled either way. A corpus gate has
|
|
260
|
+
* no lenient mode: AD-9's "an unqualified probe cannot enter a sealed set" states
|
|
261
|
+
* one bar, so the probe side applies the rule unconditionally.
|
|
262
|
+
*
|
|
263
|
+
* Declaration-dependent, so it runs only where the caller supplied an inventory,
|
|
264
|
+
* and `declarationChecksRan` says when it did not.
|
|
265
|
+
*/
|
|
266
|
+
function checkSelectorKeys(probe, signature, operation, failures) {
|
|
267
|
+
const { inputBinding } = signature.condition.selector;
|
|
268
|
+
for (const channel of TRANSPORT_CHANNELS) {
|
|
269
|
+
const binding = inputBinding[channel];
|
|
270
|
+
if (binding === null)
|
|
271
|
+
continue;
|
|
272
|
+
const { requiredKeys, permittedKeys, types } = operation.requestShape[channel];
|
|
273
|
+
for (const key of Object.keys(binding)) {
|
|
274
|
+
const at = probePath(probe, `.defectSignature.condition.selector.inputBinding.${channel}[${JSON.stringify(key)}]`);
|
|
275
|
+
if (!requiredKeys.includes(key) && !permittedKeys.includes(key)) {
|
|
276
|
+
failures.push({
|
|
277
|
+
code: 'condition-selector-key-undeclared',
|
|
278
|
+
artifactPath: at,
|
|
279
|
+
detail: `operation "${operation.operationId}" declares "${key}" in neither requiredKeys nor permittedKeys of its ${channel} channel, so this selector matches no observation and the probe can never be triggered (AD-4, AD-40)`,
|
|
280
|
+
});
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
// "Declared" has a second, narrower meaning for the one member that
|
|
284
|
+
// reads the type map: a key may be permitted and still carry no
|
|
285
|
+
// declared type, and a type-violating binding fails closed on an
|
|
286
|
+
// indeterminate type because it cannot prove a violation. Checking
|
|
287
|
+
// only key presence would leave that member matching nothing, which
|
|
288
|
+
// is the same silent pass through the other definition of declared.
|
|
289
|
+
// The other two members never read the type map and get no rule here.
|
|
290
|
+
const value = binding[key];
|
|
291
|
+
if (value === undefined || !('matcher' in value))
|
|
292
|
+
continue;
|
|
293
|
+
if (value.matcher !== 'type-violating')
|
|
294
|
+
continue;
|
|
295
|
+
const declared = types[key];
|
|
296
|
+
if (declared !== undefined && declared !== null)
|
|
297
|
+
continue;
|
|
298
|
+
failures.push({
|
|
299
|
+
code: 'condition-selector-key-undeclared',
|
|
300
|
+
artifactPath: at,
|
|
301
|
+
detail: `operation "${operation.operationId}" ${declared === undefined ? 'does not declare' : 'declares an indeterminate'} type for "${key}" in its ${channel} channel, and a type-violating binding cannot prove a violation against one, so this selector matches no observation (AD-4, AD-40)`,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* The declared observable channel's own two rules, which the pointer walk above
|
|
308
|
+
* cannot supply because it only sees what the predicate wrote.
|
|
309
|
+
*
|
|
310
|
+
* AD-40 calls it "the observable channel it manifests in", so it names what came
|
|
311
|
+
* back. `call-inputs` names what was sent and can never be where a defect
|
|
312
|
+
* manifests, and the channel rule reads this field first, so leaving it
|
|
313
|
+
* unchecked would make an unchecked field load-bearing. On an `api` signature
|
|
314
|
+
* the three text channels are rejected for the same reason a pointer into one
|
|
315
|
+
* is: an api interface never produces them, which is the same rule the pointer
|
|
316
|
+
* walk applies, read on the declaration too.
|
|
317
|
+
*/
|
|
318
|
+
function checkObservableChannel(probe, signature, failures) {
|
|
319
|
+
const path = probePath(probe, '.defectSignature.observableChannel');
|
|
320
|
+
if (!RESPONSE_SIDE_CHANNELS.has(signature.observableChannel)) {
|
|
321
|
+
failures.push({
|
|
322
|
+
code: 'signature-observable-channel-not-response-side',
|
|
323
|
+
artifactPath: path,
|
|
324
|
+
detail: `declares observableChannel "${signature.observableChannel}", which records what was sent rather than what came back, so no defect manifests in it (AD-40)`,
|
|
325
|
+
});
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
if (signature.interfaceKind === 'api' &&
|
|
329
|
+
TEXT_CHANNELS.has(signature.observableChannel)) {
|
|
330
|
+
failures.push({
|
|
331
|
+
code: 'condition-text-channel-on-api',
|
|
332
|
+
artifactPath: path,
|
|
333
|
+
detail: `declares observableChannel "${signature.observableChannel}", which an api interface never produces (AD-19, AD-26)`,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* AD-40's "name the response channel or at least two channels", made decidable:
|
|
339
|
+
* the set of distinct channels the predicate's fully-rooted pointers name must
|
|
340
|
+
* contain the declared observable channel, or hold two or more members with at
|
|
341
|
+
* least one on the response side.
|
|
342
|
+
*
|
|
343
|
+
* The selector's own bindings do not count, since they describe what was sent.
|
|
344
|
+
* The rule exists to reject "the evidence contains the string I sent", so
|
|
345
|
+
* `call-inputs` twice must not pass, and does not.
|
|
346
|
+
*/
|
|
347
|
+
function checkChannels(probe, signature, channels, failures) {
|
|
348
|
+
// Guarded on the response side rather than testing membership alone. An
|
|
349
|
+
// unguarded early return lets `observableChannel: 'call-inputs'` pass a
|
|
350
|
+
// predicate whose only pointer is a call-inputs pointer, which is the
|
|
351
|
+
// "the evidence contains the string I sent" condition this rule exists to
|
|
352
|
+
// reject, arriving through the field that names the rule. The declared
|
|
353
|
+
// channel is rejected outright one check up, and this guard keeps the rule
|
|
354
|
+
// true of this function on its own.
|
|
355
|
+
if (RESPONSE_SIDE_CHANNELS.has(signature.observableChannel) &&
|
|
356
|
+
channels.has(signature.observableChannel))
|
|
357
|
+
return;
|
|
358
|
+
const namesTwo = channels.size >= 2;
|
|
359
|
+
const namesResponse = [...channels].some((channel) => RESPONSE_SIDE_CHANNELS.has(channel));
|
|
360
|
+
if (namesTwo && namesResponse)
|
|
361
|
+
return;
|
|
362
|
+
failures.push({
|
|
363
|
+
code: 'condition-channels-underspecified',
|
|
364
|
+
artifactPath: probePath(probe, '.defectSignature.condition.predicate'),
|
|
365
|
+
detail: `names ${channels.size === 0 ? 'no channel' : [...channels].map((channel) => `"${channel}"`).join(', ')}, which is neither the declared observableChannel "${signature.observableChannel}" nor two channels with a response-side member (AD-40)`,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Every distinct channel one expression reads.
|
|
370
|
+
*
|
|
371
|
+
* A bound-element pointer roots at no step identifier, so it names no channel of
|
|
372
|
+
* its own; it reads whatever the enclosing quantifier's collection is rooted in,
|
|
373
|
+
* which `boundChannel` carries. Without that inheritance a disjunct made
|
|
374
|
+
* entirely of `@/` comparisons over a response-body collection would look like
|
|
375
|
+
* it reads nothing at all.
|
|
376
|
+
*/
|
|
377
|
+
function channelsNamedBy(expression, boundChannel) {
|
|
378
|
+
const named = new Set();
|
|
379
|
+
forEachExpressionPointer(expression, (pointer) => {
|
|
380
|
+
if (pointer.startsWith('@')) {
|
|
381
|
+
if (boundChannel !== null)
|
|
382
|
+
named.add(boundChannel);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
named.add(parseEvidenceTarget(pointer).channel);
|
|
386
|
+
});
|
|
387
|
+
return named;
|
|
388
|
+
}
|
|
389
|
+
/** The channel a quantifier's collection is rooted in, where it has one. */
|
|
390
|
+
function collectionChannel(collection) {
|
|
391
|
+
if (!('pointer' in collection))
|
|
392
|
+
return null;
|
|
393
|
+
if (collection.pointer.startsWith('@'))
|
|
394
|
+
return null;
|
|
395
|
+
return parseEvidenceTarget(collection.pointer).channel;
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* A disjunct that names only what was sent decides the whole condition on its
|
|
399
|
+
* own, so the channel rule above passes on a predicate that discriminates
|
|
400
|
+
* nothing.
|
|
401
|
+
*
|
|
402
|
+
* The channel rule counts the channels a condition names and cannot see where
|
|
403
|
+
* the truth value comes from. `any(existence(call-inputs/body/title),
|
|
404
|
+
* existence(response-body/message))` names two channels, one of them
|
|
405
|
+
* response-side, and passes. Its first disjunct is true of every candidate,
|
|
406
|
+
* because the selector's own binding already guarantees that key is present on
|
|
407
|
+
* anything that became a candidate. The condition then resolves
|
|
408
|
+
* `true` on the observation where the system behaved correctly, and a finding
|
|
409
|
+
* citing it reports a catch: the catch rate 1.00 by construction, one level in
|
|
410
|
+
* from the direct spelling.
|
|
411
|
+
*
|
|
412
|
+
* The rule is scoped to `any` and nothing else, because only a disjunction lets
|
|
413
|
+
* one operand carry the verdict alone. Under `all` no single operand can, so a
|
|
414
|
+
* sent-side conjunct is a legitimate half of the two-channel conditions AD-40's
|
|
415
|
+
* wording exists to admit: "the response echoes the request body" is exactly
|
|
416
|
+
* that shape. Syntactic and decidable from the expression, rather than an
|
|
417
|
+
* attempt to decide what a predicate's truth actually depends on, which
|
|
418
|
+
* quantifiers make undecidable.
|
|
419
|
+
*/
|
|
420
|
+
function checkDisjuncts(probe, signature, failures) {
|
|
421
|
+
const conditionPath = probePath(probe, '.defectSignature.condition.predicate');
|
|
422
|
+
walkAnyNodes(signature.condition.predicate, '', null, (operand, path, bound) => {
|
|
423
|
+
const named = channelsNamedBy(operand, bound);
|
|
424
|
+
if ([...named].some((channel) => RESPONSE_SIDE_CHANNELS.has(channel))) {
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
failures.push({
|
|
428
|
+
code: 'condition-disjunct-without-response-channel',
|
|
429
|
+
artifactPath: `${conditionPath}${path}`,
|
|
430
|
+
detail: `this operand of "any" names ${named.size === 0 ? 'no channel at all' : [...named].map((channel) => `"${channel}"`).join(', ')}, so it can satisfy the whole condition without examining anything that came back (AD-40)`,
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Visits each direct operand of every `any` node, wherever it sits, carrying the
|
|
436
|
+
* channel the nearest enclosing quantifier's collection is rooted in. Path
|
|
437
|
+
* spellings follow `walkExpression`'s, so a failure's artifact path reads the
|
|
438
|
+
* same as every other one this module emits.
|
|
439
|
+
*/
|
|
440
|
+
function walkAnyNodes(expression, path, boundChannel, visit) {
|
|
441
|
+
if (expression.op === 'any') {
|
|
442
|
+
expression.operands.forEach((operand, index) => {
|
|
443
|
+
visit(operand, `${path}.operands[${index}]`, boundChannel);
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
switch (expression.op) {
|
|
447
|
+
case 'not':
|
|
448
|
+
case 'all':
|
|
449
|
+
case 'any':
|
|
450
|
+
expression.operands.forEach((operand, index) => {
|
|
451
|
+
walkAnyNodes(operand, `${path}.operands[${index}]`, boundChannel, visit);
|
|
452
|
+
});
|
|
453
|
+
return;
|
|
454
|
+
case 'for-all':
|
|
455
|
+
case 'for-any':
|
|
456
|
+
walkAnyNodes(expression.predicate, `${path}.predicate`, collectionChannel(expression.collection), visit);
|
|
457
|
+
return;
|
|
458
|
+
default:
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
// Each shipped legality check, run over the bare predicate and reported as a
|
|
463
|
+
// qualification reason. The structural failure's own artifact path and message
|
|
464
|
+
// are carried through, so a rejection reads the same on both sides of the
|
|
465
|
+
// boundary.
|
|
466
|
+
const LEGALITY_CHECKS = [
|
|
467
|
+
{
|
|
468
|
+
code: 'condition-bound-element-outside-quantifier',
|
|
469
|
+
needsOperation: false,
|
|
470
|
+
run: (signature, artifactPath) => {
|
|
471
|
+
checkExpressionBoundElementScope(signature.condition.predicate, artifactPath);
|
|
472
|
+
},
|
|
473
|
+
},
|
|
474
|
+
{
|
|
475
|
+
code: 'condition-operand-illegal',
|
|
476
|
+
needsOperation: false,
|
|
477
|
+
run: (signature, artifactPath) => {
|
|
478
|
+
checkExpressionOperandLegality(signature.condition.predicate, artifactPath);
|
|
479
|
+
},
|
|
480
|
+
},
|
|
481
|
+
{
|
|
482
|
+
code: 'condition-regex-illegal',
|
|
483
|
+
needsOperation: false,
|
|
484
|
+
run: (signature, artifactPath) => {
|
|
485
|
+
checkExpressionRegexConstructs(signature.condition.predicate, artifactPath);
|
|
486
|
+
},
|
|
487
|
+
},
|
|
488
|
+
{
|
|
489
|
+
code: 'condition-quantifier-nesting',
|
|
490
|
+
needsOperation: false,
|
|
491
|
+
run: (signature, artifactPath) => {
|
|
492
|
+
checkExpressionQuantifierNesting(signature.condition.predicate, artifactPath);
|
|
493
|
+
},
|
|
494
|
+
},
|
|
495
|
+
{
|
|
496
|
+
code: 'condition-quantifier-over-non-collection',
|
|
497
|
+
needsOperation: true,
|
|
498
|
+
run: (signature, artifactPath, operation) => {
|
|
499
|
+
if (operation === null)
|
|
500
|
+
return;
|
|
501
|
+
checkExpressionQuantifierOverNonCollection(signature.condition.predicate, artifactPath, { operation, legIds: [OBSERVED_STEP_ID] });
|
|
502
|
+
},
|
|
503
|
+
},
|
|
504
|
+
{
|
|
505
|
+
code: 'condition-pointer-unwritable',
|
|
506
|
+
needsOperation: true,
|
|
507
|
+
run: (signature, artifactPath, operation) => {
|
|
508
|
+
if (operation === null)
|
|
509
|
+
return;
|
|
510
|
+
checkExpressionEvidenceReachability(signature.condition.predicate, artifactPath, operation);
|
|
511
|
+
},
|
|
512
|
+
},
|
|
513
|
+
];
|
|
514
|
+
function runLegalityChecks(probe, signature, homeOperation, failures) {
|
|
515
|
+
const artifactPath = probePath(probe, '.defectSignature.condition.predicate');
|
|
516
|
+
for (const check of LEGALITY_CHECKS) {
|
|
517
|
+
if (check.needsOperation && homeOperation === null)
|
|
518
|
+
continue;
|
|
519
|
+
try {
|
|
520
|
+
check.run(signature, artifactPath, homeOperation);
|
|
521
|
+
}
|
|
522
|
+
catch (error) {
|
|
523
|
+
if (!(error instanceof StructuralFailure))
|
|
524
|
+
throw error;
|
|
525
|
+
failures.push({
|
|
526
|
+
code: check.code,
|
|
527
|
+
artifactPath: error.artifactPath,
|
|
528
|
+
detail: error.message,
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* AD-9's gate over one probe. `homeOperation` is the operation the signature
|
|
535
|
+
* resolves to in whatever inventory the caller is qualifying against, or `null`
|
|
536
|
+
* when there is none to qualify against; the three declaration-dependent checks
|
|
537
|
+
* are skipped in that case and `declarationChecksRan` says so.
|
|
538
|
+
*/
|
|
539
|
+
export function qualifyProbe(probe, homeOperation) {
|
|
540
|
+
const failures = [];
|
|
541
|
+
checkRoute(probe, failures);
|
|
542
|
+
checkRouteEvidence(probe, failures);
|
|
543
|
+
const signature = probe.expectedClean ? null : probe.defectSignature;
|
|
544
|
+
if (!probe.expectedClean) {
|
|
545
|
+
if (probe.probeClass === 'canary') {
|
|
546
|
+
if (signature !== null) {
|
|
547
|
+
failures.push({
|
|
548
|
+
code: 'signature-present-on-canary',
|
|
549
|
+
artifactPath: probePath(probe, '.defectSignature'),
|
|
550
|
+
detail: 'a canary seeds no defect, so it declares no signature; AD-9 qualifies it by demonstrating that non-detection indicts the corpus or the fixture',
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
else if (signature === null) {
|
|
555
|
+
failures.push({
|
|
556
|
+
code: 'signature-absent',
|
|
557
|
+
artifactPath: probePath(probe, '.defectSignature'),
|
|
558
|
+
detail: `probeClass "${probe.probeClass}" seeds a defect, and AD-33 assigns an outcome only from a signature match, so a null signature makes this probe unscoreable (AD-40)`,
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
if (signature !== null) {
|
|
563
|
+
if (signature.interfaceKind !== 'api') {
|
|
564
|
+
failures.push({
|
|
565
|
+
code: 'signature-interface-kind-unsupported',
|
|
566
|
+
artifactPath: probePath(probe, '.defectSignature.interfaceKind'),
|
|
567
|
+
detail: `"${signature.interfaceKind}" declares a method and a path template that mean nothing off an api interface; v0 keeps all four kinds in the enum so unsupported-interface-kind stays fireable contract-side (AD-19)`,
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
checkObservableChannel(probe, signature, failures);
|
|
571
|
+
const channels = checkOperandsAndCollectChannels(probe, signature, failures);
|
|
572
|
+
checkChannels(probe, signature, channels, failures);
|
|
573
|
+
checkDisjuncts(probe, signature, failures);
|
|
574
|
+
if (homeOperation !== null) {
|
|
575
|
+
checkSelectorKeys(probe, signature, homeOperation, failures);
|
|
576
|
+
}
|
|
577
|
+
runLegalityChecks(probe, signature, homeOperation, failures);
|
|
578
|
+
}
|
|
579
|
+
return {
|
|
580
|
+
qualified: failures.length === 0,
|
|
581
|
+
failures,
|
|
582
|
+
declarationChecksRan: signature === null || homeOperation !== null,
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* AD-9's "an unqualified probe cannot enter a sealed set", as a
|
|
587
|
+
* construction-time filter that reports its exclusions.
|
|
588
|
+
*
|
|
589
|
+
* Construction-time, and score never re-filters. Silently dropping an
|
|
590
|
+
* unqualified probe at score time would shrink AD-7's denominator and
|
|
591
|
+
* desynchronise the AD-8 corpus digest from the probes actually scored, while
|
|
592
|
+
* AD-7 makes comparability the corpus digest restricted to the probes both
|
|
593
|
+
* results cover. A sealed set that nonetheless contains an unqualified probe is
|
|
594
|
+
* an invalidating condition for whoever scores it, never something the witness
|
|
595
|
+
* match quietly repairs.
|
|
596
|
+
*
|
|
597
|
+
* `homeOperationOf` is required rather than defaulted. A default resolving
|
|
598
|
+
* nothing would make the unchecked path the one a caller reaches by writing
|
|
599
|
+
* less, and the three checks it skips are the ones that catch a signature that
|
|
600
|
+
* was never writable and a selector that matches nothing. A caller who holds
|
|
601
|
+
* no inventory writes `() => null` at the call site, where the choice shows up
|
|
602
|
+
* in a diff.
|
|
603
|
+
*/
|
|
604
|
+
export function sealProbeSet(probes, homeOperationOf) {
|
|
605
|
+
const admitted = [];
|
|
606
|
+
const rejected = [];
|
|
607
|
+
for (const probe of probes) {
|
|
608
|
+
const result = qualifyProbe(probe, homeOperationOf(probe));
|
|
609
|
+
if (result.qualified)
|
|
610
|
+
admitted.push({ probe, result });
|
|
611
|
+
else
|
|
612
|
+
rejected.push({ probe, result });
|
|
613
|
+
}
|
|
614
|
+
return { admitted, rejected };
|
|
615
|
+
}
|