@githonllc/avouch 0.1.6 → 0.1.8
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 +71 -1
- package/SPEC.md +14 -8
- package/dist/cli/avouch.js +8 -0
- package/dist/cli/gate.d.ts +2 -0
- package/dist/cli/gate.js +88 -0
- package/dist/cli/jsonl.d.ts +5 -0
- package/dist/cli/jsonl.js +20 -0
- package/dist/cli/reconcile.d.ts +7 -0
- package/dist/cli/reconcile.js +110 -0
- package/dist/src/coverage.d.ts +65 -0
- package/dist/src/coverage.js +229 -0
- package/dist/src/evaluate.js +3 -1
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- package/dist/src/reconcile.d.ts +9 -2
- package/dist/src/reconcile.js +30 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -39,8 +39,10 @@ Read in this order:
|
|
|
39
39
|
| `src/checker.ts` | `check(ontology, facts)` returns the violations |
|
|
40
40
|
| `src/patch.ts` | JSON-pointer patch that the mutation tests use |
|
|
41
41
|
| `src/query.ts` | read-only queries over an ontology (`avouch query`) |
|
|
42
|
+
| `src/reconcile.ts` | runtime consistency: `reconcile`, `projectSnapshot` (SPEC section 9) |
|
|
43
|
+
| `src/coverage.ts` | decision-row coverage of reconcile rows, with a baseline (`verifyCoverage`, `avouch gate verify`) |
|
|
42
44
|
| `src/markdown.ts` | built-in adapter for plain Markdown sources (`avouch.json`); see `docs/adapter-guide.md` section 7 |
|
|
43
|
-
| `cli/` | command line: `avouch check` (`check.ts`)
|
|
45
|
+
| `cli/` | command line: `avouch check` (`check.ts`), `avouch query` (`query.ts`), `avouch reconcile` (`reconcile.ts`) and `avouch gate verify` (`gate.ts`) |
|
|
44
46
|
| `tsconfig.build.json` | configuration for the JavaScript and type declaration build |
|
|
45
47
|
| `dist/` | build output; not tracked in version control |
|
|
46
48
|
| `examples/library/` | toy project: source document, ontology, mutations, adapter, profile, evidence |
|
|
@@ -60,6 +62,9 @@ A project supplies an adapter and a profile. The adapter reads the project's sou
|
|
|
60
62
|
- `test/cli.test.ts`: command line results, schema errors, checker violations and adapter errors.
|
|
61
63
|
- `test/query.test.ts`: the query functions on the toy ontology and on patched variants of it.
|
|
62
64
|
- `test/query-cli.test.ts`: `avouch query`: the JSON envelope, the stamp (git and check), exit codes, text output, and that a query writes no file.
|
|
65
|
+
- `test/coverage.test.ts`: `verifyCoverage` on the toy ontology and on patched variants of it: units, gaps, stale baseline entries, failing reconcile rows, and the shape checks.
|
|
66
|
+
- `test/reconcile-cli.test.ts`: `avouch reconcile`: JSON lines in and out, exit codes, and the line number in each input error.
|
|
67
|
+
- `test/gate-cli.test.ts`: `avouch gate verify`: the pipeline from cases to the gate, text and JSON output, the check with `--adapter`, and exit codes.
|
|
63
68
|
- `test/markdown.test.ts`: the built-in Markdown adapter: the toy library gives the same findings as with its own adapter (outside the rules the generic adapter cannot check), and each parse convention.
|
|
64
69
|
- `test/boundary.test.ts`: the runtime (`src/` and `examples/`) imports only relative paths inside this package. It imports no npm package and no file outside the package.
|
|
65
70
|
|
|
@@ -118,6 +123,71 @@ The stamp tells which specification answered:
|
|
|
118
123
|
|
|
119
124
|
Exit code 0 means there is a result. 1 means there is no result: the id is not declared, or the list is empty (the envelope is still printed). 2 means invalid arguments, or a file, parsing or adapter error.
|
|
120
125
|
|
|
126
|
+
## Runtime consistency on the command line
|
|
127
|
+
|
|
128
|
+
`avouch reconcile` compares the result that the runtime reported with the result of the evaluator and with the scenario expectation (SPEC section 9, Runtime consistency). `avouch gate verify` checks that the reconciled cases cover each decision row. Both commands read files and write no file. They do not run project code: the project's test harness writes the cases.
|
|
129
|
+
|
|
130
|
+
```sh
|
|
131
|
+
avouch reconcile <ontology.yaml> --cases <cases.jsonl>
|
|
132
|
+
avouch gate verify <ontology.yaml> --rows <rows.jsonl> [--baseline <baseline.json>] [--json] [--adapter <module|config.json>]
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### reconcile
|
|
136
|
+
|
|
137
|
+
The cases file has one `RuntimeCase` (SPEC section 9) per line, as JSON. Empty lines are skipped. A case can have more keys than `RuntimeCase`; they are ignored. The command prints one `ReconcileRow` per case, as one JSON line, in input order. For example, `avouch reconcile library.ontology.yaml --cases cases.jsonl > rows.jsonl` writes the input of `gate verify`.
|
|
138
|
+
|
|
139
|
+
Exit code 0 means that each case gave a row, also a `mismatch` or a `cannot_reconcile` row: the command does not judge the rows. 2 means invalid arguments, a file or parse error, or a line that is not a `RuntimeCase`; the message gives the file and the line number. The command does not use exit code 1.
|
|
140
|
+
|
|
141
|
+
### gate verify
|
|
142
|
+
|
|
143
|
+
The unit of coverage is the decision row. Each row of a decision table and its `otherwise` is one unit. A unit has the name `<action>:rows.<i>` (`i` from 0) or `<action>:otherwise`, as in the R12 keys. A unit is verified when a `match` row has its action, its `row` and the result of that row. A `mismatch` row does not verify its unit. An action without a decision table has no units; it is printed as `no_table` and does not change the result. The command checks every action of the ontology.
|
|
144
|
+
|
|
145
|
+
Verified means: for one case, `decide` reached this row, and the runtime result and the expected result are the same as the result of `decide`. The runtime does not report which row it ran. A case without an expected result never verifies a unit: `reconcile` gives `expectation_missing` for it, and the gate prints it as `unasserted`. An action with `permission: unknown` never reaches its decision table in `decide`, so all its units stay as `uncovered` entries until the permission is written.
|
|
146
|
+
|
|
147
|
+
A gap is a condition that a decision row names and that is declared without an expression (`unspecified`). `decide` stops at the first row that names a gap, so the units from that row to `otherwise` are blocked: they are printed as `blocked` and are not reported as uncovered. Each gap needs an exemption with a reason in the baseline. When a gap gets an expression, the units it blocked become ordinary units. The change that adds the expression must also add cases or `uncovered` entries for them.
|
|
148
|
+
|
|
149
|
+
The baseline is a JSON file. In this example, the condition `loan_active` has no expression:
|
|
150
|
+
|
|
151
|
+
```json
|
|
152
|
+
{
|
|
153
|
+
"avouchCoverage": 1,
|
|
154
|
+
"uncovered": [
|
|
155
|
+
{ "action": "BORROW", "row": 3, "when": { "fails": "returns_in_good_condition" }, "result": "REFERRED" },
|
|
156
|
+
{ "action": "BORROW", "row": "otherwise", "when": null, "result": "BORROWED" }
|
|
157
|
+
],
|
|
158
|
+
"exempt": [
|
|
159
|
+
{ "action": "RETURN", "condition": "loan_active", "kind": "unspecified", "reason": "the source does not state when a loan is active" }
|
|
160
|
+
]
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
- `uncovered` lists units that have no `match` yet, each with the `when` and `result` of its row. An entry for a unit that is now verified fails the gate. The tool does not stop new entries; review the baseline diff for that.
|
|
165
|
+
- `exempt` lists gaps, each with a non-empty `reason`.
|
|
166
|
+
- Both keys are required. A key that is not shown above is an error (exit code 2).
|
|
167
|
+
- Without `--baseline`, both lists are empty.
|
|
168
|
+
- Units are named by position. When rows are added, removed or moved, the positions change; an `uncovered` entry whose `when` or `result` is not the same as in the ontology is stale (`moved`).
|
|
169
|
+
|
|
170
|
+
The canonical form of the file is `JSON.stringify(baseline, null, 2)` with a final line break, the keys in the order shown above, `uncovered` in unit order (actions in code-unit order, then rows from 0, then `otherwise`) and `exempt` in action order, then condition order. The gate does not check the form. `--json` prints `uncovered` and `unexempted` in the shape and order of the baseline entries; to make a first baseline, copy them and add a reason to each `unexempted` entry.
|
|
171
|
+
|
|
172
|
+
The gate fails (exit code 1) when:
|
|
173
|
+
|
|
174
|
+
| Output | Cause |
|
|
175
|
+
|---|---|
|
|
176
|
+
| `uncovered` | a unit has no `match`, is not blocked, and is not in `uncovered` of the baseline |
|
|
177
|
+
| `unexempted` | a gap has no entry in `exempt` |
|
|
178
|
+
| `unreasoned` | an `exempt` entry has no reason, or only white space |
|
|
179
|
+
| `stale` | an `uncovered` entry names a unit that does not exist, that has another `when` or `result` (`moved`), that is verified or that is blocked, or repeats an entry; an `exempt` entry names a condition that is not a gap, or repeats an entry |
|
|
180
|
+
| `failed` | a reconcile row names an action that the ontology does not have (`action_unknown`); a reconcile row is a `mismatch`; a `cannot_reconcile` row has the reason `runtime_missing`, `runtime_unmapped`, `expectation_unmapped`, `snapshot_missing` or `evaluator_error`, or the reason `indeterminate` with the detail `no_row_matched`; a `match` row names a row that the action does not have, or a result that is not the result of that row (`row_unknown`) |
|
|
181
|
+
| `check` | with `--adapter`, the same check as `avouch check` fails |
|
|
182
|
+
|
|
183
|
+
The reasons `envelope`, `expectation_missing` and `indeterminate` with another detail do not fail the gate. A replay is not a decision; a case without an expectation states nothing to compare. An `indeterminate` result does not fail the gate, except `no_row_matched`; the units that it leaves unverified stay `uncovered` or `blocked`, so the gate still sees them. `no_row_matched` means that no row applies and the table has no `otherwise`.
|
|
184
|
+
|
|
185
|
+
Known limit: when a case has no expected result, `reconcile` gives `expectation_missing` before it checks the runtime result. So the gate does not see a runtime result that the action cannot give (`runtime_unmapped`) in such a case.
|
|
186
|
+
|
|
187
|
+
Exit code 0 means the gate passes. 1 means it fails. 2 means invalid arguments (also a repeated option), a file or parse error, an ontology without an `actionTypes` mapping, or a rows or baseline file with a wrong shape. Without `--json`, the command prints one `FAIL` line for each failure, `INFO` lines (`check`, `blocked`, `no_table`, `unasserted`), and a summary line that starts with `PASS` or `FAIL`. `--json` prints `{avouchGate: 1, gate: "verify", check, result}`.
|
|
188
|
+
|
|
189
|
+
The same gate is a function in the package root, for tests that run without a file system or child processes: `verifyCoverage(ontology, rows, baseline)` returns the `result` object (`src/coverage.ts`). The command is a thin wrapper around it. The function does not run the check.
|
|
190
|
+
|
|
121
191
|
## Claude Code plugin
|
|
122
192
|
|
|
123
193
|
This repository is also a Claude Code plugin marketplace. Install the plugin:
|
package/SPEC.md
CHANGED
|
@@ -203,7 +203,7 @@ Host language comparisons (`null != "A"` is true in JavaScript) are not used. `i
|
|
|
203
203
|
|
|
204
204
|
Quantifiers are two-valued. `exists` is T when some element gives T; `all` is T when every element gives T (an element with F or U makes it F); `none = not exists`. An empty collection gives `exists` F, `all` T, `none` T: navigation results are a closed world, not U. Their bodies should be provably two-valued (R12 quantifier_absorbs_unknown; see Static UNKNOWN analysis).
|
|
205
205
|
|
|
206
|
-
Navigation: a foreign-key link (no `table`, or `table` is an object's `datasource`) follows `via`; a pure link (`table` is no object's `datasource`) reads `snapshot.links[link.id]` rows `{from, to}`. A null `via`, a dangling foreign key (missing or out of scope) and a null optional ref give the empty set; orphan data is a data-integrity question, not U.
|
|
206
|
+
Navigation: a foreign-key link (no `table`, or `table` is an object's `datasource`) follows `via`; a pure link (`table` is no object's `datasource`) reads `snapshot.links[link.id]` rows `{from, to}`. A pure link that is navigated from a row and is not a key of `snapshot.links` is an incomplete snapshot: `decide` throws `snapshot lacks link <id>`. An empty link table is passed as `[]`. A null `via`, a dangling foreign key (missing or out of scope) and a null optional ref give the empty set (navigation from a null optional ref does not read the link); orphan data is a data-integrity question, not U.
|
|
207
207
|
|
|
208
208
|
Known risk (vacuous truth): from an optional ref that is null, `all` and `none` are always T. This is not an UNKNOWN risk and is not reported. Guard first: `and(isNotNull({ref: x}), all(...))`.
|
|
209
209
|
|
|
@@ -296,8 +296,9 @@ Types:
|
|
|
296
296
|
|
|
297
297
|
- `Reported = {result, reason?}`: a result as the runtime or a scenario states it. `reason` is set only for `INVALID_BINDING`. There is no `param`: runtimes do not report it.
|
|
298
298
|
- `RuntimeCase = {id, action, snapshot, inputs: {bindings, actor, scope, now}, reported, expected}`: `id` is generated by the harness; `snapshot` is the typed snapshot taken before the invocation, or `null`; `reported` is what the runtime reported, or `null` (not reported); `expected` is the scenario expectation, or `null` (none).
|
|
299
|
-
- `ReconcileRow`: `{id, action, status: match, result}`, `{id, action, status: mismatch, evaluator, reported, expected}`, or `{id, action, status: cannot_reconcile, reason, detail?}`.
|
|
299
|
+
- `ReconcileRow`: `{id, action, status: match, result, row?}`, `{id, action, status: mismatch, evaluator, reported, expected, row?}`, or `{id, action, status: cannot_reconcile, reason, detail?}`. `row` is set only when the evaluator result is a domain result (step 9).
|
|
300
300
|
- `Decode = (raw, type) => Value | undefined` and `TypeDefect = {object, prop, row, value}`.
|
|
301
|
+
- `LinkColumns = {<link id>: {from, to}}`: for each pure link, the columns of its table that hold the `from` and `to` ids.
|
|
301
302
|
|
|
302
303
|
`reconcile(ont, cases)` gives exactly one row per case, in input order. For each case the first step that applies gives the row:
|
|
303
304
|
|
|
@@ -309,8 +310,9 @@ Types:
|
|
|
309
310
|
| 4 | `snapshot` is `null` | `cannot_reconcile`, `snapshot_missing` |
|
|
310
311
|
| 5 | `decide(ont, action, snapshot, scope, bindings, actor, now)` throws | `cannot_reconcile`, `evaluator_error`; `detail` is the error message |
|
|
311
312
|
| 6 | `reported.result` is `SCOPE_DENIED`, or is neither an invocation result (`INVOCATION_RESULTS`) nor the `result` of a decision row or of `otherwise` of this action | `cannot_reconcile`, `runtime_unmapped`; `detail` is `reported.result` |
|
|
312
|
-
| 7 | the result
|
|
313
|
-
| 8 |
|
|
313
|
+
| 7 | `expected.result` is `SCOPE_DENIED`, or is neither an invocation result (`INVOCATION_RESULTS`) nor the `result` of a decision row or of `otherwise` of this action | `cannot_reconcile`, `expectation_unmapped`; `detail` is `expected.result` |
|
|
314
|
+
| 8 | the result is INDETERMINATE | `cannot_reconcile`, `indeterminate`; `detail` is its reason |
|
|
315
|
+
| 9 | otherwise: `evaluator` is `externalResult` of the result without `param` (a domain result is `{result}`; `INVALID_BINDING` keeps `reason`) | `match` with `result = evaluator` when the three results are pairwise equal; else `mismatch`. When the result is a domain result, the row also has `row`: the `row` that `decide` gave (the index of the decision row, from 0, or `otherwise`). The runtime does not report a row, so `row` is always the row that `decide` chose |
|
|
314
316
|
|
|
315
317
|
Two `Reported` values are equal when `result` is equal and `reason` is equal (both absent counts as equal).
|
|
316
318
|
|
|
@@ -318,19 +320,23 @@ A missing runtime result is never filled in from the decision table; neither is
|
|
|
318
320
|
|
|
319
321
|
Once an invocation is inside the domain path of an action with a decision table, the runtime must report the domain result explicitly. A coarse signal, such as the status of a receipt, must not stand in for it: a coarse value can share a name with the expected result and give a false match. `runtime_missing` (nothing reported) and `runtime_unmapped` (a value the action cannot give) record the two ways this evidence fails.
|
|
320
322
|
|
|
321
|
-
`
|
|
323
|
+
`expectation_unmapped` (an expected result the action cannot give) is a defect of the scenario, not of the runtime or the ontology. Defects of the input (steps 6 and 7) come before the evaluator's own gap (INDETERMINATE, step 8).
|
|
322
324
|
|
|
323
|
-
|
|
325
|
+
`projectSnapshot(ont, stores, decode, linkColumns?)` turns stored rows (keyed by table name) into a typed `Snapshot` (objects keyed by object type, pure links keyed by link id) and returns `{snapshot, defects}`:
|
|
326
|
+
|
|
327
|
+
1. For each link type with a `table` that is not the `datasource` of any object type (a pure link), in array order: when `linkColumns` has no entry for its id, the function throws `pure link <id> is not projected`; a missing store throws `snapshot lacks store <table>`. Otherwise each row is copied with all its columns except `from` and `to`; then `from` gets the value of the column `linkColumns[id].from` and `to` gets the value of the column `linkColumns[id].to`. A mapped column absent from the row is not projected (`decide` throws when it reads it). Link columns are not decoded and give no defects. An entry of `linkColumns` that is not a pure link id is ignored.
|
|
324
328
|
2. For each object type, in key order: a missing store throws `snapshot lacks store <datasource>`. Otherwise each row is copied with all its columns, and then each property `p` with a declared `type` is projected: a column absent from the row is not projected and is not a defect (`decide` throws when it reads it, as for any incomplete snapshot); a null value stays null and is never passed to `decode`; any other value goes through `decode(raw, type)`, and a result that is `undefined` or does not fit the type (`fitsType`) is a defect `{object, prop: p, row: String(row.id), value: raw}`, and the column keeps its raw value; else the column gets the decoded value. Columns without a declared type are kept as they are.
|
|
325
|
-
3. The result is `{snapshot: {objects}, defects}
|
|
329
|
+
3. The result is `{snapshot: {objects, links}, defects}`, with one `links` entry per pure link; when the ontology has no pure link, `snapshot.links` is not set. When `defects` is not empty the snapshot holds raw values for those columns; do not reconcile with it.
|
|
326
330
|
|
|
327
331
|
The physical type mapping is checked here: the profile supplies `decode`, which turns a stored value (for example the integers 0 and 1 of SQLite for a boolean) into a value of the declared type, and `fitsType` checks the result.
|
|
328
332
|
|
|
329
333
|
`Invocation.disposition` stays the optional Stage 0 field of the fact contract. `reconcile` does not read it.
|
|
330
334
|
|
|
335
|
+
The command line reads and writes these types as JSON lines, one `RuntimeCase` or one `ReconcileRow` per line (`avouch reconcile`, `avouch gate verify`; README, "Runtime consistency on the command line"). A `Value` is a JSON string, number, boolean, null or array. An optional field that is absent (`reason`, `detail`, `row`, a binding that is not given) is an absent key; a binding with the value null is `null`. The coverage rule of `avouch gate verify` is a rule of the tool, not of this format.
|
|
336
|
+
|
|
331
337
|
### Existence is not revealed
|
|
332
338
|
|
|
333
|
-
`SCOPE_DENIED` is internal. `externalResult` maps it and `INVALID_BINDING` `not_found` to one value, `{result: INVALID_BINDING, param, reason: not_found}`, so a caller cannot learn that an id exists in another tenant. Other reasons, such as `type`, are reported as they are. A runtime must not report `SCOPE_DENIED`; `reconcile` gives `runtime_unmapped` for it.
|
|
339
|
+
`SCOPE_DENIED` is internal. `externalResult` maps it and `INVALID_BINDING` `not_found` to one value, `{result: INVALID_BINDING, param, reason: not_found}`, so a caller cannot learn that an id exists in another tenant. Other reasons, such as `type`, are reported as they are. A runtime must not report `SCOPE_DENIED`; `reconcile` gives `runtime_unmapped` for it. A scenario must not expect it either; `reconcile` gives `expectation_unmapped` for it (step 7).
|
|
334
340
|
|
|
335
341
|
### Known limit
|
|
336
342
|
|
package/dist/cli/avouch.js
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import { dirname, join, resolve } from "node:path";
|
|
4
4
|
import { CONFIG_FILE, runCheck } from "./check.js";
|
|
5
|
+
import { GATE_USAGE, runGate } from "./gate.js";
|
|
5
6
|
import { QUERY_USAGE, runQuery } from "./query.js";
|
|
7
|
+
import { RECONCILE_USAGE, runReconcile } from "./reconcile.js";
|
|
6
8
|
const CHECK_USAGE = "usage: avouch check <ontology.yaml> [--adapter <module|config.json>]";
|
|
7
9
|
async function check(args) {
|
|
8
10
|
let adapter;
|
|
@@ -38,8 +40,14 @@ async function main() {
|
|
|
38
40
|
return check(args.slice(1));
|
|
39
41
|
if (args[0] === "query")
|
|
40
42
|
return runQuery(args.slice(1));
|
|
43
|
+
if (args[0] === "reconcile")
|
|
44
|
+
return runReconcile(args.slice(1));
|
|
45
|
+
if (args[0] === "gate")
|
|
46
|
+
return runGate(args.slice(1));
|
|
41
47
|
console.error(CHECK_USAGE);
|
|
42
48
|
console.error(QUERY_USAGE);
|
|
49
|
+
console.error(RECONCILE_USAGE);
|
|
50
|
+
console.error(GATE_USAGE);
|
|
43
51
|
return 2;
|
|
44
52
|
}
|
|
45
53
|
try {
|
package/dist/cli/gate.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { verifyCoverage } from "../src/coverage.js";
|
|
4
|
+
import { runCheck } from "./check.js";
|
|
5
|
+
import { readJsonLines } from "./jsonl.js";
|
|
6
|
+
import { parseOptions, readOntology } from "./reconcile.js";
|
|
7
|
+
export const GATE_USAGE = "usage: avouch gate verify <ontology.yaml> --rows <rows.jsonl> [--baseline <baseline.json>] [--json] [--adapter <module|config.json>]";
|
|
8
|
+
const unitText = (row) => (row === "otherwise" ? "otherwise" : `rows.${row}`);
|
|
9
|
+
function text(check, r, pass) {
|
|
10
|
+
const lines = [];
|
|
11
|
+
if (check.status === "fail")
|
|
12
|
+
lines.push(`FAIL check: ${check.schemaErrors} schema error(s), ${check.violations} violation(s)`);
|
|
13
|
+
for (const u of r.uncovered)
|
|
14
|
+
lines.push(`FAIL uncovered ${u.action}:${unitText(u.row)}`);
|
|
15
|
+
for (const g of r.unexempted)
|
|
16
|
+
lines.push(`FAIL unexempted ${g.action}:${g.condition}`);
|
|
17
|
+
for (const g of r.unreasoned)
|
|
18
|
+
lines.push(`FAIL unreasoned ${g.action}:${g.condition}`);
|
|
19
|
+
for (const s of r.stale)
|
|
20
|
+
lines.push(s.list === "uncovered" ? `FAIL stale uncovered ${s.action}:${unitText(s.row)} ${s.why}` : `FAIL stale exempt ${s.action}:${s.condition} ${s.why}`);
|
|
21
|
+
for (const f of r.failed)
|
|
22
|
+
lines.push(`FAIL case ${f.id} ${f.action} ${f.why}`);
|
|
23
|
+
lines.push(`INFO check ${check.status}`);
|
|
24
|
+
for (const b of r.blocked)
|
|
25
|
+
lines.push(`INFO blocked ${b.action}:${unitText(b.row)} by ${b.condition}`);
|
|
26
|
+
for (const a of r.noTable)
|
|
27
|
+
lines.push(`INFO no_table ${a}`);
|
|
28
|
+
for (const c of r.unasserted)
|
|
29
|
+
lines.push(`INFO unasserted ${c.id} ${c.action}`);
|
|
30
|
+
lines.push(`${pass ? "PASS" : "FAIL"}: ${r.units} unit(s), ${r.verified.length} verified, ${r.baselined.length} baselined, ${r.blocked.length} blocked, ` +
|
|
31
|
+
`${r.uncovered.length} uncovered; ${r.unexempted.length} unexempted, ${r.unreasoned.length} unreasoned, ${r.stale.length} stale, ` +
|
|
32
|
+
`${r.failed.length} failed case(s), ${r.unasserted.length} unasserted`);
|
|
33
|
+
return lines.join("\n") + "\n";
|
|
34
|
+
}
|
|
35
|
+
async function verify(args) {
|
|
36
|
+
const { pos, opts } = parseOptions(args, ["--rows", "--baseline", "--adapter"], ["--json"]);
|
|
37
|
+
if (pos.length !== 1)
|
|
38
|
+
throw new Error(`expected 1 argument, found ${pos.length}`);
|
|
39
|
+
const rowsFile = opts.get("--rows");
|
|
40
|
+
if (typeof rowsFile !== "string")
|
|
41
|
+
throw new Error("--rows is required");
|
|
42
|
+
const baselineFile = opts.get("--baseline");
|
|
43
|
+
const adapter = opts.get("--adapter");
|
|
44
|
+
const ont = readOntology(pos[0]);
|
|
45
|
+
const rows = readJsonLines(rowsFile);
|
|
46
|
+
let baseline = null;
|
|
47
|
+
if (baselineFile !== undefined) {
|
|
48
|
+
const raw = readFileSync(resolve(baselineFile), "utf8");
|
|
49
|
+
try {
|
|
50
|
+
baseline = JSON.parse(raw);
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
throw new Error(`${baselineFile}: invalid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
let result;
|
|
57
|
+
try {
|
|
58
|
+
result = verifyCoverage(ont, rows, baseline);
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
62
|
+
throw new Error(`${msg.startsWith("baseline:") ? baselineFile : msg.startsWith("ontology:") ? pos[0] : rowsFile}: ${msg}`);
|
|
63
|
+
}
|
|
64
|
+
let check = { status: "not_run" };
|
|
65
|
+
if (adapter !== undefined) {
|
|
66
|
+
const { schemaErrors, report } = await runCheck(pos[0], adapter);
|
|
67
|
+
const pass = schemaErrors.length === 0 && report.violations.length === 0;
|
|
68
|
+
check = { status: pass ? "pass" : "fail", adapter, schemaErrors: schemaErrors.length, violations: report.violations.length, waived: report.waived.length };
|
|
69
|
+
}
|
|
70
|
+
const pass = result.ok && check.status !== "fail";
|
|
71
|
+
process.stdout.write(opts.get("--json") === true ? `${JSON.stringify({ avouchGate: 1, gate: "verify", check, result }, null, 2)}\n` : text(check, result, pass));
|
|
72
|
+
return pass ? 0 : 1;
|
|
73
|
+
}
|
|
74
|
+
// Exit 0: the gate passes; 1: it fails; 2: a usage, file, parse or shape error, or an adapter error (one stderr line).
|
|
75
|
+
export async function runGate(args) {
|
|
76
|
+
try {
|
|
77
|
+
if (args.length === 0)
|
|
78
|
+
throw new Error("missing gate");
|
|
79
|
+
if (args[0] !== "verify")
|
|
80
|
+
throw new Error(`unknown gate ${args[0]}`);
|
|
81
|
+
return await verify(args.slice(1));
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
const msg = (error instanceof Error ? error.message : String(error)).replace(/[\r\n]+/g, " ");
|
|
85
|
+
console.error(`${msg}; ${GATE_USAGE}`);
|
|
86
|
+
return 2;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
// One JSON value per line, with its line number (from 1). Lines that are empty or only white space are skipped; the
|
|
3
|
+
// line numbers count them.
|
|
4
|
+
export function jsonLines(file) {
|
|
5
|
+
return readFileSync(file, "utf8")
|
|
6
|
+
.split("\n")
|
|
7
|
+
.flatMap((text, i) => {
|
|
8
|
+
if (text.trim() === "")
|
|
9
|
+
return [];
|
|
10
|
+
try {
|
|
11
|
+
return [{ line: i + 1, value: JSON.parse(text) }];
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
throw new Error(`${file}:${i + 1}: invalid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
export function readJsonLines(file) {
|
|
19
|
+
return jsonLines(file).map((x) => x.value);
|
|
20
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare const RECONCILE_USAGE = "usage: avouch reconcile <ontology.yaml> --cases <cases.jsonl>";
|
|
2
|
+
export declare function parseOptions(args: string[], values: readonly string[], flags: readonly string[]): {
|
|
3
|
+
pos: string[];
|
|
4
|
+
opts: Map<string, string | true>;
|
|
5
|
+
};
|
|
6
|
+
export declare function readOntology(file: string): Record<string, unknown>;
|
|
7
|
+
export declare function runReconcile(args: string[]): Promise<number>;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { parse } from "yaml";
|
|
4
|
+
import { isObj } from "../src/expr.js";
|
|
5
|
+
import { reconcile } from "../src/reconcile.js";
|
|
6
|
+
import { jsonLines } from "./jsonl.js";
|
|
7
|
+
export const RECONCILE_USAGE = "usage: avouch reconcile <ontology.yaml> --cases <cases.jsonl>";
|
|
8
|
+
const REASONS = ["missing", "unknown_param", "type", "not_found"];
|
|
9
|
+
// Options in any position. Returns the positional arguments and the value of each option.
|
|
10
|
+
export function parseOptions(args, values, flags) {
|
|
11
|
+
const pos = [];
|
|
12
|
+
const opts = new Map();
|
|
13
|
+
for (let i = 0; i < args.length; i++) {
|
|
14
|
+
const a = args[i];
|
|
15
|
+
if (opts.has(a))
|
|
16
|
+
throw new Error(`repeated option ${a}`);
|
|
17
|
+
if (flags.includes(a))
|
|
18
|
+
opts.set(a, true);
|
|
19
|
+
else if (values.includes(a)) {
|
|
20
|
+
if (i + 1 >= args.length)
|
|
21
|
+
throw new Error(`${a} needs a file`);
|
|
22
|
+
opts.set(a, args[++i]);
|
|
23
|
+
}
|
|
24
|
+
else if (a.startsWith("--"))
|
|
25
|
+
throw new Error(`unknown option ${a}`);
|
|
26
|
+
else
|
|
27
|
+
pos.push(a);
|
|
28
|
+
}
|
|
29
|
+
return { pos, opts };
|
|
30
|
+
}
|
|
31
|
+
export function readOntology(file) {
|
|
32
|
+
const ont = parse(readFileSync(resolve(file), "utf8"));
|
|
33
|
+
if (!isObj(ont))
|
|
34
|
+
throw new Error(`${file}: the root is not a mapping`);
|
|
35
|
+
return ont;
|
|
36
|
+
}
|
|
37
|
+
// The shape of a RuntimeCase (SPEC section 9). Extra keys are allowed; the snapshot content is not checked.
|
|
38
|
+
function checkCase(x) {
|
|
39
|
+
if (!isObj(x))
|
|
40
|
+
return "case is not an object";
|
|
41
|
+
const c = x;
|
|
42
|
+
if (typeof c.id !== "string" || c.id === "")
|
|
43
|
+
return "id is not a non-empty string";
|
|
44
|
+
if (typeof c.action !== "string" || c.action === "")
|
|
45
|
+
return "action is not a non-empty string";
|
|
46
|
+
if (!Object.hasOwn(c, "snapshot"))
|
|
47
|
+
return "snapshot is missing";
|
|
48
|
+
if (c.snapshot !== null && !isObj(c.snapshot))
|
|
49
|
+
return "snapshot is not null or an object";
|
|
50
|
+
const i = c.inputs;
|
|
51
|
+
if (!isObj(i))
|
|
52
|
+
return "inputs is not an object";
|
|
53
|
+
if (!isObj(i.bindings))
|
|
54
|
+
return "inputs.bindings is not an object";
|
|
55
|
+
if (!isObj(i.actor))
|
|
56
|
+
return "inputs.actor is not an object";
|
|
57
|
+
if (typeof i.actor.id !== "string")
|
|
58
|
+
return "inputs.actor.id is not a string";
|
|
59
|
+
if (typeof i.actor.kind !== "string")
|
|
60
|
+
return "inputs.actor.kind is not a string";
|
|
61
|
+
if (!Array.isArray(i.actor.keys) || !i.actor.keys.every((k) => typeof k === "string"))
|
|
62
|
+
return "inputs.actor.keys is not a list of strings";
|
|
63
|
+
if (i.scope !== null && typeof i.scope !== "string")
|
|
64
|
+
return "inputs.scope is not a string or null";
|
|
65
|
+
if (typeof i.now !== "number" || !Number.isFinite(i.now))
|
|
66
|
+
return "inputs.now is not a finite number";
|
|
67
|
+
for (const k of ["reported", "expected"]) {
|
|
68
|
+
if (!Object.hasOwn(c, k))
|
|
69
|
+
return `${k} is missing`;
|
|
70
|
+
const r = c[k];
|
|
71
|
+
if (r === null)
|
|
72
|
+
continue;
|
|
73
|
+
if (!isObj(r))
|
|
74
|
+
return `${k} is not null or an object`;
|
|
75
|
+
if (typeof r.result !== "string" || r.result === "")
|
|
76
|
+
return `${k}.result is not a non-empty string`;
|
|
77
|
+
if (r.reason !== undefined && !REASONS.includes(r.reason))
|
|
78
|
+
return `${k}.reason is not one of ${REASONS.join(", ")}`;
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
async function main(args) {
|
|
83
|
+
const { pos, opts } = parseOptions(args, ["--cases"], []);
|
|
84
|
+
if (pos.length !== 1)
|
|
85
|
+
throw new Error(`expected 1 argument, found ${pos.length}`);
|
|
86
|
+
const cases = opts.get("--cases");
|
|
87
|
+
if (typeof cases !== "string")
|
|
88
|
+
throw new Error("--cases is required");
|
|
89
|
+
const ont = readOntology(pos[0]);
|
|
90
|
+
const lines = jsonLines(cases);
|
|
91
|
+
for (const { line, value } of lines) {
|
|
92
|
+
const problem = checkCase(value);
|
|
93
|
+
if (problem !== null)
|
|
94
|
+
throw new Error(`${cases}:${line}: ${problem}`);
|
|
95
|
+
}
|
|
96
|
+
const rows = reconcile(ont, lines.map((x) => x.value));
|
|
97
|
+
process.stdout.write(rows.map((r) => `${JSON.stringify(r)}\n`).join(""));
|
|
98
|
+
return 0;
|
|
99
|
+
}
|
|
100
|
+
// Exit 0: one row per case (the command does not judge the rows); 2: a usage, file, parse or shape error (one stderr line).
|
|
101
|
+
export async function runReconcile(args) {
|
|
102
|
+
try {
|
|
103
|
+
return await main(args);
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
const msg = (error instanceof Error ? error.message : String(error)).replace(/[\r\n]+/g, " ");
|
|
107
|
+
console.error(`${msg}; ${RECONCILE_USAGE}`);
|
|
108
|
+
return 2;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { ReconcileRow } from "./reconcile.js";
|
|
2
|
+
/** One coverage unit: a row of a decision table (index from 0) or its otherwise. Key text: `<action>:rows.<i>` / `<action>:otherwise`. */
|
|
3
|
+
export type CoverageUnit = {
|
|
4
|
+
action: string;
|
|
5
|
+
row: number | "otherwise";
|
|
6
|
+
};
|
|
7
|
+
/** A unit with the `when` and `result` of its row, so that an entry for a row that moved is stale. */
|
|
8
|
+
export type CoverageDebt = CoverageUnit & {
|
|
9
|
+
when: {
|
|
10
|
+
passes: string;
|
|
11
|
+
} | {
|
|
12
|
+
fails: string;
|
|
13
|
+
} | null;
|
|
14
|
+
result: string;
|
|
15
|
+
};
|
|
16
|
+
/** A condition that a decision row names and that is declared without an expression. */
|
|
17
|
+
export type CoverageGap = {
|
|
18
|
+
action: string;
|
|
19
|
+
condition: string;
|
|
20
|
+
kind: "unspecified";
|
|
21
|
+
};
|
|
22
|
+
export type CoverageExemption = CoverageGap & {
|
|
23
|
+
reason?: string;
|
|
24
|
+
};
|
|
25
|
+
export type CoverageBaseline = {
|
|
26
|
+
avouchCoverage: 1;
|
|
27
|
+
uncovered: CoverageDebt[];
|
|
28
|
+
exempt: CoverageExemption[];
|
|
29
|
+
};
|
|
30
|
+
export type CoverageStale = {
|
|
31
|
+
list: "uncovered";
|
|
32
|
+
action: string;
|
|
33
|
+
row: number | "otherwise";
|
|
34
|
+
why: "duplicate" | "no_such_row" | "moved" | "verified" | "blocked";
|
|
35
|
+
} | {
|
|
36
|
+
list: "exempt";
|
|
37
|
+
action: string;
|
|
38
|
+
condition: string;
|
|
39
|
+
why: "duplicate" | "not_a_gap";
|
|
40
|
+
};
|
|
41
|
+
export type CoverageFailure = {
|
|
42
|
+
id: string;
|
|
43
|
+
action: string;
|
|
44
|
+
why: "mismatch" | "action_unknown" | "row_unknown" | "no_row_matched" | "runtime_missing" | "runtime_unmapped" | "expectation_unmapped" | "snapshot_missing" | "evaluator_error";
|
|
45
|
+
};
|
|
46
|
+
export interface CoverageResult {
|
|
47
|
+
ok: boolean;
|
|
48
|
+
units: number;
|
|
49
|
+
verified: CoverageUnit[];
|
|
50
|
+
baselined: CoverageUnit[];
|
|
51
|
+
blocked: (CoverageUnit & {
|
|
52
|
+
condition: string;
|
|
53
|
+
})[];
|
|
54
|
+
noTable: string[];
|
|
55
|
+
unasserted: {
|
|
56
|
+
id: string;
|
|
57
|
+
action: string;
|
|
58
|
+
}[];
|
|
59
|
+
uncovered: CoverageDebt[];
|
|
60
|
+
unexempted: CoverageGap[];
|
|
61
|
+
unreasoned: CoverageGap[];
|
|
62
|
+
stale: CoverageStale[];
|
|
63
|
+
failed: CoverageFailure[];
|
|
64
|
+
}
|
|
65
|
+
export declare function verifyCoverage(ont: unknown, rows: readonly ReconcileRow[], baseline: CoverageBaseline | null): CoverageResult;
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// Decision-row coverage of reconcile rows (`avouch gate verify`). A rule of the tool, not of the format. A pure function:
|
|
2
|
+
// it reads only its arguments, and the order of each output list is fixed.
|
|
3
|
+
import { isObj } from "./expr.js";
|
|
4
|
+
import { queryAction } from "./query.js";
|
|
5
|
+
// Which reasons of cannot_reconcile fail the gate. A replay is not a decision; a case without an expectation states
|
|
6
|
+
// nothing to compare; INDETERMINATE does not fail (except no_row_matched: see below), and the units that it leaves
|
|
7
|
+
// unverified stay uncovered or blocked, so the gate still sees them.
|
|
8
|
+
const FAILS = {
|
|
9
|
+
envelope: false,
|
|
10
|
+
expectation_missing: false,
|
|
11
|
+
indeterminate: false,
|
|
12
|
+
runtime_missing: true,
|
|
13
|
+
runtime_unmapped: true,
|
|
14
|
+
expectation_unmapped: true,
|
|
15
|
+
snapshot_missing: true,
|
|
16
|
+
evaluator_error: true,
|
|
17
|
+
};
|
|
18
|
+
const sortStr = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
19
|
+
const show = (x) => JSON.stringify(x) ?? String(x);
|
|
20
|
+
const isRow = (x) => x === "otherwise" || (typeof x === "number" && Number.isSafeInteger(x) && x >= 0);
|
|
21
|
+
const sameWhen = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
22
|
+
function checkKeys(o, path, required, allowed) {
|
|
23
|
+
for (const k of required)
|
|
24
|
+
if (!Object.hasOwn(o, k))
|
|
25
|
+
throw new Error(`baseline: ${path}${k}: missing`);
|
|
26
|
+
for (const k of Object.keys(o))
|
|
27
|
+
if (!allowed.includes(k))
|
|
28
|
+
throw new Error(`baseline: ${path}${k}: unknown key`);
|
|
29
|
+
}
|
|
30
|
+
function checkRows(rows) {
|
|
31
|
+
if (!Array.isArray(rows))
|
|
32
|
+
throw new Error("rows: not an array");
|
|
33
|
+
rows.forEach((x, i) => {
|
|
34
|
+
const at = `rows[${i}]:`;
|
|
35
|
+
if (!isObj(x))
|
|
36
|
+
throw new Error(`${at} not an object`);
|
|
37
|
+
if (typeof x.id !== "string")
|
|
38
|
+
throw new Error(`${at} id is not a string`);
|
|
39
|
+
if (typeof x.action !== "string")
|
|
40
|
+
throw new Error(`${at} action is not a string`);
|
|
41
|
+
if (x.status === "match" || x.status === "mismatch") {
|
|
42
|
+
if (x.row !== undefined && !isRow(x.row))
|
|
43
|
+
throw new Error(`${at} row ${show(x.row)} is not a row index or "otherwise"`);
|
|
44
|
+
}
|
|
45
|
+
else if (x.status === "cannot_reconcile") {
|
|
46
|
+
if (typeof x.reason !== "string" || !Object.hasOwn(FAILS, x.reason))
|
|
47
|
+
throw new Error(`${at} reason ${show(x.reason)} is not a reason of cannot_reconcile`);
|
|
48
|
+
}
|
|
49
|
+
else
|
|
50
|
+
throw new Error(`${at} status ${show(x.status)} is not match, mismatch or cannot_reconcile`);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
function checkBaseline(b) {
|
|
54
|
+
if (!isObj(b))
|
|
55
|
+
throw new Error("baseline: not an object");
|
|
56
|
+
checkKeys(b, "", ["avouchCoverage", "uncovered", "exempt"], ["avouchCoverage", "uncovered", "exempt"]);
|
|
57
|
+
if (b.avouchCoverage !== 1)
|
|
58
|
+
throw new Error(`baseline: avouchCoverage: ${show(b.avouchCoverage)} is not 1`);
|
|
59
|
+
if (!Array.isArray(b.uncovered))
|
|
60
|
+
throw new Error("baseline: uncovered: not an array");
|
|
61
|
+
if (!Array.isArray(b.exempt))
|
|
62
|
+
throw new Error("baseline: exempt: not an array");
|
|
63
|
+
b.uncovered.forEach((x, i) => {
|
|
64
|
+
const at = `uncovered[${i}]`;
|
|
65
|
+
if (!isObj(x))
|
|
66
|
+
throw new Error(`baseline: ${at}: not an object`);
|
|
67
|
+
checkKeys(x, `${at}.`, ["action", "row", "when", "result"], ["action", "row", "when", "result"]);
|
|
68
|
+
if (typeof x.action !== "string")
|
|
69
|
+
throw new Error(`baseline: ${at}.action: not a string`);
|
|
70
|
+
if (!isRow(x.row))
|
|
71
|
+
throw new Error(`baseline: ${at}.row: ${show(x.row)} is not a row index or "otherwise"`);
|
|
72
|
+
const w = x.when;
|
|
73
|
+
const when = w === null || (isObj(w) && Object.keys(w).length === 1 && (typeof w.passes === "string" || typeof w.fails === "string"));
|
|
74
|
+
if (!when)
|
|
75
|
+
throw new Error(`baseline: ${at}.when: not null, {passes: <condition>} or {fails: <condition>}`);
|
|
76
|
+
if (typeof x.result !== "string")
|
|
77
|
+
throw new Error(`baseline: ${at}.result: not a string`);
|
|
78
|
+
});
|
|
79
|
+
b.exempt.forEach((x, i) => {
|
|
80
|
+
const at = `exempt[${i}]`;
|
|
81
|
+
if (!isObj(x))
|
|
82
|
+
throw new Error(`baseline: ${at}: not an object`);
|
|
83
|
+
checkKeys(x, `${at}.`, ["action", "condition", "kind"], ["action", "condition", "kind", "reason"]);
|
|
84
|
+
if (typeof x.action !== "string")
|
|
85
|
+
throw new Error(`baseline: ${at}.action: not a string`);
|
|
86
|
+
if (typeof x.condition !== "string")
|
|
87
|
+
throw new Error(`baseline: ${at}.condition: not a string`);
|
|
88
|
+
if (x.kind !== "unspecified")
|
|
89
|
+
throw new Error(`baseline: ${at}.kind: ${show(x.kind)} is not supported by this version`);
|
|
90
|
+
if (x.reason !== undefined && typeof x.reason !== "string")
|
|
91
|
+
throw new Error(`baseline: ${at}.reason: not a string`);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
// Coverage of the decision rows of every action of the ontology by the match rows. baseline null = both lists empty.
|
|
95
|
+
// Throws for an ontology without an actionTypes object, or rows or a baseline with a wrong shape (the message starts
|
|
96
|
+
// with "ontology:", "rows" or "baseline:").
|
|
97
|
+
export function verifyCoverage(ont, rows, baseline) {
|
|
98
|
+
if (!isObj(ont) || !isObj(ont.actionTypes))
|
|
99
|
+
throw new Error("ontology: actionTypes is not an object");
|
|
100
|
+
const actionTypes = ont.actionTypes;
|
|
101
|
+
checkRows(rows);
|
|
102
|
+
if (baseline !== null)
|
|
103
|
+
checkBaseline(baseline);
|
|
104
|
+
const base = baseline ?? { avouchCoverage: 1, uncovered: [], exempt: [] };
|
|
105
|
+
// 1. Units and gaps. A gap: a condition that a row names (as its own or as an earlier row's) and that has no expression.
|
|
106
|
+
const units = [];
|
|
107
|
+
const unitOf = new Map();
|
|
108
|
+
const gaps = [];
|
|
109
|
+
const noTable = [];
|
|
110
|
+
const key = (action, x) => JSON.stringify([action, x]);
|
|
111
|
+
for (const action of Object.keys(actionTypes).sort(sortStr)) {
|
|
112
|
+
const q = queryAction(ont, action);
|
|
113
|
+
if (q === null)
|
|
114
|
+
continue;
|
|
115
|
+
if (q.decision === null) {
|
|
116
|
+
noTable.push(action);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const isGap = (c) => q.conditions.find((x) => x.id === c)?.expr === null;
|
|
120
|
+
const named = new Set();
|
|
121
|
+
for (const r of q.decision.rows) {
|
|
122
|
+
const u = { action, q: r, gap: r.requires.find((x) => isGap(x.condition))?.condition ?? null };
|
|
123
|
+
units.push(u);
|
|
124
|
+
unitOf.set(key(action, r.row), u);
|
|
125
|
+
for (const x of r.requires)
|
|
126
|
+
if (isGap(x.condition))
|
|
127
|
+
named.add(x.condition);
|
|
128
|
+
}
|
|
129
|
+
for (const condition of [...named].sort(sortStr))
|
|
130
|
+
gaps.push({ action, condition, kind: "unspecified" });
|
|
131
|
+
}
|
|
132
|
+
const gapKeys = new Set(gaps.map((g) => key(g.action, g.condition)));
|
|
133
|
+
// 2. Case rows, in input order.
|
|
134
|
+
const verified = new Set();
|
|
135
|
+
const failed = [];
|
|
136
|
+
const unasserted = [];
|
|
137
|
+
for (const r of rows) {
|
|
138
|
+
const head = { id: r.id, action: r.action };
|
|
139
|
+
if (!Object.hasOwn(actionTypes, r.action))
|
|
140
|
+
failed.push({ ...head, why: "action_unknown" });
|
|
141
|
+
else if (r.status === "mismatch")
|
|
142
|
+
failed.push({ ...head, why: "mismatch" });
|
|
143
|
+
else if (r.status === "cannot_reconcile") {
|
|
144
|
+
if (r.reason === "indeterminate" && r.detail === "no_row_matched")
|
|
145
|
+
failed.push({ ...head, why: "no_row_matched" });
|
|
146
|
+
else if (FAILS[r.reason])
|
|
147
|
+
failed.push({ ...head, why: r.reason });
|
|
148
|
+
else if (r.reason === "expectation_missing")
|
|
149
|
+
unasserted.push(head);
|
|
150
|
+
}
|
|
151
|
+
else if (r.row !== undefined) {
|
|
152
|
+
// a match on a row: the unit must exist and give the same result
|
|
153
|
+
const u = unitOf.get(key(r.action, r.row));
|
|
154
|
+
const result = isObj(r.result) ? r.result.result : undefined;
|
|
155
|
+
if (u === undefined || u.q.result !== result)
|
|
156
|
+
failed.push({ ...head, why: "row_unknown" });
|
|
157
|
+
else
|
|
158
|
+
verified.add(u);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// 3. Exemptions, in baseline order.
|
|
162
|
+
const stale = [];
|
|
163
|
+
const exempted = new Set();
|
|
164
|
+
const unreasonedKeys = new Set();
|
|
165
|
+
const seenExempt = new Set();
|
|
166
|
+
for (const e of base.exempt) {
|
|
167
|
+
const k = key(e.action, e.condition);
|
|
168
|
+
const out = { list: "exempt", action: e.action, condition: e.condition };
|
|
169
|
+
if (seenExempt.has(k))
|
|
170
|
+
stale.push({ ...out, why: "duplicate" });
|
|
171
|
+
else if (!gapKeys.has(k))
|
|
172
|
+
stale.push({ ...out, why: "not_a_gap" });
|
|
173
|
+
else {
|
|
174
|
+
exempted.add(k);
|
|
175
|
+
if (e.reason === undefined || e.reason.trim() === "")
|
|
176
|
+
unreasonedKeys.add(k);
|
|
177
|
+
}
|
|
178
|
+
seenExempt.add(k);
|
|
179
|
+
}
|
|
180
|
+
// 4. Uncovered entries, in baseline order. The baseline must have no dead entries.
|
|
181
|
+
const owed = new Set();
|
|
182
|
+
const seenDebt = new Set();
|
|
183
|
+
for (const d of base.uncovered) {
|
|
184
|
+
const k = key(d.action, d.row);
|
|
185
|
+
const u = unitOf.get(k);
|
|
186
|
+
const out = { list: "uncovered", action: d.action, row: d.row };
|
|
187
|
+
if (seenDebt.has(k))
|
|
188
|
+
stale.push({ ...out, why: "duplicate" });
|
|
189
|
+
else if (u === undefined)
|
|
190
|
+
stale.push({ ...out, why: "no_such_row" });
|
|
191
|
+
else if (!sameWhen(d.when, u.q.when) || d.result !== u.q.result)
|
|
192
|
+
stale.push({ ...out, why: "moved" });
|
|
193
|
+
else if (verified.has(u))
|
|
194
|
+
stale.push({ ...out, why: "verified" });
|
|
195
|
+
else if (u.gap !== null)
|
|
196
|
+
stale.push({ ...out, why: "blocked" });
|
|
197
|
+
else
|
|
198
|
+
owed.add(u);
|
|
199
|
+
seenDebt.add(k);
|
|
200
|
+
}
|
|
201
|
+
// 5. Each unit, in unit order.
|
|
202
|
+
const result = {
|
|
203
|
+
ok: false,
|
|
204
|
+
units: units.length,
|
|
205
|
+
verified: [],
|
|
206
|
+
baselined: [],
|
|
207
|
+
blocked: [],
|
|
208
|
+
noTable,
|
|
209
|
+
unasserted,
|
|
210
|
+
uncovered: [],
|
|
211
|
+
unexempted: gaps.filter((g) => !exempted.has(key(g.action, g.condition))),
|
|
212
|
+
unreasoned: gaps.filter((g) => unreasonedKeys.has(key(g.action, g.condition))),
|
|
213
|
+
stale,
|
|
214
|
+
failed,
|
|
215
|
+
};
|
|
216
|
+
for (const u of units) {
|
|
217
|
+
const id = { action: u.action, row: u.q.row };
|
|
218
|
+
if (verified.has(u))
|
|
219
|
+
result.verified.push(id);
|
|
220
|
+
else if (u.gap !== null)
|
|
221
|
+
result.blocked.push({ ...id, condition: u.gap });
|
|
222
|
+
else if (owed.has(u))
|
|
223
|
+
result.baselined.push(id);
|
|
224
|
+
else
|
|
225
|
+
result.uncovered.push({ ...id, when: u.q.when, result: u.q.result });
|
|
226
|
+
}
|
|
227
|
+
result.ok = [result.uncovered, result.unexempted, result.unreasoned, result.stale, result.failed].every((x) => x.length === 0);
|
|
228
|
+
return result;
|
|
229
|
+
}
|
package/dist/src/evaluate.js
CHANGED
|
@@ -77,7 +77,9 @@ class Session {
|
|
|
77
77
|
linkRows(link) {
|
|
78
78
|
let rows = this.links.get(link.id);
|
|
79
79
|
if (rows === undefined) {
|
|
80
|
-
const all = own(this.env.snapshot.links, link.id)
|
|
80
|
+
const all = own(this.env.snapshot.links, link.id);
|
|
81
|
+
if (all === undefined)
|
|
82
|
+
throw new Error(`snapshot lacks link ${link.id}`);
|
|
81
83
|
const by = this.ont.scope?.by;
|
|
82
84
|
const tenantEnd = this.mode(link.from) === "tenant" || this.mode(link.to) === "tenant";
|
|
83
85
|
rows = by !== undefined && tenantEnd ? all.filter((r) => field(r, by, `${link.id} link row`) === this.env.scope) : all;
|
package/dist/src/index.d.ts
CHANGED
package/dist/src/index.js
CHANGED
package/dist/src/reconcile.d.ts
CHANGED
|
@@ -18,12 +18,13 @@ export interface RuntimeCase {
|
|
|
18
18
|
reported: Reported | null;
|
|
19
19
|
expected: Reported | null;
|
|
20
20
|
}
|
|
21
|
-
export type CannotReconcile = "envelope" | "runtime_missing" | "expectation_missing" | "snapshot_missing" | "evaluator_error" | "runtime_unmapped" | "indeterminate";
|
|
21
|
+
export type CannotReconcile = "envelope" | "runtime_missing" | "expectation_missing" | "snapshot_missing" | "evaluator_error" | "runtime_unmapped" | "indeterminate" | "expectation_unmapped";
|
|
22
22
|
export type ReconcileRow = {
|
|
23
23
|
id: string;
|
|
24
24
|
action: string;
|
|
25
25
|
status: "match";
|
|
26
26
|
result: Reported;
|
|
27
|
+
row?: number | "otherwise";
|
|
27
28
|
} | {
|
|
28
29
|
id: string;
|
|
29
30
|
action: string;
|
|
@@ -31,6 +32,7 @@ export type ReconcileRow = {
|
|
|
31
32
|
evaluator: Reported;
|
|
32
33
|
reported: Reported;
|
|
33
34
|
expected: Reported;
|
|
35
|
+
row?: number | "otherwise";
|
|
34
36
|
} | {
|
|
35
37
|
id: string;
|
|
36
38
|
action: string;
|
|
@@ -46,7 +48,12 @@ export type TypeDefect = {
|
|
|
46
48
|
row: string;
|
|
47
49
|
value: unknown;
|
|
48
50
|
};
|
|
49
|
-
|
|
51
|
+
/** For each pure link id: the columns of its table that hold the `from` and `to` ids. */
|
|
52
|
+
export type LinkColumns = Readonly<Record<string, {
|
|
53
|
+
readonly from: string;
|
|
54
|
+
readonly to: string;
|
|
55
|
+
}>>;
|
|
56
|
+
export declare function projectSnapshot(ont: Stage1Ontology, stores: Readonly<Record<string, readonly Readonly<Record<string, unknown>>[]>>, decode: Decode, linkColumns?: LinkColumns): {
|
|
50
57
|
snapshot: Snapshot;
|
|
51
58
|
defects: TypeDefect[];
|
|
52
59
|
};
|
package/dist/src/reconcile.js
CHANGED
|
@@ -3,12 +3,27 @@ import { decide, externalResult } from "./evaluate.js";
|
|
|
3
3
|
import { fitsType, INVOCATION_RESULTS, own } from "./expr.js";
|
|
4
4
|
// Stored rows (physical values) -> a typed snapshot. Each property with a declared type is decoded and then checked with
|
|
5
5
|
// fitsType; a value that cannot be decoded or does not fit is a defect and keeps its raw value. Null is never decoded.
|
|
6
|
-
// An absent column is not projected (decide throws when it reads it: an incomplete snapshot).
|
|
7
|
-
export function projectSnapshot(ont, stores, decode) {
|
|
6
|
+
// An absent column is not projected (decide throws when it reads it: an incomplete snapshot). A pure link is projected only with a linkColumns entry; without one it fails closed.
|
|
7
|
+
export function projectSnapshot(ont, stores, decode, linkColumns) {
|
|
8
8
|
const datasources = new Set(Object.values(ont.objectTypes).map((o) => o.datasource));
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
const links = {};
|
|
10
|
+
for (const l of ont.linkTypes) {
|
|
11
|
+
if (l.table === undefined || datasources.has(l.table))
|
|
12
|
+
continue;
|
|
13
|
+
const cols = own(linkColumns, l.id);
|
|
14
|
+
if (cols === undefined)
|
|
11
15
|
throw new Error(`pure link ${l.id} is not projected`);
|
|
16
|
+
if (!Object.hasOwn(stores, l.table))
|
|
17
|
+
throw new Error(`snapshot lacks store ${l.table}`);
|
|
18
|
+
links[l.id] = stores[l.table].map((stored) => {
|
|
19
|
+
const { from: _from, to: _to, ...row } = stored;
|
|
20
|
+
if (Object.hasOwn(stored, cols.from) && stored[cols.from] !== undefined)
|
|
21
|
+
row.from = stored[cols.from];
|
|
22
|
+
if (Object.hasOwn(stored, cols.to) && stored[cols.to] !== undefined)
|
|
23
|
+
row.to = stored[cols.to];
|
|
24
|
+
return row;
|
|
25
|
+
});
|
|
26
|
+
}
|
|
12
27
|
const objects = {};
|
|
13
28
|
const defects = [];
|
|
14
29
|
for (const [object, o] of Object.entries(ont.objectTypes)) {
|
|
@@ -29,7 +44,7 @@ export function projectSnapshot(ont, stores, decode) {
|
|
|
29
44
|
return row;
|
|
30
45
|
});
|
|
31
46
|
}
|
|
32
|
-
return { snapshot: { objects }, defects };
|
|
47
|
+
return { snapshot: Object.keys(links).length > 0 ? { objects, links } : { objects }, defects };
|
|
33
48
|
}
|
|
34
49
|
// Strict on reason: both absent is equal; INVALID_BINDING with different reasons is not.
|
|
35
50
|
const same = (a, b) => a.result === b.result && a.reason === b.reason;
|
|
@@ -55,6 +70,7 @@ export function domainResults(ont, actionId) {
|
|
|
55
70
|
// expectation is reported as such, never filled in from the decision table. Envelope results are not decisions (the
|
|
56
71
|
// evaluator does not implement the envelope; a replay does not recompute domain conditions), so decide is not called.
|
|
57
72
|
// A reported result that the action cannot give (runtime_unmapped) means the runtime did not report its domain result.
|
|
73
|
+
// An expected result that the action cannot give (expectation_unmapped) is a defect of the scenario.
|
|
58
74
|
export function reconcile(ont, cases) {
|
|
59
75
|
return cases.map((c) => {
|
|
60
76
|
const head = { id: c.id, action: c.action };
|
|
@@ -76,14 +92,19 @@ export function reconcile(ont, cases) {
|
|
|
76
92
|
catch (e) {
|
|
77
93
|
return cannot("evaluator_error", e instanceof Error ? e.message : String(e));
|
|
78
94
|
}
|
|
79
|
-
// SCOPE_DENIED
|
|
80
|
-
|
|
81
|
-
|
|
95
|
+
// A result this action can give: not SCOPE_DENIED (internal: SPEC section 9, Existence is not revealed), and an
|
|
96
|
+
// invocation result or one of its domain results. Defects of the input (runtime, then scenario) come before INDETERMINATE.
|
|
97
|
+
const results = domainResults(ont, c.action);
|
|
98
|
+
const mapped = (r) => r !== "SCOPE_DENIED" && (INVOCATION.includes(r) || results.includes(r));
|
|
99
|
+
if (!mapped(reported.result))
|
|
82
100
|
return cannot("runtime_unmapped", reported.result);
|
|
101
|
+
if (!mapped(expected.result))
|
|
102
|
+
return cannot("expectation_unmapped", expected.result);
|
|
83
103
|
if (d.layer === "indeterminate")
|
|
84
104
|
return cannot("indeterminate", d.reason);
|
|
85
105
|
const { result, reason } = externalResult(d);
|
|
86
106
|
const evaluator = reason !== undefined ? { result, reason } : { result };
|
|
87
|
-
|
|
107
|
+
const row = d.layer === "domain" ? { row: d.row } : {};
|
|
108
|
+
return same(evaluator, reported) && same(evaluator, expected) ? { ...head, status: "match", result: evaluator, ...row } : { ...head, status: "mismatch", evaluator, reported, expected, ...row };
|
|
88
109
|
});
|
|
89
110
|
}
|