@githonllc/avouch 0.1.7 → 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 +2 -0
- 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/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- 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
|
@@ -332,6 +332,8 @@ The physical type mapping is checked here: the profile supplies `decode`, which
|
|
|
332
332
|
|
|
333
333
|
`Invocation.disposition` stays the optional Stage 0 field of the fact contract. `reconcile` does not read it.
|
|
334
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
|
+
|
|
335
337
|
### Existence is not revealed
|
|
336
338
|
|
|
337
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).
|
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/index.d.ts
CHANGED
package/dist/src/index.js
CHANGED