dsh-model-everything 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 +21 -0
- package/README.en-US.md +319 -0
- package/README.md +319 -3
- package/cordis.patch.yml +20 -0
- package/examples/README.md +48 -0
- package/examples/control-dispatcher.json +113 -0
- package/examples/control-dispatcher.overbudget.json +113 -0
- package/examples/ecommerce-data-model.json +185 -0
- package/examples/order-state-machine.after.json +116 -0
- package/examples/order-state-machine.before.json +101 -0
- package/examples/user-migration.after.json +29 -0
- package/examples/user-migration.before.json +29 -0
- package/gate/locale/en.json +6 -0
- package/gate/locale/zh.json +6 -0
- package/gate/package.json +5 -0
- package/lib/baseline.js +249 -0
- package/lib/compose-tool.js +76 -0
- package/lib/concurrency-tool.js +40 -0
- package/lib/concurrency.js +206 -0
- package/lib/data-engine.js +1141 -0
- package/lib/data-tool.js +79 -0
- package/lib/design-engine.js +1149 -0
- package/lib/design-tool.js +55 -0
- package/lib/engine.js +4343 -0
- package/lib/export-tool.js +78 -0
- package/lib/exporters.js +434 -0
- package/lib/gate.js +274 -0
- package/lib/granularity.js +237 -0
- package/lib/index.js +237 -0
- package/lib/json-value.js +18 -0
- package/lib/option-shape.js +62 -0
- package/lib/report-diff-tool.js +63 -0
- package/lib/report-view.js +408 -0
- package/lib/structure-tool.js +91 -0
- package/lib/structure.js +711 -0
- package/lib/tool.js +85 -0
- package/lib/types/baseline.d.ts +65 -0
- package/lib/types/compose-tool.d.ts +7 -0
- package/lib/types/concurrency-tool.d.ts +8 -0
- package/lib/types/concurrency.d.ts +37 -0
- package/lib/types/data-engine.d.ts +211 -0
- package/lib/types/data-tool.d.ts +10 -0
- package/lib/types/design-engine.d.ts +215 -0
- package/lib/types/design-tool.d.ts +11 -0
- package/lib/types/engine.d.ts +494 -0
- package/lib/types/export-tool.d.ts +7 -0
- package/lib/types/exporters.d.ts +37 -0
- package/lib/types/gate.d.ts +91 -0
- package/lib/types/granularity.d.ts +106 -0
- package/lib/types/index.d.ts +45 -0
- package/lib/types/json-value.d.ts +21 -0
- package/lib/types/option-shape.d.ts +22 -0
- package/lib/types/report-diff-tool.d.ts +8 -0
- package/lib/types/report-view.d.ts +43 -0
- package/lib/types/structure-tool.d.ts +10 -0
- package/lib/types/structure.d.ts +152 -0
- package/lib/types/tool.d.ts +11 -0
- package/lib/types/uml-communication.d.ts +109 -0
- package/lib/types/uml-composite.d.ts +107 -0
- package/lib/types/uml-interaction-overview.d.ts +91 -0
- package/lib/types/uml-profile.d.ts +112 -0
- package/lib/types/uml-structure.d.ts +53 -0
- package/lib/types/uml-syntax.d.ts +296 -0
- package/lib/types/uml-timing.d.ts +152 -0
- package/lib/types/uml-tool.d.ts +12 -0
- package/lib/types/uml-usecase.d.ts +58 -0
- package/lib/types/uml.d.ts +303 -0
- package/lib/uml-communication.js +353 -0
- package/lib/uml-composite.js +442 -0
- package/lib/uml-interaction-overview.js +277 -0
- package/lib/uml-profile.js +373 -0
- package/lib/uml-structure.js +306 -0
- package/lib/uml-syntax.js +545 -0
- package/lib/uml-timing.js +974 -0
- package/lib/uml-tool.js +213 -0
- package/lib/uml-usecase.js +532 -0
- package/lib/uml.js +2110 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +124 -6
- package/skills/model-everything/SKILL.md +482 -0
- package/skills/model-everything/references/dsh-model-schema.md +437 -0
- package/skills/model-everything/references/gap-routing-guide.md +42 -0
- package/skills/model-everything/references/hash-spec.md +152 -0
- package/skills/model-everything/references/logic-verification-guide.md +422 -0
- package/skills/model-everything-datamodel/SKILL.md +130 -0
- package/skills/model-everything-datamodel/references/data-model-guide.md +84 -0
- package/skills/model-everything-datamodel/references/data-model-schema.md +128 -0
- package/skills/model-everything-design/SKILL.md +115 -0
- package/skills/model-everything-interleaving/SKILL.md +63 -0
- package/skills/model-everything-interleaving/SKILL.zh-CN.md +60 -0
- package/skills/model-everything-interleaving/references/concurrency-risk-guide.md +63 -0
- package/skills/model-everything-report-reading/SKILL.md +137 -0
- package/skills/model-everything-report-reading/references/reading-a-report.md +166 -0
- package/skills/model-everything-report-reading/references/reading-a-report.mmd +59 -0
- package/skills/model-everything-structure/SKILL.md +96 -0
- package/skills/model-everything-structure/references/structure-review-guide.md +201 -0
- package/skills/model-everything-uml/SKILL.md +60 -0
- package/skills/model-everything-uml/references/uml-modeling-guide.md +551 -0
- package/src/baseline.ts +288 -0
- package/src/compose-tool.ts +78 -0
- package/src/concurrency-tool.ts +44 -0
- package/src/concurrency.ts +254 -0
- package/src/data-engine.ts +1208 -0
- package/src/data-tool.ts +83 -0
- package/src/design-engine.ts +1263 -0
- package/src/design-tool.ts +60 -0
- package/src/engine.ts +4564 -0
- package/src/export-tool.ts +81 -0
- package/src/exporters.ts +399 -0
- package/src/gate.ts +330 -0
- package/src/granularity.ts +312 -0
- package/src/index.ts +289 -0
- package/src/json-value.ts +20 -0
- package/src/option-shape.ts +64 -0
- package/src/report-diff-tool.ts +67 -0
- package/src/report-view.ts +451 -0
- package/src/structure-tool.ts +93 -0
- package/src/structure.ts +786 -0
- package/src/tool.ts +94 -0
- package/src/uml-communication.ts +428 -0
- package/src/uml-composite.ts +520 -0
- package/src/uml-interaction-overview.ts +321 -0
- package/src/uml-profile.ts +439 -0
- package/src/uml-structure.ts +317 -0
- package/src/uml-syntax.ts +764 -0
- package/src/uml-timing.ts +1106 -0
- package/src/uml-tool.ts +230 -0
- package/src/uml-usecase.ts +566 -0
- package/src/uml.ts +2139 -0
package/lib/engine.js
ADDED
|
@@ -0,0 +1,4343 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
export const ENGINE_SCHEMA_VERSION = 1;
|
|
3
|
+
export function isStateGuard(when) {
|
|
4
|
+
return 'state' in when;
|
|
5
|
+
}
|
|
6
|
+
/** Allowed keys per invariant kind — the schema is closed, so anything else is a typo or an unsupported feature. */
|
|
7
|
+
const INVARIANT_KEYS = {
|
|
8
|
+
'never-states': new Set(['id', 'description', 'kind', 'states']),
|
|
9
|
+
'var-in-range': new Set(['id', 'description', 'kind', 'variable', 'min', 'max', 'when']),
|
|
10
|
+
'event-before-state': new Set(['id', 'description', 'kind', 'event', 'state']),
|
|
11
|
+
'leads-to': new Set(['id', 'description', 'kind', 'from', 'to']),
|
|
12
|
+
'sequence': new Set(['id', 'description', 'kind', 'events']),
|
|
13
|
+
'atomicity': new Set(['id', 'description', 'kind', 'events', 'commit', 'rollback']),
|
|
14
|
+
'budget': new Set(['id', 'description', 'kind', 'budget']),
|
|
15
|
+
'probability': new Set(['id', 'description', 'kind', 'target', 'op', 'p']),
|
|
16
|
+
};
|
|
17
|
+
const KNOWN_INVARIANT_KINDS = new Set(Object.keys(INVARIANT_KEYS));
|
|
18
|
+
/**
|
|
19
|
+
* Declared keys per model part. The model schema is closed: a key that is not
|
|
20
|
+
* declared is a typo or an unsupported feature, and silently ignoring it can make
|
|
21
|
+
* the engine report a property that the model does not actually have (a mistyped
|
|
22
|
+
* guard variable reads as an always-false guard, which prunes real paths and can
|
|
23
|
+
* yield a false "no deadlock"). Rejecting is the only safe option for a verifier.
|
|
24
|
+
*/
|
|
25
|
+
const MODEL_KEYS = {
|
|
26
|
+
root: new Set(['schemaVersion', 'init', 'states', 'transitions', 'variables', 'invariants', 'concurrentPairs', 'boundaryChecks', 'resourcePairs', 'idempotentEvents', 'tickEvents', 'narrative']),
|
|
27
|
+
state: new Set(['id', 'terminal', 'onEntry', 'onExit', 'maxTicks']),
|
|
28
|
+
transition: new Set(['from', 'event', 'to', 'guard', 'updates', 'cost', 'weight']),
|
|
29
|
+
update: new Set(['variable', 'op', 'value']),
|
|
30
|
+
variable: new Set(['name', 'kind', 'init', 'min', 'max', 'monotonic']),
|
|
31
|
+
// LogicModelV1 boundary checks are (variable, values). The data-model engine has
|
|
32
|
+
// its own (entity, field) shape and its own validator — the two must not be mixed.
|
|
33
|
+
boundaryCheck: new Set(['variable', 'values']),
|
|
34
|
+
resourcePair: new Set(['resource', 'acquireEvent', 'releaseEvent', 'failEvent']),
|
|
35
|
+
narrative: new Set(['states', 'events', 'scenarios']),
|
|
36
|
+
scenario: new Set(['from', 'event', 'scenario']),
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* The next-steps list every verification report carries. Derived only from the checks
|
|
40
|
+
* and the narrative coverage, so two runs over the same model produce the same list.
|
|
41
|
+
*/
|
|
42
|
+
function verificationNextSteps(checks, coverage) {
|
|
43
|
+
const steps = [];
|
|
44
|
+
// The name leads and the code follows in parentheses, so a reader who has never seen a report can act
|
|
45
|
+
// on this sentence, and a reader who wants the code still has it. The code is a label for the check,
|
|
46
|
+
// not the check's name.
|
|
47
|
+
const failed = checks.filter((check) => check.status === 'fail').map((check) => check.name + ' (' + check.id + ')');
|
|
48
|
+
const warningCodes = [...new Set(checks.flatMap((check) => check.findings).filter((finding) => finding.severity === 'warning').map((finding) => finding.code))].sort();
|
|
49
|
+
if (failed.length > 0)
|
|
50
|
+
steps.push('Resolve the failing checks first: ' + failed.join(', ') + '.');
|
|
51
|
+
// A check that had nothing to examine is named here, because a reader who is not told will assume every
|
|
52
|
+
// named check looked at the model. A list of failures does not say that, and neither does `pass`.
|
|
53
|
+
const skipped = checks.filter((check) => check.status === 'skip').map((check) => check.name + ' (' + check.id + ')');
|
|
54
|
+
if (skipped.length > 0) {
|
|
55
|
+
steps.push('These examined nothing, so their silence is not a result: ' + skipped.join(', ') + '. Declare what each one would need, or accept that the model is unverified on those points.');
|
|
56
|
+
}
|
|
57
|
+
// Named checks above, bare codes here. That is deliberate: each warning is a different kind of thing,
|
|
58
|
+
// so there is no single name to give the set, and the sentence points at where each one IS explained
|
|
59
|
+
// rather than leaving a reader to guess what the codes stand for.
|
|
60
|
+
if (warningCodes.length > 0)
|
|
61
|
+
steps.push('Review the warning findings (' + warningCodes.join(', ') + '): they are not failures, but they are not silence either. Each one is explained in its own `message` under `findings`.');
|
|
62
|
+
if (coverage !== undefined && !narrativeComplete(coverage)) {
|
|
63
|
+
steps.push('Complete the narrative (states ' + coverage.states + ', events ' + coverage.events + ', scenarios ' + coverage.scenarios + ') — a partial narrative is valid, but a reader still has to re-derive the missing symbols.');
|
|
64
|
+
}
|
|
65
|
+
// The range is dropped: the code that would be reported back is already named in `findings`, and a
|
|
66
|
+
// reader who has not seen a report yet learns nothing from four digits.
|
|
67
|
+
steps.push('Re-run with beforeModel/stateMapping after the change to prove the behaviour did not regress: behaviour preserved, invariants continuous, the state mapping consistent. Then compare the two reports with `report_diff` rather than reading them side by side.');
|
|
68
|
+
return steps;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Repair guidance for a rejected model. A refusal reports the offending path and the rule
|
|
72
|
+
* it broke, but not what to write instead; for fields whose absence is a documented trap,
|
|
73
|
+
* that is the difference between "fix it" and "guess again". Derived only from the
|
|
74
|
+
* validation messages, so the list stays deterministic.
|
|
75
|
+
*/
|
|
76
|
+
function refusalGuidance(errors) {
|
|
77
|
+
const steps = [];
|
|
78
|
+
if (errors.some((message) => message.includes('.description'))) {
|
|
79
|
+
steps.push('Add the required "description" string to every invariant (for example "description": "what this invariant guarantees"); a `_`-prefixed key is not a substitute, and the value takes part in `modelHash`.');
|
|
80
|
+
}
|
|
81
|
+
return steps;
|
|
82
|
+
}
|
|
83
|
+
const DEFAULT_MAX_STATES = 10_000;
|
|
84
|
+
const DEFAULT_MAX_PERMUTATION_EVENTS = 5;
|
|
85
|
+
/**
|
|
86
|
+
* Ceilings the caller cannot raise.
|
|
87
|
+
*
|
|
88
|
+
* These are not budget opinions, they are the difference between a bounded answer and an aborted
|
|
89
|
+
* process. `maxStates` bounds every walk in the engine with `steps > options.maxStates`, so a `NaN`
|
|
90
|
+
* makes that comparison false forever and the walk allocates until the heap dies - the robustness
|
|
91
|
+
* audit measured 4 GB and a fatal V8 error at about 70 s, from an option the tool schema accepts.
|
|
92
|
+
* `Infinity` never stops at all.
|
|
93
|
+
*
|
|
94
|
+
* The state ceiling is ten times the default, so a caller who genuinely needs more can ask.
|
|
95
|
+
* Permutations are factorial, which makes that ceiling hard: 9! is 362 880 orderings, three orders
|
|
96
|
+
* of magnitude past the default's 120, and A2's sampling path exists for the cases beyond it.
|
|
97
|
+
*/
|
|
98
|
+
const MAX_STATES_CEILING = 100_000;
|
|
99
|
+
const MAX_PERMUTATION_EVENTS_CEILING = 9;
|
|
100
|
+
/**
|
|
101
|
+
* A caller-supplied bound, or the default when the value could not bound anything.
|
|
102
|
+
*
|
|
103
|
+
* The engine never throws for a bad option, so this neither refuses nor throws: it replaces or
|
|
104
|
+
* clamps the value and records that it did, and the report carries the note. A non-finite or
|
|
105
|
+
* non-integer bound is not a claim to disagree with - it is a bound that does not bound, and
|
|
106
|
+
* `steps > NaN` is false forever.
|
|
107
|
+
*/
|
|
108
|
+
function boundedOption(value, fallback, ceiling, name, notes) {
|
|
109
|
+
if (value === undefined)
|
|
110
|
+
return fallback;
|
|
111
|
+
if (!Number.isFinite(value) || !Number.isInteger(value) || value < 1) {
|
|
112
|
+
notes.push(name + ' was ' + String(value) + ', which cannot bound a search; the default of ' + String(fallback) + ' was used instead.');
|
|
113
|
+
return fallback;
|
|
114
|
+
}
|
|
115
|
+
if (value > ceiling) {
|
|
116
|
+
notes.push(name + ' was ' + String(value) + '; it was clamped to ' + String(ceiling) + '. A larger bound does not buy a larger answer here: the search would run until the process ran out of memory.');
|
|
117
|
+
return ceiling;
|
|
118
|
+
}
|
|
119
|
+
return value;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Budget for the leads-to walk, as a multiple of `maxStates`. That walk is depth-first
|
|
123
|
+
* over run states, so `maxStates` does not bound it the way it bounds the breadth-first
|
|
124
|
+
* explorations: an `inc`/`dec` counter whose updates are not pinned by a guard has an
|
|
125
|
+
* unbounded reachable set, every run state is distinct, and the walk never revisits one.
|
|
126
|
+
* The budget is shared across every start state of a single check, so a wide search
|
|
127
|
+
* space cannot multiply it by the number of starts.
|
|
128
|
+
*/
|
|
129
|
+
const LEADS_TO_WALK_FACTOR = 20;
|
|
130
|
+
function leadsToWalkBudget(options) {
|
|
131
|
+
return Math.max(1_000, options.maxStates * LEADS_TO_WALK_FACTOR);
|
|
132
|
+
}
|
|
133
|
+
function stableStringify(value) {
|
|
134
|
+
if (value === null || typeof value !== 'object')
|
|
135
|
+
return JSON.stringify(value);
|
|
136
|
+
if (Array.isArray(value))
|
|
137
|
+
return '[' + value.map((item) => stableStringify(item)).join(',') + ']';
|
|
138
|
+
const record = value;
|
|
139
|
+
return '{' + Object.keys(record).sort().map((key) => JSON.stringify(key) + ':' + stableStringify(record[key])).join(',') + '}';
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* The hash of any model this plugin carries: the same normalization as `modelHash`,
|
|
143
|
+
* over whatever object the family defines. A use case diagram and a state machine are
|
|
144
|
+
* hashed by one rule, so a report can name either.
|
|
145
|
+
*/
|
|
146
|
+
export function valueHash(value, spec = DEFAULT_HASH_SPEC) {
|
|
147
|
+
return createHash('sha256').update(stableStringify(hashPayload(value, spec))).digest('hex');
|
|
148
|
+
}
|
|
149
|
+
export function modelHash(model, spec = DEFAULT_HASH_SPEC) {
|
|
150
|
+
return valueHash(model, spec);
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Deterministic JSON (sorted keys, no insignificant whitespace) — the serialization the
|
|
154
|
+
* hash specification is defined over. Exported so every report hashes inputs the same way.
|
|
155
|
+
*/
|
|
156
|
+
export function canonicalJson(value) {
|
|
157
|
+
return stableStringify(value);
|
|
158
|
+
}
|
|
159
|
+
export const PUBLISHED_HASH_SPECS = ['v0', 'v1'];
|
|
160
|
+
export const DEFAULT_HASH_SPEC = 'v1';
|
|
161
|
+
/**
|
|
162
|
+
* Versioned report contracts. Every tool result carries `schema`, so a consumer can
|
|
163
|
+
* branch on the contract instead of sniffing fields, and a change to a report's shape
|
|
164
|
+
* is a version bump here rather than a silent break. `findings[]` keeps the same
|
|
165
|
+
* stable core in every family: `{code, severity, message, detail?, evidence?, path?}`.
|
|
166
|
+
*/
|
|
167
|
+
export const REPORT_SCHEMAS = {
|
|
168
|
+
verify: 'model-everything/verify/v1',
|
|
169
|
+
compose: 'model-everything/compose/v1',
|
|
170
|
+
datamodel: 'model-everything/datamodel/v1',
|
|
171
|
+
concurrency: 'model-everything/concurrency/v1',
|
|
172
|
+
export: 'model-everything/export/v1',
|
|
173
|
+
umlRender: 'model-everything/uml/render/v1',
|
|
174
|
+
umlParse: 'model-everything/uml/parse/v1',
|
|
175
|
+
umlReview: 'model-everything/uml/review/v1',
|
|
176
|
+
umlExplainLabels: 'model-everything/uml/explain-labels/v1',
|
|
177
|
+
// A UML refusal happens before the action resolves, so there is no action schema to name. It still carries
|
|
178
|
+
// one: every other family's refusal does, and a reader who keys on `schema` was left with nothing.
|
|
179
|
+
umlRefusal: 'model-everything/uml/refusal/v1',
|
|
180
|
+
structure: 'model-everything/structure/v1',
|
|
181
|
+
granularity: 'model-everything/granularity/v1',
|
|
182
|
+
design: 'model-everything/design/v1',
|
|
183
|
+
baseline: 'model-everything/baseline/v1',
|
|
184
|
+
};
|
|
185
|
+
export function narrativeCoverageOf(model) {
|
|
186
|
+
const narrative = model.narrative;
|
|
187
|
+
if (narrative === undefined)
|
|
188
|
+
return undefined;
|
|
189
|
+
const describedStates = narrative.states === undefined ? 0 : Object.keys(narrative.states).length;
|
|
190
|
+
const describedEvents = narrative.events === undefined ? 0 : Object.keys(narrative.events).length;
|
|
191
|
+
const groups = new Set(model.transitions.map((transition) => transition.from + '|' + transition.event));
|
|
192
|
+
const describedGroups = narrative.scenarios === undefined
|
|
193
|
+
? 0
|
|
194
|
+
: new Set(narrative.scenarios.map((scenario) => scenario.from + '|' + scenario.event)).size;
|
|
195
|
+
const events = new Set(model.transitions.map((transition) => transition.event));
|
|
196
|
+
return {
|
|
197
|
+
states: String(describedStates) + '/' + String(model.states.length),
|
|
198
|
+
events: String(describedEvents) + '/' + String(events.size),
|
|
199
|
+
scenarios: String(describedGroups) + '/' + String(groups.size),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
/** True when every dimension of the coverage is fully described. */
|
|
203
|
+
export function narrativeComplete(coverage) {
|
|
204
|
+
if (coverage === undefined)
|
|
205
|
+
return false;
|
|
206
|
+
return coverage.states.split('/')[0] === coverage.states.split('/')[1]
|
|
207
|
+
&& coverage.events.split('/')[0] === coverage.events.split('/')[1]
|
|
208
|
+
&& coverage.scenarios.split('/')[0] === coverage.scenarios.split('/')[1];
|
|
209
|
+
}
|
|
210
|
+
/** A tool that refused the request before running anything: it neither passed nor failed a review. */
|
|
211
|
+
export function refusalVerdict(reason) {
|
|
212
|
+
return { verdict: 'fail', verdictReason: 'the tool did not run: ' + reason };
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Decide the verdict from finding counts. Any error-severity finding fails the review
|
|
216
|
+
* even though the tool ran successfully — that gap is exactly what `verdict` closes.
|
|
217
|
+
*/
|
|
218
|
+
export function verdictOf(errors, warnings, firstCode) {
|
|
219
|
+
if (errors > 0) {
|
|
220
|
+
return {
|
|
221
|
+
verdict: 'fail',
|
|
222
|
+
// `first error:` rather than `first:`: what follows is a code, and a reader who does not know the
|
|
223
|
+
// codes should still be able to tell that this field is pointing at one. The code is the label; the
|
|
224
|
+
// finding's own `message` carries the sentence.
|
|
225
|
+
verdictReason: String(errors) + ' error finding(s)' + (firstCode === undefined ? '' : ' (first error: ' + firstCode + ')'),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
if (warnings > 0) {
|
|
229
|
+
return { verdict: 'pass_with_findings', verdictReason: 'no error findings; ' + String(warnings) + ' warning finding(s)' };
|
|
230
|
+
}
|
|
231
|
+
return { verdict: 'pass', verdictReason: 'no error or warning findings' };
|
|
232
|
+
}
|
|
233
|
+
export function verdictOfFindings(findings) {
|
|
234
|
+
const errors = findings.filter((finding) => finding.severity === 'error');
|
|
235
|
+
const warnings = findings.filter((finding) => finding.severity === 'warning');
|
|
236
|
+
return verdictOf(errors.length, warnings.length, errors[0]?.code);
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Whether a finding means "the search stopped before deciding", as opposed to "the model is wrong".
|
|
240
|
+
* Such a finding must reach `summary.truncated`, or a caller reading only that flag would take an
|
|
241
|
+
* unfinished search for a clean one.
|
|
242
|
+
*
|
|
243
|
+
* The shipped schema reference states the rule as a name: the check that stopped carries a finding
|
|
244
|
+
* whose code ends in `_UNDECIDED`, and the report sets `summary.truncated`. Read from the name, that
|
|
245
|
+
* is one rule in one place. It used to be a hand-written set of codes, and A12 and A14 minted their
|
|
246
|
+
* own codes without joining it: the report carried an undecided finding and `truncated` stayed
|
|
247
|
+
* false, so the two halves of that sentence disagreed.
|
|
248
|
+
*/
|
|
249
|
+
function hasUndecidedFinding(checks) {
|
|
250
|
+
return checks.some((check) => check.findings.some((finding) => finding.code.endsWith('_UNDECIDED')));
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Paths of every `_`-prefixed key in the input, sorted (`_source`, `_verified`,
|
|
254
|
+
* `states[0]._note`; a metadata key's own children are not listed, since the subtree is
|
|
255
|
+
* metadata as a whole). The schema ignores these keys as annotation metadata; echoing
|
|
256
|
+
* them in the report makes the ignored metadata auditable instead of invisible.
|
|
257
|
+
*
|
|
258
|
+
* Iterative, with an explicit stack: this runs before validation on whatever the caller passed, so
|
|
259
|
+
* a recursive walk overflowed the stack on a deeply nested model and the `RangeError` arrived
|
|
260
|
+
* instead of any report at all. A guard nested ten thousand deep is nonsense, and it has to be
|
|
261
|
+
* refused rather than crash the walk that was going to refuse it.
|
|
262
|
+
*/
|
|
263
|
+
export function metadataKeysOf(input) {
|
|
264
|
+
const keys = [];
|
|
265
|
+
const stack = [{ value: input, path: '' }];
|
|
266
|
+
while (stack.length > 0) {
|
|
267
|
+
const frame = stack.pop();
|
|
268
|
+
const value = frame.value;
|
|
269
|
+
if (value === null || typeof value !== 'object')
|
|
270
|
+
continue;
|
|
271
|
+
if (Array.isArray(value)) {
|
|
272
|
+
for (let index = value.length - 1; index >= 0; index -= 1) {
|
|
273
|
+
stack.push({ value: value[index], path: frame.path + '[' + String(index) + ']' });
|
|
274
|
+
}
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
278
|
+
const child = frame.path === '' ? key : frame.path + '.' + key;
|
|
279
|
+
if (key.startsWith('_'))
|
|
280
|
+
keys.push(child);
|
|
281
|
+
stack.push({ value: entry, path: child });
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return keys.sort();
|
|
285
|
+
}
|
|
286
|
+
function withoutMetadata(value) {
|
|
287
|
+
if (value === null || typeof value !== 'object')
|
|
288
|
+
return value;
|
|
289
|
+
if (Array.isArray(value))
|
|
290
|
+
return value.map((entry) => withoutMetadata(entry));
|
|
291
|
+
const out = {};
|
|
292
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
293
|
+
if (key.startsWith('_'))
|
|
294
|
+
continue;
|
|
295
|
+
out[key] = withoutMetadata(entry);
|
|
296
|
+
}
|
|
297
|
+
return out;
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* The exact payload a hash spec hashes. Exported so the specification has one
|
|
301
|
+
* implementation and the documentation can be checked against it.
|
|
302
|
+
*/
|
|
303
|
+
export function hashPayload(model, spec = DEFAULT_HASH_SPEC) {
|
|
304
|
+
return spec === 'v1' ? withoutMetadata(model) : model;
|
|
305
|
+
}
|
|
306
|
+
export function validateModel(input) {
|
|
307
|
+
const errors = [];
|
|
308
|
+
const bad = (path, message) => {
|
|
309
|
+
errors.push(path + ': ' + message);
|
|
310
|
+
};
|
|
311
|
+
if (typeof input !== 'object' || input === null || Array.isArray(input)) {
|
|
312
|
+
return { ok: false, errors: ['model: must be an object'] };
|
|
313
|
+
}
|
|
314
|
+
const root = input;
|
|
315
|
+
// `_`-prefixed keys are annotation metadata (provenance, verification snapshots,
|
|
316
|
+
// extraction caveats). They are ignored here and excluded from the hash, so a model
|
|
317
|
+
// can carry its own archive record without a sidecar that drifts away from it.
|
|
318
|
+
// Everything else stays closed: a mistyped `sttes` must still be an error.
|
|
319
|
+
const rejectUnknownKeys = (value, allowed, path) => {
|
|
320
|
+
const unexpected = Object.keys(value).filter((key) => !allowed.has(key) && !key.startsWith('_'));
|
|
321
|
+
if (unexpected.length > 0) {
|
|
322
|
+
bad(path + '.' + unexpected[0], 'unknown field (allowed: ' + [...allowed].join(', ') + ')');
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
rejectUnknownKeys(root, MODEL_KEYS.root, 'model');
|
|
326
|
+
if (root.schemaVersion !== 1)
|
|
327
|
+
bad('schemaVersion', 'must be 1');
|
|
328
|
+
if (typeof root.init !== 'string' || root.init.length === 0)
|
|
329
|
+
bad('init', 'must be a non-empty string');
|
|
330
|
+
if (!Array.isArray(root.states) || root.states.length === 0) {
|
|
331
|
+
bad('states', 'must be a non-empty array');
|
|
332
|
+
}
|
|
333
|
+
else {
|
|
334
|
+
const seen = new Set();
|
|
335
|
+
root.states.forEach((entry, index) => {
|
|
336
|
+
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
|
|
337
|
+
bad('states[' + index + ']', 'must be an object');
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
const state = entry;
|
|
341
|
+
rejectUnknownKeys(state, MODEL_KEYS.state, 'states[' + index + ']');
|
|
342
|
+
if (typeof state.id !== 'string' || state.id.length === 0)
|
|
343
|
+
bad('states[' + index + '].id', 'must be a non-empty string');
|
|
344
|
+
else if (seen.has(state.id))
|
|
345
|
+
bad('states[' + index + '].id', 'duplicate state id ' + state.id);
|
|
346
|
+
else
|
|
347
|
+
seen.add(state.id);
|
|
348
|
+
if (state.terminal !== undefined && typeof state.terminal !== 'boolean')
|
|
349
|
+
bad('states[' + index + '].terminal', 'must be a boolean');
|
|
350
|
+
for (const kind of ['onEntry', 'onExit']) {
|
|
351
|
+
const actions = state[kind];
|
|
352
|
+
if (actions !== undefined) {
|
|
353
|
+
if (!Array.isArray(actions))
|
|
354
|
+
bad('states[' + index + '].' + kind, 'must be an array of action names');
|
|
355
|
+
else if (actions.length > 64)
|
|
356
|
+
bad('states[' + index + '].' + kind, 'must not exceed 64 actions');
|
|
357
|
+
else
|
|
358
|
+
actions.forEach((action, actionIndex) => {
|
|
359
|
+
if (typeof action !== 'string' || action.length === 0)
|
|
360
|
+
bad('states[' + index + '].' + kind + '[' + actionIndex + ']', 'must be a non-empty string');
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
if (typeof root.init === 'string' && root.init.length > 0 && !seen.has(root.init))
|
|
366
|
+
bad('init', 'must name a declared state');
|
|
367
|
+
}
|
|
368
|
+
if (!Array.isArray(root.transitions)) {
|
|
369
|
+
bad('transitions', 'must be an array');
|
|
370
|
+
}
|
|
371
|
+
else {
|
|
372
|
+
const stateIds = new Set(Array.isArray(root.states) ? root.states.map((state) => state.id) : []);
|
|
373
|
+
root.transitions.forEach((entry, index) => {
|
|
374
|
+
const path = 'transitions[' + index + ']';
|
|
375
|
+
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
|
|
376
|
+
bad(path, 'must be an object');
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
const transition = entry;
|
|
380
|
+
rejectUnknownKeys(transition, MODEL_KEYS.transition, path);
|
|
381
|
+
if (typeof transition.from !== 'string' || transition.from.length === 0)
|
|
382
|
+
bad(path + '.from', 'must be a non-empty string');
|
|
383
|
+
else if (!stateIds.has(transition.from))
|
|
384
|
+
bad(path + '.from', 'unknown state ' + transition.from);
|
|
385
|
+
if (typeof transition.event !== 'string' || transition.event.length === 0)
|
|
386
|
+
bad(path + '.event', 'must be a non-empty string');
|
|
387
|
+
if (typeof transition.to !== 'string' || transition.to.length === 0)
|
|
388
|
+
bad(path + '.to', 'must be a non-empty string');
|
|
389
|
+
else if (!stateIds.has(transition.to))
|
|
390
|
+
bad(path + '.to', 'unknown state ' + transition.to);
|
|
391
|
+
if (transition.guard !== undefined)
|
|
392
|
+
validateGuard(transition.guard, path + '.guard', errors, bad);
|
|
393
|
+
if (transition.updates !== undefined) {
|
|
394
|
+
if (!Array.isArray(transition.updates))
|
|
395
|
+
bad(path + '.updates', 'must be an array');
|
|
396
|
+
else
|
|
397
|
+
transition.updates.forEach((update, updateIndex) => {
|
|
398
|
+
const updatePath = path + '.updates[' + updateIndex + ']';
|
|
399
|
+
if (typeof update !== 'object' || update === null || Array.isArray(update)) {
|
|
400
|
+
bad(updatePath, 'must be an object');
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
const record = update;
|
|
404
|
+
rejectUnknownKeys(record, MODEL_KEYS.update, updatePath);
|
|
405
|
+
if (typeof record.variable !== 'string' || record.variable.length === 0)
|
|
406
|
+
bad(updatePath + '.variable', 'must be a non-empty string');
|
|
407
|
+
if (record.op !== 'set' && record.op !== 'inc' && record.op !== 'dec')
|
|
408
|
+
bad(updatePath + '.op', "must be 'set', 'inc', or 'dec'");
|
|
409
|
+
if (record.value !== undefined && (typeof record.value !== 'number' || !Number.isFinite(record.value))) {
|
|
410
|
+
// `typeof NaN === 'number'`, so the type check alone let a NaN through, and every
|
|
411
|
+
// comparison against a NaN is false: the update silently did nothing.
|
|
412
|
+
bad(updatePath + '.value', 'must be a finite number');
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
if (transition.cost !== undefined) {
|
|
417
|
+
if (typeof transition.cost !== 'number' || !Number.isFinite(transition.cost) || transition.cost < 0)
|
|
418
|
+
bad(path + '.cost', 'must be a non-negative finite number');
|
|
419
|
+
}
|
|
420
|
+
if (transition.weight !== undefined) {
|
|
421
|
+
if (typeof transition.weight !== 'number' || !Number.isFinite(transition.weight) || transition.weight < 0)
|
|
422
|
+
bad(path + '.weight', 'must be a non-negative finite number');
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
if (Array.isArray(root.states)) {
|
|
427
|
+
root.states.forEach((state, index) => {
|
|
428
|
+
if (state.maxTicks !== undefined && (!Number.isInteger(state.maxTicks) || state.maxTicks < 0)) {
|
|
429
|
+
bad('states[' + index + '].maxTicks', 'must be a non-negative integer');
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
const variableNames = new Set();
|
|
434
|
+
if (root.variables !== undefined) {
|
|
435
|
+
if (!Array.isArray(root.variables))
|
|
436
|
+
bad('variables', 'must be an array');
|
|
437
|
+
else
|
|
438
|
+
root.variables.forEach((entry, index) => {
|
|
439
|
+
const path = 'variables[' + index + ']';
|
|
440
|
+
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
|
|
441
|
+
bad(path, 'must be an object');
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
const variable = entry;
|
|
445
|
+
rejectUnknownKeys(variable, MODEL_KEYS.variable, path);
|
|
446
|
+
if (typeof variable.name !== 'string' || variable.name.length === 0)
|
|
447
|
+
bad(path + '.name', 'must be a non-empty string');
|
|
448
|
+
else if (variableNames.has(variable.name))
|
|
449
|
+
bad(path + '.name', 'duplicate variable ' + variable.name);
|
|
450
|
+
else
|
|
451
|
+
variableNames.add(variable.name);
|
|
452
|
+
if (variable.kind !== 'integer' && variable.kind !== 'boolean')
|
|
453
|
+
bad(path + '.kind', "must be 'integer' or 'boolean'");
|
|
454
|
+
const expected = variable.kind === 'boolean' ? 'boolean' : 'number';
|
|
455
|
+
if (typeof variable.init !== expected)
|
|
456
|
+
bad(path + '.init', 'must be a ' + expected);
|
|
457
|
+
// `typeof NaN === 'number'`, so the type check alone accepts NaN, and a variable initialised
|
|
458
|
+
// to NaN makes every guard over it false: the model would read as if that branch were dead.
|
|
459
|
+
else if (expected === 'number' && !Number.isFinite(variable.init))
|
|
460
|
+
bad(path + '.init', 'must be a finite number');
|
|
461
|
+
if (variable.min !== undefined && (typeof variable.min !== 'number' || !Number.isFinite(variable.min)))
|
|
462
|
+
bad(path + '.min', 'must be a finite number');
|
|
463
|
+
if (variable.max !== undefined && (typeof variable.max !== 'number' || !Number.isFinite(variable.max)))
|
|
464
|
+
bad(path + '.max', 'must be a finite number');
|
|
465
|
+
if (typeof variable.min === 'number' && typeof variable.max === 'number' && variable.min > variable.max)
|
|
466
|
+
bad(path + '.max', 'must be >= min');
|
|
467
|
+
if (variable.monotonic !== undefined && variable.monotonic !== 'inc' && variable.monotonic !== 'dec')
|
|
468
|
+
bad(path + '.monotonic', "must be 'inc' or 'dec'");
|
|
469
|
+
if (variable.kind === 'integer' && typeof variable.init === 'number') {
|
|
470
|
+
if (typeof variable.min === 'number' && variable.init < variable.min)
|
|
471
|
+
bad(path + '.init', 'must be >= min');
|
|
472
|
+
if (typeof variable.max === 'number' && variable.init > variable.max)
|
|
473
|
+
bad(path + '.init', 'must be <= max');
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
const validateVariableRef = (name, path) => {
|
|
478
|
+
if (typeof name !== 'string' || name.length === 0)
|
|
479
|
+
bad(path, 'must be a non-empty string');
|
|
480
|
+
else if (!variableNames.has(name))
|
|
481
|
+
bad(path, 'references unknown variable ' + name);
|
|
482
|
+
};
|
|
483
|
+
const invariantStateIds = new Set(Array.isArray(root.states) ? root.states.map((state) => state.id) : []);
|
|
484
|
+
// Reference sets for invariant targets: denormalising a kind's target into the
|
|
485
|
+
// report makes a mistyped id look authoritative, so every reference must resolve.
|
|
486
|
+
const invariantEventIds = new Set(Array.isArray(root.transitions) ? root.transitions.map((transition) => transition.event) : []);
|
|
487
|
+
if (root.invariants !== undefined) {
|
|
488
|
+
if (!Array.isArray(root.invariants))
|
|
489
|
+
bad('invariants', 'must be an array');
|
|
490
|
+
else
|
|
491
|
+
root.invariants.forEach((entry, index) => {
|
|
492
|
+
const path = 'invariants[' + index + ']';
|
|
493
|
+
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
|
|
494
|
+
bad(path, 'must be an object');
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
const invariant = entry;
|
|
498
|
+
if (typeof invariant.id !== 'string' || invariant.id.length === 0)
|
|
499
|
+
bad(path + '.id', 'must be a non-empty string');
|
|
500
|
+
if (typeof invariant.description !== 'string')
|
|
501
|
+
bad(path + '.description', 'must be a string');
|
|
502
|
+
// Reject keys the selected kind does not declare instead of silently ignoring
|
|
503
|
+
// them: an ignored field (e.g. a `when` scope on a kind that cannot honor it)
|
|
504
|
+
// still shows up in the echoed report and reads as if it took effect. Kinds an
|
|
505
|
+
// older reader may not know are skipped so they still fail on `.kind` alone.
|
|
506
|
+
if (KNOWN_INVARIANT_KINDS.has(invariant.kind)) {
|
|
507
|
+
const allowed = INVARIANT_KEYS[invariant.kind];
|
|
508
|
+
const unexpected = Object.keys(invariant).filter((key) => !allowed.has(key) && !key.startsWith('_'));
|
|
509
|
+
if (unexpected.length > 0) {
|
|
510
|
+
bad(path + '.' + unexpected[0], 'unknown field for kind ' + String(invariant.kind) + ' (allowed: ' + [...allowed].join(', ') + ')');
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
if (invariant.kind === 'never-states') {
|
|
514
|
+
if (!Array.isArray(invariant.states) || invariant.states.length === 0)
|
|
515
|
+
bad(path + '.states', 'must be a non-empty array');
|
|
516
|
+
else
|
|
517
|
+
invariant.states.forEach((state, stateIndex) => {
|
|
518
|
+
if (typeof state !== 'string' || state.length === 0)
|
|
519
|
+
bad(path + '.states[' + stateIndex + ']', 'must be a non-empty string');
|
|
520
|
+
else if (!invariantStateIds.has(state))
|
|
521
|
+
bad(path + '.states[' + stateIndex + ']', 'references unknown state ' + state);
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
else if (invariant.kind === 'var-in-range') {
|
|
525
|
+
validateVariableRef(invariant.variable, path + '.variable');
|
|
526
|
+
if (invariant.min !== undefined && typeof invariant.min !== 'number')
|
|
527
|
+
bad(path + '.min', 'must be a number');
|
|
528
|
+
if (invariant.max !== undefined && typeof invariant.max !== 'number')
|
|
529
|
+
bad(path + '.max', 'must be a number');
|
|
530
|
+
if (invariant.min === undefined && invariant.max === undefined)
|
|
531
|
+
bad(path, 'requires min or max (a range with neither bound is vacuous)');
|
|
532
|
+
if (invariant.when !== undefined) {
|
|
533
|
+
const when = invariant.when;
|
|
534
|
+
if (typeof when !== 'object' || when === null || Array.isArray(when)) {
|
|
535
|
+
bad(path + '.when', 'must be an object');
|
|
536
|
+
}
|
|
537
|
+
else if ('state' in when) {
|
|
538
|
+
const scope = when;
|
|
539
|
+
for (const key of Object.keys(scope)) {
|
|
540
|
+
if (key !== 'state' && !key.startsWith('_'))
|
|
541
|
+
bad(path + '.when.' + key, 'unknown field for a state scope (allowed: state)');
|
|
542
|
+
}
|
|
543
|
+
const state = scope.state;
|
|
544
|
+
if (typeof state !== 'string' || state.length === 0)
|
|
545
|
+
bad(path + '.when.state', 'must be a non-empty string');
|
|
546
|
+
else if (!invariantStateIds.has(state))
|
|
547
|
+
bad(path + '.when.state', 'references unknown state ' + state);
|
|
548
|
+
}
|
|
549
|
+
else {
|
|
550
|
+
validateGuard(when, path + '.when', errors, bad);
|
|
551
|
+
for (const variable of guardVariables(when)) {
|
|
552
|
+
if (variable === invariant.variable) {
|
|
553
|
+
bad(path + '.when', 'must not reference the constrained variable ' + invariant.variable + ' (it can mask its own violation)');
|
|
554
|
+
}
|
|
555
|
+
else {
|
|
556
|
+
validateVariableRef(variable, path + '.when');
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
else if (invariant.kind === 'event-before-state') {
|
|
563
|
+
if (typeof invariant.event !== 'string' || invariant.event.length === 0)
|
|
564
|
+
bad(path + '.event', 'must be a non-empty string');
|
|
565
|
+
else if (!invariantEventIds.has(invariant.event))
|
|
566
|
+
bad(path + '.event', 'references unknown event ' + invariant.event);
|
|
567
|
+
if (typeof invariant.state !== 'string' || invariant.state.length === 0)
|
|
568
|
+
bad(path + '.state', 'must be a non-empty string');
|
|
569
|
+
else if (!invariantStateIds.has(invariant.state))
|
|
570
|
+
bad(path + '.state', 'references unknown state ' + invariant.state);
|
|
571
|
+
}
|
|
572
|
+
else if (invariant.kind === 'leads-to') {
|
|
573
|
+
if (typeof invariant.from !== 'string' || invariant.from.length === 0)
|
|
574
|
+
bad(path + '.from', 'must be a non-empty string');
|
|
575
|
+
else if (!invariantStateIds.has(invariant.from))
|
|
576
|
+
bad(path + '.from', 'references unknown state ' + invariant.from);
|
|
577
|
+
// `to` is one state id or a target set. A set with a duplicate member is a
|
|
578
|
+
// model-authoring slip that would otherwise give one meaning two hashes, so it
|
|
579
|
+
// is rejected rather than silently deduplicated.
|
|
580
|
+
if (typeof invariant.to === 'string') {
|
|
581
|
+
if (invariant.to.length === 0)
|
|
582
|
+
bad(path + '.to', 'must be a non-empty string');
|
|
583
|
+
else if (!invariantStateIds.has(invariant.to))
|
|
584
|
+
bad(path + '.to', 'references unknown state ' + invariant.to);
|
|
585
|
+
}
|
|
586
|
+
else if (Array.isArray(invariant.to)) {
|
|
587
|
+
if (invariant.to.length === 0)
|
|
588
|
+
bad(path + '.to', 'must be a non-empty array of state ids');
|
|
589
|
+
else {
|
|
590
|
+
const targets = new Set();
|
|
591
|
+
invariant.to.forEach((target, targetIndex) => {
|
|
592
|
+
const at = path + '.to[' + targetIndex + ']';
|
|
593
|
+
if (typeof target !== 'string' || target.length === 0)
|
|
594
|
+
bad(at, 'must be a non-empty string');
|
|
595
|
+
else if (!invariantStateIds.has(target))
|
|
596
|
+
bad(at, 'references unknown state ' + target);
|
|
597
|
+
else if (targets.has(target))
|
|
598
|
+
bad(at, 'duplicates state ' + target + ' in the target set');
|
|
599
|
+
else
|
|
600
|
+
targets.add(target);
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
else {
|
|
605
|
+
bad(path + '.to', 'must be a state id or a non-empty array of state ids');
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
else if (invariant.kind === 'sequence') {
|
|
609
|
+
if (!Array.isArray(invariant.events) || invariant.events.length === 0)
|
|
610
|
+
bad(path + '.events', 'must be a non-empty array');
|
|
611
|
+
else
|
|
612
|
+
invariant.events.forEach((event, eventIndex) => {
|
|
613
|
+
if (typeof event !== 'string' || event.length === 0)
|
|
614
|
+
bad(path + '.events[' + eventIndex + ']', 'must be a non-empty string');
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
else if (invariant.kind === 'atomicity') {
|
|
618
|
+
if (!Array.isArray(invariant.events) || invariant.events.length === 0)
|
|
619
|
+
bad(path + '.events', 'must be a non-empty array');
|
|
620
|
+
else
|
|
621
|
+
invariant.events.forEach((event, eventIndex) => {
|
|
622
|
+
if (typeof event !== 'string' || event.length === 0)
|
|
623
|
+
bad(path + '.events[' + eventIndex + ']', 'must be a non-empty string');
|
|
624
|
+
});
|
|
625
|
+
if (typeof invariant.commit !== 'string' || invariant.commit.length === 0)
|
|
626
|
+
bad(path + '.commit', 'must be a non-empty string');
|
|
627
|
+
if (invariant.rollback !== undefined && typeof invariant.rollback !== 'string')
|
|
628
|
+
bad(path + '.rollback', 'must be a string');
|
|
629
|
+
}
|
|
630
|
+
else if (invariant.kind === 'budget') {
|
|
631
|
+
if (typeof invariant.budget !== 'number' || !Number.isFinite(invariant.budget) || invariant.budget < 0)
|
|
632
|
+
bad(path + '.budget', 'must be a non-negative finite number');
|
|
633
|
+
}
|
|
634
|
+
else if (invariant.kind === 'probability') {
|
|
635
|
+
if (typeof invariant.target !== 'string' || invariant.target.length === 0)
|
|
636
|
+
bad(path + '.target', 'must be a non-empty string');
|
|
637
|
+
else if (!invariantStateIds.has(invariant.target))
|
|
638
|
+
bad(path + '.target', 'references unknown state ' + invariant.target);
|
|
639
|
+
if (invariant.op !== '>=' && invariant.op !== '<=' && invariant.op !== '>' && invariant.op !== '<')
|
|
640
|
+
bad(path + '.op', "must be one of '>=', '<=', '>', '<'");
|
|
641
|
+
if (typeof invariant.p !== 'number' || !Number.isFinite(invariant.p) || invariant.p < 0 || invariant.p > 1)
|
|
642
|
+
bad(path + '.p', 'must be a number in [0, 1]');
|
|
643
|
+
}
|
|
644
|
+
else {
|
|
645
|
+
bad(path + '.kind', 'unknown invariant kind');
|
|
646
|
+
}
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
if (root.concurrentPairs !== undefined) {
|
|
650
|
+
if (!Array.isArray(root.concurrentPairs))
|
|
651
|
+
bad('concurrentPairs', 'must be an array');
|
|
652
|
+
else
|
|
653
|
+
root.concurrentPairs.forEach((entry, index) => {
|
|
654
|
+
const path = 'concurrentPairs[' + index + ']';
|
|
655
|
+
if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== 'string' || typeof entry[1] !== 'string') {
|
|
656
|
+
bad(path, 'must be a [event, event] string pair');
|
|
657
|
+
}
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
if (root.boundaryChecks !== undefined) {
|
|
661
|
+
if (!Array.isArray(root.boundaryChecks))
|
|
662
|
+
bad('boundaryChecks', 'must be an array');
|
|
663
|
+
else
|
|
664
|
+
root.boundaryChecks.forEach((entry, index) => {
|
|
665
|
+
const path = 'boundaryChecks[' + index + ']';
|
|
666
|
+
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
|
|
667
|
+
bad(path, 'must be an object');
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
const check = entry;
|
|
671
|
+
rejectUnknownKeys(check, MODEL_KEYS.boundaryCheck, path);
|
|
672
|
+
validateVariableRef(check.variable, path + '.variable');
|
|
673
|
+
if (!Array.isArray(check.values) || check.values.some((value) => typeof value !== 'number'))
|
|
674
|
+
bad(path + '.values', 'must be an array of numbers');
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
if (root.idempotentEvents !== undefined) {
|
|
678
|
+
if (!Array.isArray(root.idempotentEvents))
|
|
679
|
+
bad('idempotentEvents', 'must be an array');
|
|
680
|
+
else
|
|
681
|
+
root.idempotentEvents.forEach((entry, index) => {
|
|
682
|
+
if (typeof entry !== 'string' || entry.length === 0)
|
|
683
|
+
bad('idempotentEvents[' + index + ']', 'must be a non-empty string');
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
if (root.tickEvents !== undefined) {
|
|
687
|
+
if (!Array.isArray(root.tickEvents))
|
|
688
|
+
bad('tickEvents', 'must be an array');
|
|
689
|
+
else
|
|
690
|
+
root.tickEvents.forEach((entry, index) => {
|
|
691
|
+
if (typeof entry !== 'string' || entry.length === 0)
|
|
692
|
+
bad('tickEvents[' + index + ']', 'must be a non-empty string');
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
if (root.resourcePairs !== undefined) {
|
|
696
|
+
if (!Array.isArray(root.resourcePairs))
|
|
697
|
+
bad('resourcePairs', 'must be an array');
|
|
698
|
+
else
|
|
699
|
+
root.resourcePairs.forEach((entry, index) => {
|
|
700
|
+
const path = 'resourcePairs[' + index + ']';
|
|
701
|
+
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
|
|
702
|
+
bad(path, 'must be an object');
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
const pair = entry;
|
|
706
|
+
rejectUnknownKeys(pair, MODEL_KEYS.resourcePair, path);
|
|
707
|
+
if (typeof pair.resource !== 'string' || pair.resource.length === 0)
|
|
708
|
+
bad(path + '.resource', 'must be a non-empty string');
|
|
709
|
+
if (typeof pair.acquireEvent !== 'string' || pair.acquireEvent.length === 0)
|
|
710
|
+
bad(path + '.acquireEvent', 'must be a non-empty string');
|
|
711
|
+
if (typeof pair.releaseEvent !== 'string' || pair.releaseEvent.length === 0)
|
|
712
|
+
bad(path + '.releaseEvent', 'must be a non-empty string');
|
|
713
|
+
if (pair.failEvent !== undefined && typeof pair.failEvent !== 'string')
|
|
714
|
+
bad(path + '.failEvent', 'must be a string');
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
if (root.narrative !== undefined) {
|
|
718
|
+
const narrativePath = 'narrative';
|
|
719
|
+
if (typeof root.narrative !== 'object' || root.narrative === null || Array.isArray(root.narrative)) {
|
|
720
|
+
bad(narrativePath, 'must be an object');
|
|
721
|
+
}
|
|
722
|
+
else {
|
|
723
|
+
const narrative = root.narrative;
|
|
724
|
+
rejectUnknownKeys(narrative, MODEL_KEYS.narrative, narrativePath);
|
|
725
|
+
const stateIds = new Set(Array.isArray(root.states) ? root.states.map((state) => state.id) : []);
|
|
726
|
+
const eventIds = new Set(Array.isArray(root.transitions) ? root.transitions.map((transition) => transition.event) : []);
|
|
727
|
+
// A narrative is allowed to cover part of the model: states first, events and
|
|
728
|
+
// scenarios later is the natural authoring order, and `narrativeCoverage` reports
|
|
729
|
+
// what is still missing instead of rejecting the file. What stays an error is a
|
|
730
|
+
// narrative that is *wrong* (unknown id, empty description, duplicate scenario) or
|
|
731
|
+
// empty — an empty block claims documentation that does not exist.
|
|
732
|
+
const declaredDimensions = ['states', 'events', 'scenarios'].filter((key) => narrative[key] !== undefined);
|
|
733
|
+
if (declaredDimensions.length === 0) {
|
|
734
|
+
bad(narrativePath, 'declares no dimension: give at least one of states, events or scenarios (a partial narrative is fine)');
|
|
735
|
+
}
|
|
736
|
+
if (narrative.states !== undefined) {
|
|
737
|
+
if (typeof narrative.states !== 'object' || narrative.states === null || Array.isArray(narrative.states)) {
|
|
738
|
+
bad(narrativePath + '.states', 'must be an object mapping state id -> natural-language description');
|
|
739
|
+
}
|
|
740
|
+
else {
|
|
741
|
+
for (const [id, description] of Object.entries(narrative.states)) {
|
|
742
|
+
if (!stateIds.has(id))
|
|
743
|
+
bad(narrativePath + '.states', 'references unknown state ' + id);
|
|
744
|
+
if (typeof description !== 'string' || description.length === 0)
|
|
745
|
+
bad(narrativePath + '.states.' + id, 'must be a non-empty string');
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
if (narrative.events !== undefined) {
|
|
750
|
+
if (typeof narrative.events !== 'object' || narrative.events === null || Array.isArray(narrative.events)) {
|
|
751
|
+
bad(narrativePath + '.events', 'must be an object mapping event id -> natural-language description');
|
|
752
|
+
}
|
|
753
|
+
else {
|
|
754
|
+
for (const [id, description] of Object.entries(narrative.events)) {
|
|
755
|
+
if (!eventIds.has(id))
|
|
756
|
+
bad(narrativePath + '.events', 'references unknown event ' + id);
|
|
757
|
+
if (typeof description !== 'string' || description.length === 0)
|
|
758
|
+
bad(narrativePath + '.events.' + id, 'must be a non-empty string');
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
if (narrative.scenarios !== undefined) {
|
|
763
|
+
if (!Array.isArray(narrative.scenarios)) {
|
|
764
|
+
bad(narrativePath + '.scenarios', 'must be an array of { from, event, scenario }');
|
|
765
|
+
}
|
|
766
|
+
else {
|
|
767
|
+
const seen = new Set();
|
|
768
|
+
narrative.scenarios.forEach((entry, index) => {
|
|
769
|
+
const scenarioPath = narrativePath + '.scenarios[' + index + ']';
|
|
770
|
+
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
|
|
771
|
+
bad(scenarioPath, 'must be an object');
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
const scenario = entry;
|
|
775
|
+
rejectUnknownKeys(scenario, MODEL_KEYS.scenario, scenarioPath);
|
|
776
|
+
if (typeof scenario.from !== 'string' || scenario.from.length === 0)
|
|
777
|
+
bad(scenarioPath + '.from', 'must be a non-empty string');
|
|
778
|
+
else if (!stateIds.has(scenario.from))
|
|
779
|
+
bad(scenarioPath + '.from', 'unknown state ' + scenario.from);
|
|
780
|
+
if (typeof scenario.event !== 'string' || scenario.event.length === 0)
|
|
781
|
+
bad(scenarioPath + '.event', 'must be a non-empty string');
|
|
782
|
+
else if (!eventIds.has(scenario.event))
|
|
783
|
+
bad(scenarioPath + '.event', 'unknown event ' + scenario.event);
|
|
784
|
+
if (typeof scenario.scenario !== 'string' || scenario.scenario.length === 0)
|
|
785
|
+
bad(scenarioPath + '.scenario', 'must be a non-empty string');
|
|
786
|
+
const key = String(scenario.from) + '|' + String(scenario.event);
|
|
787
|
+
if (seen.has(key))
|
|
788
|
+
bad(scenarioPath, 'duplicate scenario for (' + scenario.from + ', ' + scenario.event + ')');
|
|
789
|
+
seen.add(key);
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
// Guard/update variable references were validated structurally; re-walk for references.
|
|
796
|
+
if (Array.isArray(root.transitions)) {
|
|
797
|
+
for (const entry of root.transitions) {
|
|
798
|
+
walkGuardReferences(entry.guard, variableNames, errors, bad);
|
|
799
|
+
for (const update of entry.updates ?? []) {
|
|
800
|
+
if (!variableNames.has(update.variable))
|
|
801
|
+
bad('transitions.updates', 'references unknown variable ' + update.variable);
|
|
802
|
+
if (update.op !== 'set' && update.value !== undefined && update.value === 0) {
|
|
803
|
+
// zero delta is harmless; no finding
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
if (errors.length > 0)
|
|
809
|
+
return { ok: false, errors };
|
|
810
|
+
return { ok: true, model: root };
|
|
811
|
+
}
|
|
812
|
+
/**
|
|
813
|
+
* Event names in the adversarial declarations that no transition and no entry/exit action
|
|
814
|
+
* declares.
|
|
815
|
+
*
|
|
816
|
+
* The robustness audit called this the dominant defect class: a misspelled name made a check
|
|
817
|
+
* skip and then report its all-clear - "All pairs balanced", "No race conditions detected", "All
|
|
818
|
+
* deadlines respected" - for a model it never ran on. A reader cannot tell an empty check from a
|
|
819
|
+
* passing one, which is what a verifier must never do.
|
|
820
|
+
*
|
|
821
|
+
* A name that resolves to nothing is reported by the check that would have used it, not refused
|
|
822
|
+
* by validation, because for some fields the absence IS the defect: a
|
|
823
|
+
* `resourcePairs[].releaseEvent` naming a step nobody built is what `A4_NO_RELEASE_EVENT` says,
|
|
824
|
+
* and refusing it would replace a finding that names the missing step with a generic one.
|
|
825
|
+
*/
|
|
826
|
+
export function unresolvedEventNames(root) {
|
|
827
|
+
const declared = new Set(Array.isArray(root.transitions) ? root.transitions.map((transition) => transition.event) : []);
|
|
828
|
+
// `onEntry`/`onExit` actions count: A4 treats them as implicit acquire/release events, so a
|
|
829
|
+
// lock modelled as an entry action is declared even though no transition names it.
|
|
830
|
+
if (Array.isArray(root.states)) {
|
|
831
|
+
for (const state of root.states) {
|
|
832
|
+
for (const action of state.onEntry ?? [])
|
|
833
|
+
declared.add(action);
|
|
834
|
+
for (const action of state.onExit ?? [])
|
|
835
|
+
declared.add(action);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
const found = [];
|
|
839
|
+
const note = (value, path, check) => {
|
|
840
|
+
if (typeof value !== 'string' || declared.has(value))
|
|
841
|
+
return;
|
|
842
|
+
found.push({ path, value, check });
|
|
843
|
+
};
|
|
844
|
+
if (Array.isArray(root.concurrentPairs)) {
|
|
845
|
+
root.concurrentPairs.forEach((entry, index) => {
|
|
846
|
+
if (!Array.isArray(entry) || entry.length !== 2)
|
|
847
|
+
return;
|
|
848
|
+
note(entry[0], 'concurrentPairs[' + String(index) + '][0]', 'A2');
|
|
849
|
+
note(entry[1], 'concurrentPairs[' + String(index) + '][1]', 'A2');
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
if (Array.isArray(root.idempotentEvents)) {
|
|
853
|
+
root.idempotentEvents.forEach((entry, index) => note(entry, 'idempotentEvents[' + String(index) + ']', 'A8'));
|
|
854
|
+
}
|
|
855
|
+
if (Array.isArray(root.tickEvents)) {
|
|
856
|
+
root.tickEvents.forEach((entry, index) => note(entry, 'tickEvents[' + String(index) + ']', 'A14'));
|
|
857
|
+
}
|
|
858
|
+
if (Array.isArray(root.resourcePairs)) {
|
|
859
|
+
root.resourcePairs.forEach((entry, index) => {
|
|
860
|
+
if (typeof entry !== 'object' || entry === null || Array.isArray(entry))
|
|
861
|
+
return;
|
|
862
|
+
const pair = entry;
|
|
863
|
+
const path = 'resourcePairs[' + String(index) + ']';
|
|
864
|
+
note(pair.acquireEvent, path + '.acquireEvent', 'A4');
|
|
865
|
+
note(pair.releaseEvent, path + '.releaseEvent', 'A4');
|
|
866
|
+
note(pair.failEvent, path + '.failEvent', 'A6');
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
return found;
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* The check's own findings, plus a note for each declared event name it did not already account
|
|
873
|
+
* for.
|
|
874
|
+
*
|
|
875
|
+
* The severity is a warning, and that is a measured decision rather than a hedge. Three shapes
|
|
876
|
+
* exist, and the repository's own textbook cases pin all three:
|
|
877
|
+
*
|
|
878
|
+
* - the check can still run and reports the defect itself (`A4_NO_RELEASE_EVENT`,
|
|
879
|
+
* `A6_NO_FAILURE_HANDLER`): the check's code owns it and the sweep stays silent. The textbook
|
|
880
|
+
* semaphore case declares `failEvent: 'timeout'`, a name that exists nowhere, and expects A6
|
|
881
|
+
* to fire with no error - because "there is no timeout handler" is the defect;
|
|
882
|
+
* - the check cannot run at all because the name resolved to nothing (A2 with a misnamed pair,
|
|
883
|
+
* A8, A14, and A4's acquire event): its `detail` would otherwise read "No race conditions
|
|
884
|
+
* detected" beside a `fail` status, and the finding supplies what the `detail` cannot;
|
|
885
|
+
* - a check with nothing to run on for other reasons is a `skip`, and stays one.
|
|
886
|
+
*
|
|
887
|
+
* Raising it to an error would make a working model unshippable for a merely sloppy declaration,
|
|
888
|
+
* which is a different defect from a check that lied about what it examined.
|
|
889
|
+
*/
|
|
890
|
+
function withUnresolved(model, check, findings) {
|
|
891
|
+
const accounted = new Set();
|
|
892
|
+
for (const finding of findings) {
|
|
893
|
+
const event = finding.evidence?.event;
|
|
894
|
+
if (typeof event === 'string')
|
|
895
|
+
accounted.add(event);
|
|
896
|
+
}
|
|
897
|
+
const extra = unresolvedEventNames(model)
|
|
898
|
+
.filter((entry) => entry.check === check && !accounted.has(entry.value))
|
|
899
|
+
.map((entry) => ({
|
|
900
|
+
code: 'MODEL_UNRESOLVED_EVENT',
|
|
901
|
+
severity: 'warning',
|
|
902
|
+
message: entry.path + ': no transition and no entry/exit action declares ' + JSON.stringify(entry.value) + ', so ' + entry.check + ' had nothing to run on it. Declare the event, or remove the declaration if it is a typo',
|
|
903
|
+
detail: 'While a declared name resolves to nothing, this check reports a result it did not earn. The rest of the report is unaffected: the name is a declaration defect, not a model defect.',
|
|
904
|
+
evidence: { path: entry.path, event: entry.value, check: entry.check },
|
|
905
|
+
}));
|
|
906
|
+
return extra.length === 0 ? findings : [...findings, ...extra];
|
|
907
|
+
}
|
|
908
|
+
/**
|
|
909
|
+
* How deeply a guard may nest.
|
|
910
|
+
*
|
|
911
|
+
* A guard is a boolean expression a person writes and reads. The limit is a structural prior
|
|
912
|
+
* rather than a runtime catch: the depth is decided while walking, so the walk must not itself be
|
|
913
|
+
* recursive - a recursive validator overflowed the stack BEFORE it could reach a depth of thirty
|
|
914
|
+
* two, which is the defect this limit was meant to prevent.
|
|
915
|
+
*/
|
|
916
|
+
const MAX_GUARD_DEPTH = 32;
|
|
917
|
+
/**
|
|
918
|
+
* Validate a guard tree, iteratively.
|
|
919
|
+
*
|
|
920
|
+
* The walk uses an explicit stack for the reason above: recursion here would make the depth limit
|
|
921
|
+
* unreachable, and a guard nested ten thousand deep has to produce the refusal below rather than a
|
|
922
|
+
* `RangeError` the caller cannot route on.
|
|
923
|
+
*/
|
|
924
|
+
function validateGuard(input, path, errors, bad) {
|
|
925
|
+
const stack = [{ node: input, path, depth: 0 }];
|
|
926
|
+
while (stack.length > 0) {
|
|
927
|
+
const frame = stack.pop();
|
|
928
|
+
const node = frame.node;
|
|
929
|
+
if (frame.depth > MAX_GUARD_DEPTH) {
|
|
930
|
+
bad(frame.path, 'nests more than ' + String(MAX_GUARD_DEPTH) + ' levels deep. A guard is a boolean expression over model variables; one this deep is not read by a person.');
|
|
931
|
+
continue;
|
|
932
|
+
}
|
|
933
|
+
if (typeof node !== 'object' || node === null || Array.isArray(node)) {
|
|
934
|
+
bad(frame.path, 'must be an object');
|
|
935
|
+
continue;
|
|
936
|
+
}
|
|
937
|
+
const guard = node;
|
|
938
|
+
if (guard.variable !== undefined) {
|
|
939
|
+
if (typeof guard.variable !== 'string' || guard.variable.length === 0)
|
|
940
|
+
bad(frame.path + '.variable', 'must be a non-empty string');
|
|
941
|
+
if (guard.op !== '==' && guard.op !== '!=' && guard.op !== '<' && guard.op !== '<=' && guard.op !== '>' && guard.op !== '>=') {
|
|
942
|
+
bad(frame.path + '.op', 'must be a comparison operator');
|
|
943
|
+
}
|
|
944
|
+
if (typeof guard.value !== 'number' && typeof guard.value !== 'boolean')
|
|
945
|
+
bad(frame.path + '.value', 'must be a number or boolean');
|
|
946
|
+
// A NaN operand makes every comparison false, so the branch would read as dead and the model
|
|
947
|
+
// would say it has a condition it can never satisfy.
|
|
948
|
+
else if (typeof guard.value === 'number' && !Number.isFinite(guard.value))
|
|
949
|
+
bad(frame.path + '.value', 'must be a finite number');
|
|
950
|
+
continue;
|
|
951
|
+
}
|
|
952
|
+
let pushed = false;
|
|
953
|
+
for (const key of ['all', 'any']) {
|
|
954
|
+
const children = guard[key];
|
|
955
|
+
if (children === undefined)
|
|
956
|
+
continue;
|
|
957
|
+
pushed = true;
|
|
958
|
+
if (!Array.isArray(children)) {
|
|
959
|
+
bad(frame.path + '.' + key, 'must be an array');
|
|
960
|
+
break;
|
|
961
|
+
}
|
|
962
|
+
if (children.length === 0) {
|
|
963
|
+
// Vacuously true for `all`, vacuously false for `any`: the first makes a guarded transition
|
|
964
|
+
// unconditional, the second makes a live branch dead.
|
|
965
|
+
bad(frame.path + '.' + key, key === 'all'
|
|
966
|
+
? 'must not be empty: an empty conjunction is always true, which would make this transition unconditional while it reads as guarded'
|
|
967
|
+
: 'must not be empty: an empty disjunction is always false, so this transition can never fire');
|
|
968
|
+
break;
|
|
969
|
+
}
|
|
970
|
+
for (let index = children.length - 1; index >= 0; index -= 1) {
|
|
971
|
+
stack.push({ node: children[index], path: frame.path + '.' + key + '[' + String(index) + ']', depth: frame.depth + 1 });
|
|
972
|
+
}
|
|
973
|
+
break;
|
|
974
|
+
}
|
|
975
|
+
if (pushed)
|
|
976
|
+
continue;
|
|
977
|
+
if (guard.not !== undefined) {
|
|
978
|
+
stack.push({ node: guard.not, path: frame.path + '.not', depth: frame.depth + 1 });
|
|
979
|
+
continue;
|
|
980
|
+
}
|
|
981
|
+
bad(frame.path, 'must be a leaf ({ variable, op, value }), { all }, { any }, or { not }');
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
/**
|
|
985
|
+
* Every variable a guard names.
|
|
986
|
+
*
|
|
987
|
+
* Total over arbitrary JSON, and iterative for the same reason `validateGuard` is: this runs while
|
|
988
|
+
* validating, so a recursive walk overflows the stack on a deeply nested guard and the caller gets a
|
|
989
|
+
* `RangeError` instead of the refusal that says the guard is too deep. A node it does not recognise
|
|
990
|
+
* contributes no references, which is the safe direction - the guard is already an error, so nothing
|
|
991
|
+
* is decided from it either way.
|
|
992
|
+
*/
|
|
993
|
+
function isGuardObject(value) {
|
|
994
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
995
|
+
}
|
|
996
|
+
function walkGuardReferences(guard, variableNames, errors, bad) {
|
|
997
|
+
const stack = [guard];
|
|
998
|
+
while (stack.length > 0) {
|
|
999
|
+
const node = stack.pop();
|
|
1000
|
+
if (node === undefined)
|
|
1001
|
+
continue;
|
|
1002
|
+
if (!isGuardObject(node))
|
|
1003
|
+
continue;
|
|
1004
|
+
if (node.variable !== undefined) {
|
|
1005
|
+
if (typeof node.variable !== 'string')
|
|
1006
|
+
continue;
|
|
1007
|
+
if (!variableNames.has(node.variable))
|
|
1008
|
+
bad('guard', 'references unknown variable ' + node.variable);
|
|
1009
|
+
continue;
|
|
1010
|
+
}
|
|
1011
|
+
let descended = false;
|
|
1012
|
+
for (const key of ['all', 'any']) {
|
|
1013
|
+
const children = node[key];
|
|
1014
|
+
if (children === undefined)
|
|
1015
|
+
continue;
|
|
1016
|
+
descended = true;
|
|
1017
|
+
// An empty array is the degenerate answer - true for `all`, false for `any` - and
|
|
1018
|
+
// `validateGuard` refuses it. Here it simply carries no references.
|
|
1019
|
+
if (!Array.isArray(children))
|
|
1020
|
+
break;
|
|
1021
|
+
for (const child of children)
|
|
1022
|
+
stack.push(child);
|
|
1023
|
+
break;
|
|
1024
|
+
}
|
|
1025
|
+
if (descended)
|
|
1026
|
+
continue;
|
|
1027
|
+
if (node.not !== undefined)
|
|
1028
|
+
stack.push(node.not);
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
/**
|
|
1032
|
+
* Every variable a guard names, total and iterative for the same reasons as
|
|
1033
|
+
* `walkGuardReferences`.
|
|
1034
|
+
*/
|
|
1035
|
+
export function guardVariables(guard) {
|
|
1036
|
+
const found = [];
|
|
1037
|
+
const stack = [guard];
|
|
1038
|
+
while (stack.length > 0) {
|
|
1039
|
+
const node = stack.pop();
|
|
1040
|
+
if (node === undefined)
|
|
1041
|
+
continue;
|
|
1042
|
+
if (!isGuardObject(node))
|
|
1043
|
+
continue;
|
|
1044
|
+
if (typeof node.variable === 'string') {
|
|
1045
|
+
found.push(node.variable);
|
|
1046
|
+
continue;
|
|
1047
|
+
}
|
|
1048
|
+
let descended = false;
|
|
1049
|
+
for (const key of ['all', 'any']) {
|
|
1050
|
+
const children = node[key];
|
|
1051
|
+
if (children === undefined)
|
|
1052
|
+
continue;
|
|
1053
|
+
descended = true;
|
|
1054
|
+
if (!Array.isArray(children))
|
|
1055
|
+
break;
|
|
1056
|
+
for (const child of children)
|
|
1057
|
+
stack.push(child);
|
|
1058
|
+
break;
|
|
1059
|
+
}
|
|
1060
|
+
if (descended)
|
|
1061
|
+
continue;
|
|
1062
|
+
if (node.not !== undefined)
|
|
1063
|
+
stack.push(node.not);
|
|
1064
|
+
}
|
|
1065
|
+
return found;
|
|
1066
|
+
}
|
|
1067
|
+
/**
|
|
1068
|
+
* Whether a guard holds, TOTAL over arbitrary JSON.
|
|
1069
|
+
*
|
|
1070
|
+
* A node that is not one of the four shapes is a guard that is never true, so a malformed guard
|
|
1071
|
+
* cannot fire a transition. Safely false is the right answer rather than a throw: the validator has
|
|
1072
|
+
* already refused the model, and a review that crashes tells the caller less than a review that
|
|
1073
|
+
* names the field that is wrong.
|
|
1074
|
+
*/
|
|
1075
|
+
function evalGuard(guard, vars) {
|
|
1076
|
+
if (!isGuardObject(guard))
|
|
1077
|
+
return false;
|
|
1078
|
+
if (typeof guard.variable === 'string') {
|
|
1079
|
+
const actual = vars[guard.variable];
|
|
1080
|
+
switch (guard.op) {
|
|
1081
|
+
case '==': return actual === guard.value;
|
|
1082
|
+
case '!=': return actual !== guard.value;
|
|
1083
|
+
case '<': return actual < guard.value;
|
|
1084
|
+
case '<=': return actual <= guard.value;
|
|
1085
|
+
case '>': return actual > guard.value;
|
|
1086
|
+
case '>=': return actual >= guard.value;
|
|
1087
|
+
}
|
|
1088
|
+
return false;
|
|
1089
|
+
}
|
|
1090
|
+
for (const key of ['all', 'any']) {
|
|
1091
|
+
const children = guard[key];
|
|
1092
|
+
if (children === undefined)
|
|
1093
|
+
continue;
|
|
1094
|
+
// An empty conjunction is true and an empty disjunction is false, which is also what
|
|
1095
|
+
// every/some do. The validator refuses both; this keeps the evaluator total for a model that
|
|
1096
|
+
// arrived anyway.
|
|
1097
|
+
if (!Array.isArray(children))
|
|
1098
|
+
return false;
|
|
1099
|
+
return key === 'all'
|
|
1100
|
+
? children.every((child) => evalGuard(child, vars))
|
|
1101
|
+
: children.some((child) => evalGuard(child, vars));
|
|
1102
|
+
}
|
|
1103
|
+
if (guard.not !== undefined)
|
|
1104
|
+
return !evalGuard(guard.not, vars);
|
|
1105
|
+
return false;
|
|
1106
|
+
}
|
|
1107
|
+
function initialState(model) {
|
|
1108
|
+
const vars = {};
|
|
1109
|
+
for (const variable of model.variables ?? [])
|
|
1110
|
+
vars[variable.name] = variable.init;
|
|
1111
|
+
return { state: model.init, vars };
|
|
1112
|
+
}
|
|
1113
|
+
function runtimeKey(runtime) {
|
|
1114
|
+
return runtime.state + '|' + stableStringify(runtime.vars);
|
|
1115
|
+
}
|
|
1116
|
+
function applyUpdates(model, transition, runtime) {
|
|
1117
|
+
const vars = { ...runtime.vars };
|
|
1118
|
+
for (const update of transition.updates ?? []) {
|
|
1119
|
+
const current = vars[update.variable];
|
|
1120
|
+
if (update.op === 'set')
|
|
1121
|
+
vars[update.variable] = update.value ?? 0;
|
|
1122
|
+
else if (update.op === 'inc')
|
|
1123
|
+
vars[update.variable] = ((typeof current === 'number' ? current : 0) + (update.value ?? 1));
|
|
1124
|
+
else
|
|
1125
|
+
vars[update.variable] = ((typeof current === 'number' ? current : 0) - (update.value ?? 1));
|
|
1126
|
+
}
|
|
1127
|
+
return { state: transition.to, vars };
|
|
1128
|
+
}
|
|
1129
|
+
function groupTransitions(model) {
|
|
1130
|
+
const groups = new Map();
|
|
1131
|
+
for (const transition of model.transitions) {
|
|
1132
|
+
const key = transition.from + '|' + transition.event;
|
|
1133
|
+
const list = groups.get(key) ?? [];
|
|
1134
|
+
list.push(transition);
|
|
1135
|
+
groups.set(key, list);
|
|
1136
|
+
}
|
|
1137
|
+
return groups;
|
|
1138
|
+
}
|
|
1139
|
+
function applicableTransitions(group, runtime) {
|
|
1140
|
+
const guarded = [];
|
|
1141
|
+
const unguarded = [];
|
|
1142
|
+
for (const transition of group) {
|
|
1143
|
+
if (transition.guard === undefined)
|
|
1144
|
+
unguarded.push(transition);
|
|
1145
|
+
else
|
|
1146
|
+
guarded.push(transition);
|
|
1147
|
+
}
|
|
1148
|
+
const matched = guarded.filter((transition) => evalGuard(transition.guard, runtime.vars));
|
|
1149
|
+
if (matched.length > 0)
|
|
1150
|
+
return matched;
|
|
1151
|
+
return unguarded;
|
|
1152
|
+
}
|
|
1153
|
+
function stepRuntime(model, runtime, event) {
|
|
1154
|
+
const group = groupTransitions(model).get(runtime.state + '|' + event);
|
|
1155
|
+
if (group === undefined || group.length === 0)
|
|
1156
|
+
return [];
|
|
1157
|
+
const seen = new Set();
|
|
1158
|
+
const outcomes = [];
|
|
1159
|
+
for (const transition of applicableTransitions(group, runtime)) {
|
|
1160
|
+
const next = applyUpdates(model, transition, runtime);
|
|
1161
|
+
const key = runtimeKey(next);
|
|
1162
|
+
if (!seen.has(key)) {
|
|
1163
|
+
seen.add(key);
|
|
1164
|
+
outcomes.push(next);
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
return outcomes;
|
|
1168
|
+
}
|
|
1169
|
+
function explore(model, options) {
|
|
1170
|
+
const init = initialState(model);
|
|
1171
|
+
const reachable = [];
|
|
1172
|
+
const reachableKeys = new Set();
|
|
1173
|
+
const queue = [init];
|
|
1174
|
+
let truncated = false;
|
|
1175
|
+
while (queue.length > 0) {
|
|
1176
|
+
const runtime = queue.shift();
|
|
1177
|
+
const key = runtimeKey(runtime);
|
|
1178
|
+
if (reachableKeys.has(key))
|
|
1179
|
+
continue;
|
|
1180
|
+
reachableKeys.add(key);
|
|
1181
|
+
reachable.push(runtime);
|
|
1182
|
+
if (reachable.length > options.maxStates) {
|
|
1183
|
+
truncated = true;
|
|
1184
|
+
break;
|
|
1185
|
+
}
|
|
1186
|
+
for (const event of allEvents(model)) {
|
|
1187
|
+
for (const next of stepRuntime(model, runtime, event)) {
|
|
1188
|
+
if (!reachableKeys.has(runtimeKey(next)))
|
|
1189
|
+
queue.push(next);
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
return { reachable, reachableKeys, truncated, initialState: init };
|
|
1194
|
+
}
|
|
1195
|
+
function allEvents(model) {
|
|
1196
|
+
return [...new Set(model.transitions.map((transition) => transition.event))].sort();
|
|
1197
|
+
}
|
|
1198
|
+
function stateById(model, id) {
|
|
1199
|
+
return model.states.find((state) => state.id === id);
|
|
1200
|
+
}
|
|
1201
|
+
function isTerminal(model, state) {
|
|
1202
|
+
return stateById(model, state)?.terminal === true;
|
|
1203
|
+
}
|
|
1204
|
+
function uniqueTargets(transitions) {
|
|
1205
|
+
return [...new Set(transitions.map((transition) => transition.to))];
|
|
1206
|
+
}
|
|
1207
|
+
/**
|
|
1208
|
+
* What a check would have needed in order to examine anything, or `undefined` if it had it.
|
|
1209
|
+
*
|
|
1210
|
+
* The verification family had no way to say "I did not look": a check whose vocabulary was empty
|
|
1211
|
+
* returned `pass` with a detail like "No budget invariants declared", and `summary.checksRun` counted
|
|
1212
|
+
* it as run. The design family has always reported `skip` for the same situation, and this file's own
|
|
1213
|
+
* doctrine says a check with nothing to run on "is a `skip`, and stays one" - so `pass` here was the
|
|
1214
|
+
* false green the repository forbids, not a stylistic difference.
|
|
1215
|
+
*
|
|
1216
|
+
* The table is one place on purpose. Eleven return sites across eleven functions would each have to
|
|
1217
|
+
* remember, and the twelfth would be the one that forgot. A check that produced a finding is never
|
|
1218
|
+
* turned into a skip: the finding is evidence that something was examined, and a name that resolved to
|
|
1219
|
+
* nothing is exactly the case where the check cannot run but must still fail loudly.
|
|
1220
|
+
*/
|
|
1221
|
+
function nothingToExamine(id, model) {
|
|
1222
|
+
const invariantsOfKind = (kind) => (model.invariants ?? []).filter((invariant) => invariant.kind === kind).length;
|
|
1223
|
+
switch (id) {
|
|
1224
|
+
case 'S7':
|
|
1225
|
+
// S7 decides state predicates. A roster of path-property invariants alone leaves it nothing to decide,
|
|
1226
|
+
// and "All invariants hold" over that roster is a pass over nothing: the verdict belongs to A9-A13.
|
|
1227
|
+
return (model.invariants ?? []).filter((invariant) => !isPathPropertyInvariant(invariant)).length === 0
|
|
1228
|
+
? 'no never-states or var-in-range invariant was declared'
|
|
1229
|
+
: undefined;
|
|
1230
|
+
case 'A7':
|
|
1231
|
+
return (model.invariants ?? []).length === 0 ? 'no invariants were declared' : undefined;
|
|
1232
|
+
case 'A2':
|
|
1233
|
+
case 'A4':
|
|
1234
|
+
return (model.concurrentPairs ?? []).length === 0 ? 'no concurrent pairs were declared' : undefined;
|
|
1235
|
+
case 'A3': {
|
|
1236
|
+
const events = new Set(model.transitions.map((transition) => transition.event));
|
|
1237
|
+
return events.size < 2 ? 'fewer than two distinct events to permute' : undefined;
|
|
1238
|
+
}
|
|
1239
|
+
case 'A5':
|
|
1240
|
+
return (model.boundaryChecks ?? []).length === 0 ? 'no boundary checks were declared' : undefined;
|
|
1241
|
+
case 'A6':
|
|
1242
|
+
return (model.resourcePairs ?? []).length === 0 ? 'no resource pairs were declared' : undefined;
|
|
1243
|
+
case 'A8':
|
|
1244
|
+
return (model.idempotentEvents ?? []).length === 0 ? 'no idempotent events were declared' : undefined;
|
|
1245
|
+
case 'S8':
|
|
1246
|
+
return (model.variables ?? []).length === 0 ? 'no variables were declared' : undefined;
|
|
1247
|
+
case 'S6':
|
|
1248
|
+
return model.transitions.some((transition) => transition.guard !== undefined) ? undefined : 'no transition declares a guard';
|
|
1249
|
+
case 'A9':
|
|
1250
|
+
return invariantsOfKind('leads-to') === 0 ? 'no leads-to invariant was declared' : undefined;
|
|
1251
|
+
case 'A10':
|
|
1252
|
+
return invariantsOfKind('sequence') === 0 ? 'no sequence invariant was declared' : undefined;
|
|
1253
|
+
case 'A11':
|
|
1254
|
+
return invariantsOfKind('atomicity') === 0 ? 'no atomicity invariant was declared' : undefined;
|
|
1255
|
+
case 'A12':
|
|
1256
|
+
return invariantsOfKind('budget') === 0 ? 'no budget invariant was declared' : undefined;
|
|
1257
|
+
case 'A13':
|
|
1258
|
+
return invariantsOfKind('probability') === 0 ? 'no probability invariant was declared' : undefined;
|
|
1259
|
+
case 'A14':
|
|
1260
|
+
return model.states.some((state) => state.maxTicks !== undefined) ? undefined : 'no state declares maxTicks';
|
|
1261
|
+
default:
|
|
1262
|
+
return undefined;
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
function checkResult(id, name, findings, detail) {
|
|
1266
|
+
const errors = findings.filter((finding) => finding.severity === 'error').length;
|
|
1267
|
+
const warnings = findings.filter((finding) => finding.severity === 'warning').length;
|
|
1268
|
+
return {
|
|
1269
|
+
id,
|
|
1270
|
+
name,
|
|
1271
|
+
// An info finding is a note, not a defect: it must not flip a check to `fail`, or the
|
|
1272
|
+
// check would be dragged into the "resolve the failing checks" list for a remark.
|
|
1273
|
+
status: errors + warnings === 0 ? 'pass' : 'fail',
|
|
1274
|
+
detail: detail + (errors > 0 ? ' (' + errors + ' errors' + (warnings > 0 ? ', ' + warnings + ' warnings' : '') + ')' : warnings > 0 ? ' (' + warnings + ' warnings)' : ''),
|
|
1275
|
+
findings,
|
|
1276
|
+
};
|
|
1277
|
+
}
|
|
1278
|
+
// ---------------------------------------------------------------------------
|
|
1279
|
+
// S1-S8
|
|
1280
|
+
// ---------------------------------------------------------------------------
|
|
1281
|
+
function S1_reachability(model, exploration) {
|
|
1282
|
+
const reached = new Set(exploration.reachable.map((runtime) => runtime.state));
|
|
1283
|
+
const unreachable = model.states.filter((state) => !reached.has(state.id)).map((state) => state.id);
|
|
1284
|
+
const findings = unreachable.map((state) => ({
|
|
1285
|
+
code: 'S1_UNREACHABLE_STATE',
|
|
1286
|
+
severity: 'warning',
|
|
1287
|
+
message: 'State ' + state + ' is not reachable from init.',
|
|
1288
|
+
}));
|
|
1289
|
+
return checkResult('S1', 'Reachability', findings, unreachable.length === 0 ? 'All states reachable' : 'Unreachable states: ' + unreachable.join(', '));
|
|
1290
|
+
}
|
|
1291
|
+
function S2_deadlock(model) {
|
|
1292
|
+
const outgoing = new Set(model.transitions.map((transition) => transition.from));
|
|
1293
|
+
const findings = [];
|
|
1294
|
+
for (const state of model.states) {
|
|
1295
|
+
if (state.terminal === true)
|
|
1296
|
+
continue;
|
|
1297
|
+
if (!outgoing.has(state.id)) {
|
|
1298
|
+
findings.push({
|
|
1299
|
+
code: 'S2_NO_TRANSITIONS',
|
|
1300
|
+
severity: 'error',
|
|
1301
|
+
message: 'Non-terminal state ' + state.id + ' has no outgoing transitions.',
|
|
1302
|
+
});
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
return checkResult('S2', 'Deadlock', findings, findings.length === 0 ? 'No deadlocks' : 'Deadlocks: ' + findings.map((finding) => finding.message).join('; '));
|
|
1306
|
+
}
|
|
1307
|
+
function sccs(model) {
|
|
1308
|
+
const states = model.states.map((state) => state.id);
|
|
1309
|
+
const edges = new Map();
|
|
1310
|
+
for (const state of states)
|
|
1311
|
+
edges.set(state, []);
|
|
1312
|
+
for (const transition of model.transitions) {
|
|
1313
|
+
const list = edges.get(transition.from);
|
|
1314
|
+
if (list !== undefined)
|
|
1315
|
+
list.push(transition.to);
|
|
1316
|
+
}
|
|
1317
|
+
let index = 0;
|
|
1318
|
+
const indices = new Map();
|
|
1319
|
+
const low = new Map();
|
|
1320
|
+
const onStack = new Set();
|
|
1321
|
+
const stack = [];
|
|
1322
|
+
const components = [];
|
|
1323
|
+
const visit = (node) => {
|
|
1324
|
+
indices.set(node, index);
|
|
1325
|
+
low.set(node, index);
|
|
1326
|
+
index += 1;
|
|
1327
|
+
stack.push(node);
|
|
1328
|
+
onStack.add(node);
|
|
1329
|
+
for (const next of edges.get(node) ?? []) {
|
|
1330
|
+
if (!indices.has(next)) {
|
|
1331
|
+
visit(next);
|
|
1332
|
+
low.set(node, Math.min(low.get(node), low.get(next)));
|
|
1333
|
+
}
|
|
1334
|
+
else if (onStack.has(next)) {
|
|
1335
|
+
low.set(node, Math.min(low.get(node), indices.get(next)));
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
if (low.get(node) === indices.get(node)) {
|
|
1339
|
+
const component = [];
|
|
1340
|
+
while (stack.length > 0) {
|
|
1341
|
+
const member = stack.pop();
|
|
1342
|
+
onStack.delete(member);
|
|
1343
|
+
component.push(member);
|
|
1344
|
+
if (member === node)
|
|
1345
|
+
break;
|
|
1346
|
+
}
|
|
1347
|
+
components.push(component);
|
|
1348
|
+
}
|
|
1349
|
+
};
|
|
1350
|
+
for (const state of states) {
|
|
1351
|
+
if (!indices.has(state))
|
|
1352
|
+
visit(state);
|
|
1353
|
+
}
|
|
1354
|
+
return components;
|
|
1355
|
+
}
|
|
1356
|
+
function S3_liveness(model) {
|
|
1357
|
+
const components = sccs(model);
|
|
1358
|
+
const findings = [];
|
|
1359
|
+
for (const component of components) {
|
|
1360
|
+
const componentSet = new Set(component);
|
|
1361
|
+
const internalEdges = model.transitions.filter((transition) => componentSet.has(transition.from) && componentSet.has(transition.to));
|
|
1362
|
+
const escapingEdges = model.transitions.filter((transition) => componentSet.has(transition.from) && !componentSet.has(transition.to));
|
|
1363
|
+
if (internalEdges.length === 0 || escapingEdges.length > 0)
|
|
1364
|
+
continue;
|
|
1365
|
+
if (component.some((state) => isTerminal(model, state)))
|
|
1366
|
+
continue;
|
|
1367
|
+
findings.push({
|
|
1368
|
+
code: 'S3_CLOSED_SCC',
|
|
1369
|
+
severity: 'error',
|
|
1370
|
+
message: 'Closed SCC has no exit and contains no terminal state: ' + component.sort().join(', '),
|
|
1371
|
+
evidence: { states: component.sort(), transitions: internalEdges.length },
|
|
1372
|
+
});
|
|
1373
|
+
}
|
|
1374
|
+
return checkResult('S3', 'Liveness', findings, findings.length === 0 ? 'No harmful closed SCCs' : 'Closed SCCs: ' + findings.length);
|
|
1375
|
+
}
|
|
1376
|
+
function assignmentsFor(model, variables) {
|
|
1377
|
+
const specs = (model.variables ?? []).filter((variable) => variables.includes(variable.name));
|
|
1378
|
+
let combinations = [{}];
|
|
1379
|
+
for (const spec of specs) {
|
|
1380
|
+
let values;
|
|
1381
|
+
if (spec.kind === 'boolean')
|
|
1382
|
+
values = [false, true];
|
|
1383
|
+
else {
|
|
1384
|
+
const constants = collectGuardConstants(model).filter((value) => guardLeaves(model).some((leaf) => leaf.variable === spec.name && typeof leaf.value === 'number' && leaf.value === value));
|
|
1385
|
+
if (spec.min !== undefined && spec.max !== undefined) {
|
|
1386
|
+
values = [];
|
|
1387
|
+
for (let value = spec.min; value <= spec.max && value <= spec.min + 200; value++)
|
|
1388
|
+
values.push(value);
|
|
1389
|
+
}
|
|
1390
|
+
else {
|
|
1391
|
+
const set = new Set([-1, 0, 1, ...constants]);
|
|
1392
|
+
values = [...set].sort((a, b) => a - b);
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
const next = [];
|
|
1396
|
+
for (const assignment of combinations) {
|
|
1397
|
+
for (const value of values) {
|
|
1398
|
+
next.push({ ...assignment, [spec.name]: value });
|
|
1399
|
+
if (next.length > 10_000)
|
|
1400
|
+
return null;
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
combinations = next;
|
|
1404
|
+
}
|
|
1405
|
+
return combinations;
|
|
1406
|
+
}
|
|
1407
|
+
function guardLeaves(model) {
|
|
1408
|
+
const leaves = [];
|
|
1409
|
+
const visit = (guard) => {
|
|
1410
|
+
if ('variable' in guard)
|
|
1411
|
+
leaves.push(guard);
|
|
1412
|
+
else if ('all' in guard)
|
|
1413
|
+
guard.all.forEach(visit);
|
|
1414
|
+
else if ('any' in guard)
|
|
1415
|
+
guard.any.forEach(visit);
|
|
1416
|
+
else if ('not' in guard)
|
|
1417
|
+
visit(guard.not);
|
|
1418
|
+
};
|
|
1419
|
+
for (const transition of model.transitions)
|
|
1420
|
+
if (transition.guard !== undefined)
|
|
1421
|
+
visit(transition.guard);
|
|
1422
|
+
return leaves;
|
|
1423
|
+
}
|
|
1424
|
+
function collectGuardConstants(model) {
|
|
1425
|
+
return guardLeaves(model).filter((leaf) => typeof leaf.value === 'number').map((leaf) => leaf.value);
|
|
1426
|
+
}
|
|
1427
|
+
/** How a variable value prints in a message. Shared by every finding that quotes one. */
|
|
1428
|
+
function varValueLabel(value) {
|
|
1429
|
+
return typeof value === 'boolean' ? (value ? 'true' : 'false') : String(value);
|
|
1430
|
+
}
|
|
1431
|
+
/** Deterministic value order: booleans first, then numbers ascending. */
|
|
1432
|
+
function compareVarValues(a, b) {
|
|
1433
|
+
const rank = (value) => typeof value === 'number' ? [1, value] : [0, value ? 1 : 0];
|
|
1434
|
+
const [rankA, numberA] = rank(a);
|
|
1435
|
+
const [rankB, numberB] = rank(b);
|
|
1436
|
+
return rankA === rankB ? numberA - numberB : rankA - rankB;
|
|
1437
|
+
}
|
|
1438
|
+
/**
|
|
1439
|
+
* Values each variable can actually hold, as an over-approximation: `init` plus every
|
|
1440
|
+
* `set` constant the model writes. `null` means "unknown", which is what an `inc` or
|
|
1441
|
+
* `dec` update produces — the reachable set is then unbounded and nothing can be
|
|
1442
|
+
* concluded from it.
|
|
1443
|
+
*
|
|
1444
|
+
* The union is taken across the whole model rather than per state, so the answer can
|
|
1445
|
+
* only be too large, never too small. A guard this function calls dead is therefore
|
|
1446
|
+
* dead in every state, while a live guard may still be unreachable in a particular one.
|
|
1447
|
+
*/
|
|
1448
|
+
function attainableValues(model) {
|
|
1449
|
+
const attainable = new Map();
|
|
1450
|
+
for (const variable of model.variables ?? [])
|
|
1451
|
+
attainable.set(variable.name, new Set([variable.init]));
|
|
1452
|
+
for (const transition of model.transitions) {
|
|
1453
|
+
for (const update of transition.updates ?? []) {
|
|
1454
|
+
if (!attainable.has(update.variable))
|
|
1455
|
+
continue;
|
|
1456
|
+
if (update.op === 'inc' || update.op === 'dec') {
|
|
1457
|
+
attainable.set(update.variable, null);
|
|
1458
|
+
continue;
|
|
1459
|
+
}
|
|
1460
|
+
const values = attainable.get(update.variable);
|
|
1461
|
+
if (values === null || values === undefined)
|
|
1462
|
+
continue;
|
|
1463
|
+
values.add(update.value ?? 0);
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
return attainable;
|
|
1467
|
+
}
|
|
1468
|
+
/**
|
|
1469
|
+
* Guarded transitions whose guard no reachable variable value can satisfy: the branch
|
|
1470
|
+
* is dead code, and a model that relies on it is not the model the author wrote. This
|
|
1471
|
+
* is the dual of S6_INCOMPLETE_GUARD — a group that never reaches a branch, rather
|
|
1472
|
+
* than one that falls through every branch.
|
|
1473
|
+
*
|
|
1474
|
+
* The test runs over {@link attainableValues}, not over the declared domain: a guard
|
|
1475
|
+
* on a variable the model only ever sets to one of its declared values is satisfiable
|
|
1476
|
+
* on paper and dead in practice, which is exactly the defect worth reporting.
|
|
1477
|
+
*/
|
|
1478
|
+
function deadGuardFindings(model) {
|
|
1479
|
+
const attainable = attainableValues(model);
|
|
1480
|
+
const findings = [];
|
|
1481
|
+
for (const [key, group] of groupTransitions(model)) {
|
|
1482
|
+
const [from, event] = key.split('|');
|
|
1483
|
+
for (const transition of group) {
|
|
1484
|
+
if (transition.guard === undefined)
|
|
1485
|
+
continue;
|
|
1486
|
+
const names = [...new Set(guardVariables(transition.guard))].filter((name) => attainable.has(name)).sort();
|
|
1487
|
+
if (names.length === 0)
|
|
1488
|
+
continue;
|
|
1489
|
+
const domains = [];
|
|
1490
|
+
let known = true;
|
|
1491
|
+
for (const name of names) {
|
|
1492
|
+
const values = attainable.get(name);
|
|
1493
|
+
if (values === undefined || values === null || values.size === 0) {
|
|
1494
|
+
known = false;
|
|
1495
|
+
break;
|
|
1496
|
+
}
|
|
1497
|
+
domains.push(values);
|
|
1498
|
+
}
|
|
1499
|
+
if (!known)
|
|
1500
|
+
continue;
|
|
1501
|
+
let size = 1;
|
|
1502
|
+
for (const values of domains)
|
|
1503
|
+
size *= values.size;
|
|
1504
|
+
if (size > 10_000)
|
|
1505
|
+
continue;
|
|
1506
|
+
const assignment = {};
|
|
1507
|
+
const satisfiable = (index) => {
|
|
1508
|
+
if (index === names.length)
|
|
1509
|
+
return evalGuard(transition.guard, assignment);
|
|
1510
|
+
for (const value of [...domains[index]].sort(compareVarValues)) {
|
|
1511
|
+
assignment[names[index]] = value;
|
|
1512
|
+
if (satisfiable(index + 1))
|
|
1513
|
+
return true;
|
|
1514
|
+
}
|
|
1515
|
+
return false;
|
|
1516
|
+
};
|
|
1517
|
+
if (satisfiable(0))
|
|
1518
|
+
continue;
|
|
1519
|
+
findings.push({
|
|
1520
|
+
code: 'S6_DEAD_GUARD',
|
|
1521
|
+
severity: 'warning',
|
|
1522
|
+
message: from + ' + ' + event + ' has a guard that can never hold: ' + names.map((name) => name + ' is only ever ' + [...attainable.get(name)].sort(compareVarValues).map(varValueLabel).join('/')).join(', ') + '. The branch is dead.',
|
|
1523
|
+
evidence: { guard: transition.guard, attainable: names.map((name) => ({ variable: name, values: [...attainable.get(name)].sort(compareVarValues).map(varValueLabel) })) },
|
|
1524
|
+
});
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
return findings;
|
|
1528
|
+
}
|
|
1529
|
+
function analyzeGuards(model) {
|
|
1530
|
+
const determinism = [];
|
|
1531
|
+
const completeness = [];
|
|
1532
|
+
let truncated = false;
|
|
1533
|
+
const groups = groupTransitions(model);
|
|
1534
|
+
for (const [key, group] of groups) {
|
|
1535
|
+
const [from, event] = key.split('|');
|
|
1536
|
+
const unguarded = group.filter((transition) => transition.guard === undefined);
|
|
1537
|
+
const guarded = group.filter((transition) => transition.guard !== undefined);
|
|
1538
|
+
if (unguarded.length > 1) {
|
|
1539
|
+
determinism.push({
|
|
1540
|
+
code: 'S4_AMBIGUOUS_DEFAULT',
|
|
1541
|
+
severity: 'error',
|
|
1542
|
+
message: from + ' + ' + event + ' has multiple unconditional transitions: ' + uniqueTargets(unguarded).join(', '),
|
|
1543
|
+
});
|
|
1544
|
+
}
|
|
1545
|
+
if (guarded.length === 0)
|
|
1546
|
+
continue;
|
|
1547
|
+
const variables = [...new Set(group.flatMap((transition) => guardVariables(transition.guard)))];
|
|
1548
|
+
const assignments = assignmentsFor(model, variables);
|
|
1549
|
+
if (assignments === null) {
|
|
1550
|
+
completeness.push({ code: 'S6_UNBOUNDED_DOMAIN', severity: 'warning', message: from + ' + ' + event + ': guard domain too large to enumerate; exhaustive check skipped.' });
|
|
1551
|
+
truncated = true;
|
|
1552
|
+
continue;
|
|
1553
|
+
}
|
|
1554
|
+
const targets = uniqueTargets(group);
|
|
1555
|
+
for (const assignment of assignments) {
|
|
1556
|
+
const trueBranches = guarded.filter((transition) => evalGuard(transition.guard, assignment));
|
|
1557
|
+
if (trueBranches.length > 1) {
|
|
1558
|
+
determinism.push({
|
|
1559
|
+
code: 'S4_NONDETERMINISTIC_GUARDS',
|
|
1560
|
+
severity: 'error',
|
|
1561
|
+
message: from + ' + ' + event + ' has ' + trueBranches.length + ' simultaneously true guards for assignment ' + stableStringify(assignment) + ': ' + uniqueTargets(trueBranches).join(', '),
|
|
1562
|
+
evidence: { assignment },
|
|
1563
|
+
});
|
|
1564
|
+
}
|
|
1565
|
+
if (trueBranches.length === 0 && unguarded.length === 0) {
|
|
1566
|
+
completeness.push({
|
|
1567
|
+
code: 'S6_INCOMPLETE_GUARD',
|
|
1568
|
+
severity: 'error',
|
|
1569
|
+
message: from + ' + ' + event + ' has no true guard and no default branch for assignment ' + stableStringify(assignment),
|
|
1570
|
+
evidence: { assignment, branches: targets },
|
|
1571
|
+
});
|
|
1572
|
+
}
|
|
1573
|
+
if (determinism.length + completeness.length > 200) {
|
|
1574
|
+
truncated = true;
|
|
1575
|
+
break;
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
if (truncated)
|
|
1579
|
+
break;
|
|
1580
|
+
}
|
|
1581
|
+
completeness.push(...deadGuardFindings(model));
|
|
1582
|
+
return { determinism, completeness, truncated };
|
|
1583
|
+
}
|
|
1584
|
+
function S4_determinism(model) {
|
|
1585
|
+
const analysis = analyzeGuards(model);
|
|
1586
|
+
return checkResult('S4', 'Determinism', analysis.determinism, analysis.determinism.length === 0 ? 'Deterministic' : 'Nondeterminism findings: ' + analysis.determinism.length);
|
|
1587
|
+
}
|
|
1588
|
+
/**
|
|
1589
|
+
* A variable whose reachable domain is provably infinite, decided from the model rather than from
|
|
1590
|
+
* a search that ran out of room.
|
|
1591
|
+
*
|
|
1592
|
+
* The engine already bounds every exploration with `steps > options.maxStates`, and reports a
|
|
1593
|
+
* search that hit the bound as undecided rather than as a pass. That is the right report, but it
|
|
1594
|
+
* is the wrong way to find out: the audit got a 4 GB heap and a fatal error from a `NaN` bound,
|
|
1595
|
+
* and a caller who passes no bound at all still pays for ten thousand states before being told the
|
|
1596
|
+
* answer was never decidable.
|
|
1597
|
+
*
|
|
1598
|
+
* Whether the space is infinite is a property of the model, and it can be read off the transition
|
|
1599
|
+
* graph:
|
|
1600
|
+
*
|
|
1601
|
+
* - a variable is MONOTONE over a cycle when every update to it on that cycle moves the same way
|
|
1602
|
+
* (`inc`, or `dec`). An unrelated update to another variable does not break it;
|
|
1603
|
+
* - if such a cycle is reachable from the initial state and nothing on it stops the climb, then
|
|
1604
|
+
* repeating the cycle yields a distinct runtime state every time. The reachable set is infinite,
|
|
1605
|
+
* so NO bound decides the question - not a larger one, not a smaller one;
|
|
1606
|
+
* - two things stop a climb, and both are read from the model: a `set` on the cycle pins the value
|
|
1607
|
+
* to a constant, and a guard that fails as the value moves that way disables the cycle. A declared
|
|
1608
|
+
* `min`/`max` does not stop it - `inc`/`dec` are never clamped to the declaration, so a counter can
|
|
1609
|
+
* walk past its own range (which S7 then reports);
|
|
1610
|
+
*
|
|
1611
|
+
* One direction is enough to make the space infinite, and any monotone direction counts, so this
|
|
1612
|
+
* reports the variable rather than trying to be exhaustive about which check suffers.
|
|
1613
|
+
*
|
|
1614
|
+
* The same reasoning answers the modelling question the audit's D-series kept circling: a model
|
|
1615
|
+
* whose state space is infinite cannot be refuted by enumeration, so "this model cannot be decided
|
|
1616
|
+
* by search" is itself a review result rather than an internal detail. It is reported as an error
|
|
1617
|
+
* for that reason - it is not the reviewer's problem, it is a gap in the model.
|
|
1618
|
+
*/
|
|
1619
|
+
function unboundedDomainFindings(model) {
|
|
1620
|
+
const variables = new Map();
|
|
1621
|
+
for (const variable of model.variables ?? [])
|
|
1622
|
+
variables.set(variable.name, variable);
|
|
1623
|
+
// The state graph: which states each transition connects, taken from the transitions alone. No
|
|
1624
|
+
// runtime state is constructed, which is the point - this runs before any exploration.
|
|
1625
|
+
const successors = new Map();
|
|
1626
|
+
for (const state of model.states)
|
|
1627
|
+
successors.set(state.id, []);
|
|
1628
|
+
for (const transition of model.transitions) {
|
|
1629
|
+
if (transition.to === undefined)
|
|
1630
|
+
continue;
|
|
1631
|
+
const out = successors.get(transition.from);
|
|
1632
|
+
if (out === undefined)
|
|
1633
|
+
continue;
|
|
1634
|
+
for (const to of String(transition.to).split('|')) {
|
|
1635
|
+
if (successors.has(to))
|
|
1636
|
+
out.push({ to, step: { from: transition.from, event: transition.event, to } });
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
const findings = [];
|
|
1640
|
+
const reported = new Set();
|
|
1641
|
+
// Only states reachable from the initial one matter: a monotone cycle on an island no event
|
|
1642
|
+
// reaches does not make the reachable set infinite, and reporting it would be a defect invented
|
|
1643
|
+
// by the analysis rather than found in the model.
|
|
1644
|
+
const start = initialState(model);
|
|
1645
|
+
const reachable = new Set([start.state]);
|
|
1646
|
+
const frontier = [start.state];
|
|
1647
|
+
while (frontier.length > 0) {
|
|
1648
|
+
const id = frontier.pop();
|
|
1649
|
+
for (const edge of successors.get(id) ?? []) {
|
|
1650
|
+
if (reachable.has(edge.to))
|
|
1651
|
+
continue;
|
|
1652
|
+
reachable.add(edge.to);
|
|
1653
|
+
frontier.push(edge.to);
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
for (const state of model.states) {
|
|
1657
|
+
if (!reachable.has(state.id))
|
|
1658
|
+
continue;
|
|
1659
|
+
// Depth-first over the state graph with an explicit stack, so a deep model cannot overflow it.
|
|
1660
|
+
const onPath = new Map();
|
|
1661
|
+
const path = [];
|
|
1662
|
+
const visited = new Set();
|
|
1663
|
+
const stack = [{ id: state.id, index: 0 }];
|
|
1664
|
+
onPath.set(state.id, 0);
|
|
1665
|
+
while (stack.length > 0) {
|
|
1666
|
+
const frame = stack[stack.length - 1];
|
|
1667
|
+
const edges = (successors.get(frame.id) ?? []).filter((edge) => reachable.has(edge.to));
|
|
1668
|
+
if (frame.index >= edges.length) {
|
|
1669
|
+
onPath.delete(frame.id);
|
|
1670
|
+
visited.add(frame.id);
|
|
1671
|
+
stack.pop();
|
|
1672
|
+
path.pop();
|
|
1673
|
+
continue;
|
|
1674
|
+
}
|
|
1675
|
+
const edge = edges[frame.index++];
|
|
1676
|
+
const seenAt = onPath.get(edge.to);
|
|
1677
|
+
if (seenAt !== undefined) {
|
|
1678
|
+
// A cycle: the edges from `seenAt` on the current path, plus this closing edge.
|
|
1679
|
+
const cycleSteps = [...path.slice(seenAt).map((entry) => entry.step), edge.step];
|
|
1680
|
+
for (const variable of variables.values()) {
|
|
1681
|
+
if (variable.kind !== 'integer')
|
|
1682
|
+
continue;
|
|
1683
|
+
if (reported.has(variable.name))
|
|
1684
|
+
continue;
|
|
1685
|
+
const analysis = monotoneOverCycle(model, cycleSteps, variable);
|
|
1686
|
+
if (analysis === undefined)
|
|
1687
|
+
continue;
|
|
1688
|
+
reported.add(variable.name);
|
|
1689
|
+
const advice = analysis.direction === 'inc'
|
|
1690
|
+
? 'a guard that pins it from above (`{ "variable": "' + variable.name + '", "op": "<", "value": 4 }`)'
|
|
1691
|
+
: 'a guard that pins it from below (`{ "variable": "' + variable.name + '", "op": ">", "value": 0 }`)';
|
|
1692
|
+
findings.push({
|
|
1693
|
+
code: 'S6_UNBOUNDED_VARIABLE',
|
|
1694
|
+
severity: 'error',
|
|
1695
|
+
message: 'the reachable state space is infinite: ' + variable.name + ' is only ever ' + analysis.word + ' around the cycle ' + cycleSteps.map((step) => step.from + ' -> ' + step.to).join(' -> ') + ', and nothing on that cycle stops it.',
|
|
1696
|
+
detail: 'Repeating this cycle produces a distinct runtime state every time, so no search bound decides any question about this model: a larger bound walks further without deciding, and a smaller one reports truncation sooner. Declare ' + advice + ', or a `set` that resets it on the cycle, to make the model decidable. A declared min/max does not bound it: inc/dec are never clamped.',
|
|
1697
|
+
evidence: { variable: variable.name, direction: analysis.direction, events: cycleSteps.map((step) => step.event) },
|
|
1698
|
+
});
|
|
1699
|
+
}
|
|
1700
|
+
continue;
|
|
1701
|
+
}
|
|
1702
|
+
if (visited.has(edge.to))
|
|
1703
|
+
continue;
|
|
1704
|
+
onPath.set(edge.to, path.length + 1);
|
|
1705
|
+
path.push(edge);
|
|
1706
|
+
stack.push({ id: edge.to, index: 0 });
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
return findings;
|
|
1710
|
+
}
|
|
1711
|
+
const NEGATED_OP = { '<': '>=', '<=': '>', '>': '<=', '>=': '<', '==': '!=', '!=': '==' };
|
|
1712
|
+
/** Whether `op` is a comparison that stops holding once the value moves far enough in `direction`. */
|
|
1713
|
+
function pinningComparison(op, direction) {
|
|
1714
|
+
if (op === '==')
|
|
1715
|
+
return true;
|
|
1716
|
+
return direction === 'inc' ? op === '<' || op === '<=' : op === '>' || op === '>=';
|
|
1717
|
+
}
|
|
1718
|
+
/**
|
|
1719
|
+
* Whether a guard pins `variable` against a climb in `direction`, i.e. eventually disables the transition.
|
|
1720
|
+
*
|
|
1721
|
+
* Only shapes that certainly fail count, because the finding this feeds accuses the model of an infinite
|
|
1722
|
+
* reachable set, and a guess in that direction is a false accusation:
|
|
1723
|
+
*
|
|
1724
|
+
* - `all` is a conjunction, so one pinning clause is enough; `any` is a disjunction, so every branch has to pin;
|
|
1725
|
+
* - a negation is read through De Morgan rather than skipped, so `not { n >= 40 }` counts as `n < 40`;
|
|
1726
|
+
* - a guard on another variable says nothing about this one.
|
|
1727
|
+
*/
|
|
1728
|
+
function guardPinsVariable(guard, variable, direction, negated = false) {
|
|
1729
|
+
if (isStateGuard(guard))
|
|
1730
|
+
return false;
|
|
1731
|
+
if ('all' in guard) {
|
|
1732
|
+
return negated
|
|
1733
|
+
? guard.all.every((part) => guardPinsVariable(part, variable, direction, true))
|
|
1734
|
+
: guard.all.some((part) => guardPinsVariable(part, variable, direction, false));
|
|
1735
|
+
}
|
|
1736
|
+
if ('any' in guard) {
|
|
1737
|
+
return negated
|
|
1738
|
+
? guard.any.some((part) => guardPinsVariable(part, variable, direction, true))
|
|
1739
|
+
: guard.any.every((part) => guardPinsVariable(part, variable, direction, false));
|
|
1740
|
+
}
|
|
1741
|
+
if ('not' in guard)
|
|
1742
|
+
return guardPinsVariable(guard.not, variable, direction, !negated);
|
|
1743
|
+
if (guard.variable !== variable)
|
|
1744
|
+
return false;
|
|
1745
|
+
return pinningComparison(negated ? NEGATED_OP[guard.op] : guard.op, direction);
|
|
1746
|
+
}
|
|
1747
|
+
/**
|
|
1748
|
+
* Whether one variable climbs one way along the whole cycle and nothing stops it.
|
|
1749
|
+
*
|
|
1750
|
+
* Three things stop a climb, and the analysis has to read all three because it reports "the reachable state
|
|
1751
|
+
* space is infinite":
|
|
1752
|
+
*
|
|
1753
|
+
* - a `set` anywhere on the cycle pins the value to a constant, so lap after lap cannot climb (the constant's
|
|
1754
|
+
* own value does not matter);
|
|
1755
|
+
* - a guard that fails as the value moves that way disables the cycle, which is how the schema reference tells
|
|
1756
|
+
* a modeller to bound a counter;
|
|
1757
|
+
* - a declared `min`/`max` does NOT stop it: `inc`/`dec` are never clamped to the declaration, so a model that
|
|
1758
|
+
* declares `max: 5` and increments unguarded climbs past 5. S7 reports the range violation that proves it,
|
|
1759
|
+
* and reading the declaration as a bound here made the two checks disagree about the same model.
|
|
1760
|
+
*
|
|
1761
|
+
* A `dec` on the same cycle means it does not climb after all, and `undefined` says so.
|
|
1762
|
+
*/
|
|
1763
|
+
function monotoneOverCycle(model, steps, variable) {
|
|
1764
|
+
let direction;
|
|
1765
|
+
const moving = [];
|
|
1766
|
+
for (const step of steps) {
|
|
1767
|
+
const matching = model.transitions.filter((transition) => transition.from === step.from && transition.event === step.event && transition.to === step.to);
|
|
1768
|
+
// Only the transitions that actually move the variable matter: a branch of the same event that leaves it alone
|
|
1769
|
+
// neither climbs nor stops a climb, and counting it as an unpinned climber would accuse a bounded model.
|
|
1770
|
+
const climbers = matching.filter((transition) => (transition.updates ?? []).some((update) => update.variable === variable.name && update.op !== 'set'));
|
|
1771
|
+
for (const transition of matching) {
|
|
1772
|
+
for (const update of transition.updates ?? []) {
|
|
1773
|
+
if (update.variable !== variable.name)
|
|
1774
|
+
continue;
|
|
1775
|
+
// A `set` fixes the value at a constant, so no lap sequence climbs without bound.
|
|
1776
|
+
if (update.op === 'set')
|
|
1777
|
+
return undefined;
|
|
1778
|
+
if (direction === undefined)
|
|
1779
|
+
direction = update.op;
|
|
1780
|
+
else if (direction !== update.op)
|
|
1781
|
+
return undefined;
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
if (climbers.length > 0)
|
|
1785
|
+
moving.push({ op: direction, climbers });
|
|
1786
|
+
}
|
|
1787
|
+
if (direction === undefined)
|
|
1788
|
+
return undefined;
|
|
1789
|
+
// One pinned edge is enough: the cycle cannot complete once that edge is disabled, whichever other edges
|
|
1790
|
+
// still allow a move.
|
|
1791
|
+
const pinned = moving
|
|
1792
|
+
.filter((edge) => edge.op === direction)
|
|
1793
|
+
.some((edge) => edge.climbers.every((transition) => transition.guard !== undefined && guardPinsVariable(transition.guard, variable.name, direction)));
|
|
1794
|
+
if (pinned)
|
|
1795
|
+
return undefined;
|
|
1796
|
+
return { direction, word: direction === 'inc' ? 'incremented' : 'decremented' };
|
|
1797
|
+
}
|
|
1798
|
+
function S6_guardCompleteness(model) {
|
|
1799
|
+
const analysis = analyzeGuards(model);
|
|
1800
|
+
// The domain findings come first: an infinite state space is why the guard enumeration could not
|
|
1801
|
+
// finish, so a reader meets the cause before the symptom.
|
|
1802
|
+
const findings = [...unboundedDomainFindings(model), ...analysis.completeness];
|
|
1803
|
+
const dead = analysis.completeness.filter((finding) => finding.code === 'S6_DEAD_GUARD').length;
|
|
1804
|
+
const unbounded = findings.filter((finding) => finding.code === 'S6_UNBOUNDED_VARIABLE').length;
|
|
1805
|
+
const detail = unbounded > 0
|
|
1806
|
+
? 'The reachable state space is infinite: ' + String(unbounded) + ' variable(s) climb around a reachable cycle that nothing stops'
|
|
1807
|
+
: findings.length === 0
|
|
1808
|
+
? 'All guard branches defined'
|
|
1809
|
+
: dead === analysis.completeness.length ? 'Dead guard branches: ' + String(dead) : 'Guard findings: ' + String(findings.length);
|
|
1810
|
+
return checkResult('S6', 'Guard Completeness', findings, detail);
|
|
1811
|
+
}
|
|
1812
|
+
function S5_eventCompleteness(model) {
|
|
1813
|
+
const events = allEvents(model);
|
|
1814
|
+
const findings = [];
|
|
1815
|
+
for (const state of model.states) {
|
|
1816
|
+
if (state.terminal === true)
|
|
1817
|
+
continue;
|
|
1818
|
+
const handled = new Set(model.transitions.filter((transition) => transition.from === state.id).map((transition) => transition.event));
|
|
1819
|
+
for (const event of events) {
|
|
1820
|
+
if (!handled.has(event)) {
|
|
1821
|
+
findings.push({
|
|
1822
|
+
code: 'S5_UNHANDLED_EVENT',
|
|
1823
|
+
severity: 'warning',
|
|
1824
|
+
message: state.id + ' silently ignores event ' + event,
|
|
1825
|
+
});
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
return checkResult('S5', 'Event Completeness', findings, findings.length === 0 ? 'All states handle all relevant events' : findings.length + ' unhandled (state, event) pairs');
|
|
1830
|
+
}
|
|
1831
|
+
/**
|
|
1832
|
+
* Why a walk that stopped at its budget reports undecided rather than a verdict. One phrasing for
|
|
1833
|
+
* every invariant walk: a reader who sees two different sentences for the same situation starts
|
|
1834
|
+
* looking for a difference between them.
|
|
1835
|
+
*/
|
|
1836
|
+
function invariantCapReason(options) {
|
|
1837
|
+
return 'the walk stopped at the ' + String(options.maxStates) + ' state budget before it finished';
|
|
1838
|
+
}
|
|
1839
|
+
/** Whether a `var-in-range` `when` scope selects this runtime state. */
|
|
1840
|
+
function whenScopeHolds(when, runtime) {
|
|
1841
|
+
return isStateGuard(when) ? when.state === runtime.state : evalGuard(when, runtime.vars);
|
|
1842
|
+
}
|
|
1843
|
+
function invariantHolds(invariant, runtime, isInitial = false) {
|
|
1844
|
+
if (invariant.kind === 'never-states')
|
|
1845
|
+
return !invariant.states.includes(runtime.state);
|
|
1846
|
+
if (invariant.kind === 'var-in-range') {
|
|
1847
|
+
// The initial runtime state is checked unconditionally: a machine must never be
|
|
1848
|
+
// able to escape the range simply by starting outside the scope.
|
|
1849
|
+
if (!isInitial && invariant.when !== undefined && !whenScopeHolds(invariant.when, runtime))
|
|
1850
|
+
return true;
|
|
1851
|
+
const value = runtime.vars[invariant.variable];
|
|
1852
|
+
if (typeof value !== 'number')
|
|
1853
|
+
return false;
|
|
1854
|
+
if (invariant.min !== undefined && value < invariant.min)
|
|
1855
|
+
return false;
|
|
1856
|
+
if (invariant.max !== undefined && value > invariant.max)
|
|
1857
|
+
return false;
|
|
1858
|
+
return true;
|
|
1859
|
+
}
|
|
1860
|
+
return true;
|
|
1861
|
+
}
|
|
1862
|
+
function shortestViolationForInvariant(model, options, invariant) {
|
|
1863
|
+
if (invariant.kind === 'event-before-state') {
|
|
1864
|
+
return shortestEventBeforeStateViolation(model, options, invariant);
|
|
1865
|
+
}
|
|
1866
|
+
const init = initialState(model);
|
|
1867
|
+
if (!invariantHolds(invariant, init, true)) {
|
|
1868
|
+
return { invariant, path: [], reason: 'Initial state violates the invariant.' };
|
|
1869
|
+
}
|
|
1870
|
+
const visited = new Set([runtimeKey(init)]);
|
|
1871
|
+
const queue = [{ runtime: init, path: [] }];
|
|
1872
|
+
let steps = 0;
|
|
1873
|
+
// Whether the walk stopped on its budget rather than on an exhausted space. The two used to share the
|
|
1874
|
+
// `undefined` return, so S7 answered "All invariants hold" for a search that never finished.
|
|
1875
|
+
let capped = false;
|
|
1876
|
+
while (queue.length > 0) {
|
|
1877
|
+
const entry = queue.shift();
|
|
1878
|
+
if (++steps > options.maxStates) {
|
|
1879
|
+
capped = true;
|
|
1880
|
+
break;
|
|
1881
|
+
}
|
|
1882
|
+
for (const event of allEvents(model)) {
|
|
1883
|
+
for (const next of stepRuntime(model, entry.runtime, event)) {
|
|
1884
|
+
const key = runtimeKey(next);
|
|
1885
|
+
if (visited.has(key))
|
|
1886
|
+
continue;
|
|
1887
|
+
const path = [...entry.path, { from: entry.runtime.state, event, to: next.state }];
|
|
1888
|
+
if (!invariantHolds(invariant, next)) {
|
|
1889
|
+
return { invariant, path, reason: 'Invariant violated after ' + path.length + ' events.' };
|
|
1890
|
+
}
|
|
1891
|
+
visited.add(key);
|
|
1892
|
+
queue.push({ runtime: next, path });
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1896
|
+
if (capped)
|
|
1897
|
+
return { invariant, path: [], reason: invariantCapReason(options), undecided: true };
|
|
1898
|
+
return undefined;
|
|
1899
|
+
}
|
|
1900
|
+
/**
|
|
1901
|
+
* Whether an invariant asserts a property of whole runs rather than of a single
|
|
1902
|
+
* runtime state. These kinds cannot be decided by a state predicate, so they are not
|
|
1903
|
+
* part of `shortestViolationForInvariant` (and therefore not of S7), which owns the
|
|
1904
|
+
* states; A9-A13 own the verdicts, and `pathPropertyViolation` below makes the same
|
|
1905
|
+
* verdicts available to A7 and D2.
|
|
1906
|
+
*/
|
|
1907
|
+
function isPathPropertyInvariant(invariant) {
|
|
1908
|
+
return invariant.kind === 'leads-to'
|
|
1909
|
+
|| invariant.kind === 'sequence'
|
|
1910
|
+
|| invariant.kind === 'atomicity'
|
|
1911
|
+
|| invariant.kind === 'budget'
|
|
1912
|
+
|| invariant.kind === 'probability';
|
|
1913
|
+
}
|
|
1914
|
+
/** Whether a probability invariant's bound fails for the computed hit probability. */
|
|
1915
|
+
function probabilityViolated(invariant, probability) {
|
|
1916
|
+
const eps = 1e-9;
|
|
1917
|
+
if (invariant.op === '>=')
|
|
1918
|
+
return probability < invariant.p - eps;
|
|
1919
|
+
if (invariant.op === '>')
|
|
1920
|
+
return probability <= invariant.p + eps;
|
|
1921
|
+
if (invariant.op === '<=')
|
|
1922
|
+
return probability > invariant.p + eps;
|
|
1923
|
+
return probability >= invariant.p - eps;
|
|
1924
|
+
}
|
|
1925
|
+
/** Every runtime state reachable from init, capped like the other searches. */
|
|
1926
|
+
function reachableRuntimeStates(model, options) {
|
|
1927
|
+
const init = initialState(model);
|
|
1928
|
+
const visited = new Set([runtimeKey(init)]);
|
|
1929
|
+
const reached = [init];
|
|
1930
|
+
const queue = [init];
|
|
1931
|
+
let steps = 0;
|
|
1932
|
+
while (queue.length > 0) {
|
|
1933
|
+
if (++steps > options.maxStates)
|
|
1934
|
+
break;
|
|
1935
|
+
const current = queue.shift();
|
|
1936
|
+
for (const event of allEvents(model)) {
|
|
1937
|
+
for (const next of stepRuntime(model, current, event)) {
|
|
1938
|
+
const key = runtimeKey(next);
|
|
1939
|
+
if (visited.has(key))
|
|
1940
|
+
continue;
|
|
1941
|
+
visited.add(key);
|
|
1942
|
+
reached.push(next);
|
|
1943
|
+
queue.push(next);
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
return reached;
|
|
1948
|
+
}
|
|
1949
|
+
/**
|
|
1950
|
+
* Violation of a path-property invariant, or undefined when it holds. Each kind
|
|
1951
|
+
* delegates to the search its dedicated probe uses (A9 leads-to, A10 sequence, A11
|
|
1952
|
+
* atomicity, A12 budget, A13 probability), so a verdict reported here can never
|
|
1953
|
+
* disagree with the probe that owns the kind.
|
|
1954
|
+
*/
|
|
1955
|
+
function pathPropertyViolation(model, options, invariant) {
|
|
1956
|
+
if (invariant.kind === 'leads-to') {
|
|
1957
|
+
const targets = new Set(leadsToTargets(invariant));
|
|
1958
|
+
const label = leadsToTargetLabel(invariant);
|
|
1959
|
+
const budget = leadsToWalkBudget(options);
|
|
1960
|
+
const walk = { remaining: budget };
|
|
1961
|
+
for (const runtime of reachableRuntimeStates(model, options)) {
|
|
1962
|
+
if (runtime.state !== invariant.from)
|
|
1963
|
+
continue;
|
|
1964
|
+
const outcome = findLeadsToBadPath(model, runtime, targets, label, walk);
|
|
1965
|
+
if (outcome.undecided) {
|
|
1966
|
+
return { invariant, path: [], reason: 'the walk spent its budget of ' + budget + ' steps without finding a run that avoids ' + label + ', or proving that none exists', undecided: true };
|
|
1967
|
+
}
|
|
1968
|
+
if (outcome.violation !== undefined)
|
|
1969
|
+
return { invariant, path: outcome.violation.path, reason: outcome.violation.reason };
|
|
1970
|
+
}
|
|
1971
|
+
return undefined;
|
|
1972
|
+
}
|
|
1973
|
+
if (invariant.kind === 'sequence')
|
|
1974
|
+
return findSequenceViolation(model, options, invariant);
|
|
1975
|
+
if (invariant.kind === 'atomicity')
|
|
1976
|
+
return findAtomicityViolation(model, options, invariant);
|
|
1977
|
+
if (invariant.kind === 'budget') {
|
|
1978
|
+
const violation = findBudgetViolation(model, options, invariant);
|
|
1979
|
+
// The walk can also stop early, and that is the third outcome: no witness was found, and the walk did not
|
|
1980
|
+
// finish looking. Folding it into `undefined` made A7 (and D2) report "every invariant holds" about a search
|
|
1981
|
+
// that decided nothing - the same defect the other walks had, in the one caller that reads this function.
|
|
1982
|
+
if (violation === 'undecided')
|
|
1983
|
+
return { invariant, path: [], reason: invariantCapReason(options), undecided: true };
|
|
1984
|
+
if (violation === undefined)
|
|
1985
|
+
return undefined;
|
|
1986
|
+
return {
|
|
1987
|
+
invariant,
|
|
1988
|
+
path: violation.path,
|
|
1989
|
+
reason: violation.unbounded
|
|
1990
|
+
? 'a reachable cycle keeps accumulating cost, so no finite budget holds'
|
|
1991
|
+
: 'a run accumulates cost ' + String(violation.cost) + ', above the declared budget ' + String(invariant.budget),
|
|
1992
|
+
};
|
|
1993
|
+
}
|
|
1994
|
+
if (invariant.kind === 'probability') {
|
|
1995
|
+
const { probability, converged } = computeHitProbability(model, options, invariant.target);
|
|
1996
|
+
if (!probabilityViolated(invariant, probability))
|
|
1997
|
+
return undefined;
|
|
1998
|
+
return {
|
|
1999
|
+
invariant,
|
|
2000
|
+
path: [],
|
|
2001
|
+
reason: 'P(hit ' + invariant.target + ') = ' + probability.toFixed(6) + ' does not satisfy ' + invariant.op + ' ' + String(invariant.p)
|
|
2002
|
+
+ (converged ? '' : ' (value iteration did not converge)'),
|
|
2003
|
+
};
|
|
2004
|
+
}
|
|
2005
|
+
return undefined;
|
|
2006
|
+
}
|
|
2007
|
+
function shortestEventBeforeStateViolation(model, options, invariant) {
|
|
2008
|
+
const init = initialState(model);
|
|
2009
|
+
const seenInitially = false;
|
|
2010
|
+
if (init.state === invariant.state && !seenInitially) {
|
|
2011
|
+
return { invariant, path: [], reason: 'Target state ' + invariant.state + ' is initial and the required event ' + invariant.event + ' has not occurred.' };
|
|
2012
|
+
}
|
|
2013
|
+
const key = (runtime, seen) => runtimeKey(runtime) + '|' + (seen ? '1' : '0');
|
|
2014
|
+
const visited = new Set([key(init, false)]);
|
|
2015
|
+
const queue = [{ runtime: init, seen: false, path: [] }];
|
|
2016
|
+
let steps = 0;
|
|
2017
|
+
let capped = false;
|
|
2018
|
+
while (queue.length > 0) {
|
|
2019
|
+
const entry = queue.shift();
|
|
2020
|
+
if (++steps > options.maxStates) {
|
|
2021
|
+
capped = true;
|
|
2022
|
+
break;
|
|
2023
|
+
}
|
|
2024
|
+
for (const event of allEvents(model)) {
|
|
2025
|
+
for (const next of stepRuntime(model, entry.runtime, event)) {
|
|
2026
|
+
const seen = entry.seen || event === invariant.event;
|
|
2027
|
+
const nextKey = key(next, seen);
|
|
2028
|
+
if (visited.has(nextKey))
|
|
2029
|
+
continue;
|
|
2030
|
+
const path = [...entry.path, { from: entry.runtime.state, event, to: next.state }];
|
|
2031
|
+
if (next.state === invariant.state && !seen) {
|
|
2032
|
+
return { invariant, path, reason: 'Target state ' + invariant.state + ' is reachable without event ' + invariant.event + ' in ' + path.length + ' steps.' };
|
|
2033
|
+
}
|
|
2034
|
+
visited.add(nextKey);
|
|
2035
|
+
queue.push({ runtime: next, seen, path });
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
// Same cap, same answer: this walk is S7's too, so an unfinished search here must not read as a pass.
|
|
2040
|
+
if (capped)
|
|
2041
|
+
return { invariant, path: [], reason: invariantCapReason(options), undecided: true };
|
|
2042
|
+
return undefined;
|
|
2043
|
+
}
|
|
2044
|
+
function runInvariants(model, options, includePathProperties = false) {
|
|
2045
|
+
const violations = [];
|
|
2046
|
+
for (const invariant of model.invariants ?? []) {
|
|
2047
|
+
// A path property is not a predicate of one runtime state, so without `includePathProperties` this loop
|
|
2048
|
+
// has nothing to say about it. Walking the space anyway was harmless only while a cap read as a pass: the
|
|
2049
|
+
// walk can never find a violation (the predicate holds for these kinds by construction), so once it
|
|
2050
|
+
// reports its budget honestly it reports undecided about a question it was never asked. S7's roster is
|
|
2051
|
+
// the state kinds; A9-A13 and D2 decide the rest.
|
|
2052
|
+
if (!includePathProperties && isPathPropertyInvariant(invariant))
|
|
2053
|
+
continue;
|
|
2054
|
+
const violation = isPathPropertyInvariant(invariant)
|
|
2055
|
+
? pathPropertyViolation(model, options, invariant)
|
|
2056
|
+
: shortestViolationForInvariant(model, options, invariant);
|
|
2057
|
+
if (violation !== undefined)
|
|
2058
|
+
violations.push(violation);
|
|
2059
|
+
}
|
|
2060
|
+
return violations;
|
|
2061
|
+
}
|
|
2062
|
+
function S7_invariants(model, options) {
|
|
2063
|
+
const violations = runInvariants(model, options);
|
|
2064
|
+
const findings = violations.map((violation) => violation.undecided ? {
|
|
2065
|
+
code: 'S7_UNDECIDED',
|
|
2066
|
+
severity: 'warning',
|
|
2067
|
+
message: 'Invariant "' + violation.invariant.id + '" (' + violation.invariant.description + ') could not be decided: ' + violation.reason + '. Absence is not proven.',
|
|
2068
|
+
evidence: { invariant: violation.invariant },
|
|
2069
|
+
} : {
|
|
2070
|
+
code: 'S7_INVARIANT_VIOLATION',
|
|
2071
|
+
severity: 'error',
|
|
2072
|
+
message: 'Invariant "' + violation.invariant.id + '" (' + violation.invariant.description + ') violated: ' + violation.reason,
|
|
2073
|
+
path: violation.path,
|
|
2074
|
+
evidence: { invariant: violation.invariant },
|
|
2075
|
+
});
|
|
2076
|
+
const undecided = findings.some((finding) => finding.code === 'S7_UNDECIDED');
|
|
2077
|
+
return checkResult('S7', 'Invariant Validity', findings, violations.length === 0
|
|
2078
|
+
? 'All invariants hold'
|
|
2079
|
+
: undecided && findings.every((finding) => finding.code === 'S7_UNDECIDED') ? 'Invariants undecided: the search budget ran out' : 'Invariant violations: ' + violations.length);
|
|
2080
|
+
}
|
|
2081
|
+
// ---------------------------------------------------------------------------
|
|
2082
|
+
// A1-A14
|
|
2083
|
+
// ---------------------------------------------------------------------------
|
|
2084
|
+
function A1_unexpectedEvents(model) {
|
|
2085
|
+
const events = allEvents(model);
|
|
2086
|
+
const findings = [];
|
|
2087
|
+
for (const state of model.states) {
|
|
2088
|
+
if (state.terminal === true)
|
|
2089
|
+
continue;
|
|
2090
|
+
const handled = new Set(model.transitions.filter((transition) => transition.from === state.id).map((transition) => transition.event));
|
|
2091
|
+
for (const event of events) {
|
|
2092
|
+
if (!handled.has(event)) {
|
|
2093
|
+
findings.push({
|
|
2094
|
+
code: 'A1_UNHANDLED_EVENT',
|
|
2095
|
+
severity: 'warning',
|
|
2096
|
+
message: 'Event ' + event + ' in state ' + state.id + ' has no defined transition (silent ignore).',
|
|
2097
|
+
});
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
return checkResult('A1', 'Unexpected Event Injection', findings, findings.length === 0 ? 'All event/state combinations defined' : findings.length + ' unhandled combinations');
|
|
2102
|
+
}
|
|
2103
|
+
function firstOutcome(runtime, outcomes) {
|
|
2104
|
+
return outcomes[0];
|
|
2105
|
+
}
|
|
2106
|
+
function A2_raceInterleaving(model, exploration) {
|
|
2107
|
+
const findings = [];
|
|
2108
|
+
for (const [e1, e2] of model.concurrentPairs ?? []) {
|
|
2109
|
+
for (const runtime of exploration.reachable) {
|
|
2110
|
+
const first = firstOutcome(runtime, stepRuntime(model, runtime, e1));
|
|
2111
|
+
const second = firstOutcome(runtime, stepRuntime(model, runtime, e2));
|
|
2112
|
+
if (first === undefined && second === undefined)
|
|
2113
|
+
continue;
|
|
2114
|
+
const final12 = first === undefined ? runtime : firstOutcome(first, stepRuntime(model, first, e2)) ?? first;
|
|
2115
|
+
const final21 = second === undefined ? runtime : firstOutcome(second, stepRuntime(model, second, e1)) ?? second;
|
|
2116
|
+
if (runtimeKey(final12) !== runtimeKey(final21)) {
|
|
2117
|
+
findings.push({
|
|
2118
|
+
code: 'A2_ORDER_DEPENDENT',
|
|
2119
|
+
severity: 'warning',
|
|
2120
|
+
message: 'Events ' + e1 + ' and ' + e2 + ' produce order-dependent outcomes from state ' + runtime.state,
|
|
2121
|
+
evidence: { e1ThenE2: final12, e2ThenE1: final21 },
|
|
2122
|
+
});
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
}
|
|
2126
|
+
return checkResult('A2', 'Race Interleaving', withUnresolved(model, 'A2', findings), findings.length === 0 ? 'No race conditions detected' : 'Order-dependent outcomes: ' + findings.length);
|
|
2127
|
+
}
|
|
2128
|
+
function stepSequence(model, init, events) {
|
|
2129
|
+
let runtime = init;
|
|
2130
|
+
for (const event of events) {
|
|
2131
|
+
const next = firstOutcome(runtime, stepRuntime(model, runtime, event));
|
|
2132
|
+
if (next === undefined)
|
|
2133
|
+
continue;
|
|
2134
|
+
runtime = next;
|
|
2135
|
+
}
|
|
2136
|
+
return runtime;
|
|
2137
|
+
}
|
|
2138
|
+
function permutations(items) {
|
|
2139
|
+
if (items.length === 0)
|
|
2140
|
+
return [[]];
|
|
2141
|
+
const result = [];
|
|
2142
|
+
for (let index = 0; index < items.length; index++) {
|
|
2143
|
+
const rest = items.slice(0, index).concat(items.slice(index + 1));
|
|
2144
|
+
for (const suffix of permutations(rest))
|
|
2145
|
+
result.push([items[index], ...suffix]);
|
|
2146
|
+
}
|
|
2147
|
+
return result;
|
|
2148
|
+
}
|
|
2149
|
+
function A3_orderPermutation(model, options) {
|
|
2150
|
+
const events = allEvents(model).slice(0, options.maxPermutationEvents);
|
|
2151
|
+
if (events.length < 2)
|
|
2152
|
+
return checkResult('A3', 'Order Permutation', [], 'Fewer than 2 events — skipped');
|
|
2153
|
+
const init = initialState(model);
|
|
2154
|
+
const outcomes = new Map();
|
|
2155
|
+
for (const permutation of permutations(events)) {
|
|
2156
|
+
const final = stepSequence(model, init, permutation);
|
|
2157
|
+
const key = runtimeKey(final);
|
|
2158
|
+
const list = outcomes.get(key) ?? [];
|
|
2159
|
+
if (list.length < 3)
|
|
2160
|
+
list.push(permutation.join(','));
|
|
2161
|
+
outcomes.set(key, list);
|
|
2162
|
+
}
|
|
2163
|
+
if (outcomes.size > 1) {
|
|
2164
|
+
const examples = [...outcomes.entries()].map(([key, sequences]) => ({ final: key.split('|')[0], example: sequences[0] }));
|
|
2165
|
+
return checkResult('A3', 'Order Permutation', [{
|
|
2166
|
+
code: 'A3_ORDER_DEPENDENT',
|
|
2167
|
+
severity: 'warning',
|
|
2168
|
+
message: 'Same event set produces ' + outcomes.size + ' different outcomes depending on order.',
|
|
2169
|
+
evidence: { examples },
|
|
2170
|
+
}], 'Order-dependent outcomes: ' + outcomes.size);
|
|
2171
|
+
}
|
|
2172
|
+
return checkResult('A3', 'Order Permutation', [], 'Order-independent (sampled first ' + events.length + ' events)');
|
|
2173
|
+
}
|
|
2174
|
+
function stateActionList(model, stateId, kind) {
|
|
2175
|
+
const state = model.states.find((entry) => entry.id === stateId);
|
|
2176
|
+
const list = state === undefined ? undefined : state[kind];
|
|
2177
|
+
return list ?? [];
|
|
2178
|
+
}
|
|
2179
|
+
function actionsDeclared(model) {
|
|
2180
|
+
return model.states.some((state) => (state.onEntry ?? []).length > 0 || (state.onExit ?? []).length > 0);
|
|
2181
|
+
}
|
|
2182
|
+
/** Apply an ordered action list to the held flag. Actions never change state or variables; they only matter to pairing checks. */
|
|
2183
|
+
function applyActionList(list, acquireEvent, releaseEvent, held) {
|
|
2184
|
+
let next = held;
|
|
2185
|
+
let reacquired = false;
|
|
2186
|
+
for (const action of list) {
|
|
2187
|
+
if (action === acquireEvent) {
|
|
2188
|
+
if (next)
|
|
2189
|
+
reacquired = true;
|
|
2190
|
+
next = true;
|
|
2191
|
+
}
|
|
2192
|
+
else if (action === releaseEvent) {
|
|
2193
|
+
next = false;
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
return { held: next, reacquired };
|
|
2197
|
+
}
|
|
2198
|
+
function A4_pairSymmetry(model) {
|
|
2199
|
+
const findings = [];
|
|
2200
|
+
const hasActions = actionsDeclared(model);
|
|
2201
|
+
const transitionEvents = new Set(model.transitions.map((transition) => transition.event));
|
|
2202
|
+
const actionEvents = new Set();
|
|
2203
|
+
if (hasActions) {
|
|
2204
|
+
for (const state of model.states) {
|
|
2205
|
+
for (const action of stateActionList(model, state.id, 'onEntry'))
|
|
2206
|
+
actionEvents.add(action);
|
|
2207
|
+
for (const action of stateActionList(model, state.id, 'onExit'))
|
|
2208
|
+
actionEvents.add(action);
|
|
2209
|
+
}
|
|
2210
|
+
}
|
|
2211
|
+
const pairHasEvent = (event) => transitionEvents.has(event) || actionEvents.has(event);
|
|
2212
|
+
const releaseReachableFrom = (stateId, releaseEvent) => {
|
|
2213
|
+
const reachable = stateGraphReachable(model, stateId);
|
|
2214
|
+
return [...reachable].some((state) => model.transitions.some((transition) => transition.from === state && transition.event === releaseEvent)
|
|
2215
|
+
|| (hasActions && (stateActionList(model, state, 'onEntry').includes(releaseEvent) || stateActionList(model, state, 'onExit').includes(releaseEvent))));
|
|
2216
|
+
};
|
|
2217
|
+
const edges = stateGraphEdges(model);
|
|
2218
|
+
for (const pair of model.resourcePairs ?? []) {
|
|
2219
|
+
const acquireTransitions = model.transitions.filter((transition) => transition.event === pair.acquireEvent);
|
|
2220
|
+
const actionAcquire = hasActions && model.states.some((state) => stateActionList(model, state.id, 'onEntry').includes(pair.acquireEvent) || stateActionList(model, state.id, 'onExit').includes(pair.acquireEvent));
|
|
2221
|
+
const releaseExists = pairHasEvent(pair.releaseEvent);
|
|
2222
|
+
if (acquireTransitions.length === 0 && !actionAcquire && !releaseExists)
|
|
2223
|
+
continue;
|
|
2224
|
+
if ((acquireTransitions.length > 0 || actionAcquire) && !releaseExists) {
|
|
2225
|
+
findings.push({
|
|
2226
|
+
code: 'A4_NO_RELEASE_EVENT',
|
|
2227
|
+
severity: 'error',
|
|
2228
|
+
message: 'Resource "' + pair.resource + '": acquire event ' + pair.acquireEvent + ' exists but release event ' + pair.releaseEvent + ' is never defined.',
|
|
2229
|
+
// In the evidence so the sweep can see that this finding already accounts for the name:
|
|
2230
|
+
// one defect, one finding, in the vocabulary of the check that owns it.
|
|
2231
|
+
evidence: { resource: pair.resource, acquireEvent: pair.acquireEvent, releaseEvent: pair.releaseEvent, event: pair.releaseEvent },
|
|
2232
|
+
});
|
|
2233
|
+
continue;
|
|
2234
|
+
}
|
|
2235
|
+
// Collect acquire sites: transitions whose event acquires, and state entry/exit actions that end holding the resource.
|
|
2236
|
+
const seeds = [];
|
|
2237
|
+
for (const acquire of acquireTransitions) {
|
|
2238
|
+
if (!releaseReachableFrom(acquire.to, pair.releaseEvent)) {
|
|
2239
|
+
findings.push({
|
|
2240
|
+
code: 'A4_NO_RELEASE_REACHABLE',
|
|
2241
|
+
severity: 'error',
|
|
2242
|
+
message: 'Resource "' + pair.resource + '": after ' + pair.acquireEvent + ' into ' + acquire.to + ', no ' + pair.releaseEvent + ' is reachable.',
|
|
2243
|
+
evidence: { acquireTransition: acquire },
|
|
2244
|
+
});
|
|
2245
|
+
continue;
|
|
2246
|
+
}
|
|
2247
|
+
seeds.push({ state: acquire.to, held: true, path: [] });
|
|
2248
|
+
}
|
|
2249
|
+
if (hasActions) {
|
|
2250
|
+
for (const state of model.states) {
|
|
2251
|
+
// acquire fired by onEntry: resource is held for the whole stay in the state
|
|
2252
|
+
const entry = stateActionList(model, state.id, 'onEntry');
|
|
2253
|
+
if (entry.includes(pair.acquireEvent)) {
|
|
2254
|
+
const sim = applyActionList(entry, pair.acquireEvent, pair.releaseEvent, false);
|
|
2255
|
+
if (sim.reacquired) {
|
|
2256
|
+
findings.push({
|
|
2257
|
+
code: 'A4_REACQUIRE_WITHOUT_RELEASE',
|
|
2258
|
+
severity: 'warning',
|
|
2259
|
+
message: 'Resource "' + pair.resource + '" is acquired more than once inside onEntry of ' + state.id + ' before ' + pair.releaseEvent + '.',
|
|
2260
|
+
});
|
|
2261
|
+
}
|
|
2262
|
+
if (sim.held)
|
|
2263
|
+
seeds.push({ state: state.id, held: true, path: [] });
|
|
2264
|
+
}
|
|
2265
|
+
// acquire fired by onExit: happens on every outgoing edge, never on a terminal state
|
|
2266
|
+
const exit = stateActionList(model, state.id, 'onExit');
|
|
2267
|
+
if (exit.includes(pair.acquireEvent) && !state.terminal) {
|
|
2268
|
+
const sim = applyActionList(exit, pair.acquireEvent, pair.releaseEvent, false);
|
|
2269
|
+
if (sim.reacquired) {
|
|
2270
|
+
findings.push({
|
|
2271
|
+
code: 'A4_REACQUIRE_WITHOUT_RELEASE',
|
|
2272
|
+
severity: 'warning',
|
|
2273
|
+
message: 'Resource "' + pair.resource + '" is acquired more than once inside onExit of ' + state.id + ' before ' + pair.releaseEvent + '.',
|
|
2274
|
+
});
|
|
2275
|
+
}
|
|
2276
|
+
if (sim.held)
|
|
2277
|
+
seeds.push({ state: state.id, held: false, path: [] });
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
}
|
|
2281
|
+
// Path-sensitive probe: track the held flag along state-graph paths, firing
|
|
2282
|
+
// onExit of the source state before each transition and onEntry of the target after it.
|
|
2283
|
+
for (const seed of seeds) {
|
|
2284
|
+
const visited = new Set();
|
|
2285
|
+
const queue = [seed];
|
|
2286
|
+
let steps = 0;
|
|
2287
|
+
while (queue.length > 0) {
|
|
2288
|
+
const entry = queue.shift();
|
|
2289
|
+
if (++steps > 1000)
|
|
2290
|
+
break;
|
|
2291
|
+
const key = entry.state + '|' + (entry.held ? '1' : '0');
|
|
2292
|
+
if (visited.has(key))
|
|
2293
|
+
continue;
|
|
2294
|
+
visited.add(key);
|
|
2295
|
+
if (entry.held && isTerminal(model, entry.state)) {
|
|
2296
|
+
findings.push({
|
|
2297
|
+
code: 'A4_TERMINAL_WITH_RESOURCE',
|
|
2298
|
+
severity: 'error',
|
|
2299
|
+
message: 'Resource "' + pair.resource + '" is still held when entering terminal state ' + entry.state + '.',
|
|
2300
|
+
path: entry.path,
|
|
2301
|
+
});
|
|
2302
|
+
continue;
|
|
2303
|
+
}
|
|
2304
|
+
for (const transition of edges.get(entry.state) ?? []) {
|
|
2305
|
+
let held = entry.held;
|
|
2306
|
+
if (hasActions) {
|
|
2307
|
+
const sim = applyActionList(stateActionList(model, entry.state, 'onExit'), pair.acquireEvent, pair.releaseEvent, held);
|
|
2308
|
+
if (sim.reacquired) {
|
|
2309
|
+
findings.push({
|
|
2310
|
+
code: 'A4_REACQUIRE_WITHOUT_RELEASE',
|
|
2311
|
+
severity: 'warning',
|
|
2312
|
+
message: 'Resource "' + pair.resource + '" is acquired again in onExit of ' + entry.state + ' before ' + pair.releaseEvent + '.',
|
|
2313
|
+
path: entry.path,
|
|
2314
|
+
});
|
|
2315
|
+
continue;
|
|
2316
|
+
}
|
|
2317
|
+
held = sim.held;
|
|
2318
|
+
}
|
|
2319
|
+
if (transition.event === pair.releaseEvent)
|
|
2320
|
+
held = false;
|
|
2321
|
+
else if (transition.event === pair.acquireEvent) {
|
|
2322
|
+
if (held) {
|
|
2323
|
+
findings.push({
|
|
2324
|
+
code: 'A4_REACQUIRE_WITHOUT_RELEASE',
|
|
2325
|
+
severity: 'warning',
|
|
2326
|
+
message: 'Resource "' + pair.resource + '" is acquired again in state ' + entry.state + ' before ' + pair.releaseEvent + '.',
|
|
2327
|
+
path: [...entry.path, { from: entry.state, event: transition.event, to: transition.to }],
|
|
2328
|
+
});
|
|
2329
|
+
continue;
|
|
2330
|
+
}
|
|
2331
|
+
held = true;
|
|
2332
|
+
}
|
|
2333
|
+
if (hasActions) {
|
|
2334
|
+
const sim = applyActionList(stateActionList(model, transition.to, 'onEntry'), pair.acquireEvent, pair.releaseEvent, held);
|
|
2335
|
+
if (sim.reacquired) {
|
|
2336
|
+
findings.push({
|
|
2337
|
+
code: 'A4_REACQUIRE_WITHOUT_RELEASE',
|
|
2338
|
+
severity: 'warning',
|
|
2339
|
+
message: 'Resource "' + pair.resource + '" is acquired again inside onEntry of ' + transition.to + ' before ' + pair.releaseEvent + '.',
|
|
2340
|
+
path: [...entry.path, { from: entry.state, event: transition.event, to: transition.to }],
|
|
2341
|
+
});
|
|
2342
|
+
continue;
|
|
2343
|
+
}
|
|
2344
|
+
held = sim.held;
|
|
2345
|
+
}
|
|
2346
|
+
queue.push({ state: transition.to, held, path: [...entry.path, { from: entry.state, event: transition.event, to: transition.to }] });
|
|
2347
|
+
}
|
|
2348
|
+
}
|
|
2349
|
+
}
|
|
2350
|
+
}
|
|
2351
|
+
return checkResult('A4', 'Pair Symmetry', withUnresolved(model, 'A4', findings), findings.length === 0 ? 'All pairs balanced' : 'Asymmetric pairs: ' + findings.length);
|
|
2352
|
+
}
|
|
2353
|
+
function stateGraphEdges(model) {
|
|
2354
|
+
const edges = new Map();
|
|
2355
|
+
for (const state of model.states)
|
|
2356
|
+
edges.set(state.id, []);
|
|
2357
|
+
for (const transition of model.transitions)
|
|
2358
|
+
edges.get(transition.from)?.push(transition);
|
|
2359
|
+
return edges;
|
|
2360
|
+
}
|
|
2361
|
+
function stateGraphReachable(model, start) {
|
|
2362
|
+
const edges = new Map();
|
|
2363
|
+
for (const state of model.states)
|
|
2364
|
+
edges.set(state.id, []);
|
|
2365
|
+
for (const transition of model.transitions)
|
|
2366
|
+
edges.get(transition.from)?.push(transition.to);
|
|
2367
|
+
const visited = new Set();
|
|
2368
|
+
const queue = [start];
|
|
2369
|
+
while (queue.length > 0) {
|
|
2370
|
+
const current = queue.shift();
|
|
2371
|
+
if (visited.has(current))
|
|
2372
|
+
continue;
|
|
2373
|
+
visited.add(current);
|
|
2374
|
+
for (const next of edges.get(current) ?? [])
|
|
2375
|
+
queue.push(next);
|
|
2376
|
+
}
|
|
2377
|
+
return visited;
|
|
2378
|
+
}
|
|
2379
|
+
function A5_boundaryBlast(model) {
|
|
2380
|
+
const findings = [];
|
|
2381
|
+
for (const check of model.boundaryChecks ?? []) {
|
|
2382
|
+
const variable = (model.variables ?? []).find((entry) => entry.name === check.variable);
|
|
2383
|
+
if (variable === undefined)
|
|
2384
|
+
continue;
|
|
2385
|
+
for (const value of check.values) {
|
|
2386
|
+
if (variable.min !== undefined && value < variable.min) {
|
|
2387
|
+
findings.push({ code: 'A5_OUT_OF_DOMAIN', severity: 'warning', message: 'Boundary value ' + value + ' for ' + check.variable + ' is below min ' + variable.min });
|
|
2388
|
+
}
|
|
2389
|
+
if (variable.max !== undefined && value > variable.max) {
|
|
2390
|
+
findings.push({ code: 'A5_OUT_OF_DOMAIN', severity: 'warning', message: 'Boundary value ' + value + ' for ' + check.variable + ' is above max ' + variable.max });
|
|
2391
|
+
}
|
|
2392
|
+
const assignment = { ...(model.variables ?? []).reduce((result, entry) => { result[entry.name] = entry.init; return result; }, {}), [check.variable]: value };
|
|
2393
|
+
for (const [key, group] of groupTransitions(model)) {
|
|
2394
|
+
const [from, event] = key.split('|');
|
|
2395
|
+
if (!group.some((transition) => guardVariables(transition.guard).includes(check.variable)))
|
|
2396
|
+
continue;
|
|
2397
|
+
const guarded = group.filter((transition) => transition.guard !== undefined);
|
|
2398
|
+
const unguarded = group.filter((transition) => transition.guard === undefined);
|
|
2399
|
+
const matched = guarded.filter((transition) => evalGuard(transition.guard, assignment));
|
|
2400
|
+
if (matched.length === 0 && unguarded.length === 0) {
|
|
2401
|
+
findings.push({
|
|
2402
|
+
code: 'A5_GUARD_HOLE',
|
|
2403
|
+
severity: 'warning',
|
|
2404
|
+
message: from + ' + ' + event + ' has no branch for ' + check.variable + '=' + value,
|
|
2405
|
+
evidence: { assignment },
|
|
2406
|
+
});
|
|
2407
|
+
}
|
|
2408
|
+
if (matched.length > 1) {
|
|
2409
|
+
findings.push({
|
|
2410
|
+
code: 'A5_GUARD_OVERLAP',
|
|
2411
|
+
severity: 'warning',
|
|
2412
|
+
message: from + ' + ' + event + ' has ' + matched.length + ' true branches for ' + check.variable + '=' + value,
|
|
2413
|
+
evidence: { assignment },
|
|
2414
|
+
});
|
|
2415
|
+
}
|
|
2416
|
+
}
|
|
2417
|
+
}
|
|
2418
|
+
}
|
|
2419
|
+
return checkResult('A5', 'Boundary Blast', findings, findings.length === 0 ? 'Boundary checks passed' : 'Boundary findings: ' + findings.length);
|
|
2420
|
+
}
|
|
2421
|
+
function A6_resourceInjection(model) {
|
|
2422
|
+
const findings = [];
|
|
2423
|
+
for (const pair of model.resourcePairs ?? []) {
|
|
2424
|
+
if (pair.failEvent === undefined)
|
|
2425
|
+
continue;
|
|
2426
|
+
const acquireStates = new Set(model.transitions.filter((transition) => transition.event === pair.acquireEvent).map((transition) => transition.from));
|
|
2427
|
+
for (const state of acquireStates) {
|
|
2428
|
+
const handled = model.transitions.some((transition) => transition.from === state && transition.event === pair.failEvent);
|
|
2429
|
+
if (!handled) {
|
|
2430
|
+
findings.push({
|
|
2431
|
+
code: 'A6_NO_FAILURE_HANDLER',
|
|
2432
|
+
severity: 'warning',
|
|
2433
|
+
message: 'State ' + state + ' can ' + pair.acquireEvent + ' for "' + pair.resource + '" but has no ' + pair.failEvent + ' transition.',
|
|
2434
|
+
});
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
2437
|
+
}
|
|
2438
|
+
return checkResult('A6', 'Resource Injection', withUnresolved(model, 'A6', findings), findings.length === 0 ? 'No resource vulnerabilities detected' : 'Resource failure paths missing: ' + findings.length);
|
|
2439
|
+
}
|
|
2440
|
+
function A7_shortestViolations(model, options) {
|
|
2441
|
+
// Path-property invariants are included: "for any invariant that fails" is this
|
|
2442
|
+
// check's contract, and a check that silently skips five of the eight kinds cannot
|
|
2443
|
+
// honour it. The searches for the state kinds, for sequence, atomicity and budget
|
|
2444
|
+
// are breadth-first, so their first witness is the shortest one; the leads-to walk
|
|
2445
|
+
// and the probability bound have no such guarantee, and their message says what it
|
|
2446
|
+
// found instead of claiming minimality.
|
|
2447
|
+
const violations = runInvariants(model, options, true);
|
|
2448
|
+
const findings = violations.map((violation) => {
|
|
2449
|
+
if (violation.undecided) {
|
|
2450
|
+
return {
|
|
2451
|
+
code: 'A7_UNDECIDED',
|
|
2452
|
+
severity: 'warning',
|
|
2453
|
+
message: 'Invariant "' + violation.invariant.id + '" could not be decided: ' + violation.reason + '. Absence is not proven, so there is no shortest counterexample to report.',
|
|
2454
|
+
evidence: { invariant: violation.invariant.id },
|
|
2455
|
+
};
|
|
2456
|
+
}
|
|
2457
|
+
const pathLength = violation.path.length;
|
|
2458
|
+
const message = isPathPropertyInvariant(violation.invariant)
|
|
2459
|
+
? 'Invariant "' + violation.invariant.id + '" violated: ' + violation.reason
|
|
2460
|
+
+ (pathLength === 0 ? '' : ' (witness path length: ' + pathLength + ')')
|
|
2461
|
+
: 'Invariant "' + violation.invariant.id + '" shortest violating path length: ' + pathLength + (pathLength === 0 ? ' (initial state)' : '');
|
|
2462
|
+
return {
|
|
2463
|
+
code: 'A7_SHORTEST_COUNTEREXAMPLE',
|
|
2464
|
+
severity: 'warning',
|
|
2465
|
+
message,
|
|
2466
|
+
path: violation.path,
|
|
2467
|
+
evidence: { invariant: violation.invariant.id },
|
|
2468
|
+
};
|
|
2469
|
+
});
|
|
2470
|
+
const undecided = findings.some((finding) => finding.code === 'A7_UNDECIDED');
|
|
2471
|
+
return checkResult('A7', 'Minimal Counter-Example', findings, violations.length === 0
|
|
2472
|
+
? 'All invariants hold for all reachable paths'
|
|
2473
|
+
: undecided && findings.every((finding) => finding.code === 'A7_UNDECIDED') ? 'Invariant walks stopped at the state budget: undecided' : 'Violated invariants: ' + findings.length);
|
|
2474
|
+
}
|
|
2475
|
+
function mapStateId(mapping, state) {
|
|
2476
|
+
return mapping[state] ?? state;
|
|
2477
|
+
}
|
|
2478
|
+
function D1_behavioralPreservation(before, after, mapping) {
|
|
2479
|
+
const findings = [];
|
|
2480
|
+
const afterStates = new Set(after.states.map((state) => state.id));
|
|
2481
|
+
const afterByFromEvent = new Map();
|
|
2482
|
+
for (const transition of after.transitions) {
|
|
2483
|
+
const key = transition.from + '|' + transition.event;
|
|
2484
|
+
const set = afterByFromEvent.get(key) ?? new Set();
|
|
2485
|
+
set.add(transition.to);
|
|
2486
|
+
afterByFromEvent.set(key, set);
|
|
2487
|
+
}
|
|
2488
|
+
const beforeInitMapped = mapStateId(mapping, before.init);
|
|
2489
|
+
if (beforeInitMapped !== after.init) {
|
|
2490
|
+
findings.push({
|
|
2491
|
+
code: 'D1_INIT_MISMATCH',
|
|
2492
|
+
severity: 'warning',
|
|
2493
|
+
message: 'Mapped BEFORE init ' + beforeInitMapped + ' does not match AFTER init ' + after.init + '.',
|
|
2494
|
+
evidence: { beforeInit: before.init, mappedInit: beforeInitMapped, afterInit: after.init },
|
|
2495
|
+
});
|
|
2496
|
+
}
|
|
2497
|
+
for (const state of before.states) {
|
|
2498
|
+
const mapped = mapStateId(mapping, state.id);
|
|
2499
|
+
if (!afterStates.has(mapped)) {
|
|
2500
|
+
findings.push({
|
|
2501
|
+
code: 'D1_MAPPED_STATE_MISSING',
|
|
2502
|
+
severity: 'error',
|
|
2503
|
+
message: 'BEFORE state ' + state.id + ' maps to ' + mapped + ', which is not declared in AFTER.',
|
|
2504
|
+
evidence: { beforeState: state.id, mappedState: mapped },
|
|
2505
|
+
});
|
|
2506
|
+
continue;
|
|
2507
|
+
}
|
|
2508
|
+
for (const transition of before.transitions.filter((entry) => entry.from === state.id)) {
|
|
2509
|
+
const key = mapped + '|' + transition.event;
|
|
2510
|
+
const targets = afterByFromEvent.get(key);
|
|
2511
|
+
if (targets === undefined || targets.size === 0) {
|
|
2512
|
+
findings.push({
|
|
2513
|
+
code: 'D1_EVENT_DISABLED',
|
|
2514
|
+
severity: 'error',
|
|
2515
|
+
message: 'BEFORE can fire event ' + transition.event + ' from ' + state.id + ' (mapped to ' + mapped + '), but AFTER has no transition for that (state, event).',
|
|
2516
|
+
path: [{ from: state.id, event: transition.event, to: transition.to }],
|
|
2517
|
+
evidence: { beforeState: state.id, mappedState: mapped, event: transition.event },
|
|
2518
|
+
});
|
|
2519
|
+
}
|
|
2520
|
+
}
|
|
2521
|
+
}
|
|
2522
|
+
return checkResult('D1', 'Behavioral Preservation', findings, findings.length === 0 ? 'BEFORE event behavior is preserved in AFTER' : 'Behavioral preservation findings: ' + findings.length);
|
|
2523
|
+
}
|
|
2524
|
+
function mapInvariantForComparison(invariant, mapping) {
|
|
2525
|
+
if (invariant.kind === 'never-states') {
|
|
2526
|
+
return {
|
|
2527
|
+
...invariant,
|
|
2528
|
+
id: invariant.id + ':before',
|
|
2529
|
+
description: invariant.description + ' (from BEFORE)',
|
|
2530
|
+
states: invariant.states.map((state) => mapStateId(mapping, state)),
|
|
2531
|
+
};
|
|
2532
|
+
}
|
|
2533
|
+
if (invariant.kind === 'event-before-state') {
|
|
2534
|
+
return {
|
|
2535
|
+
...invariant,
|
|
2536
|
+
id: invariant.id + ':before',
|
|
2537
|
+
description: invariant.description + ' (from BEFORE)',
|
|
2538
|
+
state: mapStateId(mapping, invariant.state),
|
|
2539
|
+
};
|
|
2540
|
+
}
|
|
2541
|
+
if (invariant.kind === 'var-in-range') {
|
|
2542
|
+
const mapped = { ...invariant, id: invariant.id + ':before', description: invariant.description + ' (from BEFORE)' };
|
|
2543
|
+
// A state scope must follow the state rename, otherwise D2 reports a spurious
|
|
2544
|
+
// regression when the scope later refers to a state id that no longer exists.
|
|
2545
|
+
if (mapped.when !== undefined && isStateGuard(mapped.when)) {
|
|
2546
|
+
mapped.when = { state: mapStateId(mapping, mapped.when.state) };
|
|
2547
|
+
}
|
|
2548
|
+
return mapped;
|
|
2549
|
+
}
|
|
2550
|
+
if (invariant.kind === 'leads-to') {
|
|
2551
|
+
// A target set maps member by member; a rename that drops a state is reported by
|
|
2552
|
+
// D2's own mapped-state check rather than silently changing the property.
|
|
2553
|
+
return {
|
|
2554
|
+
...invariant,
|
|
2555
|
+
id: invariant.id + ':before',
|
|
2556
|
+
description: invariant.description + ' (from BEFORE)',
|
|
2557
|
+
from: mapStateId(mapping, invariant.from),
|
|
2558
|
+
to: typeof invariant.to === 'string' ? mapStateId(mapping, invariant.to) : invariant.to.map((state) => mapStateId(mapping, state)),
|
|
2559
|
+
};
|
|
2560
|
+
}
|
|
2561
|
+
if (invariant.kind === 'probability') {
|
|
2562
|
+
return {
|
|
2563
|
+
...invariant,
|
|
2564
|
+
id: invariant.id + ':before',
|
|
2565
|
+
description: invariant.description + ' (from BEFORE)',
|
|
2566
|
+
target: mapStateId(mapping, invariant.target),
|
|
2567
|
+
};
|
|
2568
|
+
}
|
|
2569
|
+
return { ...invariant, id: invariant.id + ':before', description: invariant.description + ' (from BEFORE)' };
|
|
2570
|
+
}
|
|
2571
|
+
function D2_invariantContinuity(before, after, options, mapping) {
|
|
2572
|
+
const findings = [];
|
|
2573
|
+
const afterStates = new Set(after.states.map((state) => state.id));
|
|
2574
|
+
const afterVariables = new Set((after.variables ?? []).map((variable) => variable.name));
|
|
2575
|
+
for (const invariant of before.invariants ?? []) {
|
|
2576
|
+
const mapped = mapInvariantForComparison(invariant, mapping);
|
|
2577
|
+
if (mapped.kind === 'never-states') {
|
|
2578
|
+
for (const state of mapped.states) {
|
|
2579
|
+
if (!afterStates.has(state)) {
|
|
2580
|
+
findings.push({
|
|
2581
|
+
code: 'D2_MAPPED_STATE_MISSING',
|
|
2582
|
+
severity: 'warning',
|
|
2583
|
+
message: 'BEFORE invariant "' + invariant.id + '" maps to state ' + state + ', which is not declared in AFTER.',
|
|
2584
|
+
evidence: { invariant: invariant.id, state },
|
|
2585
|
+
});
|
|
2586
|
+
}
|
|
2587
|
+
}
|
|
2588
|
+
}
|
|
2589
|
+
else if (mapped.kind === 'event-before-state') {
|
|
2590
|
+
if (!afterStates.has(mapped.state)) {
|
|
2591
|
+
findings.push({
|
|
2592
|
+
code: 'D2_MAPPED_STATE_MISSING',
|
|
2593
|
+
severity: 'warning',
|
|
2594
|
+
message: 'BEFORE invariant "' + invariant.id + '" maps to state ' + mapped.state + ', which is not declared in AFTER.',
|
|
2595
|
+
evidence: { invariant: invariant.id, state: mapped.state },
|
|
2596
|
+
});
|
|
2597
|
+
}
|
|
2598
|
+
}
|
|
2599
|
+
else if (mapped.kind === 'var-in-range') {
|
|
2600
|
+
if (!afterVariables.has(mapped.variable)) {
|
|
2601
|
+
findings.push({
|
|
2602
|
+
code: 'D2_VARIABLE_MISSING',
|
|
2603
|
+
severity: 'warning',
|
|
2604
|
+
message: 'BEFORE invariant "' + invariant.id + '" references variable ' + mapped.variable + ', which is not declared in AFTER.',
|
|
2605
|
+
evidence: { invariant: invariant.id, variable: mapped.variable },
|
|
2606
|
+
});
|
|
2607
|
+
continue;
|
|
2608
|
+
}
|
|
2609
|
+
}
|
|
2610
|
+
else if (mapped.kind === 'leads-to') {
|
|
2611
|
+
// The state references of a path-property invariant must follow stateMapping too,
|
|
2612
|
+
// otherwise a pure rename searches for a state AFTER does not declare.
|
|
2613
|
+
for (const state of [mapped.from, ...leadsToTargets(mapped)]) {
|
|
2614
|
+
if (!afterStates.has(state)) {
|
|
2615
|
+
findings.push({
|
|
2616
|
+
code: 'D2_MAPPED_STATE_MISSING',
|
|
2617
|
+
severity: 'warning',
|
|
2618
|
+
message: 'BEFORE invariant "' + invariant.id + '" maps to state ' + state + ', which is not declared in AFTER.',
|
|
2619
|
+
evidence: { invariant: invariant.id, state },
|
|
2620
|
+
});
|
|
2621
|
+
}
|
|
2622
|
+
}
|
|
2623
|
+
}
|
|
2624
|
+
else if (mapped.kind === 'probability') {
|
|
2625
|
+
if (!afterStates.has(mapped.target)) {
|
|
2626
|
+
findings.push({
|
|
2627
|
+
code: 'D2_MAPPED_STATE_MISSING',
|
|
2628
|
+
severity: 'warning',
|
|
2629
|
+
message: 'BEFORE invariant "' + invariant.id + '" maps to state ' + mapped.target + ', which is not declared in AFTER.',
|
|
2630
|
+
evidence: { invariant: invariant.id, state: mapped.target },
|
|
2631
|
+
});
|
|
2632
|
+
}
|
|
2633
|
+
}
|
|
2634
|
+
// Every invariant the engine can decide is re-decided against AFTER. Path-property
|
|
2635
|
+
// kinds go through the same searches their dedicated probes use, so D2 can no
|
|
2636
|
+
// longer report "all BEFORE invariants continue to hold" while A9-A13 fail on the
|
|
2637
|
+
// very same model.
|
|
2638
|
+
const violation = isPathPropertyInvariant(mapped)
|
|
2639
|
+
? pathPropertyViolation(after, options, mapped)
|
|
2640
|
+
: shortestViolationForInvariant(after, options, mapped);
|
|
2641
|
+
if (violation !== undefined) {
|
|
2642
|
+
findings.push(violation.undecided ? {
|
|
2643
|
+
code: 'D2_UNDECIDED',
|
|
2644
|
+
severity: 'warning',
|
|
2645
|
+
message: 'BEFORE invariant "' + invariant.id + '" could not be re-decided against AFTER: ' + violation.reason + '. Absence is not proven.',
|
|
2646
|
+
evidence: { beforeInvariant: invariant, afterInvariant: mapped },
|
|
2647
|
+
} : {
|
|
2648
|
+
code: 'D2_INVARIANT_REGRESSION',
|
|
2649
|
+
severity: 'error',
|
|
2650
|
+
message: 'BEFORE invariant "' + invariant.id + '" no longer holds in AFTER: ' + violation.reason,
|
|
2651
|
+
path: violation.path,
|
|
2652
|
+
evidence: { beforeInvariant: invariant, afterInvariant: mapped },
|
|
2653
|
+
});
|
|
2654
|
+
}
|
|
2655
|
+
}
|
|
2656
|
+
return checkResult('D2', 'Invariant Continuity', findings, findings.length === 0 ? 'All BEFORE invariants continue to hold' : 'Invariant continuity findings: ' + findings.length);
|
|
2657
|
+
}
|
|
2658
|
+
function D3_regressionDelta(before, after, mapping) {
|
|
2659
|
+
const afterStateIds = new Set(after.states.map((state) => state.id));
|
|
2660
|
+
const beforeMappedIds = new Set(before.states.map((state) => mapStateId(mapping, state.id)));
|
|
2661
|
+
const addedStates = after.states.filter((state) => !beforeMappedIds.has(state.id)).map((state) => state.id).sort();
|
|
2662
|
+
const removedStates = before.states.filter((state) => !afterStateIds.has(mapStateId(mapping, state.id))).map((state) => state.id).sort();
|
|
2663
|
+
const beforeEvents = new Set(before.transitions.map((transition) => transition.event));
|
|
2664
|
+
const afterEvents = new Set(after.transitions.map((transition) => transition.event));
|
|
2665
|
+
const addedEvents = [...afterEvents].filter((event) => !beforeEvents.has(event)).sort();
|
|
2666
|
+
const removedEvents = [...beforeEvents].filter((event) => !afterEvents.has(event)).sort();
|
|
2667
|
+
const removedTransitions = before.transitions.filter((transition) => !after.transitions.some((candidate) => candidate.from === mapStateId(mapping, transition.from) &&
|
|
2668
|
+
candidate.event === transition.event &&
|
|
2669
|
+
candidate.to === mapStateId(mapping, transition.to)));
|
|
2670
|
+
const addedTransitions = after.transitions.filter((transition) => !before.transitions.some((candidate) => mapStateId(mapping, candidate.from) === transition.from &&
|
|
2671
|
+
candidate.event === transition.event &&
|
|
2672
|
+
mapStateId(mapping, candidate.to) === transition.to));
|
|
2673
|
+
const findings = [];
|
|
2674
|
+
for (const state of removedStates) {
|
|
2675
|
+
findings.push({ code: 'D3_REMOVED_STATE', severity: 'warning', message: 'BEFORE state ' + state + ' is not present in AFTER under the given mapping.', evidence: { state } });
|
|
2676
|
+
}
|
|
2677
|
+
for (const event of removedEvents) {
|
|
2678
|
+
findings.push({ code: 'D3_REMOVED_EVENT', severity: 'warning', message: 'BEFORE event ' + event + ' is not present in AFTER.', evidence: { event } });
|
|
2679
|
+
}
|
|
2680
|
+
for (const transition of removedTransitions) {
|
|
2681
|
+
findings.push({
|
|
2682
|
+
code: 'D3_REMOVED_TRANSITION',
|
|
2683
|
+
severity: 'warning',
|
|
2684
|
+
message: 'BEFORE transition ' + transition.from + ' -' + transition.event + '-> ' + transition.to + ' has no exact AFTER counterpart.',
|
|
2685
|
+
evidence: { transition },
|
|
2686
|
+
});
|
|
2687
|
+
}
|
|
2688
|
+
const detail = 'Delta: +' + addedStates.length + ' states, -' + removedStates.length + ' states, +' + addedEvents.length + ' events, -' + removedEvents.length + ' events, +' + addedTransitions.length + ' transitions, -' + removedTransitions.length + ' transitions';
|
|
2689
|
+
return checkResult('D3', 'Regression Delta', findings, detail);
|
|
2690
|
+
}
|
|
2691
|
+
function deadlockStateIds(model) {
|
|
2692
|
+
const outgoing = new Set(model.transitions.map((transition) => transition.from));
|
|
2693
|
+
return model.states.filter((state) => state.terminal !== true && !outgoing.has(state.id)).map((state) => state.id);
|
|
2694
|
+
}
|
|
2695
|
+
function closedSccStateSets(model) {
|
|
2696
|
+
return sccs(model)
|
|
2697
|
+
.filter((component) => {
|
|
2698
|
+
const componentSet = new Set(component);
|
|
2699
|
+
const internalEdges = model.transitions.some((transition) => componentSet.has(transition.from) && componentSet.has(transition.to));
|
|
2700
|
+
const escapingEdges = model.transitions.some((transition) => componentSet.has(transition.from) && !componentSet.has(transition.to));
|
|
2701
|
+
return internalEdges && !escapingEdges && !component.some((state) => isTerminal(model, state));
|
|
2702
|
+
})
|
|
2703
|
+
.map((component) => component.sort());
|
|
2704
|
+
}
|
|
2705
|
+
function D4_deadlockLivenessRegression(before, after, mapping) {
|
|
2706
|
+
const findings = [];
|
|
2707
|
+
const beforeDeadlock = new Set(deadlockStateIds(before).map((state) => mapStateId(mapping, state)));
|
|
2708
|
+
const afterDeadlock = deadlockStateIds(after);
|
|
2709
|
+
for (const state of afterDeadlock) {
|
|
2710
|
+
if (!beforeDeadlock.has(state)) {
|
|
2711
|
+
findings.push({
|
|
2712
|
+
code: 'D4_DEADLOCK_REGRESSION',
|
|
2713
|
+
severity: 'error',
|
|
2714
|
+
message: 'AFTER introduces deadlock in state ' + state + ' that was not deadlocked in BEFORE.',
|
|
2715
|
+
evidence: { state },
|
|
2716
|
+
});
|
|
2717
|
+
}
|
|
2718
|
+
}
|
|
2719
|
+
const beforeScc = new Set(closedSccStateSets(before).map((component) => component.map((state) => mapStateId(mapping, state)).sort().join(',')));
|
|
2720
|
+
const afterScc = closedSccStateSets(after);
|
|
2721
|
+
for (const component of afterScc) {
|
|
2722
|
+
const key = component.join(',');
|
|
2723
|
+
if (!beforeScc.has(key)) {
|
|
2724
|
+
findings.push({
|
|
2725
|
+
code: 'D4_LIVENESS_REGRESSION',
|
|
2726
|
+
severity: 'error',
|
|
2727
|
+
message: 'AFTER introduces a closed SCC with no exit and no terminal state: ' + key,
|
|
2728
|
+
evidence: { states: component },
|
|
2729
|
+
});
|
|
2730
|
+
}
|
|
2731
|
+
}
|
|
2732
|
+
return checkResult('D4', 'Deadlock/Liveness Regression', findings, findings.length === 0 ? 'No new deadlock or liveness regressions' : 'Regression findings: ' + findings.length);
|
|
2733
|
+
}
|
|
2734
|
+
function buildComparisonSummary(before, after, mapping) {
|
|
2735
|
+
const afterStateIds = new Set(after.states.map((state) => state.id));
|
|
2736
|
+
const beforeMappedIds = new Set(before.states.map((state) => mapStateId(mapping, state.id)));
|
|
2737
|
+
const addedStates = after.states.filter((state) => !beforeMappedIds.has(state.id)).map((state) => state.id).sort();
|
|
2738
|
+
const removedStates = before.states.filter((state) => !afterStateIds.has(mapStateId(mapping, state.id))).map((state) => state.id).sort();
|
|
2739
|
+
const beforeEvents = new Set(before.transitions.map((transition) => transition.event));
|
|
2740
|
+
const afterEvents = new Set(after.transitions.map((transition) => transition.event));
|
|
2741
|
+
const addedEvents = [...afterEvents].filter((event) => !beforeEvents.has(event)).sort();
|
|
2742
|
+
const removedEvents = [...beforeEvents].filter((event) => !afterEvents.has(event)).sort();
|
|
2743
|
+
const removedTransitions = before.transitions.filter((transition) => !after.transitions.some((candidate) => candidate.from === mapStateId(mapping, transition.from) &&
|
|
2744
|
+
candidate.event === transition.event &&
|
|
2745
|
+
candidate.to === mapStateId(mapping, transition.to)));
|
|
2746
|
+
const addedTransitions = after.transitions.filter((transition) => !before.transitions.some((candidate) => mapStateId(mapping, candidate.from) === transition.from &&
|
|
2747
|
+
candidate.event === transition.event &&
|
|
2748
|
+
mapStateId(mapping, candidate.to) === transition.to));
|
|
2749
|
+
return {
|
|
2750
|
+
beforeModelHash: modelHash(before),
|
|
2751
|
+
afterModelHash: modelHash(after),
|
|
2752
|
+
stateMapping: mapping,
|
|
2753
|
+
beforeStates: before.states.length,
|
|
2754
|
+
beforeTransitions: before.transitions.length,
|
|
2755
|
+
afterStates: after.states.length,
|
|
2756
|
+
afterTransitions: after.transitions.length,
|
|
2757
|
+
addedStates,
|
|
2758
|
+
removedStates,
|
|
2759
|
+
addedEvents,
|
|
2760
|
+
removedEvents,
|
|
2761
|
+
addedTransitions,
|
|
2762
|
+
removedTransitions,
|
|
2763
|
+
};
|
|
2764
|
+
}
|
|
2765
|
+
function A8_idempotentReplay(model, exploration) {
|
|
2766
|
+
const events = model.idempotentEvents ?? [];
|
|
2767
|
+
const findings = [];
|
|
2768
|
+
for (const event of events) {
|
|
2769
|
+
for (const runtime of exploration.reachable) {
|
|
2770
|
+
const onceOptions = stepRuntime(model, runtime, event);
|
|
2771
|
+
if (onceOptions.length === 0)
|
|
2772
|
+
continue;
|
|
2773
|
+
for (const once of onceOptions) {
|
|
2774
|
+
const twiceOptions = stepRuntime(model, once, event);
|
|
2775
|
+
if (twiceOptions.length === 0) {
|
|
2776
|
+
findings.push({
|
|
2777
|
+
code: 'A8_NOT_REPLAYABLE',
|
|
2778
|
+
severity: 'warning',
|
|
2779
|
+
message: 'Idempotent event ' + event + ' is not replayable after first application from ' + runtime.state + '.',
|
|
2780
|
+
path: [{ from: runtime.state, event, to: once.state }],
|
|
2781
|
+
evidence: { state: runtime.state, event },
|
|
2782
|
+
});
|
|
2783
|
+
continue;
|
|
2784
|
+
}
|
|
2785
|
+
for (const twice of twiceOptions) {
|
|
2786
|
+
if (runtimeKey(twice) !== runtimeKey(once)) {
|
|
2787
|
+
findings.push({
|
|
2788
|
+
code: 'A8_NOT_IDEMPOTENT',
|
|
2789
|
+
severity: 'error',
|
|
2790
|
+
message: 'Idempotent event ' + event + ' changes state when applied twice from ' + runtime.state + '.',
|
|
2791
|
+
path: [{ from: runtime.state, event, to: once.state }, { from: once.state, event, to: twice.state }],
|
|
2792
|
+
evidence: { state: runtime.state, event, afterOnce: once, afterTwice: twice },
|
|
2793
|
+
});
|
|
2794
|
+
break;
|
|
2795
|
+
}
|
|
2796
|
+
}
|
|
2797
|
+
}
|
|
2798
|
+
}
|
|
2799
|
+
}
|
|
2800
|
+
return checkResult('A8', 'Idempotent Replay', withUnresolved(model, 'A8', findings), findings.length === 0 ? 'Idempotent events are replay-safe' : 'Idempotent replay findings: ' + findings.length);
|
|
2801
|
+
}
|
|
2802
|
+
/**
|
|
2803
|
+
* Whether the guard proves `variable` stays at or below `bound` wherever it holds.
|
|
2804
|
+
*
|
|
2805
|
+
* Deliberately conservative in one direction only: a guard this function cannot read
|
|
2806
|
+
* answers `false`, so an unreadable guard is reported as "not proven" rather than as
|
|
2807
|
+
* "enforced". A conjunction bounds the value if any conjunct does; a disjunction has to
|
|
2808
|
+
* bound it on every branch; a negation is never reasoned about.
|
|
2809
|
+
*/
|
|
2810
|
+
function guardImpliesUpperBound(guard, variable, bound) {
|
|
2811
|
+
if (guard === undefined)
|
|
2812
|
+
return false;
|
|
2813
|
+
if ('variable' in guard) {
|
|
2814
|
+
if (guard.variable !== variable || typeof guard.value !== 'number')
|
|
2815
|
+
return false;
|
|
2816
|
+
if (guard.op === '<')
|
|
2817
|
+
return guard.value - 1 <= bound;
|
|
2818
|
+
if (guard.op === '<=' || guard.op === '==')
|
|
2819
|
+
return guard.value <= bound;
|
|
2820
|
+
return false;
|
|
2821
|
+
}
|
|
2822
|
+
if ('all' in guard)
|
|
2823
|
+
return guard.all.some((child) => guardImpliesUpperBound(child, variable, bound));
|
|
2824
|
+
if ('any' in guard)
|
|
2825
|
+
return guard.any.every((child) => guardImpliesUpperBound(child, variable, bound));
|
|
2826
|
+
return false;
|
|
2827
|
+
}
|
|
2828
|
+
/** Mirror of {@link guardImpliesUpperBound} for the lower bound. */
|
|
2829
|
+
function guardImpliesLowerBound(guard, variable, bound) {
|
|
2830
|
+
if (guard === undefined)
|
|
2831
|
+
return false;
|
|
2832
|
+
if ('variable' in guard) {
|
|
2833
|
+
if (guard.variable !== variable || typeof guard.value !== 'number')
|
|
2834
|
+
return false;
|
|
2835
|
+
if (guard.op === '>')
|
|
2836
|
+
return guard.value + 1 >= bound;
|
|
2837
|
+
if (guard.op === '>=' || guard.op === '==')
|
|
2838
|
+
return guard.value >= bound;
|
|
2839
|
+
return false;
|
|
2840
|
+
}
|
|
2841
|
+
if ('all' in guard)
|
|
2842
|
+
return guard.all.some((child) => guardImpliesLowerBound(child, variable, bound));
|
|
2843
|
+
if ('any' in guard)
|
|
2844
|
+
return guard.any.every((child) => guardImpliesLowerBound(child, variable, bound));
|
|
2845
|
+
return false;
|
|
2846
|
+
}
|
|
2847
|
+
/**
|
|
2848
|
+
* Whether the model's own declarations about a variable hold against what its
|
|
2849
|
+
* transitions do. `inc` and `dec` are never clamped to `min`/`max` — the declared
|
|
2850
|
+
* range is a property to check, not a bound on the search — so a counter whose guard
|
|
2851
|
+
* does not pin it ranges past its own declaration, and the reachable set is unbounded
|
|
2852
|
+
* however narrow the declaration looks.
|
|
2853
|
+
*
|
|
2854
|
+
* Reporting the declaration rather than silently clamping is deliberate. Clamping would
|
|
2855
|
+
* delete the very states a range violation is witnessed by, turning a counterexample
|
|
2856
|
+
* into a false pass.
|
|
2857
|
+
*/
|
|
2858
|
+
function declaredDomainFindings(model) {
|
|
2859
|
+
const findings = [];
|
|
2860
|
+
for (const transition of model.transitions) {
|
|
2861
|
+
for (const update of transition.updates ?? []) {
|
|
2862
|
+
const variable = (model.variables ?? []).find((entry) => entry.name === update.variable);
|
|
2863
|
+
if (variable === undefined)
|
|
2864
|
+
continue;
|
|
2865
|
+
const delta = typeof update.value === 'number' ? update.value : 1;
|
|
2866
|
+
const at = transition.from + ' + ' + transition.event;
|
|
2867
|
+
if (update.op === 'inc' && variable.max !== undefined && !guardImpliesUpperBound(transition.guard, variable.name, variable.max - delta)) {
|
|
2868
|
+
findings.push({
|
|
2869
|
+
code: 'S8_DOMAIN_NOT_ENFORCED',
|
|
2870
|
+
severity: 'warning',
|
|
2871
|
+
message: at + ' increments ' + variable.name + ', but ' + variable.name + ' declares max ' + variable.max + ' and no guard keeps it there: the declared range is not a bound on reachable values.',
|
|
2872
|
+
evidence: { variable: variable.name, declaredMax: variable.max, transition: at },
|
|
2873
|
+
});
|
|
2874
|
+
}
|
|
2875
|
+
if (update.op === 'dec' && variable.min !== undefined && !guardImpliesLowerBound(transition.guard, variable.name, variable.min + delta)) {
|
|
2876
|
+
findings.push({
|
|
2877
|
+
code: 'S8_DOMAIN_NOT_ENFORCED',
|
|
2878
|
+
severity: 'warning',
|
|
2879
|
+
message: at + ' decrements ' + variable.name + ', but ' + variable.name + ' declares min ' + variable.min + ' and no guard keeps it there: the declared range is not a bound on reachable values.',
|
|
2880
|
+
evidence: { variable: variable.name, declaredMin: variable.min, transition: at },
|
|
2881
|
+
});
|
|
2882
|
+
}
|
|
2883
|
+
if (update.op === 'set' && typeof update.value === 'number') {
|
|
2884
|
+
if (variable.min !== undefined && update.value < variable.min) {
|
|
2885
|
+
findings.push({
|
|
2886
|
+
code: 'S8_DOMAIN_SET_OUTSIDE',
|
|
2887
|
+
severity: 'error',
|
|
2888
|
+
message: at + ' sets ' + variable.name + ' to ' + update.value + ', below its declared min ' + variable.min + '.',
|
|
2889
|
+
evidence: { variable: variable.name, value: update.value, declaredMin: variable.min },
|
|
2890
|
+
});
|
|
2891
|
+
}
|
|
2892
|
+
if (variable.max !== undefined && update.value > variable.max) {
|
|
2893
|
+
findings.push({
|
|
2894
|
+
code: 'S8_DOMAIN_SET_OUTSIDE',
|
|
2895
|
+
severity: 'error',
|
|
2896
|
+
message: at + ' sets ' + variable.name + ' to ' + update.value + ', above its declared max ' + variable.max + '.',
|
|
2897
|
+
evidence: { variable: variable.name, value: update.value, declaredMax: variable.max },
|
|
2898
|
+
});
|
|
2899
|
+
}
|
|
2900
|
+
}
|
|
2901
|
+
}
|
|
2902
|
+
}
|
|
2903
|
+
return findings;
|
|
2904
|
+
}
|
|
2905
|
+
function S8_monotonicVariables(model) {
|
|
2906
|
+
const findings = [];
|
|
2907
|
+
for (const variable of model.variables ?? []) {
|
|
2908
|
+
if (variable.monotonic === undefined)
|
|
2909
|
+
continue;
|
|
2910
|
+
for (const transition of model.transitions) {
|
|
2911
|
+
for (const update of transition.updates ?? []) {
|
|
2912
|
+
if (update.variable !== variable.name)
|
|
2913
|
+
continue;
|
|
2914
|
+
if (variable.monotonic === 'inc' && update.op === 'dec') {
|
|
2915
|
+
findings.push({ code: 'S8_MONOTONIC_DECREASE', severity: 'error', message: 'Monotonic (inc) variable ' + variable.name + ' is decreased by ' + transition.event + '.', evidence: { variable: variable.name, transition } });
|
|
2916
|
+
}
|
|
2917
|
+
else if (variable.monotonic === 'dec' && update.op === 'inc') {
|
|
2918
|
+
findings.push({ code: 'S8_MONOTONIC_INCREASE', severity: 'error', message: 'Monotonic (dec) variable ' + variable.name + ' is increased by ' + transition.event + '.', evidence: { variable: variable.name, transition } });
|
|
2919
|
+
}
|
|
2920
|
+
else if (update.op === 'set') {
|
|
2921
|
+
findings.push({ code: 'S8_MONOTONIC_SET_REVIEW', severity: 'warning', message: 'Monotonic variable ' + variable.name + ' uses set in ' + transition.event + '; verify it cannot move backwards.', evidence: { variable: variable.name, transition } });
|
|
2922
|
+
}
|
|
2923
|
+
}
|
|
2924
|
+
}
|
|
2925
|
+
}
|
|
2926
|
+
findings.push(...declaredDomainFindings(model));
|
|
2927
|
+
return checkResult('S8', 'Variable Declarations', findings, findings.length === 0 ? 'Variable declarations are respected' : 'Variable declaration findings: ' + findings.length);
|
|
2928
|
+
}
|
|
2929
|
+
/**
|
|
2930
|
+
* Target states of a `leads-to` invariant. The schema accepts one state id or a
|
|
2931
|
+
* non-empty array of them, so "reach any of these" needs no separate invariant kind.
|
|
2932
|
+
*/
|
|
2933
|
+
function leadsToTargets(invariant) {
|
|
2934
|
+
return typeof invariant.to === 'string' ? [invariant.to] : invariant.to;
|
|
2935
|
+
}
|
|
2936
|
+
/** Target list as findings print it. A single target renders as its bare id, so existing messages stay byte-identical. */
|
|
2937
|
+
function leadsToTargetLabel(invariant) {
|
|
2938
|
+
return leadsToTargets(invariant).join(', ');
|
|
2939
|
+
}
|
|
2940
|
+
/**
|
|
2941
|
+
* Find a run from `start` that reaches none of `targets`, or return undefined when
|
|
2942
|
+
* every run reaches at least one of them. The property is universal (see the A9 row
|
|
2943
|
+
* in `SKILL.md`): one branch that loops forever, or that stops before any target,
|
|
2944
|
+
* refutes it.
|
|
2945
|
+
*
|
|
2946
|
+
* The search is a depth-first walk of the run graph with three colours, and runs at a
|
|
2947
|
+
* target are success leaves that are never expanded:
|
|
2948
|
+
*
|
|
2949
|
+
* - a node reached while it is on the current walk (GRAY) closes a cycle that avoids
|
|
2950
|
+
* every target, so some run never reaches one;
|
|
2951
|
+
* - a node with no outgoing step at all stops the machine where it stands, which is
|
|
2952
|
+
* the same violation for a different reason;
|
|
2953
|
+
* - a node whose walk completed without a violation is BLACK, and reaching it again
|
|
2954
|
+
* from another branch is a shared sub-graph, not a cycle.
|
|
2955
|
+
*
|
|
2956
|
+
* That last point is why the colour map cannot be a plain visited set: a diamond
|
|
2957
|
+
* (two branches rejoining) is acyclic and must pass, while a genuine cycle must not.
|
|
2958
|
+
* A run is identified by its state plus its variable values, so the same state id
|
|
2959
|
+
* with different values is a different node.
|
|
2960
|
+
*
|
|
2961
|
+
* Two properties keep the walk from taking the host down, and both are load-bearing:
|
|
2962
|
+
* the walk is **budgeted**, and it stores a **parent link** per node instead of a copy
|
|
2963
|
+
* of the path. Without the budget an unbounded counter makes the run graph infinite, so
|
|
2964
|
+
* the three colours above never repeat a key and the walk never returns; without the
|
|
2965
|
+
* parent link every frame retains its own copy of the path, which grows the walk's
|
|
2966
|
+
* memory quadratically in its depth. Either one alone turns "the model is wrong" into
|
|
2967
|
+
* an out-of-memory abort, which is the failure this function must not have.
|
|
2968
|
+
*
|
|
2969
|
+
* An exhausted budget is reported as `undecided`, never as `undefined`: a walk that
|
|
2970
|
+
* did not finish has not proven that every run reaches a target.
|
|
2971
|
+
*/
|
|
2972
|
+
function findLeadsToBadPath(model, start, targets, label, walk) {
|
|
2973
|
+
if (targets.has(start.state))
|
|
2974
|
+
return { undecided: false };
|
|
2975
|
+
const GRAY = 1;
|
|
2976
|
+
const BLACK = 2;
|
|
2977
|
+
const pathOf = (frame) => {
|
|
2978
|
+
const steps = [];
|
|
2979
|
+
let node = frame;
|
|
2980
|
+
while (node !== undefined && node.step !== undefined) {
|
|
2981
|
+
steps.push(node.step);
|
|
2982
|
+
node = node.parent;
|
|
2983
|
+
}
|
|
2984
|
+
return steps.reverse();
|
|
2985
|
+
};
|
|
2986
|
+
const color = new Map();
|
|
2987
|
+
const stack = [{ runtime: start, parent: undefined, step: undefined, nexts: undefined, index: 0 }];
|
|
2988
|
+
color.set(runtimeKey(start), GRAY);
|
|
2989
|
+
while (stack.length > 0) {
|
|
2990
|
+
if (walk.remaining <= 0)
|
|
2991
|
+
return { undecided: true };
|
|
2992
|
+
walk.remaining -= 1;
|
|
2993
|
+
const frame = stack[stack.length - 1];
|
|
2994
|
+
if (frame.nexts === undefined) {
|
|
2995
|
+
const nexts = [];
|
|
2996
|
+
for (const event of allEvents(model)) {
|
|
2997
|
+
for (const next of stepRuntime(model, frame.runtime, event))
|
|
2998
|
+
nexts.push({ next, event });
|
|
2999
|
+
}
|
|
3000
|
+
frame.nexts = nexts;
|
|
3001
|
+
if (nexts.length === 0)
|
|
3002
|
+
return { violation: { path: pathOf(frame), reason: 'Dead end before target ' + label }, undecided: false };
|
|
3003
|
+
}
|
|
3004
|
+
if (frame.index >= frame.nexts.length) {
|
|
3005
|
+
color.set(runtimeKey(frame.runtime), BLACK);
|
|
3006
|
+
stack.pop();
|
|
3007
|
+
continue;
|
|
3008
|
+
}
|
|
3009
|
+
const { next, event } = frame.nexts[frame.index];
|
|
3010
|
+
frame.index += 1;
|
|
3011
|
+
const step = { from: frame.runtime.state, event, to: next.state };
|
|
3012
|
+
if (targets.has(next.state))
|
|
3013
|
+
continue;
|
|
3014
|
+
const key = runtimeKey(next);
|
|
3015
|
+
const seen = color.get(key);
|
|
3016
|
+
if (seen === GRAY)
|
|
3017
|
+
return { violation: { path: [...pathOf(frame), step], reason: 'Cycle avoids target ' + label }, undecided: false };
|
|
3018
|
+
if (seen === BLACK)
|
|
3019
|
+
continue;
|
|
3020
|
+
color.set(key, GRAY);
|
|
3021
|
+
stack.push({ runtime: next, parent: frame, step, nexts: undefined, index: 0 });
|
|
3022
|
+
}
|
|
3023
|
+
// Every branch either reached a target or rejoined a branch that did.
|
|
3024
|
+
return { undecided: false };
|
|
3025
|
+
}
|
|
3026
|
+
function A9_leadsTo(model, exploration, options) {
|
|
3027
|
+
const findings = [];
|
|
3028
|
+
for (const invariant of model.invariants ?? []) {
|
|
3029
|
+
if (invariant.kind !== 'leads-to')
|
|
3030
|
+
continue;
|
|
3031
|
+
const targets = new Set(leadsToTargets(invariant));
|
|
3032
|
+
const label = leadsToTargetLabel(invariant);
|
|
3033
|
+
const budget = leadsToWalkBudget(options);
|
|
3034
|
+
const walk = { remaining: budget };
|
|
3035
|
+
for (const runtime of exploration.reachable) {
|
|
3036
|
+
if (runtime.state !== invariant.from)
|
|
3037
|
+
continue;
|
|
3038
|
+
const outcome = findLeadsToBadPath(model, runtime, targets, label, walk);
|
|
3039
|
+
if (outcome.undecided) {
|
|
3040
|
+
findings.push({
|
|
3041
|
+
code: 'A9_LEADS_TO_UNDECIDED',
|
|
3042
|
+
severity: 'warning',
|
|
3043
|
+
message: 'Leads-to invariant "' + invariant.id + '" is undecided: the walk spent its budget of ' + budget + ' steps from ' + invariant.from + ' without finding a run that avoids ' + label + ', or proving that none exists. Absence is not proven.',
|
|
3044
|
+
evidence: { invariant },
|
|
3045
|
+
});
|
|
3046
|
+
break;
|
|
3047
|
+
}
|
|
3048
|
+
if (outcome.violation !== undefined) {
|
|
3049
|
+
findings.push({
|
|
3050
|
+
code: 'A9_LEADS_TO_VIOLATION',
|
|
3051
|
+
severity: 'error',
|
|
3052
|
+
message: 'Leads-to invariant "' + invariant.id + '" violated from ' + invariant.from + ': ' + outcome.violation.reason,
|
|
3053
|
+
path: outcome.violation.path,
|
|
3054
|
+
evidence: { invariant },
|
|
3055
|
+
});
|
|
3056
|
+
break;
|
|
3057
|
+
}
|
|
3058
|
+
}
|
|
3059
|
+
}
|
|
3060
|
+
const undecided = findings.some((finding) => finding.code === 'A9_LEADS_TO_UNDECIDED');
|
|
3061
|
+
return checkResult('A9', 'Leads-To', findings, findings.length === 0
|
|
3062
|
+
? 'All leads-to invariants hold'
|
|
3063
|
+
: undecided ? 'Leads-to undecided: the search budget ran out' : 'Leads-to findings: ' + findings.length);
|
|
3064
|
+
}
|
|
3065
|
+
function findSequenceViolation(model, options, invariant) {
|
|
3066
|
+
const events = invariant.events;
|
|
3067
|
+
const init = initialState(model);
|
|
3068
|
+
const key = (runtime, progress) => runtimeKey(runtime) + '|' + progress;
|
|
3069
|
+
const visited = new Set([key(init, 0)]);
|
|
3070
|
+
const queue = [{ runtime: init, progress: 0, path: [] }];
|
|
3071
|
+
let steps = 0;
|
|
3072
|
+
// A cap is not a verdict. This walk keys on the runtime plus the progress along the declared order, so it
|
|
3073
|
+
// outlasts the exploration, and the caller has to be able to tell "no violation" from "not finished".
|
|
3074
|
+
let capped = false;
|
|
3075
|
+
while (queue.length > 0) {
|
|
3076
|
+
const entry = queue.shift();
|
|
3077
|
+
if (++steps > options.maxStates) {
|
|
3078
|
+
capped = true;
|
|
3079
|
+
break;
|
|
3080
|
+
}
|
|
3081
|
+
for (const event of allEvents(model)) {
|
|
3082
|
+
for (const next of stepRuntime(model, entry.runtime, event)) {
|
|
3083
|
+
let progress = entry.progress;
|
|
3084
|
+
let violation = false;
|
|
3085
|
+
if (progress < events.length && event === events[progress]) {
|
|
3086
|
+
progress += 1;
|
|
3087
|
+
}
|
|
3088
|
+
else {
|
|
3089
|
+
const index = events.indexOf(event);
|
|
3090
|
+
if (index > progress)
|
|
3091
|
+
violation = true;
|
|
3092
|
+
}
|
|
3093
|
+
const path = [...entry.path, { from: entry.runtime.state, event, to: next.state }];
|
|
3094
|
+
if (violation) {
|
|
3095
|
+
return { invariant, path, reason: 'Event ' + event + ' occurred before ' + events[progress] };
|
|
3096
|
+
}
|
|
3097
|
+
const nextKey = key(next, progress);
|
|
3098
|
+
if (!visited.has(nextKey)) {
|
|
3099
|
+
visited.add(nextKey);
|
|
3100
|
+
queue.push({ runtime: next, progress, path });
|
|
3101
|
+
}
|
|
3102
|
+
}
|
|
3103
|
+
}
|
|
3104
|
+
}
|
|
3105
|
+
if (capped)
|
|
3106
|
+
return { invariant, path: [], reason: invariantCapReason(options), undecided: true };
|
|
3107
|
+
return undefined;
|
|
3108
|
+
}
|
|
3109
|
+
function A10_sequenceOrder(model, options) {
|
|
3110
|
+
const findings = [];
|
|
3111
|
+
for (const invariant of model.invariants ?? []) {
|
|
3112
|
+
if (invariant.kind !== 'sequence')
|
|
3113
|
+
continue;
|
|
3114
|
+
const violation = findSequenceViolation(model, options, invariant);
|
|
3115
|
+
if (violation === undefined)
|
|
3116
|
+
continue;
|
|
3117
|
+
if (violation.undecided) {
|
|
3118
|
+
findings.push({
|
|
3119
|
+
code: 'A10_UNDECIDED',
|
|
3120
|
+
severity: 'warning',
|
|
3121
|
+
message: 'Sequence invariant "' + invariant.id + '" (' + invariant.description + ') could not be decided: ' + violation.reason + '. Absence of a violation is not proven.',
|
|
3122
|
+
evidence: { invariant, maxStates: options.maxStates },
|
|
3123
|
+
});
|
|
3124
|
+
continue;
|
|
3125
|
+
}
|
|
3126
|
+
findings.push({
|
|
3127
|
+
code: 'A10_SEQUENCE_VIOLATION',
|
|
3128
|
+
severity: 'error',
|
|
3129
|
+
message: 'Sequence invariant "' + invariant.id + '" violated: ' + violation.reason,
|
|
3130
|
+
path: violation.path,
|
|
3131
|
+
evidence: { invariant },
|
|
3132
|
+
});
|
|
3133
|
+
}
|
|
3134
|
+
const undecided = findings.some((finding) => finding.code === 'A10_UNDECIDED');
|
|
3135
|
+
return checkResult('A10', 'Sequence Order', findings, findings.length === 0
|
|
3136
|
+
? 'All sequence invariants hold'
|
|
3137
|
+
: undecided && findings.every((finding) => finding.code === 'A10_UNDECIDED') ? 'Sequence walk stopped at the state budget: undecided' : 'Sequence findings: ' + findings.length);
|
|
3138
|
+
}
|
|
3139
|
+
function findAtomicityViolation(model, options, invariant) {
|
|
3140
|
+
const atomic = new Set(invariant.events);
|
|
3141
|
+
const init = initialState(model);
|
|
3142
|
+
const key = (runtime, started, closed) => runtimeKey(runtime) + '|' + (started ? '1' : '0') + '|' + (closed ? '1' : '0');
|
|
3143
|
+
const visited = new Set([key(init, false, false)]);
|
|
3144
|
+
const queue = [{ runtime: init, started: false, closed: false, path: [] }];
|
|
3145
|
+
let steps = 0;
|
|
3146
|
+
// Same third outcome as the other walks: this one keys on the runtime plus the two flags of the atomic group,
|
|
3147
|
+
// so it can outlast the exploration, and "no leak" is not the same answer as "not finished".
|
|
3148
|
+
let capped = false;
|
|
3149
|
+
while (queue.length > 0) {
|
|
3150
|
+
const entry = queue.shift();
|
|
3151
|
+
if (++steps > options.maxStates) {
|
|
3152
|
+
capped = true;
|
|
3153
|
+
break;
|
|
3154
|
+
}
|
|
3155
|
+
for (const event of allEvents(model)) {
|
|
3156
|
+
for (const next of stepRuntime(model, entry.runtime, event)) {
|
|
3157
|
+
const started = entry.started || atomic.has(event);
|
|
3158
|
+
const closed = entry.closed || event === invariant.commit || (invariant.rollback !== undefined && event === invariant.rollback);
|
|
3159
|
+
const path = [...entry.path, { from: entry.runtime.state, event, to: next.state }];
|
|
3160
|
+
if (entry.started && !entry.closed && !atomic.has(event) && event !== invariant.commit && event !== invariant.rollback) {
|
|
3161
|
+
return { invariant, path, reason: 'Left atomic scope via ' + event + ' without commit/rollback' };
|
|
3162
|
+
}
|
|
3163
|
+
if (started && !closed && isTerminal(model, next.state)) {
|
|
3164
|
+
return { invariant, path, reason: 'Terminal state reached with incomplete atomic group' };
|
|
3165
|
+
}
|
|
3166
|
+
const nextKey = key(next, started, closed);
|
|
3167
|
+
if (!visited.has(nextKey)) {
|
|
3168
|
+
visited.add(nextKey);
|
|
3169
|
+
queue.push({ runtime: next, started, closed, path });
|
|
3170
|
+
}
|
|
3171
|
+
}
|
|
3172
|
+
}
|
|
3173
|
+
}
|
|
3174
|
+
if (capped)
|
|
3175
|
+
return { invariant, path: [], reason: invariantCapReason(options), undecided: true };
|
|
3176
|
+
return undefined;
|
|
3177
|
+
}
|
|
3178
|
+
function A11_atomicity(model, options) {
|
|
3179
|
+
const findings = [];
|
|
3180
|
+
for (const invariant of model.invariants ?? []) {
|
|
3181
|
+
if (invariant.kind !== 'atomicity')
|
|
3182
|
+
continue;
|
|
3183
|
+
const violation = findAtomicityViolation(model, options, invariant);
|
|
3184
|
+
if (violation === undefined)
|
|
3185
|
+
continue;
|
|
3186
|
+
if (violation.undecided) {
|
|
3187
|
+
findings.push({
|
|
3188
|
+
code: 'A11_UNDECIDED',
|
|
3189
|
+
severity: 'warning',
|
|
3190
|
+
message: 'Atomicity invariant "' + invariant.id + '" (' + invariant.description + ') could not be decided: ' + violation.reason + '. Absence of a violation is not proven.',
|
|
3191
|
+
evidence: { invariant, maxStates: options.maxStates },
|
|
3192
|
+
});
|
|
3193
|
+
continue;
|
|
3194
|
+
}
|
|
3195
|
+
findings.push({
|
|
3196
|
+
code: 'A11_ATOMICITY_VIOLATION',
|
|
3197
|
+
severity: 'error',
|
|
3198
|
+
message: 'Atomicity invariant "' + invariant.id + '" violated: ' + violation.reason,
|
|
3199
|
+
path: violation.path,
|
|
3200
|
+
evidence: { invariant },
|
|
3201
|
+
});
|
|
3202
|
+
}
|
|
3203
|
+
const undecided = findings.some((finding) => finding.code === 'A11_UNDECIDED');
|
|
3204
|
+
return checkResult('A11', 'Atomicity', findings, findings.length === 0
|
|
3205
|
+
? 'All atomicity invariants hold'
|
|
3206
|
+
: undecided && findings.every((finding) => finding.code === 'A11_UNDECIDED') ? 'Atomicity walk stopped at the state budget: undecided' : 'Atomicity findings: ' + findings.length);
|
|
3207
|
+
}
|
|
3208
|
+
// ---------------------------------------------------------------------------
|
|
3209
|
+
// A12 — Budget (worst-case path cost)
|
|
3210
|
+
// ---------------------------------------------------------------------------
|
|
3211
|
+
function transitionCost(transition) {
|
|
3212
|
+
return transition.cost ?? 1;
|
|
3213
|
+
}
|
|
3214
|
+
function runtimeGraph(model, options) {
|
|
3215
|
+
const groupMap = groupTransitions(model);
|
|
3216
|
+
const exploration = explore(model, options);
|
|
3217
|
+
const edges = new Map();
|
|
3218
|
+
for (const runtime of exploration.reachable) {
|
|
3219
|
+
const out = [];
|
|
3220
|
+
for (const event of allEvents(model)) {
|
|
3221
|
+
const group = groupMap.get(runtime.state + '|' + event);
|
|
3222
|
+
if (group === undefined || group.length === 0)
|
|
3223
|
+
continue;
|
|
3224
|
+
for (const transition of applicableTransitions(group, runtime)) {
|
|
3225
|
+
const next = applyUpdates(model, transition, runtime);
|
|
3226
|
+
out.push({
|
|
3227
|
+
to: runtimeKey(next),
|
|
3228
|
+
step: { from: runtime.state, event: transition.event, to: next.state },
|
|
3229
|
+
cost: transitionCost(transition),
|
|
3230
|
+
});
|
|
3231
|
+
}
|
|
3232
|
+
}
|
|
3233
|
+
edges.set(runtimeKey(runtime), out);
|
|
3234
|
+
}
|
|
3235
|
+
return {
|
|
3236
|
+
edges,
|
|
3237
|
+
keys: exploration.reachable.map((runtime) => runtimeKey(runtime)),
|
|
3238
|
+
initKey: runtimeKey(exploration.initialState),
|
|
3239
|
+
};
|
|
3240
|
+
}
|
|
3241
|
+
/** Shortest path from init to targetKey over the runtime graph, or undefined when unreachable. */
|
|
3242
|
+
function runtimePathToKey(graph, targetKey) {
|
|
3243
|
+
const parent = new Map();
|
|
3244
|
+
const visited = new Set([graph.initKey]);
|
|
3245
|
+
const queue = [graph.initKey];
|
|
3246
|
+
while (queue.length > 0) {
|
|
3247
|
+
const current = queue.shift();
|
|
3248
|
+
if (current === targetKey) {
|
|
3249
|
+
const steps = [];
|
|
3250
|
+
let key = targetKey;
|
|
3251
|
+
while (key !== graph.initKey) {
|
|
3252
|
+
const step = parent.get(key);
|
|
3253
|
+
if (step === undefined)
|
|
3254
|
+
break;
|
|
3255
|
+
steps.unshift(step);
|
|
3256
|
+
key = step.from === undefined ? graph.initKey : keyOfFromStep(graph, step, key);
|
|
3257
|
+
}
|
|
3258
|
+
return steps;
|
|
3259
|
+
}
|
|
3260
|
+
for (const edge of graph.edges.get(current) ?? []) {
|
|
3261
|
+
if (visited.has(edge.to))
|
|
3262
|
+
continue;
|
|
3263
|
+
visited.add(edge.to);
|
|
3264
|
+
parent.set(edge.to, edge.step);
|
|
3265
|
+
queue.push(edge.to);
|
|
3266
|
+
}
|
|
3267
|
+
}
|
|
3268
|
+
return undefined;
|
|
3269
|
+
}
|
|
3270
|
+
function keyOfFromStep(graph, step, targetKey) {
|
|
3271
|
+
// Reconstruct the source key by scanning predecessors of targetKey whose step matches.
|
|
3272
|
+
for (const [from, edges] of graph.edges) {
|
|
3273
|
+
for (const edge of edges) {
|
|
3274
|
+
if (edge.to === targetKey && edge.step.event === step.event && edge.step.from === step.from && edge.step.to === step.to) {
|
|
3275
|
+
return from;
|
|
3276
|
+
}
|
|
3277
|
+
}
|
|
3278
|
+
}
|
|
3279
|
+
return graph.initKey;
|
|
3280
|
+
}
|
|
3281
|
+
/** Find a reachable cycle whose total cost is positive; return a witness path init -> one full round. */
|
|
3282
|
+
function findUnboundedCycle(model, options) {
|
|
3283
|
+
const graph = runtimeGraph(model, options);
|
|
3284
|
+
const color = new Map();
|
|
3285
|
+
for (const start of graph.keys) {
|
|
3286
|
+
if (color.has(start))
|
|
3287
|
+
continue;
|
|
3288
|
+
const nodeStack = [start];
|
|
3289
|
+
const idxStack = [0];
|
|
3290
|
+
const depthOf = new Map([[start, 0]]);
|
|
3291
|
+
const costAt = new Map([[start, 0]]);
|
|
3292
|
+
const prevKey = new Map();
|
|
3293
|
+
const prevStep = new Map();
|
|
3294
|
+
color.set(start, 1);
|
|
3295
|
+
while (nodeStack.length > 0) {
|
|
3296
|
+
const node = nodeStack[nodeStack.length - 1];
|
|
3297
|
+
const outs = graph.edges.get(node) ?? [];
|
|
3298
|
+
if (idxStack[idxStack.length - 1] < outs.length) {
|
|
3299
|
+
const edge = outs[idxStack[idxStack.length - 1]++];
|
|
3300
|
+
const seen = color.get(edge.to);
|
|
3301
|
+
if (seen === undefined) {
|
|
3302
|
+
color.set(edge.to, 1);
|
|
3303
|
+
depthOf.set(edge.to, depthOf.get(node) + 1);
|
|
3304
|
+
costAt.set(edge.to, costAt.get(node) + edge.cost);
|
|
3305
|
+
prevKey.set(edge.to, node);
|
|
3306
|
+
prevStep.set(edge.to, edge.step);
|
|
3307
|
+
nodeStack.push(edge.to);
|
|
3308
|
+
idxStack.push(0);
|
|
3309
|
+
}
|
|
3310
|
+
else if (seen === 1) {
|
|
3311
|
+
// Back edge on the current path: this is a true cycle.
|
|
3312
|
+
const cycleCost = costAt.get(node) + edge.cost - costAt.get(edge.to);
|
|
3313
|
+
if (cycleCost > 0) {
|
|
3314
|
+
const entry = runtimePathToKey(graph, edge.to) ?? [];
|
|
3315
|
+
const round = [];
|
|
3316
|
+
let cursor = node;
|
|
3317
|
+
while (cursor !== edge.to && prevStep.has(cursor)) {
|
|
3318
|
+
round.unshift(prevStep.get(cursor));
|
|
3319
|
+
cursor = prevKey.get(cursor);
|
|
3320
|
+
}
|
|
3321
|
+
round.push(edge.step);
|
|
3322
|
+
return [...entry, ...round];
|
|
3323
|
+
}
|
|
3324
|
+
}
|
|
3325
|
+
}
|
|
3326
|
+
else {
|
|
3327
|
+
color.set(node, 2);
|
|
3328
|
+
nodeStack.pop();
|
|
3329
|
+
idxStack.pop();
|
|
3330
|
+
depthOf.delete(node);
|
|
3331
|
+
costAt.delete(node);
|
|
3332
|
+
prevKey.delete(node);
|
|
3333
|
+
prevStep.delete(node);
|
|
3334
|
+
}
|
|
3335
|
+
}
|
|
3336
|
+
}
|
|
3337
|
+
return undefined;
|
|
3338
|
+
}
|
|
3339
|
+
function findBudgetViolation(model, options, invariant) {
|
|
3340
|
+
const groupMap = groupTransitions(model);
|
|
3341
|
+
const init = initialState(model);
|
|
3342
|
+
const bestCost = new Map([[runtimeKey(init), 0]]);
|
|
3343
|
+
const queue = [{ runtime: init, cost: 0, path: [] }];
|
|
3344
|
+
let steps = 0;
|
|
3345
|
+
while (queue.length > 0) {
|
|
3346
|
+
const entry = queue.shift();
|
|
3347
|
+
// Stopping here decides nothing: the cost of the paths beyond the budget is unknown, which is not the
|
|
3348
|
+
// same answer as 'no violation'. It is the third outcome this function now has.
|
|
3349
|
+
if (++steps > options.maxStates)
|
|
3350
|
+
return 'undecided';
|
|
3351
|
+
for (const event of allEvents(model)) {
|
|
3352
|
+
const group = groupMap.get(entry.runtime.state + '|' + event);
|
|
3353
|
+
if (group === undefined || group.length === 0)
|
|
3354
|
+
continue;
|
|
3355
|
+
for (const transition of applicableTransitions(group, entry.runtime)) {
|
|
3356
|
+
const next = applyUpdates(model, transition, entry.runtime);
|
|
3357
|
+
const cost = entry.cost + transitionCost(transition);
|
|
3358
|
+
const path = [...entry.path, { from: entry.runtime.state, event: transition.event, to: next.state }];
|
|
3359
|
+
// Costs are non-negative, so the first step that pushes the accumulated cost
|
|
3360
|
+
// over the budget is already a witness.
|
|
3361
|
+
if (cost > invariant.budget)
|
|
3362
|
+
return { path, cost, unbounded: false };
|
|
3363
|
+
const key = runtimeKey(next);
|
|
3364
|
+
const best = bestCost.get(key);
|
|
3365
|
+
if (best === undefined || cost < best) {
|
|
3366
|
+
bestCost.set(key, cost);
|
|
3367
|
+
queue.push({ runtime: next, cost, path });
|
|
3368
|
+
}
|
|
3369
|
+
}
|
|
3370
|
+
}
|
|
3371
|
+
}
|
|
3372
|
+
// No finite path exceeds the budget within reachable space; a reachable positive-cost
|
|
3373
|
+
// cycle still makes the worst-case unbounded. Plain DAG merges are not cycles.
|
|
3374
|
+
const cycle = findUnboundedCycle(model, options);
|
|
3375
|
+
if (cycle !== undefined)
|
|
3376
|
+
return { path: cycle, unbounded: true };
|
|
3377
|
+
return undefined;
|
|
3378
|
+
}
|
|
3379
|
+
function A12_budget(model, options) {
|
|
3380
|
+
const budgets = (model.invariants ?? []).filter((invariant) => invariant.kind === 'budget');
|
|
3381
|
+
const usesCost = model.transitions.some((transition) => transition.cost !== undefined);
|
|
3382
|
+
const findings = [];
|
|
3383
|
+
if (usesCost && budgets.length === 0) {
|
|
3384
|
+
findings.push({
|
|
3385
|
+
code: 'A12_COST_WITHOUT_BUDGET',
|
|
3386
|
+
severity: 'warning',
|
|
3387
|
+
message: 'Transitions declare cost, but no budget invariant is declared, so worst-case path cost is not verified. Add an invariant of kind budget to check it.',
|
|
3388
|
+
});
|
|
3389
|
+
}
|
|
3390
|
+
for (const invariant of budgets) {
|
|
3391
|
+
const violation = findBudgetViolation(model, options, invariant);
|
|
3392
|
+
if (violation === 'undecided') {
|
|
3393
|
+
findings.push({
|
|
3394
|
+
code: 'A12_UNDECIDED',
|
|
3395
|
+
severity: 'warning',
|
|
3396
|
+
message: 'The worst-case path search for budget invariant "' + invariant.id + '" stopped at the ' + String(options.maxStates) + ' state budget before it finished, so the budget is undecided rather than met. Raise maxStates, or narrow the model.',
|
|
3397
|
+
evidence: { invariant, maxStates: options.maxStates },
|
|
3398
|
+
});
|
|
3399
|
+
continue;
|
|
3400
|
+
}
|
|
3401
|
+
if (violation !== undefined) {
|
|
3402
|
+
findings.push({
|
|
3403
|
+
code: 'A12_BUDGET_OVER',
|
|
3404
|
+
severity: 'error',
|
|
3405
|
+
message: violation.unbounded
|
|
3406
|
+
? 'Budget invariant "' + invariant.id + '" (' + invariant.description + ') exceeded: a reachable positive-cost cycle lets path cost grow without bound, so no finite budget ' + invariant.budget + ' holds.'
|
|
3407
|
+
: 'Budget invariant "' + invariant.id + '" (' + invariant.description + ') exceeded: worst-case path cost ' + violation.cost + ' is over budget ' + invariant.budget + '.',
|
|
3408
|
+
path: violation.path,
|
|
3409
|
+
evidence: violation.unbounded ? { invariant, unbounded: true } : { invariant, totalCost: violation.cost, unbounded: false },
|
|
3410
|
+
});
|
|
3411
|
+
}
|
|
3412
|
+
}
|
|
3413
|
+
const undecided = findings.some((finding) => finding.code === 'A12_UNDECIDED');
|
|
3414
|
+
return checkResult('A12', 'Budget', findings, undecided ? 'Budget walk stopped at the state budget: undecided' : findings.length === 0 ? (budgets.length === 0 ? 'No budget invariants declared' : 'All budget invariants hold') : 'Budget findings: ' + findings.length);
|
|
3415
|
+
}
|
|
3416
|
+
// ---------------------------------------------------------------------------
|
|
3417
|
+
// A13 — Probability reachability (DTMC)
|
|
3418
|
+
// ---------------------------------------------------------------------------
|
|
3419
|
+
function probabilityOutcomes(model, runtime) {
|
|
3420
|
+
const groupMap = groupTransitions(model);
|
|
3421
|
+
const outcomes = new Map();
|
|
3422
|
+
for (const event of allEvents(model)) {
|
|
3423
|
+
const group = groupMap.get(runtime.state + '|' + event);
|
|
3424
|
+
if (group === undefined || group.length === 0)
|
|
3425
|
+
continue;
|
|
3426
|
+
for (const transition of applicableTransitions(group, runtime)) {
|
|
3427
|
+
const weight = transition.weight ?? 1;
|
|
3428
|
+
if (!(weight > 0))
|
|
3429
|
+
continue;
|
|
3430
|
+
const next = applyUpdates(model, transition, runtime);
|
|
3431
|
+
const key = runtimeKey(next);
|
|
3432
|
+
const current = outcomes.get(key);
|
|
3433
|
+
if (current === undefined)
|
|
3434
|
+
outcomes.set(key, { state: next.state, weight });
|
|
3435
|
+
else
|
|
3436
|
+
current.weight += weight;
|
|
3437
|
+
}
|
|
3438
|
+
}
|
|
3439
|
+
return outcomes;
|
|
3440
|
+
}
|
|
3441
|
+
/**
|
|
3442
|
+
* Probability of ever reaching targetState from the initial state under the DTMC
|
|
3443
|
+
* interpretation: at each runtime state every applicable transition with weight > 0 is an
|
|
3444
|
+
* outcome chosen proportionally to its weight. Solved by value iteration (least fixed
|
|
3445
|
+
* point of the absorbing chain).
|
|
3446
|
+
*/
|
|
3447
|
+
function computeHitProbability(model, options, targetState) {
|
|
3448
|
+
const exploration = explore(model, options);
|
|
3449
|
+
const reachable = exploration.reachable;
|
|
3450
|
+
const n = reachable.length;
|
|
3451
|
+
const keyIndex = new Map();
|
|
3452
|
+
reachable.forEach((runtime, i) => keyIndex.set(runtimeKey(runtime), i));
|
|
3453
|
+
const fixedValue = new Array(n).fill(null);
|
|
3454
|
+
const chains = [];
|
|
3455
|
+
for (let i = 0; i < n; i++) {
|
|
3456
|
+
const runtime = reachable[i];
|
|
3457
|
+
if (runtime.state === targetState) {
|
|
3458
|
+
fixedValue[i] = 1;
|
|
3459
|
+
chains.push([]);
|
|
3460
|
+
continue;
|
|
3461
|
+
}
|
|
3462
|
+
const outs = probabilityOutcomes(model, runtime);
|
|
3463
|
+
let sum = 0;
|
|
3464
|
+
const list = [];
|
|
3465
|
+
for (const [key, entry] of outs) {
|
|
3466
|
+
const j = keyIndex.get(key);
|
|
3467
|
+
if (j === undefined)
|
|
3468
|
+
continue;
|
|
3469
|
+
list.push({ j, w: entry.weight });
|
|
3470
|
+
sum += entry.weight;
|
|
3471
|
+
}
|
|
3472
|
+
if (sum <= 0) {
|
|
3473
|
+
fixedValue[i] = 0;
|
|
3474
|
+
chains.push([]);
|
|
3475
|
+
continue;
|
|
3476
|
+
}
|
|
3477
|
+
chains.push(list.map((item) => ({ j: item.j, w: item.w / sum })));
|
|
3478
|
+
}
|
|
3479
|
+
const p = new Array(n).fill(0);
|
|
3480
|
+
let converged = false;
|
|
3481
|
+
for (let iter = 0; iter < 20000; iter++) {
|
|
3482
|
+
let maxDelta = 0;
|
|
3483
|
+
for (let i = 0; i < n; i++) {
|
|
3484
|
+
if (fixedValue[i] !== null)
|
|
3485
|
+
continue;
|
|
3486
|
+
const list = chains[i];
|
|
3487
|
+
let acc = 0;
|
|
3488
|
+
for (const item of list) {
|
|
3489
|
+
const fixed = fixedValue[item.j];
|
|
3490
|
+
acc += item.w * (fixed === null ? p[item.j] : fixed);
|
|
3491
|
+
}
|
|
3492
|
+
const delta = Math.abs(acc - p[i]);
|
|
3493
|
+
if (delta > maxDelta)
|
|
3494
|
+
maxDelta = delta;
|
|
3495
|
+
p[i] = acc;
|
|
3496
|
+
}
|
|
3497
|
+
if (maxDelta < 1e-9) {
|
|
3498
|
+
converged = true;
|
|
3499
|
+
break;
|
|
3500
|
+
}
|
|
3501
|
+
}
|
|
3502
|
+
const initIndex = keyIndex.get(runtimeKey(exploration.initialState)) ?? 0;
|
|
3503
|
+
const fixed = fixedValue[initIndex];
|
|
3504
|
+
return { probability: fixed === null ? p[initIndex] : fixed, converged };
|
|
3505
|
+
}
|
|
3506
|
+
function A13_probability(model, options) {
|
|
3507
|
+
const findings = [];
|
|
3508
|
+
for (const invariant of model.invariants ?? []) {
|
|
3509
|
+
if (invariant.kind !== 'probability')
|
|
3510
|
+
continue;
|
|
3511
|
+
const { probability, converged } = computeHitProbability(model, options, invariant.target);
|
|
3512
|
+
if (probabilityViolated(invariant, probability)) {
|
|
3513
|
+
findings.push({
|
|
3514
|
+
code: 'A13_PROBABILITY_VIOLATION',
|
|
3515
|
+
severity: 'error',
|
|
3516
|
+
message: 'Probability invariant "' + invariant.id + '" (' + invariant.description + ') violated: P(hit ' + invariant.target + ') = ' + probability.toFixed(6) + ' does not satisfy ' + invariant.op + ' ' + invariant.p + '.',
|
|
3517
|
+
evidence: { invariant, computed: probability, converged },
|
|
3518
|
+
});
|
|
3519
|
+
}
|
|
3520
|
+
else if (!converged) {
|
|
3521
|
+
findings.push({
|
|
3522
|
+
code: 'A13_NO_CONVERGENCE',
|
|
3523
|
+
severity: 'warning',
|
|
3524
|
+
message: 'Probability invariant "' + invariant.id + '" passes, but value iteration did not fully converge within the iteration cap.',
|
|
3525
|
+
evidence: { invariant, computed: probability, converged },
|
|
3526
|
+
});
|
|
3527
|
+
}
|
|
3528
|
+
}
|
|
3529
|
+
return checkResult('A13', 'Probability Reachability', findings, findings.length === 0 ? 'No probability invariants declared or all hold' : 'Probability findings: ' + findings.length);
|
|
3530
|
+
}
|
|
3531
|
+
// ---------------------------------------------------------------------------
|
|
3532
|
+
// A14 — Deadline (discrete tick clock)
|
|
3533
|
+
// ---------------------------------------------------------------------------
|
|
3534
|
+
function A14_deadline(model, options) {
|
|
3535
|
+
const findings = [];
|
|
3536
|
+
const tickEvents = new Set(model.tickEvents ?? []);
|
|
3537
|
+
const limits = new Map();
|
|
3538
|
+
let hasLimit = false;
|
|
3539
|
+
for (const state of model.states) {
|
|
3540
|
+
if (state.maxTicks !== undefined) {
|
|
3541
|
+
limits.set(state.id, state.maxTicks);
|
|
3542
|
+
hasLimit = true;
|
|
3543
|
+
}
|
|
3544
|
+
}
|
|
3545
|
+
if (!hasLimit) {
|
|
3546
|
+
return checkResult('A14', 'Deadline', withUnresolved(model, 'A14', []), 'No state declares maxTicks');
|
|
3547
|
+
}
|
|
3548
|
+
if (tickEvents.size === 0) {
|
|
3549
|
+
return checkResult('A14', 'Deadline', withUnresolved(model, 'A14', [{
|
|
3550
|
+
code: 'A14_NO_TICK_EVENTS',
|
|
3551
|
+
severity: 'warning',
|
|
3552
|
+
message: 'States declare maxTicks, but no tickEvents are declared, so deadline compliance cannot be verified.',
|
|
3553
|
+
}]), 'Missing tick events');
|
|
3554
|
+
}
|
|
3555
|
+
const groupMap = groupTransitions(model);
|
|
3556
|
+
const init = initialState(model);
|
|
3557
|
+
const limitOf = (stateId) => limits.get(stateId);
|
|
3558
|
+
const queue = [{ runtime: init, ticks: 0, path: [] }];
|
|
3559
|
+
const seen = new Set();
|
|
3560
|
+
let steps = 0;
|
|
3561
|
+
// Whether the walk stopped because the budget ran out rather than because the space was exhausted. A
|
|
3562
|
+
// search that did not finish cannot answer "all deadlines hold", so the check has to say which it was.
|
|
3563
|
+
let capped = false;
|
|
3564
|
+
while (queue.length > 0) {
|
|
3565
|
+
const entry = queue.shift();
|
|
3566
|
+
if (++steps > options.maxStates) {
|
|
3567
|
+
capped = true;
|
|
3568
|
+
break;
|
|
3569
|
+
}
|
|
3570
|
+
const key = runtimeKey(entry.runtime) + '|' + entry.ticks;
|
|
3571
|
+
if (seen.has(key))
|
|
3572
|
+
continue;
|
|
3573
|
+
seen.add(key);
|
|
3574
|
+
for (const event of allEvents(model)) {
|
|
3575
|
+
const group = groupMap.get(entry.runtime.state + '|' + event);
|
|
3576
|
+
if (group === undefined || group.length === 0)
|
|
3577
|
+
continue;
|
|
3578
|
+
for (const transition of applicableTransitions(group, entry.runtime)) {
|
|
3579
|
+
const next = applyUpdates(model, transition, entry.runtime);
|
|
3580
|
+
const isTick = tickEvents.has(event);
|
|
3581
|
+
const stays = next.state === entry.runtime.state;
|
|
3582
|
+
const path = [...entry.path, { from: entry.runtime.state, event: transition.event, to: next.state }];
|
|
3583
|
+
let ticks;
|
|
3584
|
+
if (isTick && stays) {
|
|
3585
|
+
const lim = limitOf(next.state);
|
|
3586
|
+
if (lim !== undefined && entry.ticks + 1 > lim) {
|
|
3587
|
+
findings.push({
|
|
3588
|
+
code: 'A14_DEADLINE_MISS',
|
|
3589
|
+
severity: 'error',
|
|
3590
|
+
message: 'Deadline missed: state ' + next.state + ' can remain resident for more than ' + lim + ' tick(s).',
|
|
3591
|
+
path,
|
|
3592
|
+
evidence: { state: next.state, maxTicks: lim, ticks: entry.ticks + 1 },
|
|
3593
|
+
});
|
|
3594
|
+
continue;
|
|
3595
|
+
}
|
|
3596
|
+
ticks = entry.ticks + 1;
|
|
3597
|
+
}
|
|
3598
|
+
else if (isTick && !stays) {
|
|
3599
|
+
ticks = 0;
|
|
3600
|
+
}
|
|
3601
|
+
else if (!isTick && stays) {
|
|
3602
|
+
ticks = entry.ticks;
|
|
3603
|
+
}
|
|
3604
|
+
else {
|
|
3605
|
+
ticks = 0;
|
|
3606
|
+
}
|
|
3607
|
+
const nextKey = runtimeKey(next) + '|' + ticks;
|
|
3608
|
+
if (!seen.has(nextKey))
|
|
3609
|
+
queue.push({ runtime: next, ticks, path });
|
|
3610
|
+
}
|
|
3611
|
+
}
|
|
3612
|
+
}
|
|
3613
|
+
// A capped walk decided nothing about the part it never reached. `_UNDECIDED` is the vocabulary the schema
|
|
3614
|
+
// reference names for exactly this, and a code ending in `_UNDECIDED` carries it into `summary.truncated`.
|
|
3615
|
+
if (capped) {
|
|
3616
|
+
findings.push({
|
|
3617
|
+
code: 'A14_UNDECIDED',
|
|
3618
|
+
severity: 'warning',
|
|
3619
|
+
message: 'The deadline walk stopped at the ' + String(options.maxStates) + ' state budget before it finished, so deadline compliance is undecided rather than proven. Raise maxStates, or narrow the model.',
|
|
3620
|
+
evidence: { maxStates: options.maxStates, steps },
|
|
3621
|
+
});
|
|
3622
|
+
}
|
|
3623
|
+
return checkResult('A14', 'Deadline', withUnresolved(model, 'A14', findings), capped ? 'Deadline walk stopped at the state budget: undecided' : findings.length === 0 ? 'All deadlines respected' : 'Deadline findings: ' + findings.length);
|
|
3624
|
+
}
|
|
3625
|
+
// ---------------------------------------------------------------------------
|
|
3626
|
+
// Coverage notes (informational gap notices)
|
|
3627
|
+
// ---------------------------------------------------------------------------
|
|
3628
|
+
// Underscore-separated identifiers like watchdog_timeout or isr_cmd are common in
|
|
3629
|
+
// state machines, so boundaries must treat '_' as a separator rather than a word char.
|
|
3630
|
+
const TIMING_VOCABULARY = /(?<![a-z0-9])(timeout|watchdog|timer|tick|deadline|period|delay|elapsed|latency)(?![a-z0-9])/i;
|
|
3631
|
+
const PREEMPTION_VOCABULARY = /(?<![a-z0-9])(isr|irq|interrupt|task|thread|preempt|rtos)(?![a-z0-9])/i;
|
|
3632
|
+
const HYBRID_VOCABULARY = /(?<![a-z0-9])(pid|plant|feedback|control.?loop|stability|stable|settling|damping|oscillat|chatter|kalman|foc|field.?weaken|motor|torque)(?![a-z0-9])/i;
|
|
3633
|
+
const PROBABILISTIC_VOCABULARY = /(?<![a-z0-9])(mtbf|mttf|failure.?rate|reliability|probability|probabilistic|markov|stochastic|fault.?tree|fmea)(?![a-z0-9])/i;
|
|
3634
|
+
function computeCoverageNotes(model) {
|
|
3635
|
+
const names = [];
|
|
3636
|
+
for (const state of model.states) {
|
|
3637
|
+
names.push(state.id);
|
|
3638
|
+
for (const action of state.onEntry ?? [])
|
|
3639
|
+
names.push(action);
|
|
3640
|
+
for (const action of state.onExit ?? [])
|
|
3641
|
+
names.push(action);
|
|
3642
|
+
}
|
|
3643
|
+
for (const transition of model.transitions)
|
|
3644
|
+
names.push(transition.event);
|
|
3645
|
+
const corpus = names.join(' ');
|
|
3646
|
+
const notes = [];
|
|
3647
|
+
if (TIMING_VOCABULARY.test(corpus)) {
|
|
3648
|
+
notes.push('The model references time-like vocabulary (timeout/watchdog/timer/deadline...). This plugin verifies ordering, counts and path budgets (A12) but not hard real-time semantics: deadlines, periods, and clock invariants need a timed model checker (e.g. UPPAAL).');
|
|
3649
|
+
}
|
|
3650
|
+
if (PREEMPTION_VOCABULARY.test(corpus)) {
|
|
3651
|
+
notes.push('The model references preemption/concurrency vocabulary (ISR/IRQ/task/interrupt...). This plugin models event-order interleavings (A2/A3) but not preemptive concurrency; absolute claims such as thread-safe or interrupt-safe need dedicated verification (TSan, CBMC, or a model checker such as TLA+).');
|
|
3652
|
+
}
|
|
3653
|
+
if (HYBRID_VOCABULARY.test(corpus)) {
|
|
3654
|
+
notes.push('The model references control/hybrid vocabulary (pid/plant/feedback/stability/motor...). This plugin verifies discrete transitions only; stability, settling time, and mode-switch dynamics over a continuous plant need hybrid verification (SpaceEx, Flow*, or Simulink/Stateflow analysis).');
|
|
3655
|
+
}
|
|
3656
|
+
if (PROBABILISTIC_VOCABULARY.test(corpus)) {
|
|
3657
|
+
notes.push('The model references probabilistic/reliability vocabulary (mtbf/failure rate/probability...). This plugin is a qualitative model checker; reliability or probability claims need a stochastic model checker (PRISM, Storm) or fault-tree analysis.');
|
|
3658
|
+
}
|
|
3659
|
+
return notes;
|
|
3660
|
+
}
|
|
3661
|
+
// ---------------------------------------------------------------------------
|
|
3662
|
+
// main entry
|
|
3663
|
+
// ---------------------------------------------------------------------------
|
|
3664
|
+
export function runVerification(input, options = {}) {
|
|
3665
|
+
// A bound that was replaced or clamped is recorded here and reported through the summary, so a
|
|
3666
|
+
// caller who asked for 10^12 learns the answer came from a smaller search instead of reading a
|
|
3667
|
+
// truncated report as a complete one.
|
|
3668
|
+
const boundNotes = [];
|
|
3669
|
+
const normalized = {
|
|
3670
|
+
maxStates: boundedOption(options.maxStates, DEFAULT_MAX_STATES, MAX_STATES_CEILING, 'maxStates', boundNotes),
|
|
3671
|
+
maxPermutationEvents: boundedOption(options.maxPermutationEvents, DEFAULT_MAX_PERMUTATION_EVENTS, MAX_PERMUTATION_EVENTS_CEILING, 'maxPermutationEvents', boundNotes),
|
|
3672
|
+
};
|
|
3673
|
+
const hashSpec = options.hashSpec ?? DEFAULT_HASH_SPEC;
|
|
3674
|
+
const metadataKeys = metadataKeysOf(input);
|
|
3675
|
+
const metadata = metadataKeys.length === 0 ? {} : { metadataKeys };
|
|
3676
|
+
const validation = validateModel(input);
|
|
3677
|
+
if (!validation.ok) {
|
|
3678
|
+
const findings = validation.errors.map((message) => ({ code: 'MODEL_INVALID', severity: 'error', message }));
|
|
3679
|
+
const modelCheck = {
|
|
3680
|
+
id: 'MODEL',
|
|
3681
|
+
name: 'Model Validation',
|
|
3682
|
+
status: 'fail',
|
|
3683
|
+
detail: 'Model schema validation failed: ' + validation.errors.length + ' errors',
|
|
3684
|
+
findings,
|
|
3685
|
+
};
|
|
3686
|
+
return {
|
|
3687
|
+
ok: false,
|
|
3688
|
+
ran: true,
|
|
3689
|
+
...verdictOfFindings(findings),
|
|
3690
|
+
schema: REPORT_SCHEMAS.verify,
|
|
3691
|
+
schemaVersion: 1,
|
|
3692
|
+
hashSpec,
|
|
3693
|
+
modelHash: '',
|
|
3694
|
+
hashes: { hashSpec, modelHash: '' },
|
|
3695
|
+
...metadata,
|
|
3696
|
+
summary: { states: 0, transitions: 0, errors: validation.errors.length, warnings: 0, checksRun: 0 },
|
|
3697
|
+
checks: [modelCheck],
|
|
3698
|
+
nextSteps: [...refusalGuidance(validation.errors), ...verificationNextSteps([modelCheck], undefined)],
|
|
3699
|
+
};
|
|
3700
|
+
}
|
|
3701
|
+
const model = validation.model;
|
|
3702
|
+
const exploration = explore(model, normalized);
|
|
3703
|
+
const checks = [
|
|
3704
|
+
S1_reachability(model, exploration),
|
|
3705
|
+
S2_deadlock(model),
|
|
3706
|
+
S3_liveness(model),
|
|
3707
|
+
S4_determinism(model),
|
|
3708
|
+
S5_eventCompleteness(model),
|
|
3709
|
+
S6_guardCompleteness(model),
|
|
3710
|
+
S7_invariants(model, normalized),
|
|
3711
|
+
S8_monotonicVariables(model),
|
|
3712
|
+
A1_unexpectedEvents(model),
|
|
3713
|
+
A2_raceInterleaving(model, exploration),
|
|
3714
|
+
A3_orderPermutation(model, normalized),
|
|
3715
|
+
A4_pairSymmetry(model),
|
|
3716
|
+
A5_boundaryBlast(model),
|
|
3717
|
+
A6_resourceInjection(model),
|
|
3718
|
+
A7_shortestViolations(model, normalized),
|
|
3719
|
+
A8_idempotentReplay(model, exploration),
|
|
3720
|
+
A9_leadsTo(model, exploration, normalized),
|
|
3721
|
+
A10_sequenceOrder(model, normalized),
|
|
3722
|
+
A11_atomicity(model, normalized),
|
|
3723
|
+
A12_budget(model, normalized),
|
|
3724
|
+
A13_probability(model, normalized),
|
|
3725
|
+
A14_deadline(model, normalized),
|
|
3726
|
+
];
|
|
3727
|
+
let comparison;
|
|
3728
|
+
if (options.beforeModel !== undefined) {
|
|
3729
|
+
const beforeValidation = validateModel(options.beforeModel);
|
|
3730
|
+
if (!beforeValidation.ok) {
|
|
3731
|
+
const beforeFindings = beforeValidation.errors.map((message) => ({ code: 'BEFORE_MODEL_INVALID', severity: 'error', message }));
|
|
3732
|
+
const allFindings = [...checks.flatMap((check) => check.findings), ...beforeFindings];
|
|
3733
|
+
return {
|
|
3734
|
+
ok: false,
|
|
3735
|
+
ran: true,
|
|
3736
|
+
...verdictOfFindings(allFindings),
|
|
3737
|
+
schema: REPORT_SCHEMAS.verify,
|
|
3738
|
+
schemaVersion: 1,
|
|
3739
|
+
hashSpec,
|
|
3740
|
+
modelHash: modelHash(model, hashSpec),
|
|
3741
|
+
hashes: { hashSpec, modelHash: modelHash(model, hashSpec), afterModelHash: modelHash(model, hashSpec) },
|
|
3742
|
+
...metadata,
|
|
3743
|
+
summary: {
|
|
3744
|
+
states: model.states.length,
|
|
3745
|
+
transitions: model.transitions.length,
|
|
3746
|
+
errors: beforeValidation.errors.length,
|
|
3747
|
+
warnings: 0,
|
|
3748
|
+
checksRun: checks.length + 1,
|
|
3749
|
+
truncated: exploration.truncated || hasUndecidedFinding(checks),
|
|
3750
|
+
},
|
|
3751
|
+
checks: [
|
|
3752
|
+
...checks,
|
|
3753
|
+
{
|
|
3754
|
+
id: 'BEFORE_MODEL',
|
|
3755
|
+
name: 'Before Model Validation',
|
|
3756
|
+
status: 'fail',
|
|
3757
|
+
detail: 'Before model schema validation failed: ' + beforeValidation.errors.length + ' errors',
|
|
3758
|
+
findings: beforeFindings,
|
|
3759
|
+
},
|
|
3760
|
+
],
|
|
3761
|
+
nextSteps: verificationNextSteps(checks, narrativeCoverageOf(model)),
|
|
3762
|
+
};
|
|
3763
|
+
}
|
|
3764
|
+
const before = beforeValidation.model;
|
|
3765
|
+
const mapping = options.stateMapping ?? {};
|
|
3766
|
+
checks.push(D1_behavioralPreservation(before, model, mapping), D2_invariantContinuity(before, model, normalized, mapping), D3_regressionDelta(before, model, mapping), D4_deadlockLivenessRegression(before, model, mapping));
|
|
3767
|
+
comparison = buildComparisonSummary(before, model, mapping);
|
|
3768
|
+
}
|
|
3769
|
+
// A check whose vocabulary the model never declared examined nothing, and says so. It has to happen
|
|
3770
|
+
// before the summary and `nextSteps` are derived, or both keep counting it as a run.
|
|
3771
|
+
for (const check of checks) {
|
|
3772
|
+
if (check.status !== 'pass')
|
|
3773
|
+
continue;
|
|
3774
|
+
const why = nothingToExamine(check.id, model);
|
|
3775
|
+
if (why === undefined)
|
|
3776
|
+
continue;
|
|
3777
|
+
check.status = 'skip';
|
|
3778
|
+
check.detail = 'skipped: ' + why;
|
|
3779
|
+
}
|
|
3780
|
+
const errors = checks.reduce((sum, check) => sum + check.findings.filter((finding) => finding.severity === 'error').length, 0);
|
|
3781
|
+
const warnings = checks.reduce((sum, check) => sum + check.findings.filter((finding) => finding.severity === 'warning').length, 0);
|
|
3782
|
+
const coverageNotes = computeCoverageNotes(model);
|
|
3783
|
+
const narrativeCoverage = narrativeCoverageOf(model);
|
|
3784
|
+
// R3: a partial narrative is valid, but the gap must be machine-readable instead of only a
|
|
3785
|
+
// sentence in `nextSteps`. It rides on S5 — the check that already reports undocumented
|
|
3786
|
+
// (state, event) combinations — so the documented check count stays 22.
|
|
3787
|
+
if (narrativeCoverage !== undefined && !narrativeComplete(narrativeCoverage)) {
|
|
3788
|
+
checks.find((check) => check.id === 'S5')?.findings.push({
|
|
3789
|
+
code: 'NARRATIVE_PARTIAL',
|
|
3790
|
+
severity: 'info',
|
|
3791
|
+
message: 'Narrative coverage is partial (states ' + narrativeCoverage.states + ', events ' + narrativeCoverage.events + ', scenarios ' + narrativeCoverage.scenarios + '): the checks still run over the whole model, but the undocumented symbols have to be re-derived by the reader.',
|
|
3792
|
+
});
|
|
3793
|
+
}
|
|
3794
|
+
return {
|
|
3795
|
+
ok: true,
|
|
3796
|
+
ran: true,
|
|
3797
|
+
...verdictOfFindings(checks.flatMap((check) => check.findings)),
|
|
3798
|
+
schema: REPORT_SCHEMAS.verify,
|
|
3799
|
+
schemaVersion: 1,
|
|
3800
|
+
hashSpec,
|
|
3801
|
+
modelHash: modelHash(model, hashSpec),
|
|
3802
|
+
hashes: {
|
|
3803
|
+
hashSpec,
|
|
3804
|
+
modelHash: modelHash(model, hashSpec),
|
|
3805
|
+
...(comparison === undefined ? {} : { beforeModelHash: comparison.beforeModelHash, afterModelHash: comparison.afterModelHash }),
|
|
3806
|
+
},
|
|
3807
|
+
...metadata,
|
|
3808
|
+
...(narrativeCoverage === undefined ? {} : { narrativeCoverage }),
|
|
3809
|
+
summary: {
|
|
3810
|
+
states: model.states.length,
|
|
3811
|
+
transitions: model.transitions.length,
|
|
3812
|
+
errors,
|
|
3813
|
+
warnings,
|
|
3814
|
+
// `checksRun` counts the checks that examined the model, which is what the name promises. It used to
|
|
3815
|
+
// count every check object, so a model that declared nothing still read as "22 checks ran".
|
|
3816
|
+
checksRun: checks.filter((check) => check.status !== 'skip').length,
|
|
3817
|
+
...(() => {
|
|
3818
|
+
const skipped = checks.filter((check) => check.status === 'skip').map((check) => check.id);
|
|
3819
|
+
return skipped.length === 0 ? {} : { checksSkipped: skipped };
|
|
3820
|
+
})(),
|
|
3821
|
+
truncated: exploration.truncated || hasUndecidedFinding(checks),
|
|
3822
|
+
...(boundNotes.length === 0 ? {} : { boundNotes }),
|
|
3823
|
+
},
|
|
3824
|
+
checks,
|
|
3825
|
+
nextSteps: verificationNextSteps(checks, narrativeCoverage),
|
|
3826
|
+
...(model.narrative === undefined ? {} : { narrative: model.narrative }),
|
|
3827
|
+
...(comparison === undefined ? {} : { comparison }),
|
|
3828
|
+
...(coverageNotes.length === 0 ? {} : { coverageNotes }),
|
|
3829
|
+
};
|
|
3830
|
+
}
|
|
3831
|
+
/**
|
|
3832
|
+
* Check the sharing declarations before the search runs.
|
|
3833
|
+
*
|
|
3834
|
+
* A declaration that names a machine, an event or a location that does not exist would
|
|
3835
|
+
* otherwise quietly exclude that access from every later check - the access simply would not
|
|
3836
|
+
* be found, and the report would say "no race" about a pair it never looked at.
|
|
3837
|
+
*/
|
|
3838
|
+
function analyzeSharing(models, machineEventSets, options) {
|
|
3839
|
+
const findings = [];
|
|
3840
|
+
const byMachine = new Map();
|
|
3841
|
+
const locations = new Set();
|
|
3842
|
+
for (const location of options.shared ?? []) {
|
|
3843
|
+
if (locations.has(location.id)) {
|
|
3844
|
+
findings.push({ code: 'C3_SHARED_ACCESS_INVALID', severity: 'error', message: 'shared location "' + location.id + '" is declared twice; one declaration per location' });
|
|
3845
|
+
continue;
|
|
3846
|
+
}
|
|
3847
|
+
locations.add(location.id);
|
|
3848
|
+
}
|
|
3849
|
+
const accesses = options.accesses ?? [];
|
|
3850
|
+
if (accesses.length > 0 && locations.size === 0) {
|
|
3851
|
+
findings.push({ code: 'C3_SHARED_ACCESS_INVALID', severity: 'error', message: 'accesses were declared but `shared` names no location, so no access can be checked' });
|
|
3852
|
+
}
|
|
3853
|
+
accesses.forEach((access, index) => {
|
|
3854
|
+
const where = 'accesses[' + String(index) + ']';
|
|
3855
|
+
if (!Number.isInteger(access.machine) || access.machine < 0 || access.machine >= models.length) {
|
|
3856
|
+
findings.push({ code: 'C3_SHARED_ACCESS_INVALID', severity: 'error', message: where + ' names machine ' + String(access.machine) + ', and this composition has ' + String(models.length) });
|
|
3857
|
+
return;
|
|
3858
|
+
}
|
|
3859
|
+
if (machineEventSets[access.machine]?.has(access.event) !== true) {
|
|
3860
|
+
findings.push({ code: 'C3_SHARED_ACCESS_INVALID', severity: 'error', message: where + ' names event "' + access.event + '", which machine ' + String(access.machine) + ' does not declare in any transition' });
|
|
3861
|
+
return;
|
|
3862
|
+
}
|
|
3863
|
+
if (!locations.has(access.location)) {
|
|
3864
|
+
findings.push({ code: 'C3_SHARED_ACCESS_INVALID', severity: 'error', message: where + ' names location "' + access.location + '", which `shared` does not declare' });
|
|
3865
|
+
return;
|
|
3866
|
+
}
|
|
3867
|
+
if (access.kind !== 'read' && access.kind !== 'write' && access.kind !== 'read-write') {
|
|
3868
|
+
findings.push({ code: 'C3_SHARED_ACCESS_INVALID', severity: 'error', message: where + ' has kind "' + String(access.kind) + '"; expected read, write or read-write' });
|
|
3869
|
+
return;
|
|
3870
|
+
}
|
|
3871
|
+
if (access.protectedBy !== undefined && !locations.has(access.protectedBy)) {
|
|
3872
|
+
findings.push({ code: 'C3_SHARED_ACCESS_INVALID', severity: 'error', message: where + ' is protected by "' + access.protectedBy + '", which `shared` does not declare' });
|
|
3873
|
+
return;
|
|
3874
|
+
}
|
|
3875
|
+
const perMachine = byMachine.get(access.machine) ?? new Map();
|
|
3876
|
+
const list = perMachine.get(access.event) ?? [];
|
|
3877
|
+
list.push(access);
|
|
3878
|
+
perMachine.set(access.event, list);
|
|
3879
|
+
byMachine.set(access.machine, perMachine);
|
|
3880
|
+
});
|
|
3881
|
+
return { findings, byMachine, locations, accessCount: accesses.length };
|
|
3882
|
+
}
|
|
3883
|
+
/**
|
|
3884
|
+
* N-machine composition semantics (documented in dsh-model-schema.md):
|
|
3885
|
+
* - a non-rendezvous event advances exactly ONE non-terminal machine that fires it;
|
|
3886
|
+
* - a rendezvous event (handshake) fires only when AT LEAST TWO machines declare it in
|
|
3887
|
+
* their alphabet and EVERY such non-terminal machine has it enabled (guards held);
|
|
3888
|
+
* all participants then advance simultaneously. A machine that is terminal, or whose
|
|
3889
|
+
* alphabet does not include the event, does not participate;
|
|
3890
|
+
* - a terminal machine is stopped and takes no further part.
|
|
3891
|
+
* Checks: C1 composition deadlock (reachable node where no machine can advance while at
|
|
3892
|
+
* least one is not terminal) and C2 rendezvous that can never fire.
|
|
3893
|
+
*/
|
|
3894
|
+
export function runCompositionVerification(machinesInput, options = {}) {
|
|
3895
|
+
const hashSpec = options.hashSpec ?? DEFAULT_HASH_SPEC;
|
|
3896
|
+
const models = [];
|
|
3897
|
+
const hashes = [];
|
|
3898
|
+
const modelFindings = [];
|
|
3899
|
+
machinesInput.forEach((input, index) => {
|
|
3900
|
+
const validation = validateModel(input);
|
|
3901
|
+
if (!validation.ok) {
|
|
3902
|
+
modelFindings.push({ code: 'MODEL_INVALID', severity: 'error', message: 'machine ' + index + ' invalid: ' + validation.errors.join('; ') });
|
|
3903
|
+
}
|
|
3904
|
+
else {
|
|
3905
|
+
models.push(validation.model);
|
|
3906
|
+
hashes.push(modelHash(validation.model, hashSpec));
|
|
3907
|
+
}
|
|
3908
|
+
});
|
|
3909
|
+
const machineSummary = models.map((m, i) => ({ modelHash: hashes[i] ?? '', states: m.states.length, transitions: m.transitions.length }));
|
|
3910
|
+
if (modelFindings.length > 0 || models.length < 2) {
|
|
3911
|
+
if (models.length < 2 && modelFindings.length === 0) {
|
|
3912
|
+
modelFindings.push({ code: 'MODEL_INVALID', severity: 'error', message: 'composition requires at least two machines' });
|
|
3913
|
+
}
|
|
3914
|
+
return {
|
|
3915
|
+
ok: false,
|
|
3916
|
+
ran: true,
|
|
3917
|
+
...verdictOfFindings(modelFindings),
|
|
3918
|
+
schema: REPORT_SCHEMAS.compose,
|
|
3919
|
+
hashSpec,
|
|
3920
|
+
hashes: { hashSpec, machines: hashes },
|
|
3921
|
+
summary: { machineCount: models.length, machines: machineSummary, compositeStates: 0, errors: modelFindings.length, warnings: 0, truncated: false },
|
|
3922
|
+
checks: [{ id: 'MODEL', name: 'Machine Validation', status: 'fail', detail: 'composition input validation failed', findings: modelFindings }],
|
|
3923
|
+
nextSteps: ['Fix the machine that failed validation, then re-run the composition.'],
|
|
3924
|
+
};
|
|
3925
|
+
}
|
|
3926
|
+
const rendezvous = new Set(options.rendezvous ?? []);
|
|
3927
|
+
const maxStates = options.maxStates ?? DEFAULT_MAX_STATES;
|
|
3928
|
+
const machineEventSets = models.map((model) => new Set(model.transitions.map((transition) => transition.event)));
|
|
3929
|
+
// Sharing: which machine events touch which declared location, and how. The declarations are
|
|
3930
|
+
// checked first (C3), because an access that names a machine or location that does not exist
|
|
3931
|
+
// simply never enters the scan below - and the report would then say "no race" about a pair it
|
|
3932
|
+
// never looked at.
|
|
3933
|
+
const sharing = analyzeSharing(models, machineEventSets, options);
|
|
3934
|
+
const c3Findings = [...sharing.findings];
|
|
3935
|
+
const c4Findings = [];
|
|
3936
|
+
const c5Findings = [];
|
|
3937
|
+
const accessEventsByMachine = new Map();
|
|
3938
|
+
for (const [machine, perEvent] of sharing.byMachine)
|
|
3939
|
+
accessEventsByMachine.set(machine, [...perEvent.keys()]);
|
|
3940
|
+
const seenSharedPairs = new Set();
|
|
3941
|
+
let excludedPairs = 0;
|
|
3942
|
+
/** A cap, so a large model cannot turn this into a quadratic report: 64 witnesses is plenty. */
|
|
3943
|
+
const MAX_SHARED_WITNESSES = 64;
|
|
3944
|
+
/**
|
|
3945
|
+
* Look for two accesses on the same location that are both enabled in this composite state.
|
|
3946
|
+
*
|
|
3947
|
+
* Co-enabled means the model does not exclude the interleaving: with a read-modify-write on
|
|
3948
|
+
* each side, read(A), read(B), write(A), write(B) is reachable and loses one update. Two
|
|
3949
|
+
* accesses that name the SAME lock are treated as exclusive and counted, not reported; that
|
|
3950
|
+
* the lock is actually held across the whole operation is a single-machine question and is
|
|
3951
|
+
* not decided here.
|
|
3952
|
+
*/
|
|
3953
|
+
const scanSharedAccesses = (node) => {
|
|
3954
|
+
if (c4Findings.length + c5Findings.length >= MAX_SHARED_WITNESSES)
|
|
3955
|
+
return;
|
|
3956
|
+
const enabled = [];
|
|
3957
|
+
for (const [machine, events] of accessEventsByMachine) {
|
|
3958
|
+
if (machine >= models.length)
|
|
3959
|
+
continue;
|
|
3960
|
+
if (isTerminal(models[machine], node.runtimes[machine].state))
|
|
3961
|
+
continue;
|
|
3962
|
+
for (const event of events) {
|
|
3963
|
+
if (stepRuntime(models[machine], node.runtimes[machine], event).length === 0)
|
|
3964
|
+
continue;
|
|
3965
|
+
for (const access of sharing.byMachine.get(machine)?.get(event) ?? [])
|
|
3966
|
+
enabled.push({ machine, event, access });
|
|
3967
|
+
}
|
|
3968
|
+
}
|
|
3969
|
+
for (let left = 0; left < enabled.length; left += 1) {
|
|
3970
|
+
for (let right = left + 1; right < enabled.length; right += 1) {
|
|
3971
|
+
const first = enabled[left];
|
|
3972
|
+
const second = enabled[right];
|
|
3973
|
+
if (first.machine === second.machine)
|
|
3974
|
+
continue;
|
|
3975
|
+
if (first.access.location !== second.access.location)
|
|
3976
|
+
continue;
|
|
3977
|
+
const bothRead = first.access.kind === 'read' && second.access.kind === 'read';
|
|
3978
|
+
if (bothRead)
|
|
3979
|
+
continue;
|
|
3980
|
+
if (first.access.protectedBy !== undefined && first.access.protectedBy === second.access.protectedBy) {
|
|
3981
|
+
excludedPairs += 1;
|
|
3982
|
+
continue;
|
|
3983
|
+
}
|
|
3984
|
+
const pairKey = [first.access.location, String(first.machine) + ':' + first.event, String(second.machine) + ':' + second.event].join('|');
|
|
3985
|
+
if (seenSharedPairs.has(pairKey))
|
|
3986
|
+
continue;
|
|
3987
|
+
seenSharedPairs.add(pairKey);
|
|
3988
|
+
if (c4Findings.length + c5Findings.length >= MAX_SHARED_WITNESSES)
|
|
3989
|
+
return;
|
|
3990
|
+
const nonAtomic = first.access.kind === 'read-write' && second.access.kind === 'read-write';
|
|
3991
|
+
const where = 'machine ' + String(first.machine) + ' "' + first.event + '" (' + first.access.kind + ') and machine ' + String(second.machine) + ' "' + second.event + '" (' + second.access.kind + ')';
|
|
3992
|
+
const guarding = first.access.protectedBy === undefined && second.access.protectedBy === undefined
|
|
3993
|
+
? 'Neither access names a protecting lock.'
|
|
3994
|
+
: 'The two accesses name different protecting locks (' + (first.access.protectedBy ?? 'none') + ' / ' + (second.access.protectedBy ?? 'none') + '), so neither excludes the other.';
|
|
3995
|
+
const at = node.runtimes.map((runtime) => runtime.state).join(', ');
|
|
3996
|
+
const finding = {
|
|
3997
|
+
code: nonAtomic ? 'C5_NON_ATOMIC_UPDATE' : 'C4_SHARED_WRITE_RACE',
|
|
3998
|
+
severity: nonAtomic ? 'error' : 'warning',
|
|
3999
|
+
message: nonAtomic
|
|
4000
|
+
? 'Non-atomic update of "' + first.access.location + '": ' + where + ' both declare read-modify-write and are both enabled at (' + at + ').'
|
|
4001
|
+
: 'Shared write to "' + first.access.location + '" without exclusion: ' + where + ' are both enabled at (' + at + ') and at least one writes.',
|
|
4002
|
+
detail: 'Shortest interleaving to the co-enabled state: ' + String(node.path.length) + ' step(s) from the initial state. ' + guarding + (nonAtomic
|
|
4003
|
+
? ' Read(A), read(B), write(A), write(B) is an interleaving this model does not exclude, and it loses one update. What makes it safe is outside the model: a single atomic step (compare-and-set, atomic increment), one owner, or a lock both sides name and hold across the whole operation.'
|
|
4004
|
+
: ' Which write lands last is decided at runtime. If last-writer-wins is the design, say so; otherwise order the accesses with a lock both sides name, or with a rendezvous.'),
|
|
4005
|
+
evidence: {
|
|
4006
|
+
location: first.access.location,
|
|
4007
|
+
machines: [first.machine, second.machine],
|
|
4008
|
+
events: [first.event, second.event],
|
|
4009
|
+
kinds: [first.access.kind, second.access.kind],
|
|
4010
|
+
states: node.runtimes.map((runtime) => runtime.state),
|
|
4011
|
+
depth: node.path.length,
|
|
4012
|
+
path: node.path,
|
|
4013
|
+
},
|
|
4014
|
+
};
|
|
4015
|
+
if (nonAtomic)
|
|
4016
|
+
c5Findings.push(finding);
|
|
4017
|
+
else
|
|
4018
|
+
c4Findings.push(finding);
|
|
4019
|
+
}
|
|
4020
|
+
}
|
|
4021
|
+
};
|
|
4022
|
+
const compositionMoves = (node) => {
|
|
4023
|
+
const moves = [];
|
|
4024
|
+
// single-machine (non-rendezvous) steps
|
|
4025
|
+
for (let i = 0; i < models.length; i++) {
|
|
4026
|
+
if (isTerminal(models[i], node.runtimes[i].state))
|
|
4027
|
+
continue;
|
|
4028
|
+
for (const event of allEvents(models[i])) {
|
|
4029
|
+
if (rendezvous.has(event))
|
|
4030
|
+
continue;
|
|
4031
|
+
for (const next of stepRuntime(models[i], node.runtimes[i], event)) {
|
|
4032
|
+
const runtimes = node.runtimes.slice();
|
|
4033
|
+
runtimes[i] = next;
|
|
4034
|
+
moves.push({ next: { runtimes, path: [] }, event, machines: [i] });
|
|
4035
|
+
}
|
|
4036
|
+
}
|
|
4037
|
+
}
|
|
4038
|
+
// synchronized rendezvous steps (>= 2 participants, all enabled)
|
|
4039
|
+
for (const event of rendezvous) {
|
|
4040
|
+
const participants = [];
|
|
4041
|
+
for (let i = 0; i < models.length; i++) {
|
|
4042
|
+
if (isTerminal(models[i], node.runtimes[i].state))
|
|
4043
|
+
continue;
|
|
4044
|
+
if (!machineEventSets[i].has(event))
|
|
4045
|
+
continue;
|
|
4046
|
+
participants.push(i);
|
|
4047
|
+
}
|
|
4048
|
+
if (participants.length < 2)
|
|
4049
|
+
continue; // handshake needs a partner
|
|
4050
|
+
const outcomes = participants.map((i) => stepRuntime(models[i], node.runtimes[i], event));
|
|
4051
|
+
if (outcomes.some((list) => list.length === 0))
|
|
4052
|
+
continue; // not all participants ready
|
|
4053
|
+
let combos = [node.runtimes.slice()];
|
|
4054
|
+
participants.forEach((i, slot) => {
|
|
4055
|
+
const nextCombos = [];
|
|
4056
|
+
for (const combo of combos) {
|
|
4057
|
+
for (const outcome of outcomes[slot]) {
|
|
4058
|
+
const copy = combo.slice();
|
|
4059
|
+
copy[i] = outcome;
|
|
4060
|
+
nextCombos.push(copy);
|
|
4061
|
+
}
|
|
4062
|
+
}
|
|
4063
|
+
combos = nextCombos;
|
|
4064
|
+
});
|
|
4065
|
+
for (const runtimes of combos) {
|
|
4066
|
+
moves.push({ next: { runtimes, path: [] }, event, machines: participants });
|
|
4067
|
+
}
|
|
4068
|
+
}
|
|
4069
|
+
return moves;
|
|
4070
|
+
};
|
|
4071
|
+
const keyOf = (node) => node.runtimes.map((runtime) => runtimeKey(runtime)).join('|');
|
|
4072
|
+
const init = { runtimes: models.map((model) => initialState(model)), path: [] };
|
|
4073
|
+
const visited = new Set();
|
|
4074
|
+
const queue = [init];
|
|
4075
|
+
let compositeStates = 0;
|
|
4076
|
+
let truncated = false;
|
|
4077
|
+
const firedCount = new Map();
|
|
4078
|
+
for (const event of rendezvous)
|
|
4079
|
+
firedCount.set(event, 0);
|
|
4080
|
+
// Per-machine enablement, collected while the state space is explored: a rendezvous
|
|
4081
|
+
// that never fires needs to say *which* machine never had it enabled, not just that
|
|
4082
|
+
// the handshake did not happen.
|
|
4083
|
+
const everEnabled = new Map();
|
|
4084
|
+
const enabledStates = new Map();
|
|
4085
|
+
const coEnabledNodes = new Map();
|
|
4086
|
+
for (const event of rendezvous) {
|
|
4087
|
+
everEnabled.set(event, new Set());
|
|
4088
|
+
enabledStates.set(event, models.map(() => []));
|
|
4089
|
+
coEnabledNodes.set(event, 0);
|
|
4090
|
+
}
|
|
4091
|
+
const c1Findings = [];
|
|
4092
|
+
while (queue.length > 0) {
|
|
4093
|
+
const node = queue.shift();
|
|
4094
|
+
const key = keyOf(node);
|
|
4095
|
+
if (visited.has(key))
|
|
4096
|
+
continue;
|
|
4097
|
+
visited.add(key);
|
|
4098
|
+
compositeStates += 1;
|
|
4099
|
+
if (compositeStates > maxStates) {
|
|
4100
|
+
truncated = true;
|
|
4101
|
+
break;
|
|
4102
|
+
}
|
|
4103
|
+
if (accessEventsByMachine.size > 0)
|
|
4104
|
+
scanSharedAccesses(node);
|
|
4105
|
+
const moves = compositionMoves(node);
|
|
4106
|
+
const allTerminal = node.runtimes.every((runtime, i) => isTerminal(models[i], runtime.state));
|
|
4107
|
+
// Enablement census for C2: which machines could fire each rendezvous here.
|
|
4108
|
+
for (const event of rendezvous) {
|
|
4109
|
+
const declaring = [];
|
|
4110
|
+
for (let i = 0; i < models.length; i++)
|
|
4111
|
+
if (machineEventSets[i].has(event))
|
|
4112
|
+
declaring.push(i);
|
|
4113
|
+
if (declaring.length === 0)
|
|
4114
|
+
continue;
|
|
4115
|
+
let allEnabled = declaring.length >= 2;
|
|
4116
|
+
for (const i of declaring) {
|
|
4117
|
+
const enabled = !isTerminal(models[i], node.runtimes[i].state) && stepRuntime(models[i], node.runtimes[i], event).length > 0;
|
|
4118
|
+
if (!enabled) {
|
|
4119
|
+
allEnabled = false;
|
|
4120
|
+
continue;
|
|
4121
|
+
}
|
|
4122
|
+
everEnabled.get(event)?.add(i);
|
|
4123
|
+
const states = enabledStates.get(event)?.[i];
|
|
4124
|
+
if (states !== undefined && !states.includes(node.runtimes[i].state) && states.length < 8)
|
|
4125
|
+
states.push(node.runtimes[i].state);
|
|
4126
|
+
}
|
|
4127
|
+
if (allEnabled)
|
|
4128
|
+
coEnabledNodes.set(event, (coEnabledNodes.get(event) ?? 0) + 1);
|
|
4129
|
+
}
|
|
4130
|
+
if (moves.length === 0 && !allTerminal) {
|
|
4131
|
+
// Why nobody can advance, per machine: its state, its alphabet, and for every event
|
|
4132
|
+
// either the rendezvous partner that is not ready or the transition that does not fire.
|
|
4133
|
+
const perMachine = node.runtimes.map((runtime, i) => {
|
|
4134
|
+
const terminal = isTerminal(models[i], runtime.state);
|
|
4135
|
+
const blocked = terminal ? [] : allEvents(models[i]).map((event) => {
|
|
4136
|
+
const enabled = stepRuntime(models[i], runtime, event).length > 0;
|
|
4137
|
+
// A rendezvous event is not fireable just because this machine has a transition:
|
|
4138
|
+
// it needs every declaring machine enabled at the same time. Check that first, or
|
|
4139
|
+
// the deadlock census would report "enabled" for a handshake nobody can complete.
|
|
4140
|
+
if (!rendezvous.has(event))
|
|
4141
|
+
return { event, reason: enabled ? 'enabled' : 'no-enabled-transition' };
|
|
4142
|
+
const declaring = [];
|
|
4143
|
+
for (let j = 0; j < models.length; j++)
|
|
4144
|
+
if (machineEventSets[j].has(event))
|
|
4145
|
+
declaring.push(j);
|
|
4146
|
+
if (declaring.length < 2)
|
|
4147
|
+
return { event, reason: 'rendezvous-needs-partner' };
|
|
4148
|
+
const partnersReady = declaring.every((j) => !isTerminal(models[j], node.runtimes[j].state)
|
|
4149
|
+
&& stepRuntime(models[j], node.runtimes[j], event).length > 0);
|
|
4150
|
+
if (!partnersReady)
|
|
4151
|
+
return { event, reason: 'rendezvous-partner-not-ready' };
|
|
4152
|
+
return { event, reason: enabled ? 'enabled' : 'no-enabled-transition' };
|
|
4153
|
+
});
|
|
4154
|
+
return { index: i, state: runtime.state, terminal, blocked };
|
|
4155
|
+
});
|
|
4156
|
+
const reasons = perMachine
|
|
4157
|
+
.filter((machine) => !machine.terminal)
|
|
4158
|
+
.map((machine) => 'machine ' + String(machine.index) + ' at ' + machine.state + ': ' + (machine.blocked.length === 0
|
|
4159
|
+
? 'empty event alphabet'
|
|
4160
|
+
: machine.blocked.map((entry) => entry.event + ' (' + entry.reason + ')').join(', ')));
|
|
4161
|
+
// A non-terminal state with no outgoing transition anywhere in its own model is a
|
|
4162
|
+
// modelling gap, not a design defect: without this note the census reads as
|
|
4163
|
+
// "the architecture deadlocks" when the model was simply left unfinished.
|
|
4164
|
+
const gaps = node.runtimes
|
|
4165
|
+
.map((runtime, index) => ({ index, state: runtime.state }))
|
|
4166
|
+
.filter((entry) => !isTerminal(models[entry.index], entry.state)
|
|
4167
|
+
&& !models[entry.index].transitions.some((transition) => transition.from === entry.state));
|
|
4168
|
+
const gapNote = gaps.length === 0
|
|
4169
|
+
? ''
|
|
4170
|
+
: ' Modelling note: ' + gaps.map((gap) => 'machine ' + String(gap.index) + ' state ' + gap.state).join(', ') + ' has no outgoing transition in its own model, so this deadlock may be a modelling gap rather than a design defect.';
|
|
4171
|
+
c1Findings.push({
|
|
4172
|
+
code: 'C1_COMPOSITION_DEADLOCK',
|
|
4173
|
+
severity: 'error',
|
|
4174
|
+
message: 'Composition deadlock: no machine can advance from (' + node.runtimes.map((runtime) => runtime.state).join(', ') + ') while at least one is not terminal.',
|
|
4175
|
+
detail: 'Shortest counterexample (breadth-first): ' + String(node.path.length) + ' step(s) to reach it. ' + reasons.join(' | ') + gapNote,
|
|
4176
|
+
evidence: { steps: node.path, states: node.runtimes.map((runtime) => runtime.state), depth: node.path.length, perMachine, reasons },
|
|
4177
|
+
});
|
|
4178
|
+
continue;
|
|
4179
|
+
}
|
|
4180
|
+
for (const move of moves) {
|
|
4181
|
+
if (move.machines.length > 1)
|
|
4182
|
+
firedCount.set(move.event, (firedCount.get(move.event) ?? 0) + 1);
|
|
4183
|
+
const nextKey = keyOf(move.next);
|
|
4184
|
+
if (visited.has(nextKey))
|
|
4185
|
+
continue;
|
|
4186
|
+
queue.push({ runtimes: move.next.runtimes, path: [...node.path, { event: move.event, machines: move.machines }] });
|
|
4187
|
+
}
|
|
4188
|
+
}
|
|
4189
|
+
const c2Findings = [];
|
|
4190
|
+
const allEventsSet = new Set();
|
|
4191
|
+
for (const set of machineEventSets)
|
|
4192
|
+
for (const event of set)
|
|
4193
|
+
allEventsSet.add(event);
|
|
4194
|
+
// A name the machines never declare resolves to nothing, and the loop below used to `continue` past it: a
|
|
4195
|
+
// typo in `rendezvous` skipped the handshake entirely while the detail still reported that every rendezvous
|
|
4196
|
+
// can fire - a pass over a check that never ran. The verify family answers the same mistake with
|
|
4197
|
+
// MODEL_UNRESOLVED_EVENT; this is that answer for the composition.
|
|
4198
|
+
for (const event of rendezvous) {
|
|
4199
|
+
if (allEventsSet.has(event))
|
|
4200
|
+
continue;
|
|
4201
|
+
c2Findings.push({
|
|
4202
|
+
code: 'C2_RENDEZVOUS_UNRESOLVED',
|
|
4203
|
+
// An error: the caller named a handshake and the composition could not check it. As a warning the report said
|
|
4204
|
+
// `pass_with_findings` for a composition whose named handshake was never verified - the false green the rest of
|
|
4205
|
+
// this report contract exists to prevent.
|
|
4206
|
+
severity: 'error',
|
|
4207
|
+
message: 'Rendezvous event "' + event + '" is declared nowhere: no machine has a transition or action on it, so this handshake was not checked. Name an event the machines declare, or remove it from `rendezvous`.',
|
|
4208
|
+
evidence: { event, declared: [...allEventsSet].sort() },
|
|
4209
|
+
});
|
|
4210
|
+
}
|
|
4211
|
+
for (const event of rendezvous) {
|
|
4212
|
+
if (!allEventsSet.has(event))
|
|
4213
|
+
continue;
|
|
4214
|
+
if ((firedCount.get(event) ?? 0) === 0) {
|
|
4215
|
+
// Why the handshake never happens: who declares it, who ever had it enabled, and
|
|
4216
|
+
// whether the enabling states could ever coincide.
|
|
4217
|
+
const machines = models.map((model, index) => ({
|
|
4218
|
+
index,
|
|
4219
|
+
declares: machineEventSets[index].has(event),
|
|
4220
|
+
everEnabled: everEnabled.get(event)?.has(index) === true,
|
|
4221
|
+
enabledAt: enabledStates.get(event)?.[index] ?? [],
|
|
4222
|
+
}));
|
|
4223
|
+
const declaring = machines.filter((machine) => machine.declares);
|
|
4224
|
+
const neverEnabled = declaring.filter((machine) => !machine.everEnabled);
|
|
4225
|
+
const coEnabled = coEnabledNodes.get(event) ?? 0;
|
|
4226
|
+
let reason;
|
|
4227
|
+
if (declaring.length < 2) {
|
|
4228
|
+
reason = 'only ' + String(declaring.length) + ' machine(s) declare it (machine ' + declaring.map((machine) => machine.index).join(', ') + '); a handshake needs at least two';
|
|
4229
|
+
}
|
|
4230
|
+
else if (neverEnabled.length > 0) {
|
|
4231
|
+
reason = 'machine ' + neverEnabled.map((machine) => machine.index).join(', ') + ' declares it but never has it enabled (no transition, or the guard never holds)';
|
|
4232
|
+
}
|
|
4233
|
+
else {
|
|
4234
|
+
reason = 'every declaring machine enables it somewhere, but never at the same time (' + String(coEnabled) + ' composite state(s) had all of them ready)';
|
|
4235
|
+
}
|
|
4236
|
+
// A truncated exploration cannot prove that a handshake never happens: it may simply
|
|
4237
|
+
// not have reached the composite state where both machines are ready.
|
|
4238
|
+
const caveat = truncated ? ' The search was truncated at ' + String(maxStates) + ' composite states, so this may be an artefact of the cap rather than a property of the model.' : '';
|
|
4239
|
+
c2Findings.push({
|
|
4240
|
+
code: 'C2_RENDEZVOUS_NEVER_FIRES',
|
|
4241
|
+
severity: 'error',
|
|
4242
|
+
message: 'Rendezvous event ' + event + ' can never fire: ' + reason + '.' + caveat,
|
|
4243
|
+
detail: machines.map((machine) => 'machine ' + String(machine.index) + (machine.declares ? ' declares' : ' does not declare')
|
|
4244
|
+
+ (machine.everEnabled ? ', enabled at ' + (machine.enabledAt.length === 0 ? 'no visited state' : machine.enabledAt.join('/')) : ', never enabled')).join(' | '),
|
|
4245
|
+
evidence: { event, machines, declaring: declaring.map((machine) => machine.index), coEnabledNodes: coEnabled, reason, truncated },
|
|
4246
|
+
});
|
|
4247
|
+
}
|
|
4248
|
+
}
|
|
4249
|
+
// C2 is an error, not a warning: a rendezvous that can never fire means the composition
|
|
4250
|
+
// cannot advance through that handshake — the same "stuck forever" outcome C1 reports,
|
|
4251
|
+
// so a gate that only reads `verdict` must be able to stop on it.
|
|
4252
|
+
const allCompositionFindings = [...c1Findings, ...c2Findings, ...c3Findings, ...c4Findings, ...c5Findings];
|
|
4253
|
+
const errors = allCompositionFindings.filter((finding) => finding.severity === 'error').length;
|
|
4254
|
+
const warnings = allCompositionFindings.filter((finding) => finding.severity === 'warning').length;
|
|
4255
|
+
const c1Detail = c1Findings.length > 0
|
|
4256
|
+
? 'Composition deadlocks: ' + String(c1Findings.length)
|
|
4257
|
+
: truncated
|
|
4258
|
+
? 'No composition deadlock found, but the search was truncated at ' + String(maxStates) + ' composite states: absence is not proven'
|
|
4259
|
+
: 'No composition deadlock reachable';
|
|
4260
|
+
// The detail has to describe what was established. A name that resolved to nothing was never examined, so
|
|
4261
|
+
// the count of findings is not the same statement as "every rendezvous can fire".
|
|
4262
|
+
const unresolvedCount = c2Findings.filter((finding) => finding.code === 'C2_RENDEZVOUS_UNRESOLVED').length;
|
|
4263
|
+
const c2Detail = c2Findings.length > 0
|
|
4264
|
+
? (unresolvedCount === c2Findings.length && c2Findings.length > 0
|
|
4265
|
+
? String(unresolvedCount) + ' rendezvous name(s) resolved to no event, so that handshake was not checked'
|
|
4266
|
+
: 'Rendezvous errors: ' + String(c2Findings.length) + (unresolvedCount > 0 ? ' (' + String(unresolvedCount) + ' unresolved name(s))' : ''))
|
|
4267
|
+
: truncated
|
|
4268
|
+
? 'All rendezvous events fired, but the search was truncated at ' + String(maxStates) + ' composite states'
|
|
4269
|
+
: 'All rendezvous events can fire';
|
|
4270
|
+
// The sharing checks say what they established, not only how many findings they produced: a
|
|
4271
|
+
// composition that declares nothing shares nothing, and that is a different statement from
|
|
4272
|
+
// "the shared accesses were checked and they are exclusive".
|
|
4273
|
+
const c3Detail = c3Findings.length > 0
|
|
4274
|
+
? 'Sharing declarations are inconsistent: ' + String(c3Findings.length) + ' declaration error(s); those accesses were not checked'
|
|
4275
|
+
: sharing.accessCount === 0
|
|
4276
|
+
? 'No shared location is declared, so nothing is shared between the machines'
|
|
4277
|
+
: 'Sharing declarations resolve: ' + String(sharing.locations.size) + ' location(s), ' + String(sharing.accessCount) + ' access(es)';
|
|
4278
|
+
const sharedExclusion = excludedPairs === 0 ? '' : ' ' + String(excludedPairs) + ' co-enabled access pair(s) were excluded by a lock both sides name.';
|
|
4279
|
+
const c4Detail = c4Findings.length > 0
|
|
4280
|
+
? 'Shared writes that the model does not order: ' + String(c4Findings.length) + '.' + sharedExclusion
|
|
4281
|
+
: sharing.accessCount === 0
|
|
4282
|
+
? 'Not checked: no shared access was declared'
|
|
4283
|
+
: truncated
|
|
4284
|
+
? 'No unordered shared write found, but the search was truncated at ' + String(maxStates) + ' composite states: absence is not proven'
|
|
4285
|
+
: 'Every co-enabled shared write names one lock on both sides, or never overlaps.' + sharedExclusion;
|
|
4286
|
+
const c5Detail = c5Findings.length > 0
|
|
4287
|
+
? 'Read-modify-write pairs that are not atomic together: ' + String(c5Findings.length) + '.' + sharedExclusion
|
|
4288
|
+
: sharing.accessCount === 0
|
|
4289
|
+
? 'Not checked: no shared access was declared'
|
|
4290
|
+
: truncated
|
|
4291
|
+
? 'No non-atomic update found, but the search was truncated at ' + String(maxStates) + ' composite states: absence is not proven'
|
|
4292
|
+
: 'No read-modify-write pair is left co-enabled without a shared lock.' + sharedExclusion;
|
|
4293
|
+
const checks = [
|
|
4294
|
+
checkResult('C1', 'Composition Deadlock', c1Findings, c1Detail),
|
|
4295
|
+
checkResult('C2', 'Rendezvous Sync', c2Findings, c2Detail),
|
|
4296
|
+
checkResult('C3', 'Shared Access Declarations', c3Findings, c3Detail),
|
|
4297
|
+
checkResult('C4', 'Shared Write Order', c4Findings, c4Detail),
|
|
4298
|
+
checkResult('C5', 'Atomic Shared Update', c5Findings, c5Detail),
|
|
4299
|
+
];
|
|
4300
|
+
const nextSteps = [];
|
|
4301
|
+
if (errors > 0) {
|
|
4302
|
+
const why = c1Findings.length > 0
|
|
4303
|
+
? 'the composition deadlocks at a reachable state, so at least one machine is missing an exit or a handshake partner'
|
|
4304
|
+
: c5Findings.length > 0
|
|
4305
|
+
? 'two machines update one shared location without one atomic step between them, so an update can be lost'
|
|
4306
|
+
: c3Findings.length > 0
|
|
4307
|
+
? 'a sharing declaration names something the composition does not have, so those accesses were never checked'
|
|
4308
|
+
: 'a rendezvous event can never fire, so the composition cannot advance through that handshake';
|
|
4309
|
+
nextSteps.push('Resolve the error findings first: ' + why + '.');
|
|
4310
|
+
}
|
|
4311
|
+
if (c5Findings.length > 0) {
|
|
4312
|
+
nextSteps.push('Make the update atomic: one machine owns the value, a compare-and-set or atomic increment replaces read-then-write, or both sides name one lock in `protectedBy` and hold it across the whole operation.');
|
|
4313
|
+
}
|
|
4314
|
+
if (c4Findings.length > 0) {
|
|
4315
|
+
nextSteps.push('Decide what the overlapping write means: last-writer-wins by design (say so in the narrative), a lock both sides name in `protectedBy`, or a rendezvous that orders the two events.');
|
|
4316
|
+
}
|
|
4317
|
+
if (excludedPairs > 0) {
|
|
4318
|
+
nextSteps.push('The lock names make ' + String(excludedPairs) + ' pair(s) exclusive, and that is all this checks: whether the lock is acquired and released around the whole operation is a single-machine question - model that machine and run `model_verify` on it.');
|
|
4319
|
+
}
|
|
4320
|
+
// Composition couples machines through rendezvous and, since the sharing declarations exist,
|
|
4321
|
+
// through a location both declare. Each machine's `variables` are still private to it, so two
|
|
4322
|
+
// machines cannot share a predicate. Name them when the input looks like it is trying to.
|
|
4323
|
+
const owners = models
|
|
4324
|
+
.map((entry, index) => ({ index, names: (entry.variables ?? []).map((variable) => variable.name) }))
|
|
4325
|
+
.filter((entry) => entry.names.length > 0);
|
|
4326
|
+
if (owners.length >= 2) {
|
|
4327
|
+
nextSteps.push('Each machine\'s variables are private to it (' + owners.map((entry) => 'machine ' + String(entry.index) + ': ' + entry.names.join(', ')).join('; ') + '): a value two machines must agree on is either declared in `shared` with an `accesses` entry per side, or modelled as a handshake.' + (sharing.accessCount === 0 ? ' This composition declares no shared location, so nothing is shared between its machines.' : ''));
|
|
4328
|
+
}
|
|
4329
|
+
// A composition answers a question no single machine can, so the next step names the tool that answers
|
|
4330
|
+
// the other one. Without it a reader who wanted per-machine detail has nowhere to go from here.
|
|
4331
|
+
nextSteps.push('Re-run the composition after the change; every machine must be able to advance, or explain why it is terminal. For one machine on its own - its unreachable states, its invariants, its cost against budget - use `model_verify`, then compare the two compositions with `report_diff`.');
|
|
4332
|
+
return {
|
|
4333
|
+
ok: errors === 0,
|
|
4334
|
+
ran: true,
|
|
4335
|
+
...verdictOf(errors, warnings, [...c1Findings, ...c2Findings, ...c3Findings, ...c4Findings, ...c5Findings][0]?.code),
|
|
4336
|
+
schema: REPORT_SCHEMAS.compose,
|
|
4337
|
+
hashSpec,
|
|
4338
|
+
hashes: { hashSpec, machines: hashes },
|
|
4339
|
+
summary: { machineCount: models.length, machines: machineSummary, compositeStates, errors, warnings, truncated },
|
|
4340
|
+
checks,
|
|
4341
|
+
nextSteps,
|
|
4342
|
+
};
|
|
4343
|
+
}
|