@githonllc/avouch 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +6 -0
- package/README.md +174 -2
- package/SPEC.md +337 -0
- package/dist/cli/avouch.d.ts +2 -0
- package/dist/cli/avouch.js +51 -0
- package/dist/cli/check.d.ts +8 -0
- package/dist/cli/check.js +63 -0
- package/dist/cli/query.d.ts +2 -0
- package/dist/cli/query.js +150 -0
- package/dist/src/checker.d.ts +3 -0
- package/dist/src/checker.js +1001 -0
- package/dist/src/contract.d.ts +135 -0
- package/dist/src/contract.js +3 -0
- package/dist/src/evaluate.d.ts +66 -0
- package/dist/src/evaluate.js +478 -0
- package/dist/src/expr.d.ts +190 -0
- package/dist/src/expr.js +426 -0
- package/dist/src/index.d.ts +8 -0
- package/dist/src/index.js +8 -0
- package/dist/src/markdown.d.ts +15 -0
- package/dist/src/markdown.js +476 -0
- package/dist/src/patch.d.ts +6 -0
- package/dist/src/patch.js +37 -0
- package/dist/src/query.d.ts +304 -0
- package/dist/src/query.js +681 -0
- package/dist/src/reconcile.d.ts +53 -0
- package/dist/src/reconcile.js +78 -0
- package/dist/src/stage1-check.d.ts +29 -0
- package/dist/src/stage1-check.js +305 -0
- package/dist/src/unknown.d.ts +10 -0
- package/dist/src/unknown.js +172 -0
- package/docs/adapter-guide.md +155 -0
- package/ontology.schema.json +1690 -0
- package/package.json +53 -4
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type Actor, type Bindings, type Snapshot, type Value } from "./evaluate.js";
|
|
2
|
+
import { type Stage1Ontology, type Type } from "./expr.js";
|
|
3
|
+
/** A result as the runtime or a scenario states it. `reason` only for INVALID_BINDING. No `param`: runtimes do not report it. */
|
|
4
|
+
export type Reported = {
|
|
5
|
+
result: string;
|
|
6
|
+
reason?: "missing" | "unknown_param" | "type" | "not_found";
|
|
7
|
+
};
|
|
8
|
+
export interface RuntimeCase {
|
|
9
|
+
id: string;
|
|
10
|
+
action: string;
|
|
11
|
+
snapshot: Snapshot | null;
|
|
12
|
+
inputs: {
|
|
13
|
+
bindings: Bindings;
|
|
14
|
+
actor: Actor;
|
|
15
|
+
scope: string | null;
|
|
16
|
+
now: number;
|
|
17
|
+
};
|
|
18
|
+
reported: Reported | null;
|
|
19
|
+
expected: Reported | null;
|
|
20
|
+
}
|
|
21
|
+
export type CannotReconcile = "envelope" | "runtime_missing" | "expectation_missing" | "snapshot_missing" | "evaluator_error" | "runtime_unmapped" | "indeterminate";
|
|
22
|
+
export type ReconcileRow = {
|
|
23
|
+
id: string;
|
|
24
|
+
action: string;
|
|
25
|
+
status: "match";
|
|
26
|
+
result: Reported;
|
|
27
|
+
} | {
|
|
28
|
+
id: string;
|
|
29
|
+
action: string;
|
|
30
|
+
status: "mismatch";
|
|
31
|
+
evaluator: Reported;
|
|
32
|
+
reported: Reported;
|
|
33
|
+
expected: Reported;
|
|
34
|
+
} | {
|
|
35
|
+
id: string;
|
|
36
|
+
action: string;
|
|
37
|
+
status: "cannot_reconcile";
|
|
38
|
+
reason: CannotReconcile;
|
|
39
|
+
detail?: string;
|
|
40
|
+
};
|
|
41
|
+
/** Decodes one stored value of a property with a declared type; undefined = cannot decode. Never called with null. */
|
|
42
|
+
export type Decode = (raw: unknown, type: Type) => Value | undefined;
|
|
43
|
+
export type TypeDefect = {
|
|
44
|
+
object: string;
|
|
45
|
+
prop: string;
|
|
46
|
+
row: string;
|
|
47
|
+
value: unknown;
|
|
48
|
+
};
|
|
49
|
+
export declare function projectSnapshot(ont: Stage1Ontology, stores: Readonly<Record<string, readonly Readonly<Record<string, unknown>>[]>>, decode: Decode): {
|
|
50
|
+
snapshot: Snapshot;
|
|
51
|
+
defects: TypeDefect[];
|
|
52
|
+
};
|
|
53
|
+
export declare function reconcile(ont: Stage1Ontology, cases: readonly RuntimeCase[]): ReconcileRow[];
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Runtime consistency: the evaluator, the runtime and the scenario must state one result for one invocation.
|
|
2
|
+
import { decide, externalResult } from "./evaluate.js";
|
|
3
|
+
import { fitsType, INVOCATION_RESULTS, own } from "./expr.js";
|
|
4
|
+
// Stored rows (physical values) -> a typed snapshot. Each property with a declared type is decoded and then checked with
|
|
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). Pure link tables fail closed.
|
|
7
|
+
export function projectSnapshot(ont, stores, decode) {
|
|
8
|
+
const datasources = new Set(Object.values(ont.objectTypes).map((o) => o.datasource));
|
|
9
|
+
for (const l of ont.linkTypes)
|
|
10
|
+
if (l.table !== undefined && !datasources.has(l.table))
|
|
11
|
+
throw new Error(`pure link ${l.id} is not projected`);
|
|
12
|
+
const objects = {};
|
|
13
|
+
const defects = [];
|
|
14
|
+
for (const [object, o] of Object.entries(ont.objectTypes)) {
|
|
15
|
+
if (!Object.hasOwn(stores, o.datasource))
|
|
16
|
+
throw new Error(`snapshot lacks store ${o.datasource}`);
|
|
17
|
+
objects[object] = stores[o.datasource].map((stored) => {
|
|
18
|
+
const row = { ...stored };
|
|
19
|
+
for (const [prop, p] of Object.entries(o.properties)) {
|
|
20
|
+
const raw = row[prop];
|
|
21
|
+
if (p.type === undefined || raw === undefined || raw === null)
|
|
22
|
+
continue;
|
|
23
|
+
const v = decode(raw, p.type);
|
|
24
|
+
if (v === undefined || !fitsType(v, p.type))
|
|
25
|
+
defects.push({ object, prop, row: String(row.id), value: raw });
|
|
26
|
+
else
|
|
27
|
+
row[prop] = v;
|
|
28
|
+
}
|
|
29
|
+
return row;
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
return { snapshot: { objects }, defects };
|
|
33
|
+
}
|
|
34
|
+
// Strict on reason: both absent is equal; INVALID_BINDING with different reasons is not.
|
|
35
|
+
const same = (a, b) => a.result === b.result && a.reason === b.reason;
|
|
36
|
+
const ENVELOPE = ["REPLAYED", "IDEMPOTENCY_CONFLICT"];
|
|
37
|
+
const INVOCATION = INVOCATION_RESULTS;
|
|
38
|
+
// The results a runtime may report for an action: the invocation results and the results its decision table gives.
|
|
39
|
+
function mapped(ont, action, result) {
|
|
40
|
+
if (INVOCATION.includes(result))
|
|
41
|
+
return true;
|
|
42
|
+
const decision = own(ont.actionTypes, action)?.decision;
|
|
43
|
+
return (decision?.rows ?? []).some((r) => r.result === result) || decision?.otherwise?.result === result;
|
|
44
|
+
}
|
|
45
|
+
// One row per case, in input order. The evaluator result is computed only to compare: a missing runtime result or
|
|
46
|
+
// expectation is reported as such, never filled in from the decision table. Envelope results are not decisions (the
|
|
47
|
+
// evaluator does not implement the envelope; a replay does not recompute domain conditions), so decide is not called.
|
|
48
|
+
// A reported result that the action cannot give (runtime_unmapped) means the runtime did not report its domain result.
|
|
49
|
+
export function reconcile(ont, cases) {
|
|
50
|
+
return cases.map((c) => {
|
|
51
|
+
const head = { id: c.id, action: c.action };
|
|
52
|
+
const cannot = (reason, detail) => ({ ...head, status: "cannot_reconcile", reason, ...(detail !== undefined ? { detail } : {}) });
|
|
53
|
+
const { reported, expected, snapshot } = c;
|
|
54
|
+
if (reported === null)
|
|
55
|
+
return cannot("runtime_missing");
|
|
56
|
+
if (ENVELOPE.includes(reported.result))
|
|
57
|
+
return cannot("envelope");
|
|
58
|
+
if (expected === null)
|
|
59
|
+
return cannot("expectation_missing");
|
|
60
|
+
if (snapshot === null)
|
|
61
|
+
return cannot("snapshot_missing");
|
|
62
|
+
const { bindings, actor, scope, now } = c.inputs;
|
|
63
|
+
let d;
|
|
64
|
+
try {
|
|
65
|
+
d = decide(ont, c.action, snapshot, scope, bindings, actor, now);
|
|
66
|
+
}
|
|
67
|
+
catch (e) {
|
|
68
|
+
return cannot("evaluator_error", e instanceof Error ? e.message : String(e));
|
|
69
|
+
}
|
|
70
|
+
if (!mapped(ont, c.action, reported.result))
|
|
71
|
+
return cannot("runtime_unmapped", reported.result);
|
|
72
|
+
if (d.layer === "indeterminate")
|
|
73
|
+
return cannot("indeterminate", d.reason);
|
|
74
|
+
const { result, reason } = externalResult(d);
|
|
75
|
+
const evaluator = reason !== undefined ? { result, reason } : { result };
|
|
76
|
+
return same(evaluator, reported) && same(evaluator, expected) ? { ...head, status: "match", result: evaluator } : { ...head, status: "mismatch", evaluator, reported, expected };
|
|
77
|
+
});
|
|
78
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type SourceFacts, type Violation } from "./contract.js";
|
|
2
|
+
import { type Expr, type ExprReads } from "./expr.js";
|
|
3
|
+
type Any = any;
|
|
4
|
+
export declare const quotesOf: (cites: unknown) => string[];
|
|
5
|
+
export interface Scope {
|
|
6
|
+
by: string;
|
|
7
|
+
globals: Set<string>;
|
|
8
|
+
root: string | null;
|
|
9
|
+
isTenant(objectType: string): boolean;
|
|
10
|
+
}
|
|
11
|
+
export declare function parseScope(ont: Record<string, Any>): Scope | null;
|
|
12
|
+
export type ConditionAnalysis = {
|
|
13
|
+
expr: Expr;
|
|
14
|
+
} & ({
|
|
15
|
+
reads: ExprReads;
|
|
16
|
+
} | {
|
|
17
|
+
error: string;
|
|
18
|
+
});
|
|
19
|
+
export type ConditionAnalyses = Map<string, Map<string, ConditionAnalysis>>;
|
|
20
|
+
export declare function analyzeConditions(ont: Record<string, Any>): ConditionAnalyses;
|
|
21
|
+
export declare function asReads(r: ExprReads): Any[];
|
|
22
|
+
export interface Stage1Context {
|
|
23
|
+
ont: Record<string, Any>;
|
|
24
|
+
facts: SourceFacts;
|
|
25
|
+
scope: Scope | null;
|
|
26
|
+
conditions: ConditionAnalyses;
|
|
27
|
+
}
|
|
28
|
+
export declare function stage1Violations({ ont, facts, scope, conditions }: Stage1Context): Violation[];
|
|
29
|
+
export {};
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
// Stage 1 static checks: derived properties and types (R1, R2, R4), scope (R1, R2), dispositions (R1, R2), condition
|
|
2
|
+
// shape (R1) and decision tables (R1, R12). Pure: no I/O. check() (./checker) parses the scope and analyzes the expression
|
|
3
|
+
// conditions once (parseScope, analyzeConditions), uses both itself (R4, R6) and passes them to stage1Violations.
|
|
4
|
+
import { IDENT } from "./contract.js";
|
|
5
|
+
import { checkPredicate, derivedPropertyType, DISPOSITION_CLASSES, INVOCATION_RESULTS, isObj, newReads } from "./expr.js";
|
|
6
|
+
import { absorbingQuantifiers, mayNullIn, mayUnknown } from "./unknown.js";
|
|
7
|
+
const isStr = (x) => typeof x === "string" && x.length > 0;
|
|
8
|
+
const arr = (x) => (Array.isArray(x) ? x : []);
|
|
9
|
+
const mapOf = (x) => (isObj(x) ? x : {});
|
|
10
|
+
// a key of an ontology mapping: own keys only (`in` would follow the prototype chain, e.g. "toString")
|
|
11
|
+
const hasKey = (m, k) => isObj(m) && typeof k === "string" && Object.hasOwn(m, k);
|
|
12
|
+
const errMsg = (e) => (e instanceof Error ? e.message : String(e));
|
|
13
|
+
// The quotes of a `cites: [{cite}]` list.
|
|
14
|
+
export const quotesOf = (cites) => arr(cites).map((x) => x?.cite?.quote).filter(isStr);
|
|
15
|
+
// The declared scope, or null when there is none or it lacks `by` / `cite` (checkScope reports that as R1).
|
|
16
|
+
export function parseScope(ont) {
|
|
17
|
+
const s = ont.scope;
|
|
18
|
+
if (!isObj(s) || !isStr(s.by) || !isObj(s.cite))
|
|
19
|
+
return null;
|
|
20
|
+
const globals = new Set(arr(s.global).filter((g) => isObj(g) && isStr(g.type)).map((g) => g.type));
|
|
21
|
+
const root = isObj(s.root) && isStr(s.root.type) ? s.root.type : null;
|
|
22
|
+
return { by: s.by, globals, root, isTenant: (o) => !globals.has(o) && o !== root };
|
|
23
|
+
}
|
|
24
|
+
// Type-checks every expression condition once and records what it reads.
|
|
25
|
+
export function analyzeConditions(ont) {
|
|
26
|
+
const out = new Map();
|
|
27
|
+
for (const [cid, c] of Object.entries(mapOf(ont.actionTypes))) {
|
|
28
|
+
const m = new Map();
|
|
29
|
+
for (const pc of isObj(c) ? arr(c.conditions) : []) {
|
|
30
|
+
if (!isObj(pc) || !isStr(pc.id) || pc.expr === undefined)
|
|
31
|
+
continue;
|
|
32
|
+
const reads = newReads();
|
|
33
|
+
try {
|
|
34
|
+
checkPredicate(ont, pc.expr, { action: cid, locals: {}, collect: reads });
|
|
35
|
+
m.set(pc.id, { expr: pc.expr, reads });
|
|
36
|
+
}
|
|
37
|
+
catch (e) {
|
|
38
|
+
m.set(pc.id, { expr: pc.expr, error: errMsg(e) });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
out.set(cid, m);
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
// The property reads of an expression in the shape of hand-written `reads`: a value read `{prop, values}` when every read
|
|
46
|
+
// of the property is an eq / neq with a string literal, else `Object.prop`.
|
|
47
|
+
export function asReads(r) {
|
|
48
|
+
const lits = new Map();
|
|
49
|
+
for (const x of r.reads)
|
|
50
|
+
lits.set(x.prop, [...(lits.get(x.prop) ?? []), x.lit]);
|
|
51
|
+
return [...lits].map(([prop, ls]) => (ls.every((l) => l !== undefined) ? { prop, values: [...new Set(ls)] } : prop));
|
|
52
|
+
}
|
|
53
|
+
const objType = (ont, o) => hasKey(ont.objectTypes, o) && isObj(ont.objectTypes[o]);
|
|
54
|
+
// ------------------------------------------------------------------------------------------ derived properties and types
|
|
55
|
+
function checkDerived({ ont, facts, v }) {
|
|
56
|
+
for (const [dn, dp] of Object.entries(mapOf(ont.derivedProperties))) {
|
|
57
|
+
if (!isObj(dp))
|
|
58
|
+
continue;
|
|
59
|
+
const key = `derivedProperties.${dn}`;
|
|
60
|
+
if (dp.expr === undefined) {
|
|
61
|
+
if (dp.type !== undefined && !isObj(dp.cite))
|
|
62
|
+
v("R2", key, "a declared type needs a cite", "type_without_provenance");
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
// with an expression, a missing type or cite is a shape error (R1 only)
|
|
66
|
+
if (dp.type === undefined)
|
|
67
|
+
v("R1", key, "a derived property with an expression needs a type");
|
|
68
|
+
if (!isObj(dp.cite))
|
|
69
|
+
v("R1", key, "a derived property with an expression needs a cite");
|
|
70
|
+
if (dp.type === undefined || !objType(ont, dp.of))
|
|
71
|
+
continue;
|
|
72
|
+
const reads = newReads();
|
|
73
|
+
try {
|
|
74
|
+
derivedPropertyType(ont, dn, reads);
|
|
75
|
+
}
|
|
76
|
+
catch (e) {
|
|
77
|
+
v("R1", key, errMsg(e));
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
// each quantifier body on its own: absorbingQuantifiers skips a body that reaches a derived property without expression
|
|
81
|
+
for (const q of absorbingQuantifiers(ont, facts, dp.expr, { self: dp.of, locals: {} }))
|
|
82
|
+
v("R12", `${key}:${q}`, absorbsMsg(q, `derived property ${dn}`), "quantifier_absorbs_unknown");
|
|
83
|
+
if (!isObj(dp.cite))
|
|
84
|
+
continue; // reported above (R1); its literals have nothing to be checked against
|
|
85
|
+
const quotes = isStr(dp.cite.quote) ? [dp.cite.quote] : [];
|
|
86
|
+
for (const x of new Set(reads.literals.map(String)))
|
|
87
|
+
if (!quotes.some((q) => IDENT(x).test(q)))
|
|
88
|
+
v("R4", `${key}:${x}`, `derived property ${dn} uses the literal ${x}, which does not appear in its cite quote`, "literal_unsupported");
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function checkPropertyTypes({ ont, v }) {
|
|
92
|
+
for (const [name, o] of Object.entries(mapOf(ont.objectTypes)))
|
|
93
|
+
for (const [pn, pr] of Object.entries(mapOf(o?.properties)))
|
|
94
|
+
if (isObj(pr) && pr.type !== undefined && !isObj(pr.cite))
|
|
95
|
+
v("R2", `${name}.${pn}`, "a declared type needs a cite", "type_without_provenance");
|
|
96
|
+
}
|
|
97
|
+
// ------------------------------------------------------------------------------------------ scope
|
|
98
|
+
function checkScope({ ont, facts, v }, scope) {
|
|
99
|
+
const s = ont.scope;
|
|
100
|
+
if (s === undefined)
|
|
101
|
+
return;
|
|
102
|
+
if (scope === null)
|
|
103
|
+
return v("R1", "scope", "scope needs by and cite");
|
|
104
|
+
if (s.global !== undefined && !(Array.isArray(s.global) && s.global.length > 0))
|
|
105
|
+
v("R1", "scope", "scope.global must be a non-empty list");
|
|
106
|
+
const entries = [...arr(s.global).map((g, i) => [`scope.global[${i}]`, g]), ...(s.root !== undefined ? [["scope.root", s.root]] : [])];
|
|
107
|
+
const seen = new Set();
|
|
108
|
+
for (const [where, g] of entries) {
|
|
109
|
+
if (!isObj(g) || !isObj(g.cite) || !objType(ont, g.type))
|
|
110
|
+
v("R1", "scope", `${where} needs a declared object type and a cite`);
|
|
111
|
+
else if (seen.has(g.type))
|
|
112
|
+
v("R1", "scope", `${g.type} is listed twice in scope`);
|
|
113
|
+
else
|
|
114
|
+
seen.add(g.type);
|
|
115
|
+
}
|
|
116
|
+
if (isStr(s.cite.quote) && !IDENT(scope.by).test(s.cite.quote))
|
|
117
|
+
v("R2", "scope", `the scope field ${scope.by} does not appear in the scope cite quote`, "scope_by_not_in_quote");
|
|
118
|
+
for (const [name, o] of Object.entries(mapOf(ont.objectTypes))) {
|
|
119
|
+
// the root type has no field rule; a missing field list is reported as field_list_missing
|
|
120
|
+
const fl = isObj(o) && isStr(o.doc) && name !== scope.root ? (facts.fieldList(o.doc, name)?.value ?? null) : null;
|
|
121
|
+
if (!fl)
|
|
122
|
+
continue;
|
|
123
|
+
if (scope.globals.has(name) && fl.has(scope.by))
|
|
124
|
+
v("R2", `${name}.${scope.by}`, `${name} is global, but its field list in ${o.doc} has ${scope.by}`, "scope_field_present");
|
|
125
|
+
if (!scope.globals.has(name) && !fl.has(scope.by))
|
|
126
|
+
v("R2", `${name}.${scope.by}`, `${name} is a tenant type, but its field list in ${o.doc} has no ${scope.by}`, "scope_field_missing");
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// ------------------------------------------------------------------------------------------ dispositions
|
|
130
|
+
function checkDispositions({ ont, v }) {
|
|
131
|
+
if (ont.dispositions !== undefined && !isObj(ont.dispositions))
|
|
132
|
+
v("R1", "dispositions", "must be a mapping result id -> {class, cite}");
|
|
133
|
+
for (const [id, d] of Object.entries(mapOf(ont.dispositions))) {
|
|
134
|
+
const key = `dispositions.${id}`;
|
|
135
|
+
if (INVOCATION_RESULTS.includes(id))
|
|
136
|
+
v("R1", key, `${id} is an invocation result; a disposition needs another name`);
|
|
137
|
+
if (!isObj(d) || !DISPOSITION_CLASSES.includes(d.class) || !isObj(d.cite))
|
|
138
|
+
v("R1", key, `needs class (${DISPOSITION_CLASSES.join(" | ")}) and cite`);
|
|
139
|
+
else if (isStr(d.cite.quote) && !IDENT(id).test(d.cite.quote))
|
|
140
|
+
v("R2", key, `${id} does not appear in its cite quote`, "disposition_not_in_quote");
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// ------------------------------------------------------------------------------------------ conditions (shape)
|
|
144
|
+
function checkConditions({ v }, cid, c, analyses) {
|
|
145
|
+
for (const pc of arr(c.conditions)) {
|
|
146
|
+
if (!isObj(pc) || !isStr(pc.id))
|
|
147
|
+
continue; // checker.ts reports it
|
|
148
|
+
const key = `actionTypes.${cid}.conditions.${pc.id}`;
|
|
149
|
+
const hasE = pc.expr !== undefined, hasU = pc.unspecified !== undefined;
|
|
150
|
+
if (hasE === hasU)
|
|
151
|
+
v("R1", key, "exactly one of expr / unspecified");
|
|
152
|
+
else if (hasU && !isStr(pc.unspecified))
|
|
153
|
+
v("R1", key, "unspecified must say what is not written");
|
|
154
|
+
if (hasE && (pc.reads !== undefined || pc.reads_unspecified !== undefined))
|
|
155
|
+
v("R1", key, "reads and reads_unspecified go only with unspecified");
|
|
156
|
+
const a = analyses.get(pc.id);
|
|
157
|
+
if (a !== undefined && "error" in a)
|
|
158
|
+
v("R1", key, a.error);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// ------------------------------------------------------------------------------------------ decision tables
|
|
162
|
+
// The position cite of a row (implementation decision; facts carry no order across sections): among the cites whose
|
|
163
|
+
// quote names the row's result, the doc of the first one listed, and the earliest of that doc's such quotes.
|
|
164
|
+
function position(facts, r) {
|
|
165
|
+
const named = arr(r.cites).map((x) => x?.cite).filter((ci) => isObj(ci) && isStr(ci.doc) && isStr(ci.quote) && IDENT(r.result).test(ci.quote));
|
|
166
|
+
if (named.length === 0)
|
|
167
|
+
return null;
|
|
168
|
+
const doc = named[0].doc;
|
|
169
|
+
const s = facts.section(doc);
|
|
170
|
+
if (typeof s === "string")
|
|
171
|
+
return null;
|
|
172
|
+
const ps = named.filter((ci) => ci.doc === doc).map((ci) => s.text.indexOf(ci.quote)).filter((x) => x >= 0);
|
|
173
|
+
return ps.length ? { doc, pos: Math.min(...ps) } : null;
|
|
174
|
+
}
|
|
175
|
+
// Shape of one row or of otherwise (R1), its result support and an unspecified next (R12).
|
|
176
|
+
function checkOutcome({ ont, v }, cid, label, r, condIds) {
|
|
177
|
+
const dk = `actionTypes.${cid}.decision`;
|
|
178
|
+
if (!isObj(r))
|
|
179
|
+
return v("R1", dk, `${label} must be a mapping`);
|
|
180
|
+
const w = r.when;
|
|
181
|
+
if (condIds && !(isObj(w) && Object.keys(w).length === 1 && condIds.has(w.passes ?? w.fails)))
|
|
182
|
+
v("R1", dk, `${label}: when must be {passes} or {fails} with a condition id of ${cid}`);
|
|
183
|
+
const d = hasKey(ont.dispositions, r.result) ? ont.dispositions[r.result] : undefined;
|
|
184
|
+
if (d === undefined)
|
|
185
|
+
v("R1", dk, `${label}: result ${r.result} is not a key of dispositions`);
|
|
186
|
+
if (!(Array.isArray(r.cites) && r.cites.length > 0 && r.cites.every((x) => isObj(x) && isObj(x.cite))))
|
|
187
|
+
v("R1", dk, `${label} needs cites[{cite}]`);
|
|
188
|
+
if (isObj(d) && (d.class === "pending") !== (r.next !== undefined))
|
|
189
|
+
v("R1", dk, `${label}: a pending result has next, and only a pending result has next`);
|
|
190
|
+
const n = r.next;
|
|
191
|
+
const ks = isObj(n) ? ["actions", "external", "unspecified"].filter((k) => k in n) : [];
|
|
192
|
+
const nextOk = n === undefined ||
|
|
193
|
+
(ks.length === 1 &&
|
|
194
|
+
(ks[0] === "actions" ? arr(n.actions).length > 0 && n.actions.every((a) => hasKey(ont.actionTypes, a)) : ks[0] === "external" ? isStr(n.external) && isObj(n.cite) : isStr(n.unspecified)));
|
|
195
|
+
if (!nextOk)
|
|
196
|
+
v("R1", dk, `${label}: next must be {actions: [declared actions]}, {external, cite} or {unspecified}`);
|
|
197
|
+
else if (ks[0] === "unspecified")
|
|
198
|
+
v("R12", `${cid}:${label}`, `${label} of ${cid}: the next step is unspecified: ${n.unspecified}`, "next_unspecified");
|
|
199
|
+
if (isStr(r.result) && !quotesOf(r.cites).some((q) => IDENT(r.result).test(q)))
|
|
200
|
+
v("R12", `${cid}:${label}`, `${label} of ${cid}: no cite quote names its result ${r.result}`, "result_unsupported");
|
|
201
|
+
}
|
|
202
|
+
// Rows i < j with different results whose position cites are in one section must follow source order there. Rows whose
|
|
203
|
+
// position cites are in different sections are not compared.
|
|
204
|
+
function checkRowOrder({ facts, v }, cid, rows) {
|
|
205
|
+
const pos = rows.map((r) => (isObj(r) && isStr(r.result) ? position(facts, r) : null));
|
|
206
|
+
for (let i = 0; i < rows.length; i++)
|
|
207
|
+
for (let j = i + 1; j < rows.length; j++) {
|
|
208
|
+
const a = pos[i], b = pos[j];
|
|
209
|
+
if (!a || !b || a.doc !== b.doc || rows[i].result === rows[j].result || a.pos < b.pos)
|
|
210
|
+
continue;
|
|
211
|
+
v("R12", `${cid}:rows.${i}:rows.${j}`, `rows.${i} (${rows[i].result}) comes before rows.${j} (${rows[j].result}), but its cite does not come earlier in ${a.doc}`, "row_order_mismatch");
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
function checkDecision(ctx, cid, c, analyses) {
|
|
215
|
+
const { v } = ctx;
|
|
216
|
+
const conds = arr(c.conditions).filter((pc) => isObj(pc) && isStr(pc.id));
|
|
217
|
+
const d = c.decision;
|
|
218
|
+
if (d === undefined) {
|
|
219
|
+
if (conds.length > 0)
|
|
220
|
+
v("R12", cid, `${cid} has conditions but no decision table`, "decision_table_missing");
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const dk = `actionTypes.${cid}.decision`;
|
|
224
|
+
if (!isObj(d))
|
|
225
|
+
return v("R1", dk, "must be {hitPolicy, order?, rows, otherwise?}");
|
|
226
|
+
if (d.hitPolicy !== "first")
|
|
227
|
+
v("R1", dk, "hitPolicy must be first");
|
|
228
|
+
if (!Array.isArray(d.rows))
|
|
229
|
+
v("R1", dk, "rows must be a list");
|
|
230
|
+
if (d.order !== undefined && !(isObj(d.order) && isObj(d.order.cite)))
|
|
231
|
+
v("R1", dk, "order must be {cite}");
|
|
232
|
+
const rows = arr(d.rows);
|
|
233
|
+
const condIds = new Set(conds.map((pc) => pc.id));
|
|
234
|
+
rows.forEach((r, i) => checkOutcome(ctx, cid, `rows.${i}`, r, condIds));
|
|
235
|
+
if (d.otherwise === undefined)
|
|
236
|
+
v("R12", cid, `the decision table of ${cid} has no otherwise`, "otherwise_missing");
|
|
237
|
+
else
|
|
238
|
+
checkOutcome(ctx, cid, "otherwise", d.otherwise, null);
|
|
239
|
+
if (new Set(rows.map((r) => r?.result)).size > 1 && d.order === undefined)
|
|
240
|
+
v("R12", cid, `the rows of ${cid} give different results, but no cite says that they are checked in order`, "row_order_uncited");
|
|
241
|
+
// condition_unspecified: a row uses a condition without expression, or one that reaches a derived property without one
|
|
242
|
+
const used = new Set(rows.map((r) => r?.when?.passes ?? r?.when?.fails).filter(isStr));
|
|
243
|
+
for (const pc of conds.filter((x) => used.has(x.id))) {
|
|
244
|
+
const a = analyses.get(pc.id);
|
|
245
|
+
const undef = a !== undefined && "reads" in a ? [...a.reads.undefinedDerived] : [];
|
|
246
|
+
if (pc.expr === undefined)
|
|
247
|
+
v("R12", `${cid}:${pc.id}`, `a row of ${cid} uses ${pc.id}, which is not written as an expression`, "condition_unspecified");
|
|
248
|
+
else if (undef.length)
|
|
249
|
+
v("R12", `${cid}:${pc.id}`, `a row of ${cid} uses ${pc.id}, which reads the derived ${undef.join(", ")} that has no expression`, "condition_unspecified");
|
|
250
|
+
}
|
|
251
|
+
checkRowOrder(ctx, cid, rows);
|
|
252
|
+
}
|
|
253
|
+
// ------------------------------------------------------------------------------------------ static UNKNOWN analysis
|
|
254
|
+
const absorbsMsg = (q, where) => `the body of the quantifier at ${q} in ${where} may be UNKNOWN, and the quantifier turns UNKNOWN into T or F; guard its nullable reads with isNotNull`;
|
|
255
|
+
// R12 unsafe_fallthrough and quantifier_absorbs_unknown on the expression conditions that type-check and reach no derived
|
|
256
|
+
// property without an expression (the absent-expression rule; for a condition it excludes the whole expression).
|
|
257
|
+
function checkUnknown({ ont, facts, v }, cid, c, analyses) {
|
|
258
|
+
const o = ont;
|
|
259
|
+
const byId = new Map();
|
|
260
|
+
for (const [id, a] of analyses)
|
|
261
|
+
if ("reads" in a && a.reads.undefinedDerived.size === 0)
|
|
262
|
+
byId.set(id, a.expr);
|
|
263
|
+
const ectx = { action: cid, locals: {} };
|
|
264
|
+
const mn = mayNullIn(o, facts, ectx);
|
|
265
|
+
for (const [id, expr] of byId)
|
|
266
|
+
for (const q of absorbingQuantifiers(o, facts, expr, ectx))
|
|
267
|
+
v("R12", `${cid}:${id}:${q}`, absorbsMsg(q, `condition ${id} of ${cid}`), "quantifier_absorbs_unknown");
|
|
268
|
+
if (!isObj(c.decision))
|
|
269
|
+
return;
|
|
270
|
+
const rows = arr(c.decision.rows);
|
|
271
|
+
// implementation decision: Stage 1 does not analyse the other conditions, so every later row is taken as reachable;
|
|
272
|
+
// but when c is U, a later fails(c) row matches, so the rows after it (and otherwise) are not reached
|
|
273
|
+
const appliesOrPends = (r) => hasKey(ont.dispositions, r.result) && ["applied", "pending"].includes(ont.dispositions[r.result]?.class);
|
|
274
|
+
rows.forEach((r, i) => {
|
|
275
|
+
const id = isObj(r) && isObj(r.when) ? r.when.passes : undefined;
|
|
276
|
+
if (!isStr(id) || !byId.has(id))
|
|
277
|
+
return;
|
|
278
|
+
if (!mayUnknown(o, byId.get(id), mn))
|
|
279
|
+
return;
|
|
280
|
+
if (rows.slice(0, i).some((x) => isObj(x) && isObj(x.when) && x.when.fails === id))
|
|
281
|
+
return;
|
|
282
|
+
const later = [...rows.slice(i + 1), c.decision.otherwise];
|
|
283
|
+
const stop = later.findIndex((x) => isObj(x) && isObj(x.when) && x.when.fails === id);
|
|
284
|
+
if (!(stop < 0 ? later : later.slice(0, stop + 1)).filter(isObj).some(appliesOrPends))
|
|
285
|
+
return;
|
|
286
|
+
v("R12", `${cid}:rows.${i}`, `rows.${i} of ${cid} uses passes(${id}), but ${id} may be UNKNOWN; then the table can fall through to an applied or pending result. Consume it with fails(${id}) first, or guard its nullable reads`, "unsafe_fallthrough");
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
export function stage1Violations({ ont, facts, scope, conditions }) {
|
|
290
|
+
const out = [];
|
|
291
|
+
const ctx = { ont, facts, v: (rule, key, msg, kind = "generic") => void out.push({ rule, key, msg, kind }) };
|
|
292
|
+
checkDerived(ctx);
|
|
293
|
+
checkPropertyTypes(ctx);
|
|
294
|
+
checkScope(ctx, scope);
|
|
295
|
+
checkDispositions(ctx);
|
|
296
|
+
for (const [cid, c] of Object.entries(mapOf(ont.actionTypes))) {
|
|
297
|
+
if (!isObj(c))
|
|
298
|
+
continue;
|
|
299
|
+
const analyses = conditions.get(cid) ?? new Map();
|
|
300
|
+
checkConditions(ctx, cid, c, analyses);
|
|
301
|
+
checkDecision(ctx, cid, c, analyses);
|
|
302
|
+
checkUnknown(ctx, cid, c, analyses);
|
|
303
|
+
}
|
|
304
|
+
return out;
|
|
305
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { SourceFacts } from "./contract.js";
|
|
2
|
+
import { type Expr, type ExprContext, type Stage1Ontology } from "./expr.js";
|
|
3
|
+
export declare const MAX_NULLABLE_REFS = 8;
|
|
4
|
+
export type Truth = "T" | "F" | "U";
|
|
5
|
+
export type MayNull = (path: string) => boolean;
|
|
6
|
+
export declare function freeNullableRefs(ont: Stage1Ontology, e: Expr, mayNull: MayNull): string[];
|
|
7
|
+
export declare function abstractEval(ont: Stage1Ontology, e: Expr, isNull: (path: string) => boolean): ReadonlySet<Truth>;
|
|
8
|
+
export declare function mayUnknown(ont: Stage1Ontology, e: Expr, mayNull: MayNull): boolean;
|
|
9
|
+
export declare function mayNullIn(ont: Stage1Ontology, facts: SourceFacts, ctx: ExprContext): MayNull;
|
|
10
|
+
export declare function absorbingQuantifiers(ont: Stage1Ontology, facts: SourceFacts, e: Expr, ctx: ExprContext): string[];
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { navType, objectBinding, own } from "./expr.js";
|
|
2
|
+
export const MAX_NULLABLE_REFS = 8;
|
|
3
|
+
class NoExpression extends Error {
|
|
4
|
+
}
|
|
5
|
+
// One domain for values and predicates: a null value is {U}, a non-null value is {T, F}.
|
|
6
|
+
const U = new Set(["U"]);
|
|
7
|
+
const TF = new Set(["T", "F"]);
|
|
8
|
+
const QUANTIFIERS = new Set(["exists", "none", "all"]);
|
|
9
|
+
const nodeKey = (e) => Object.keys(e)[0];
|
|
10
|
+
const operands = (e) => e[nodeKey(e)];
|
|
11
|
+
// `x.prop` -> ["x", "prop"]; `x` -> ["x", undefined]
|
|
12
|
+
function split(path) {
|
|
13
|
+
const dot = path.indexOf(".");
|
|
14
|
+
return dot < 0 ? [path, undefined] : [path.slice(0, dot), path.slice(dot + 1)];
|
|
15
|
+
}
|
|
16
|
+
// A path with the head `self` replaced by the current self (an object binding name, or "self" at the root).
|
|
17
|
+
function norm(path, self) {
|
|
18
|
+
const [head, prop] = split(path);
|
|
19
|
+
return head !== "self" ? path : prop === undefined ? self : `${self}.${prop}`;
|
|
20
|
+
}
|
|
21
|
+
const and3 = (a, b) => (a === "F" || b === "F" ? "F" : a === "U" || b === "U" ? "U" : "T");
|
|
22
|
+
const or3 = (a, b) => (a === "T" || b === "T" ? "T" : a === "U" || b === "U" ? "U" : "F");
|
|
23
|
+
const not3 = (a) => (a === "T" ? "F" : a === "F" ? "T" : "U");
|
|
24
|
+
// a Kleene connective lifted to sets: the product of the operand sets
|
|
25
|
+
function lift(xs, ys, f) {
|
|
26
|
+
const out = new Set();
|
|
27
|
+
for (const a of xs)
|
|
28
|
+
for (const b of ys)
|
|
29
|
+
out.add(f(a, b));
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
// The abstract result of e for one combination: isNull says which normalized paths are null. Every operand of every node
|
|
33
|
+
// is evaluated (no short-circuit), and only isNull(of) of a derived property prunes: freeNullableRefs relies on both.
|
|
34
|
+
function ev(ont, e, self, isNull) {
|
|
35
|
+
const k = nodeKey(e);
|
|
36
|
+
switch (k) {
|
|
37
|
+
case "lit":
|
|
38
|
+
return TF;
|
|
39
|
+
case "ref":
|
|
40
|
+
return isNull(norm(e.ref, self)) ? U : TF;
|
|
41
|
+
case "derived": {
|
|
42
|
+
// the only place a derived property is inlined: null without reading the expression when its `of` is null
|
|
43
|
+
const d = e.derived;
|
|
44
|
+
const of = norm(d.of.ref, self);
|
|
45
|
+
if (isNull(of))
|
|
46
|
+
return U;
|
|
47
|
+
const x = own(ont.derivedProperties, d.id)?.expr;
|
|
48
|
+
if (x === undefined)
|
|
49
|
+
throw new NoExpression(d.id);
|
|
50
|
+
return ev(ont, x, of, isNull);
|
|
51
|
+
}
|
|
52
|
+
case "exists":
|
|
53
|
+
case "none":
|
|
54
|
+
case "all":
|
|
55
|
+
return TF; // two-valued; the body is analysed on its own
|
|
56
|
+
case "and":
|
|
57
|
+
case "or": {
|
|
58
|
+
const [first, ...rest] = operands(e).map((x) => ev(ont, x, self, isNull));
|
|
59
|
+
return rest.reduce((acc, p) => lift(acc, p, k === "and" ? and3 : or3), first);
|
|
60
|
+
}
|
|
61
|
+
case "not":
|
|
62
|
+
return new Set([...ev(ont, operands(e)[0], self, isNull)].map(not3));
|
|
63
|
+
case "isNull":
|
|
64
|
+
case "isNotNull": {
|
|
65
|
+
const a = ev(ont, operands(e)[0], self, isNull);
|
|
66
|
+
const out = new Set();
|
|
67
|
+
if (a.has("U"))
|
|
68
|
+
out.add(k === "isNull" ? "T" : "F");
|
|
69
|
+
if (a.has("T") || a.has("F"))
|
|
70
|
+
out.add(k === "isNull" ? "F" : "T");
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
default: {
|
|
74
|
+
// strict nodes (comparisons, in, subsetOf, plus, dateIn): a null operand gives null; non-null operands give T or F
|
|
75
|
+
// (no narrowing by constants)
|
|
76
|
+
const xs = operands(e).map((x) => ev(ont, x, self, isNull));
|
|
77
|
+
const out = new Set();
|
|
78
|
+
if (xs.some((x) => x.has("U")))
|
|
79
|
+
out.add("U");
|
|
80
|
+
if (xs.every((x) => x.has("T") || x.has("F")))
|
|
81
|
+
(out.add("T"), out.add("F"));
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// The distinct normalized paths that may be null and that the outer evaluation reads, sorted. One run of ev with no path
|
|
87
|
+
// null reaches every read (ev evaluates every operand; only a null `of` prunes); quantifiers are not entered, and a
|
|
88
|
+
// `nav.from` is never a read.
|
|
89
|
+
export function freeNullableRefs(ont, e, mayNull) {
|
|
90
|
+
const seen = new Set();
|
|
91
|
+
ev(ont, e, "self", (p) => (seen.add(p), false));
|
|
92
|
+
return [...seen].filter(mayNull).sort();
|
|
93
|
+
}
|
|
94
|
+
// The predicate results of e for one combination: isNull says which paths are null.
|
|
95
|
+
export function abstractEval(ont, e, isNull) {
|
|
96
|
+
return ev(ont, e, "self", isNull);
|
|
97
|
+
}
|
|
98
|
+
// Whether some combination of nulls over the free nullable reads of e may give U.
|
|
99
|
+
export function mayUnknown(ont, e, mayNull) {
|
|
100
|
+
const refs = freeNullableRefs(ont, e, mayNull);
|
|
101
|
+
if (refs.length > MAX_NULLABLE_REFS)
|
|
102
|
+
return true;
|
|
103
|
+
for (let mask = 0; mask < 2 ** refs.length; mask++) {
|
|
104
|
+
const nulls = new Set(refs.filter((_, i) => (mask >> i) & 1));
|
|
105
|
+
if (abstractEval(ont, e, (p) => nulls.has(p)).has("U"))
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
// Nullability for any context of one ontology and its facts; the field lists are read once per object type.
|
|
111
|
+
function nullability(ont, facts) {
|
|
112
|
+
const lists = new Map();
|
|
113
|
+
const fields = (objectType) => {
|
|
114
|
+
if (!lists.has(objectType)) {
|
|
115
|
+
const doc = own(ont.objectTypes, objectType)?.doc;
|
|
116
|
+
lists.set(objectType, typeof doc === "string" ? (facts.fieldList(doc, objectType)?.value ?? null) : null);
|
|
117
|
+
}
|
|
118
|
+
return lists.get(objectType);
|
|
119
|
+
};
|
|
120
|
+
return (ctx) => (path) => {
|
|
121
|
+
const [head, prop] = split(path);
|
|
122
|
+
if (head === "now" || head === "actor")
|
|
123
|
+
return false;
|
|
124
|
+
const local = head === "self" || Object.hasOwn(ctx.locals, head);
|
|
125
|
+
const param = local || ctx.action === undefined ? undefined : own(ont.actionTypes[ctx.action]?.parameters, head);
|
|
126
|
+
if (!local && param === undefined)
|
|
127
|
+
return true; // not reachable after the type check; conservative
|
|
128
|
+
if (param?.optional === true)
|
|
129
|
+
return true; // an optional parameter, and every property read through it
|
|
130
|
+
if (prop === undefined)
|
|
131
|
+
return false; // an object binding, or a required parameter
|
|
132
|
+
const o = objectBinding(ont, head, ctx);
|
|
133
|
+
return o === undefined || fields(o)?.get(prop)?.nullable !== false; // non-null only when the field list says so
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
// Nullability of the paths in ctx. A field is non-null only when its field list says so; a field that is not listed,
|
|
137
|
+
// or an object without a field list, has unknown nullability and may be null. An optional parameter may be null; so
|
|
138
|
+
// may every property read through it.
|
|
139
|
+
export function mayNullIn(ont, facts, ctx) {
|
|
140
|
+
return nullability(ont, facts)(ctx);
|
|
141
|
+
}
|
|
142
|
+
// The quantifiers of e (preorder, through and / or / not and quantifier bodies) whose body may be UNKNOWN. Paths use
|
|
143
|
+
// the type-error notation: `$.none`, `$.and[1].none`, `$.all.where.and[0].exists`. Derived property nodes are not
|
|
144
|
+
// entered: a quantifier inside a derived property is reported once, for the derived property itself. A body that
|
|
145
|
+
// reaches a derived property without an expression is not analysed (its nested quantifiers still are).
|
|
146
|
+
export function absorbingQuantifiers(ont, facts, e, ctx) {
|
|
147
|
+
const out = [];
|
|
148
|
+
const mayNullFor = nullability(ont, facts);
|
|
149
|
+
const walk = (x, at, c) => {
|
|
150
|
+
const k = nodeKey(x);
|
|
151
|
+
const p = `${at}.${k}`;
|
|
152
|
+
if (k === "and" || k === "or" || k === "not")
|
|
153
|
+
operands(x).forEach((y, i) => walk(y, `${p}[${i}]`, c));
|
|
154
|
+
else if (QUANTIFIERS.has(k)) {
|
|
155
|
+
const q = x[k];
|
|
156
|
+
if (q.where === undefined)
|
|
157
|
+
return;
|
|
158
|
+
const c2 = { ...c, locals: { ...c.locals, [q.as]: navType(ont, q.in, c, `${p}.in`) } };
|
|
159
|
+
try {
|
|
160
|
+
if (mayUnknown(ont, q.where, mayNullFor(c2)))
|
|
161
|
+
out.push(p);
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
if (!(err instanceof NoExpression))
|
|
165
|
+
throw err;
|
|
166
|
+
}
|
|
167
|
+
walk(q.where, `${p}.where`, c2);
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
walk(e, "$", ctx);
|
|
171
|
+
return out;
|
|
172
|
+
}
|