dsh-logicprobe 0.8.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en-US.md +79 -14
- package/README.md +79 -14
- package/lib/baseline.js +142 -0
- package/lib/client.js +56 -1
- package/lib/compose-tool.js +1 -1
- package/lib/concurrency-tool.js +1 -1
- package/lib/concurrency.js +14 -0
- package/lib/data-engine.js +42 -8
- package/lib/engine.js +609 -118
- package/lib/export-tool.js +27 -2
- package/lib/granularity.js +237 -0
- package/lib/index.js +63 -32
- package/lib/report-diff-tool.js +39 -0
- package/lib/structure-tool.js +64 -0
- package/lib/structure.js +648 -0
- package/lib/tool.js +3 -3
- package/lib/types/baseline.d.ts +69 -0
- package/lib/types/concurrency.d.ts +10 -0
- package/lib/types/data-engine.d.ts +13 -1
- package/lib/types/engine.d.ts +139 -2
- package/lib/types/granularity.d.ts +106 -0
- package/lib/types/index.d.ts +9 -8
- package/lib/types/report-diff-tool.d.ts +8 -0
- package/lib/types/structure-tool.d.ts +10 -0
- package/lib/types/structure.d.ts +156 -0
- package/lib/types/tool.d.ts +2 -2
- package/lib/types/uml.d.ts +89 -0
- package/lib/uml-tool.js +36 -7
- package/lib/uml.js +217 -23
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +16 -14
- package/skills/logicprobe/SKILL.md +97 -58
- package/skills/logicprobe/references/dsh-model-schema.md +87 -15
- package/skills/logicprobe/references/gap-routing-guide.md +37 -36
- package/skills/logicprobe/references/hash-spec.md +128 -0
- package/skills/logicprobe/references/logic-verification-guide.md +29 -248
- package/skills/logicprobe-concurrency/SKILL.md +43 -0
- package/skills/{logicprobe → logicprobe-concurrency}/references/concurrency-risk-guide.md +1 -1
- package/skills/logicprobe-datamodel/SKILL.md +5 -4
- package/skills/logicprobe-datamodel/references/data-model-guide.md +1 -1
- package/skills/logicprobe-datamodel/references/data-model-schema.md +1 -1
- package/skills/logicprobe-structure/SKILL.md +95 -0
- package/skills/logicprobe-structure/references/structure-review-guide.md +177 -0
- package/skills/logicprobe-uml/SKILL.md +55 -0
- package/skills/{logicprobe → logicprobe-uml}/references/uml-modeling-guide.md +120 -8
- package/src/baseline.ts +180 -0
- package/src/client.js +56 -1
- package/src/compose-tool.ts +47 -47
- package/src/concurrency-tool.ts +2 -2
- package/src/concurrency.ts +23 -0
- package/src/data-engine.ts +51 -9
- package/src/engine.ts +656 -101
- package/src/export-tool.ts +73 -48
- package/src/granularity.ts +312 -0
- package/src/index.ts +63 -32
- package/src/report-diff-tool.ts +43 -0
- package/src/structure-tool.ts +68 -0
- package/src/structure.ts +724 -0
- package/src/tool.ts +4 -4
- package/src/uml-tool.ts +38 -8
- package/src/uml.ts +275 -24
- package/skills/logicprobe/references/logicprobe-engine.py +0 -4780
- package/skills/logicprobe/references/verification-harness.py +0 -959
- package/skills/logicprobe-datamodel/references/data-model-harness.py +0 -528
package/lib/engine.js
CHANGED
|
@@ -35,6 +35,24 @@ const MODEL_KEYS = {
|
|
|
35
35
|
narrative: new Set(['states', 'events', 'scenarios']),
|
|
36
36
|
scenario: new Set(['from', 'event', 'scenario']),
|
|
37
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
|
+
const failed = checks.filter((check) => check.status === 'fail').map((check) => check.id + ' ' + check.name);
|
|
45
|
+
const warningCodes = [...new Set(checks.flatMap((check) => check.findings).filter((finding) => finding.severity === 'warning').map((finding) => finding.code))].sort();
|
|
46
|
+
if (failed.length > 0)
|
|
47
|
+
steps.push('Resolve the failing checks first: ' + failed.join(', ') + '.');
|
|
48
|
+
if (warningCodes.length > 0)
|
|
49
|
+
steps.push('Review the warning findings (' + warningCodes.join(', ') + '): they are not failures, but they are not silence either.');
|
|
50
|
+
if (coverage !== undefined && !narrativeComplete(coverage)) {
|
|
51
|
+
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.');
|
|
52
|
+
}
|
|
53
|
+
steps.push('Re-run with beforeModel/stateMapping after the change to prove the behaviour did not regress (D1-D4).');
|
|
54
|
+
return steps;
|
|
55
|
+
}
|
|
38
56
|
const DEFAULT_MAX_STATES = 10_000;
|
|
39
57
|
const DEFAULT_MAX_PERMUTATION_EVENTS = 5;
|
|
40
58
|
function stableStringify(value) {
|
|
@@ -45,8 +63,131 @@ function stableStringify(value) {
|
|
|
45
63
|
const record = value;
|
|
46
64
|
return '{' + Object.keys(record).sort().map((key) => JSON.stringify(key) + ':' + stableStringify(record[key])).join(',') + '}';
|
|
47
65
|
}
|
|
48
|
-
export function modelHash(model) {
|
|
49
|
-
return createHash('sha256').update(stableStringify(model)).digest('hex');
|
|
66
|
+
export function modelHash(model, spec = DEFAULT_HASH_SPEC) {
|
|
67
|
+
return createHash('sha256').update(stableStringify(hashPayload(model, spec))).digest('hex');
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Deterministic JSON (sorted keys, no insignificant whitespace) — the serialization the
|
|
71
|
+
* hash specification is defined over. Exported so every report hashes inputs the same way.
|
|
72
|
+
*/
|
|
73
|
+
export function canonicalJson(value) {
|
|
74
|
+
return stableStringify(value);
|
|
75
|
+
}
|
|
76
|
+
export const PUBLISHED_HASH_SPECS = ['v0', 'v1'];
|
|
77
|
+
export const DEFAULT_HASH_SPEC = 'v1';
|
|
78
|
+
/**
|
|
79
|
+
* Versioned report contracts. Every tool result carries `schema`, so a consumer can
|
|
80
|
+
* branch on the contract instead of sniffing fields, and a change to a report's shape
|
|
81
|
+
* is a version bump here rather than a silent break. `findings[]` keeps the same
|
|
82
|
+
* stable core in every family: `{code, severity, message, detail?, evidence?, path?}`.
|
|
83
|
+
*/
|
|
84
|
+
export const REPORT_SCHEMAS = {
|
|
85
|
+
verify: 'logicprobe/verify/v1',
|
|
86
|
+
compose: 'logicprobe/compose/v1',
|
|
87
|
+
datamodel: 'logicprobe/datamodel/v1',
|
|
88
|
+
concurrency: 'logicprobe/concurrency/v1',
|
|
89
|
+
export: 'logicprobe/export/v1',
|
|
90
|
+
umlRender: 'logicprobe/uml/render/v1',
|
|
91
|
+
umlParse: 'logicprobe/uml/parse/v1',
|
|
92
|
+
umlReview: 'logicprobe/uml/review/v1',
|
|
93
|
+
structure: 'logicprobe/structure/v1',
|
|
94
|
+
granularity: 'logicprobe/granularity/v1',
|
|
95
|
+
baseline: 'logicprobe/baseline/v1',
|
|
96
|
+
};
|
|
97
|
+
export function narrativeCoverageOf(model) {
|
|
98
|
+
const narrative = model.narrative;
|
|
99
|
+
if (narrative === undefined)
|
|
100
|
+
return undefined;
|
|
101
|
+
const describedStates = narrative.states === undefined ? 0 : Object.keys(narrative.states).length;
|
|
102
|
+
const describedEvents = narrative.events === undefined ? 0 : Object.keys(narrative.events).length;
|
|
103
|
+
const groups = new Set(model.transitions.map((transition) => transition.from + '|' + transition.event));
|
|
104
|
+
const describedGroups = narrative.scenarios === undefined
|
|
105
|
+
? 0
|
|
106
|
+
: new Set(narrative.scenarios.map((scenario) => scenario.from + '|' + scenario.event)).size;
|
|
107
|
+
const events = new Set(model.transitions.map((transition) => transition.event));
|
|
108
|
+
return {
|
|
109
|
+
states: String(describedStates) + '/' + String(model.states.length),
|
|
110
|
+
events: String(describedEvents) + '/' + String(events.size),
|
|
111
|
+
scenarios: String(describedGroups) + '/' + String(groups.size),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
/** True when every dimension of the coverage is fully described. */
|
|
115
|
+
export function narrativeComplete(coverage) {
|
|
116
|
+
if (coverage === undefined)
|
|
117
|
+
return false;
|
|
118
|
+
return coverage.states.split('/')[0] === coverage.states.split('/')[1]
|
|
119
|
+
&& coverage.events.split('/')[0] === coverage.events.split('/')[1]
|
|
120
|
+
&& coverage.scenarios.split('/')[0] === coverage.scenarios.split('/')[1];
|
|
121
|
+
}
|
|
122
|
+
/** A tool that refused the request before running anything: it neither passed nor failed a review. */
|
|
123
|
+
export function refusalVerdict(reason) {
|
|
124
|
+
return { verdict: 'fail', verdictReason: 'the tool did not run: ' + reason };
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Decide the verdict from finding counts. Any error-severity finding fails the review
|
|
128
|
+
* even though the tool ran successfully — that gap is exactly what `verdict` closes.
|
|
129
|
+
*/
|
|
130
|
+
export function verdictOf(errors, warnings, firstCode) {
|
|
131
|
+
if (errors > 0) {
|
|
132
|
+
return {
|
|
133
|
+
verdict: 'fail',
|
|
134
|
+
verdictReason: String(errors) + ' error finding(s)' + (firstCode === undefined ? '' : ' (first: ' + firstCode + ')'),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
if (warnings > 0) {
|
|
138
|
+
return { verdict: 'pass_with_findings', verdictReason: 'no error findings; ' + String(warnings) + ' warning finding(s)' };
|
|
139
|
+
}
|
|
140
|
+
return { verdict: 'pass', verdictReason: 'no error or warning findings' };
|
|
141
|
+
}
|
|
142
|
+
export function verdictOfFindings(findings) {
|
|
143
|
+
const errors = findings.filter((finding) => finding.severity === 'error');
|
|
144
|
+
const warnings = findings.filter((finding) => finding.severity === 'warning');
|
|
145
|
+
return verdictOf(errors.length, warnings.length, errors[0]?.code);
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Paths of every `_`-prefixed key in the input, sorted (`_source`, `_verified`,
|
|
149
|
+
* `states[0]._note`; a metadata key's own children are not listed, since the subtree is
|
|
150
|
+
* metadata as a whole). The schema ignores these keys as annotation metadata; echoing
|
|
151
|
+
* them in the report makes the ignored metadata auditable instead of invisible.
|
|
152
|
+
*/
|
|
153
|
+
export function metadataKeysOf(input) {
|
|
154
|
+
const keys = [];
|
|
155
|
+
const walk = (value, path) => {
|
|
156
|
+
if (value === null || typeof value !== 'object')
|
|
157
|
+
return;
|
|
158
|
+
if (Array.isArray(value)) {
|
|
159
|
+
value.forEach((entry, index) => walk(entry, path + '[' + String(index) + ']'));
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
163
|
+
const child = path === '' ? key : path + '.' + key;
|
|
164
|
+
if (key.startsWith('_'))
|
|
165
|
+
keys.push(child);
|
|
166
|
+
walk(entry, child);
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
walk(input, '');
|
|
170
|
+
return keys.sort();
|
|
171
|
+
}
|
|
172
|
+
function withoutMetadata(value) {
|
|
173
|
+
if (value === null || typeof value !== 'object')
|
|
174
|
+
return value;
|
|
175
|
+
if (Array.isArray(value))
|
|
176
|
+
return value.map((entry) => withoutMetadata(entry));
|
|
177
|
+
const out = {};
|
|
178
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
179
|
+
if (key.startsWith('_'))
|
|
180
|
+
continue;
|
|
181
|
+
out[key] = withoutMetadata(entry);
|
|
182
|
+
}
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* The exact payload a hash spec hashes. Exported so the specification has one
|
|
187
|
+
* implementation and the documentation can be checked against it.
|
|
188
|
+
*/
|
|
189
|
+
export function hashPayload(model, spec = DEFAULT_HASH_SPEC) {
|
|
190
|
+
return spec === 'v1' ? withoutMetadata(model) : model;
|
|
50
191
|
}
|
|
51
192
|
export function validateModel(input) {
|
|
52
193
|
const errors = [];
|
|
@@ -57,8 +198,12 @@ export function validateModel(input) {
|
|
|
57
198
|
return { ok: false, errors: ['model: must be an object'] };
|
|
58
199
|
}
|
|
59
200
|
const root = input;
|
|
201
|
+
// `_`-prefixed keys are annotation metadata (provenance, verification snapshots,
|
|
202
|
+
// extraction caveats). They are ignored here and excluded from the hash, so a model
|
|
203
|
+
// can carry its own archive record without a sidecar that drifts away from it.
|
|
204
|
+
// Everything else stays closed: a mistyped `sttes` must still be an error.
|
|
60
205
|
const rejectUnknownKeys = (value, allowed, path) => {
|
|
61
|
-
const unexpected = Object.keys(value).filter((key) => !allowed.has(key));
|
|
206
|
+
const unexpected = Object.keys(value).filter((key) => !allowed.has(key) && !key.startsWith('_'));
|
|
62
207
|
if (unexpected.length > 0) {
|
|
63
208
|
bad(path + '.' + unexpected[0], 'unknown field (allowed: ' + [...allowed].join(', ') + ')');
|
|
64
209
|
}
|
|
@@ -239,7 +384,7 @@ export function validateModel(input) {
|
|
|
239
384
|
// older reader may not know are skipped so they still fail on `.kind` alone.
|
|
240
385
|
if (KNOWN_INVARIANT_KINDS.has(invariant.kind)) {
|
|
241
386
|
const allowed = INVARIANT_KEYS[invariant.kind];
|
|
242
|
-
const unexpected = Object.keys(invariant).filter((key) => !allowed.has(key));
|
|
387
|
+
const unexpected = Object.keys(invariant).filter((key) => !allowed.has(key) && !key.startsWith('_'));
|
|
243
388
|
if (unexpected.length > 0) {
|
|
244
389
|
bad(path + '.' + unexpected[0], 'unknown field for kind ' + String(invariant.kind) + ' (allowed: ' + [...allowed].join(', ') + ')');
|
|
245
390
|
}
|
|
@@ -271,7 +416,7 @@ export function validateModel(input) {
|
|
|
271
416
|
else if ('state' in when) {
|
|
272
417
|
const scope = when;
|
|
273
418
|
for (const key of Object.keys(scope)) {
|
|
274
|
-
if (key !== 'state')
|
|
419
|
+
if (key !== 'state' && !key.startsWith('_'))
|
|
275
420
|
bad(path + '.when.' + key, 'unknown field for a state scope (allowed: state)');
|
|
276
421
|
}
|
|
277
422
|
const state = scope.state;
|
|
@@ -308,10 +453,36 @@ export function validateModel(input) {
|
|
|
308
453
|
bad(path + '.from', 'must be a non-empty string');
|
|
309
454
|
else if (!invariantStateIds.has(invariant.from))
|
|
310
455
|
bad(path + '.from', 'references unknown state ' + invariant.from);
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
456
|
+
// `to` is one state id or a target set. A set with a duplicate member is a
|
|
457
|
+
// model-authoring slip that would otherwise give one meaning two hashes, so it
|
|
458
|
+
// is rejected rather than silently deduplicated.
|
|
459
|
+
if (typeof invariant.to === 'string') {
|
|
460
|
+
if (invariant.to.length === 0)
|
|
461
|
+
bad(path + '.to', 'must be a non-empty string');
|
|
462
|
+
else if (!invariantStateIds.has(invariant.to))
|
|
463
|
+
bad(path + '.to', 'references unknown state ' + invariant.to);
|
|
464
|
+
}
|
|
465
|
+
else if (Array.isArray(invariant.to)) {
|
|
466
|
+
if (invariant.to.length === 0)
|
|
467
|
+
bad(path + '.to', 'must be a non-empty array of state ids');
|
|
468
|
+
else {
|
|
469
|
+
const targets = new Set();
|
|
470
|
+
invariant.to.forEach((target, targetIndex) => {
|
|
471
|
+
const at = path + '.to[' + targetIndex + ']';
|
|
472
|
+
if (typeof target !== 'string' || target.length === 0)
|
|
473
|
+
bad(at, 'must be a non-empty string');
|
|
474
|
+
else if (!invariantStateIds.has(target))
|
|
475
|
+
bad(at, 'references unknown state ' + target);
|
|
476
|
+
else if (targets.has(target))
|
|
477
|
+
bad(at, 'duplicates state ' + target + ' in the target set');
|
|
478
|
+
else
|
|
479
|
+
targets.add(target);
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
else {
|
|
484
|
+
bad(path + '.to', 'must be a state id or a non-empty array of state ids');
|
|
485
|
+
}
|
|
315
486
|
}
|
|
316
487
|
else if (invariant.kind === 'sequence') {
|
|
317
488
|
if (!Array.isArray(invariant.events) || invariant.events.length === 0)
|
|
@@ -432,70 +603,70 @@ export function validateModel(input) {
|
|
|
432
603
|
rejectUnknownKeys(narrative, MODEL_KEYS.narrative, narrativePath);
|
|
433
604
|
const stateIds = new Set(Array.isArray(root.states) ? root.states.map((state) => state.id) : []);
|
|
434
605
|
const eventIds = new Set(Array.isArray(root.transitions) ? root.transitions.map((transition) => transition.event) : []);
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
606
|
+
// A narrative is allowed to cover part of the model: states first, events and
|
|
607
|
+
// scenarios later is the natural authoring order, and `narrativeCoverage` reports
|
|
608
|
+
// what is still missing instead of rejecting the file. What stays an error is a
|
|
609
|
+
// narrative that is *wrong* (unknown id, empty description, duplicate scenario) or
|
|
610
|
+
// empty — an empty block claims documentation that does not exist.
|
|
611
|
+
const declaredDimensions = ['states', 'events', 'scenarios'].filter((key) => narrative[key] !== undefined);
|
|
612
|
+
if (declaredDimensions.length === 0) {
|
|
613
|
+
bad(narrativePath, 'declares no dimension: give at least one of states, events or scenarios (a partial narrative is fine)');
|
|
438
614
|
}
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
bad(narrativePath + '.states', 'references unknown state ' + id);
|
|
443
|
-
if (typeof description !== 'string' || description.length === 0)
|
|
444
|
-
bad(narrativePath + '.states.' + id, 'must be a non-empty string');
|
|
615
|
+
if (narrative.states !== undefined) {
|
|
616
|
+
if (typeof narrative.states !== 'object' || narrative.states === null || Array.isArray(narrative.states)) {
|
|
617
|
+
bad(narrativePath + '.states', 'must be an object mapping state id -> natural-language description');
|
|
445
618
|
}
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
619
|
+
else {
|
|
620
|
+
for (const [id, description] of Object.entries(narrative.states)) {
|
|
621
|
+
if (!stateIds.has(id))
|
|
622
|
+
bad(narrativePath + '.states', 'references unknown state ' + id);
|
|
623
|
+
if (typeof description !== 'string' || description.length === 0)
|
|
624
|
+
bad(narrativePath + '.states.' + id, 'must be a non-empty string');
|
|
625
|
+
}
|
|
449
626
|
}
|
|
450
627
|
}
|
|
451
|
-
if (
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
else {
|
|
455
|
-
for (const [id, description] of Object.entries(narrative.events)) {
|
|
456
|
-
if (!eventIds.has(id))
|
|
457
|
-
bad(narrativePath + '.events', 'references unknown event ' + id);
|
|
458
|
-
if (typeof description !== 'string' || description.length === 0)
|
|
459
|
-
bad(narrativePath + '.events.' + id, 'must be a non-empty string');
|
|
628
|
+
if (narrative.events !== undefined) {
|
|
629
|
+
if (typeof narrative.events !== 'object' || narrative.events === null || Array.isArray(narrative.events)) {
|
|
630
|
+
bad(narrativePath + '.events', 'must be an object mapping event id -> natural-language description');
|
|
460
631
|
}
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
632
|
+
else {
|
|
633
|
+
for (const [id, description] of Object.entries(narrative.events)) {
|
|
634
|
+
if (!eventIds.has(id))
|
|
635
|
+
bad(narrativePath + '.events', 'references unknown event ' + id);
|
|
636
|
+
if (typeof description !== 'string' || description.length === 0)
|
|
637
|
+
bad(narrativePath + '.events.' + id, 'must be a non-empty string');
|
|
638
|
+
}
|
|
464
639
|
}
|
|
465
640
|
}
|
|
466
|
-
if (
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
if (!seen.has(key)) {
|
|
496
|
-
const sep = key.indexOf('|');
|
|
497
|
-
bad(narrativePath + '.scenarios', 'missing scenario for (' + key.slice(0, sep) + ', ' + key.slice(sep + 1) + ')');
|
|
498
|
-
}
|
|
641
|
+
if (narrative.scenarios !== undefined) {
|
|
642
|
+
if (!Array.isArray(narrative.scenarios)) {
|
|
643
|
+
bad(narrativePath + '.scenarios', 'must be an array of { from, event, scenario }');
|
|
644
|
+
}
|
|
645
|
+
else {
|
|
646
|
+
const seen = new Set();
|
|
647
|
+
narrative.scenarios.forEach((entry, index) => {
|
|
648
|
+
const scenarioPath = narrativePath + '.scenarios[' + index + ']';
|
|
649
|
+
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
|
|
650
|
+
bad(scenarioPath, 'must be an object');
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
const scenario = entry;
|
|
654
|
+
rejectUnknownKeys(scenario, MODEL_KEYS.scenario, scenarioPath);
|
|
655
|
+
if (typeof scenario.from !== 'string' || scenario.from.length === 0)
|
|
656
|
+
bad(scenarioPath + '.from', 'must be a non-empty string');
|
|
657
|
+
else if (!stateIds.has(scenario.from))
|
|
658
|
+
bad(scenarioPath + '.from', 'unknown state ' + scenario.from);
|
|
659
|
+
if (typeof scenario.event !== 'string' || scenario.event.length === 0)
|
|
660
|
+
bad(scenarioPath + '.event', 'must be a non-empty string');
|
|
661
|
+
else if (!eventIds.has(scenario.event))
|
|
662
|
+
bad(scenarioPath + '.event', 'unknown event ' + scenario.event);
|
|
663
|
+
if (typeof scenario.scenario !== 'string' || scenario.scenario.length === 0)
|
|
664
|
+
bad(scenarioPath + '.scenario', 'must be a non-empty string');
|
|
665
|
+
const key = String(scenario.from) + '|' + String(scenario.event);
|
|
666
|
+
if (seen.has(key))
|
|
667
|
+
bad(scenarioPath, 'duplicate scenario for (' + scenario.from + ', ' + scenario.event + ')');
|
|
668
|
+
seen.add(key);
|
|
669
|
+
});
|
|
499
670
|
}
|
|
500
671
|
}
|
|
501
672
|
}
|
|
@@ -724,7 +895,7 @@ function checkResult(id, name, findings, detail) {
|
|
|
724
895
|
};
|
|
725
896
|
}
|
|
726
897
|
// ---------------------------------------------------------------------------
|
|
727
|
-
// S1-
|
|
898
|
+
// S1-S8
|
|
728
899
|
// ---------------------------------------------------------------------------
|
|
729
900
|
function S1_reachability(model, exploration) {
|
|
730
901
|
const reached = new Set(exploration.reachable.map((runtime) => runtime.state));
|
|
@@ -1007,6 +1178,103 @@ function shortestViolationForInvariant(model, options, invariant) {
|
|
|
1007
1178
|
}
|
|
1008
1179
|
return undefined;
|
|
1009
1180
|
}
|
|
1181
|
+
/**
|
|
1182
|
+
* Whether an invariant asserts a property of whole runs rather than of a single
|
|
1183
|
+
* runtime state. These kinds cannot be decided by a state predicate, so they are not
|
|
1184
|
+
* part of `shortestViolationForInvariant` (and therefore not of S7), which owns the
|
|
1185
|
+
* states; A9-A13 own the verdicts, and `pathPropertyViolation` below makes the same
|
|
1186
|
+
* verdicts available to A7 and D2.
|
|
1187
|
+
*/
|
|
1188
|
+
function isPathPropertyInvariant(invariant) {
|
|
1189
|
+
return invariant.kind === 'leads-to'
|
|
1190
|
+
|| invariant.kind === 'sequence'
|
|
1191
|
+
|| invariant.kind === 'atomicity'
|
|
1192
|
+
|| invariant.kind === 'budget'
|
|
1193
|
+
|| invariant.kind === 'probability';
|
|
1194
|
+
}
|
|
1195
|
+
/** Whether a probability invariant's bound fails for the computed hit probability. */
|
|
1196
|
+
function probabilityViolated(invariant, probability) {
|
|
1197
|
+
const eps = 1e-9;
|
|
1198
|
+
if (invariant.op === '>=')
|
|
1199
|
+
return probability < invariant.p - eps;
|
|
1200
|
+
if (invariant.op === '>')
|
|
1201
|
+
return probability <= invariant.p + eps;
|
|
1202
|
+
if (invariant.op === '<=')
|
|
1203
|
+
return probability > invariant.p + eps;
|
|
1204
|
+
return probability >= invariant.p - eps;
|
|
1205
|
+
}
|
|
1206
|
+
/** Every runtime state reachable from init, capped like the other searches. */
|
|
1207
|
+
function reachableRuntimeStates(model, options) {
|
|
1208
|
+
const init = initialState(model);
|
|
1209
|
+
const visited = new Set([runtimeKey(init)]);
|
|
1210
|
+
const reached = [init];
|
|
1211
|
+
const queue = [init];
|
|
1212
|
+
let steps = 0;
|
|
1213
|
+
while (queue.length > 0) {
|
|
1214
|
+
if (++steps > options.maxStates)
|
|
1215
|
+
break;
|
|
1216
|
+
const current = queue.shift();
|
|
1217
|
+
for (const event of allEvents(model)) {
|
|
1218
|
+
for (const next of stepRuntime(model, current, event)) {
|
|
1219
|
+
const key = runtimeKey(next);
|
|
1220
|
+
if (visited.has(key))
|
|
1221
|
+
continue;
|
|
1222
|
+
visited.add(key);
|
|
1223
|
+
reached.push(next);
|
|
1224
|
+
queue.push(next);
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
return reached;
|
|
1229
|
+
}
|
|
1230
|
+
/**
|
|
1231
|
+
* Violation of a path-property invariant, or undefined when it holds. Each kind
|
|
1232
|
+
* delegates to the search its dedicated probe uses (A9 leads-to, A10 sequence, A11
|
|
1233
|
+
* atomicity, A12 budget, A13 probability), so a verdict reported here can never
|
|
1234
|
+
* disagree with the probe that owns the kind.
|
|
1235
|
+
*/
|
|
1236
|
+
function pathPropertyViolation(model, options, invariant) {
|
|
1237
|
+
if (invariant.kind === 'leads-to') {
|
|
1238
|
+
const targets = new Set(leadsToTargets(invariant));
|
|
1239
|
+
const label = leadsToTargetLabel(invariant);
|
|
1240
|
+
for (const runtime of reachableRuntimeStates(model, options)) {
|
|
1241
|
+
if (runtime.state !== invariant.from)
|
|
1242
|
+
continue;
|
|
1243
|
+
const bad = findLeadsToBadPath(model, runtime, targets, label);
|
|
1244
|
+
if (bad !== undefined)
|
|
1245
|
+
return { invariant, path: bad.path, reason: bad.reason };
|
|
1246
|
+
}
|
|
1247
|
+
return undefined;
|
|
1248
|
+
}
|
|
1249
|
+
if (invariant.kind === 'sequence')
|
|
1250
|
+
return findSequenceViolation(model, options, invariant);
|
|
1251
|
+
if (invariant.kind === 'atomicity')
|
|
1252
|
+
return findAtomicityViolation(model, options, invariant);
|
|
1253
|
+
if (invariant.kind === 'budget') {
|
|
1254
|
+
const violation = findBudgetViolation(model, options, invariant);
|
|
1255
|
+
if (violation === undefined)
|
|
1256
|
+
return undefined;
|
|
1257
|
+
return {
|
|
1258
|
+
invariant,
|
|
1259
|
+
path: violation.path,
|
|
1260
|
+
reason: violation.unbounded
|
|
1261
|
+
? 'a reachable cycle keeps accumulating cost, so no finite budget holds'
|
|
1262
|
+
: 'a run accumulates cost ' + String(violation.cost) + ', above the declared budget ' + String(invariant.budget),
|
|
1263
|
+
};
|
|
1264
|
+
}
|
|
1265
|
+
if (invariant.kind === 'probability') {
|
|
1266
|
+
const { probability, converged } = computeHitProbability(model, options, invariant.target);
|
|
1267
|
+
if (!probabilityViolated(invariant, probability))
|
|
1268
|
+
return undefined;
|
|
1269
|
+
return {
|
|
1270
|
+
invariant,
|
|
1271
|
+
path: [],
|
|
1272
|
+
reason: 'P(hit ' + invariant.target + ') = ' + probability.toFixed(6) + ' does not satisfy ' + invariant.op + ' ' + String(invariant.p)
|
|
1273
|
+
+ (converged ? '' : ' (value iteration did not converge)'),
|
|
1274
|
+
};
|
|
1275
|
+
}
|
|
1276
|
+
return undefined;
|
|
1277
|
+
}
|
|
1010
1278
|
function shortestEventBeforeStateViolation(model, options, invariant) {
|
|
1011
1279
|
const init = initialState(model);
|
|
1012
1280
|
const seenInitially = false;
|
|
@@ -1038,10 +1306,12 @@ function shortestEventBeforeStateViolation(model, options, invariant) {
|
|
|
1038
1306
|
}
|
|
1039
1307
|
return undefined;
|
|
1040
1308
|
}
|
|
1041
|
-
function runInvariants(model, options) {
|
|
1309
|
+
function runInvariants(model, options, includePathProperties = false) {
|
|
1042
1310
|
const violations = [];
|
|
1043
1311
|
for (const invariant of model.invariants ?? []) {
|
|
1044
|
-
const violation =
|
|
1312
|
+
const violation = includePathProperties && isPathPropertyInvariant(invariant)
|
|
1313
|
+
? pathPropertyViolation(model, options, invariant)
|
|
1314
|
+
: shortestViolationForInvariant(model, options, invariant);
|
|
1045
1315
|
if (violation !== undefined)
|
|
1046
1316
|
violations.push(violation);
|
|
1047
1317
|
}
|
|
@@ -1059,7 +1329,7 @@ function S7_invariants(model, options) {
|
|
|
1059
1329
|
return checkResult('S7', 'Invariant Validity', findings, violations.length === 0 ? 'All invariants hold' : 'Invariant violations: ' + violations.length);
|
|
1060
1330
|
}
|
|
1061
1331
|
// ---------------------------------------------------------------------------
|
|
1062
|
-
// A1-
|
|
1332
|
+
// A1-A14
|
|
1063
1333
|
// ---------------------------------------------------------------------------
|
|
1064
1334
|
function A1_unexpectedEvents(model) {
|
|
1065
1335
|
const events = allEvents(model);
|
|
@@ -1415,14 +1685,27 @@ function A6_resourceInjection(model) {
|
|
|
1415
1685
|
return checkResult('A6', 'Resource Injection', findings, findings.length === 0 ? 'No resource vulnerabilities detected' : 'Resource failure paths missing: ' + findings.length);
|
|
1416
1686
|
}
|
|
1417
1687
|
function A7_shortestViolations(model, options) {
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1688
|
+
// Path-property invariants are included: "for any invariant that fails" is this
|
|
1689
|
+
// check's contract, and a check that silently skips five of the eight kinds cannot
|
|
1690
|
+
// honour it. The searches for the state kinds, for sequence, atomicity and budget
|
|
1691
|
+
// are breadth-first, so their first witness is the shortest one; the leads-to walk
|
|
1692
|
+
// and the probability bound have no such guarantee, and their message says what it
|
|
1693
|
+
// found instead of claiming minimality.
|
|
1694
|
+
const violations = runInvariants(model, options, true);
|
|
1695
|
+
const findings = violations.map((violation) => {
|
|
1696
|
+
const pathLength = violation.path.length;
|
|
1697
|
+
const message = isPathPropertyInvariant(violation.invariant)
|
|
1698
|
+
? 'Invariant "' + violation.invariant.id + '" violated: ' + violation.reason
|
|
1699
|
+
+ (pathLength === 0 ? '' : ' (witness path length: ' + pathLength + ')')
|
|
1700
|
+
: 'Invariant "' + violation.invariant.id + '" shortest violating path length: ' + pathLength + (pathLength === 0 ? ' (initial state)' : '');
|
|
1701
|
+
return {
|
|
1702
|
+
code: 'A7_SHORTEST_COUNTEREXAMPLE',
|
|
1703
|
+
severity: 'warning',
|
|
1704
|
+
message,
|
|
1705
|
+
path: violation.path,
|
|
1706
|
+
evidence: { invariant: violation.invariant.id },
|
|
1707
|
+
};
|
|
1708
|
+
});
|
|
1426
1709
|
return checkResult('A7', 'Minimal Counter-Example', findings, violations.length === 0 ? 'All invariants hold for all reachable paths' : 'Violated invariants: ' + findings.length);
|
|
1427
1710
|
}
|
|
1428
1711
|
function mapStateId(mapping, state) {
|
|
@@ -1500,6 +1783,25 @@ function mapInvariantForComparison(invariant, mapping) {
|
|
|
1500
1783
|
}
|
|
1501
1784
|
return mapped;
|
|
1502
1785
|
}
|
|
1786
|
+
if (invariant.kind === 'leads-to') {
|
|
1787
|
+
// A target set maps member by member; a rename that drops a state is reported by
|
|
1788
|
+
// D2's own mapped-state check rather than silently changing the property.
|
|
1789
|
+
return {
|
|
1790
|
+
...invariant,
|
|
1791
|
+
id: invariant.id + ':before',
|
|
1792
|
+
description: invariant.description + ' (from BEFORE)',
|
|
1793
|
+
from: mapStateId(mapping, invariant.from),
|
|
1794
|
+
to: typeof invariant.to === 'string' ? mapStateId(mapping, invariant.to) : invariant.to.map((state) => mapStateId(mapping, state)),
|
|
1795
|
+
};
|
|
1796
|
+
}
|
|
1797
|
+
if (invariant.kind === 'probability') {
|
|
1798
|
+
return {
|
|
1799
|
+
...invariant,
|
|
1800
|
+
id: invariant.id + ':before',
|
|
1801
|
+
description: invariant.description + ' (from BEFORE)',
|
|
1802
|
+
target: mapStateId(mapping, invariant.target),
|
|
1803
|
+
};
|
|
1804
|
+
}
|
|
1503
1805
|
return { ...invariant, id: invariant.id + ':before', description: invariant.description + ' (from BEFORE)' };
|
|
1504
1806
|
}
|
|
1505
1807
|
function D2_invariantContinuity(before, after, options, mapping) {
|
|
@@ -1541,7 +1843,37 @@ function D2_invariantContinuity(before, after, options, mapping) {
|
|
|
1541
1843
|
continue;
|
|
1542
1844
|
}
|
|
1543
1845
|
}
|
|
1544
|
-
|
|
1846
|
+
else if (mapped.kind === 'leads-to') {
|
|
1847
|
+
// The state references of a path-property invariant must follow stateMapping too,
|
|
1848
|
+
// otherwise a pure rename searches for a state AFTER does not declare.
|
|
1849
|
+
for (const state of [mapped.from, ...leadsToTargets(mapped)]) {
|
|
1850
|
+
if (!afterStates.has(state)) {
|
|
1851
|
+
findings.push({
|
|
1852
|
+
code: 'D2_MAPPED_STATE_MISSING',
|
|
1853
|
+
severity: 'warning',
|
|
1854
|
+
message: 'BEFORE invariant "' + invariant.id + '" maps to state ' + state + ', which is not declared in AFTER.',
|
|
1855
|
+
evidence: { invariant: invariant.id, state },
|
|
1856
|
+
});
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
else if (mapped.kind === 'probability') {
|
|
1861
|
+
if (!afterStates.has(mapped.target)) {
|
|
1862
|
+
findings.push({
|
|
1863
|
+
code: 'D2_MAPPED_STATE_MISSING',
|
|
1864
|
+
severity: 'warning',
|
|
1865
|
+
message: 'BEFORE invariant "' + invariant.id + '" maps to state ' + mapped.target + ', which is not declared in AFTER.',
|
|
1866
|
+
evidence: { invariant: invariant.id, state: mapped.target },
|
|
1867
|
+
});
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
// Every invariant the engine can decide is re-decided against AFTER. Path-property
|
|
1871
|
+
// kinds go through the same searches their dedicated probes use, so D2 can no
|
|
1872
|
+
// longer report "all BEFORE invariants continue to hold" while A9-A13 fail on the
|
|
1873
|
+
// very same model.
|
|
1874
|
+
const violation = isPathPropertyInvariant(mapped)
|
|
1875
|
+
? pathPropertyViolation(after, options, mapped)
|
|
1876
|
+
: shortestViolationForInvariant(after, options, mapped);
|
|
1545
1877
|
if (violation !== undefined) {
|
|
1546
1878
|
findings.push({
|
|
1547
1879
|
code: 'D2_INVARIANT_REGRESSION',
|
|
@@ -1722,15 +2054,27 @@ function S8_monotonicVariables(model) {
|
|
|
1722
2054
|
return checkResult('S8', 'Monotonic Variables', findings, findings.length === 0 ? 'Monotonic variables are respected' : 'Monotonic findings: ' + findings.length);
|
|
1723
2055
|
}
|
|
1724
2056
|
/**
|
|
1725
|
-
*
|
|
1726
|
-
*
|
|
1727
|
-
|
|
2057
|
+
* Target states of a `leads-to` invariant. The schema accepts one state id or a
|
|
2058
|
+
* non-empty array of them, so "reach any of these" needs no separate invariant kind.
|
|
2059
|
+
*/
|
|
2060
|
+
function leadsToTargets(invariant) {
|
|
2061
|
+
return typeof invariant.to === 'string' ? [invariant.to] : invariant.to;
|
|
2062
|
+
}
|
|
2063
|
+
/** Target list as findings print it. A single target renders as its bare id, so existing messages stay byte-identical. */
|
|
2064
|
+
function leadsToTargetLabel(invariant) {
|
|
2065
|
+
return leadsToTargets(invariant).join(', ');
|
|
2066
|
+
}
|
|
2067
|
+
/**
|
|
2068
|
+
* Find a run from `start` that reaches none of `targets`, or return undefined when
|
|
2069
|
+
* every run reaches at least one of them. The property is universal (see the A9 row
|
|
2070
|
+
* in `SKILL.md`): one branch that loops forever, or that stops before any target,
|
|
2071
|
+
* refutes it.
|
|
1728
2072
|
*
|
|
1729
|
-
* The search is a depth-first walk of the run graph with three colours, and
|
|
1730
|
-
*
|
|
2073
|
+
* The search is a depth-first walk of the run graph with three colours, and runs at a
|
|
2074
|
+
* target are success leaves that are never expanded:
|
|
1731
2075
|
*
|
|
1732
2076
|
* - a node reached while it is on the current walk (GRAY) closes a cycle that avoids
|
|
1733
|
-
*
|
|
2077
|
+
* every target, so some run never reaches one;
|
|
1734
2078
|
* - a node with no outgoing step at all stops the machine where it stands, which is
|
|
1735
2079
|
* the same violation for a different reason;
|
|
1736
2080
|
* - a node whose walk completed without a violation is BLACK, and reaching it again
|
|
@@ -1741,8 +2085,8 @@ function S8_monotonicVariables(model) {
|
|
|
1741
2085
|
* A run is identified by its state plus its variable values, so the same state id
|
|
1742
2086
|
* with different values is a different node.
|
|
1743
2087
|
*/
|
|
1744
|
-
function findLeadsToBadPath(model, start,
|
|
1745
|
-
if (start.state
|
|
2088
|
+
function findLeadsToBadPath(model, start, targets, label) {
|
|
2089
|
+
if (targets.has(start.state))
|
|
1746
2090
|
return undefined;
|
|
1747
2091
|
const GRAY = 1;
|
|
1748
2092
|
const BLACK = 2;
|
|
@@ -1759,7 +2103,7 @@ function findLeadsToBadPath(model, start, target) {
|
|
|
1759
2103
|
}
|
|
1760
2104
|
frame.nexts = nexts;
|
|
1761
2105
|
if (nexts.length === 0)
|
|
1762
|
-
return { path: frame.path, reason: 'Dead end before target ' +
|
|
2106
|
+
return { path: frame.path, reason: 'Dead end before target ' + label };
|
|
1763
2107
|
}
|
|
1764
2108
|
if (frame.index >= frame.nexts.length) {
|
|
1765
2109
|
color.set(runtimeKey(frame.runtime), BLACK);
|
|
@@ -1769,18 +2113,18 @@ function findLeadsToBadPath(model, start, target) {
|
|
|
1769
2113
|
const { next, event } = frame.nexts[frame.index];
|
|
1770
2114
|
frame.index += 1;
|
|
1771
2115
|
const step = { from: frame.runtime.state, event, to: next.state };
|
|
1772
|
-
if (next.state
|
|
2116
|
+
if (targets.has(next.state))
|
|
1773
2117
|
continue;
|
|
1774
2118
|
const key = runtimeKey(next);
|
|
1775
2119
|
const seen = color.get(key);
|
|
1776
2120
|
if (seen === GRAY)
|
|
1777
|
-
return { path: [...frame.path, step], reason: 'Cycle avoids target ' +
|
|
2121
|
+
return { path: [...frame.path, step], reason: 'Cycle avoids target ' + label };
|
|
1778
2122
|
if (seen === BLACK)
|
|
1779
2123
|
continue;
|
|
1780
2124
|
color.set(key, GRAY);
|
|
1781
2125
|
stack.push({ runtime: next, path: [...frame.path, step], nexts: undefined, index: 0 });
|
|
1782
2126
|
}
|
|
1783
|
-
// Every branch either reached
|
|
2127
|
+
// Every branch either reached a target or rejoined a branch that did.
|
|
1784
2128
|
return undefined;
|
|
1785
2129
|
}
|
|
1786
2130
|
function A9_leadsTo(model, exploration) {
|
|
@@ -1788,10 +2132,12 @@ function A9_leadsTo(model, exploration) {
|
|
|
1788
2132
|
for (const invariant of model.invariants ?? []) {
|
|
1789
2133
|
if (invariant.kind !== 'leads-to')
|
|
1790
2134
|
continue;
|
|
2135
|
+
const targets = new Set(leadsToTargets(invariant));
|
|
2136
|
+
const label = leadsToTargetLabel(invariant);
|
|
1791
2137
|
for (const runtime of exploration.reachable) {
|
|
1792
2138
|
if (runtime.state !== invariant.from)
|
|
1793
2139
|
continue;
|
|
1794
|
-
const bad = findLeadsToBadPath(model, runtime,
|
|
2140
|
+
const bad = findLeadsToBadPath(model, runtime, targets, label);
|
|
1795
2141
|
if (bad !== undefined) {
|
|
1796
2142
|
findings.push({
|
|
1797
2143
|
code: 'A9_LEADS_TO_VIOLATION',
|
|
@@ -2199,21 +2545,11 @@ function computeHitProbability(model, options, targetState) {
|
|
|
2199
2545
|
}
|
|
2200
2546
|
function A13_probability(model, options) {
|
|
2201
2547
|
const findings = [];
|
|
2202
|
-
const eps = 1e-9;
|
|
2203
2548
|
for (const invariant of model.invariants ?? []) {
|
|
2204
2549
|
if (invariant.kind !== 'probability')
|
|
2205
2550
|
continue;
|
|
2206
2551
|
const { probability, converged } = computeHitProbability(model, options, invariant.target);
|
|
2207
|
-
|
|
2208
|
-
if (invariant.op === '>=')
|
|
2209
|
-
violated = probability < invariant.p - eps;
|
|
2210
|
-
else if (invariant.op === '>')
|
|
2211
|
-
violated = probability <= invariant.p + eps;
|
|
2212
|
-
else if (invariant.op === '<=')
|
|
2213
|
-
violated = probability > invariant.p + eps;
|
|
2214
|
-
else
|
|
2215
|
-
violated = probability >= invariant.p - eps;
|
|
2216
|
-
if (violated) {
|
|
2552
|
+
if (probabilityViolated(invariant, probability)) {
|
|
2217
2553
|
findings.push({
|
|
2218
2554
|
code: 'A13_PROBABILITY_VIOLATION',
|
|
2219
2555
|
severity: 'error',
|
|
@@ -2355,20 +2691,32 @@ export function runVerification(input, options = {}) {
|
|
|
2355
2691
|
maxStates: options.maxStates ?? DEFAULT_MAX_STATES,
|
|
2356
2692
|
maxPermutationEvents: options.maxPermutationEvents ?? DEFAULT_MAX_PERMUTATION_EVENTS,
|
|
2357
2693
|
};
|
|
2694
|
+
const hashSpec = options.hashSpec ?? DEFAULT_HASH_SPEC;
|
|
2695
|
+
const metadataKeys = metadataKeysOf(input);
|
|
2696
|
+
const metadata = metadataKeys.length === 0 ? {} : { metadataKeys };
|
|
2358
2697
|
const validation = validateModel(input);
|
|
2359
2698
|
if (!validation.ok) {
|
|
2699
|
+
const findings = validation.errors.map((message) => ({ code: 'MODEL_INVALID', severity: 'error', message }));
|
|
2700
|
+
const modelCheck = {
|
|
2701
|
+
id: 'MODEL',
|
|
2702
|
+
name: 'Model Validation',
|
|
2703
|
+
status: 'fail',
|
|
2704
|
+
detail: 'Model schema validation failed: ' + validation.errors.length + ' errors',
|
|
2705
|
+
findings,
|
|
2706
|
+
};
|
|
2360
2707
|
return {
|
|
2361
2708
|
ok: false,
|
|
2709
|
+
ran: true,
|
|
2710
|
+
...verdictOfFindings(findings),
|
|
2711
|
+
schema: REPORT_SCHEMAS.verify,
|
|
2362
2712
|
schemaVersion: 1,
|
|
2713
|
+
hashSpec,
|
|
2363
2714
|
modelHash: '',
|
|
2715
|
+
hashes: { hashSpec, modelHash: '' },
|
|
2716
|
+
...metadata,
|
|
2364
2717
|
summary: { states: 0, transitions: 0, errors: validation.errors.length, warnings: 0, checksRun: 0 },
|
|
2365
|
-
checks: [
|
|
2366
|
-
|
|
2367
|
-
name: 'Model Validation',
|
|
2368
|
-
status: 'fail',
|
|
2369
|
-
detail: 'Model schema validation failed: ' + validation.errors.length + ' errors',
|
|
2370
|
-
findings: validation.errors.map((message) => ({ code: 'MODEL_INVALID', severity: 'error', message })),
|
|
2371
|
-
}],
|
|
2718
|
+
checks: [modelCheck],
|
|
2719
|
+
nextSteps: verificationNextSteps([modelCheck], undefined),
|
|
2372
2720
|
};
|
|
2373
2721
|
}
|
|
2374
2722
|
const model = validation.model;
|
|
@@ -2401,10 +2749,18 @@ export function runVerification(input, options = {}) {
|
|
|
2401
2749
|
if (options.beforeModel !== undefined) {
|
|
2402
2750
|
const beforeValidation = validateModel(options.beforeModel);
|
|
2403
2751
|
if (!beforeValidation.ok) {
|
|
2752
|
+
const beforeFindings = beforeValidation.errors.map((message) => ({ code: 'BEFORE_MODEL_INVALID', severity: 'error', message }));
|
|
2753
|
+
const allFindings = [...checks.flatMap((check) => check.findings), ...beforeFindings];
|
|
2404
2754
|
return {
|
|
2405
2755
|
ok: false,
|
|
2756
|
+
ran: true,
|
|
2757
|
+
...verdictOfFindings(allFindings),
|
|
2758
|
+
schema: REPORT_SCHEMAS.verify,
|
|
2406
2759
|
schemaVersion: 1,
|
|
2407
|
-
|
|
2760
|
+
hashSpec,
|
|
2761
|
+
modelHash: modelHash(model, hashSpec),
|
|
2762
|
+
hashes: { hashSpec, modelHash: modelHash(model, hashSpec), afterModelHash: modelHash(model, hashSpec) },
|
|
2763
|
+
...metadata,
|
|
2408
2764
|
summary: {
|
|
2409
2765
|
states: model.states.length,
|
|
2410
2766
|
transitions: model.transitions.length,
|
|
@@ -2420,9 +2776,10 @@ export function runVerification(input, options = {}) {
|
|
|
2420
2776
|
name: 'Before Model Validation',
|
|
2421
2777
|
status: 'fail',
|
|
2422
2778
|
detail: 'Before model schema validation failed: ' + beforeValidation.errors.length + ' errors',
|
|
2423
|
-
findings:
|
|
2779
|
+
findings: beforeFindings,
|
|
2424
2780
|
},
|
|
2425
2781
|
],
|
|
2782
|
+
nextSteps: verificationNextSteps(checks, narrativeCoverageOf(model)),
|
|
2426
2783
|
};
|
|
2427
2784
|
}
|
|
2428
2785
|
const before = beforeValidation.model;
|
|
@@ -2433,10 +2790,22 @@ export function runVerification(input, options = {}) {
|
|
|
2433
2790
|
const errors = checks.reduce((sum, check) => sum + check.findings.filter((finding) => finding.severity === 'error').length, 0);
|
|
2434
2791
|
const warnings = checks.reduce((sum, check) => sum + check.findings.filter((finding) => finding.severity === 'warning').length, 0);
|
|
2435
2792
|
const coverageNotes = computeCoverageNotes(model);
|
|
2793
|
+
const narrativeCoverage = narrativeCoverageOf(model);
|
|
2436
2794
|
return {
|
|
2437
2795
|
ok: true,
|
|
2796
|
+
ran: true,
|
|
2797
|
+
...verdictOfFindings(checks.flatMap((check) => check.findings)),
|
|
2798
|
+
schema: REPORT_SCHEMAS.verify,
|
|
2438
2799
|
schemaVersion: 1,
|
|
2439
|
-
|
|
2800
|
+
hashSpec,
|
|
2801
|
+
modelHash: modelHash(model, hashSpec),
|
|
2802
|
+
hashes: {
|
|
2803
|
+
hashSpec,
|
|
2804
|
+
modelHash: modelHash(model, hashSpec),
|
|
2805
|
+
...(comparison === undefined ? {} : { beforeModelHash: comparison.beforeModelHash, afterModelHash: comparison.afterModelHash }),
|
|
2806
|
+
},
|
|
2807
|
+
...metadata,
|
|
2808
|
+
...(narrativeCoverage === undefined ? {} : { narrativeCoverage }),
|
|
2440
2809
|
summary: {
|
|
2441
2810
|
states: model.states.length,
|
|
2442
2811
|
transitions: model.transitions.length,
|
|
@@ -2446,6 +2815,7 @@ export function runVerification(input, options = {}) {
|
|
|
2446
2815
|
truncated: exploration.truncated,
|
|
2447
2816
|
},
|
|
2448
2817
|
checks,
|
|
2818
|
+
nextSteps: verificationNextSteps(checks, narrativeCoverage),
|
|
2449
2819
|
...(model.narrative === undefined ? {} : { narrative: model.narrative }),
|
|
2450
2820
|
...(comparison === undefined ? {} : { comparison }),
|
|
2451
2821
|
...(coverageNotes.length === 0 ? {} : { coverageNotes }),
|
|
@@ -2463,6 +2833,7 @@ export function runVerification(input, options = {}) {
|
|
|
2463
2833
|
* least one is not terminal) and C2 rendezvous that can never fire.
|
|
2464
2834
|
*/
|
|
2465
2835
|
export function runCompositionVerification(machinesInput, options = {}) {
|
|
2836
|
+
const hashSpec = options.hashSpec ?? DEFAULT_HASH_SPEC;
|
|
2466
2837
|
const models = [];
|
|
2467
2838
|
const hashes = [];
|
|
2468
2839
|
const modelFindings = [];
|
|
@@ -2473,7 +2844,7 @@ export function runCompositionVerification(machinesInput, options = {}) {
|
|
|
2473
2844
|
}
|
|
2474
2845
|
else {
|
|
2475
2846
|
models.push(validation.model);
|
|
2476
|
-
hashes.push(modelHash(validation.model));
|
|
2847
|
+
hashes.push(modelHash(validation.model, hashSpec));
|
|
2477
2848
|
}
|
|
2478
2849
|
});
|
|
2479
2850
|
const machineSummary = models.map((m, i) => ({ modelHash: hashes[i] ?? '', states: m.states.length, transitions: m.transitions.length }));
|
|
@@ -2483,8 +2854,14 @@ export function runCompositionVerification(machinesInput, options = {}) {
|
|
|
2483
2854
|
}
|
|
2484
2855
|
return {
|
|
2485
2856
|
ok: false,
|
|
2857
|
+
ran: true,
|
|
2858
|
+
...verdictOfFindings(modelFindings),
|
|
2859
|
+
schema: REPORT_SCHEMAS.compose,
|
|
2860
|
+
hashSpec,
|
|
2861
|
+
hashes: { hashSpec, machines: hashes },
|
|
2486
2862
|
summary: { machineCount: models.length, machines: machineSummary, compositeStates: 0, errors: modelFindings.length, warnings: 0, truncated: false },
|
|
2487
2863
|
checks: [{ id: 'MODEL', name: 'Machine Validation', status: 'fail', detail: 'composition input validation failed', findings: modelFindings }],
|
|
2864
|
+
nextSteps: ['Fix the machine that failed validation, then re-run the composition.'],
|
|
2488
2865
|
};
|
|
2489
2866
|
}
|
|
2490
2867
|
const rendezvous = new Set(options.rendezvous ?? []);
|
|
@@ -2548,6 +2925,17 @@ export function runCompositionVerification(machinesInput, options = {}) {
|
|
|
2548
2925
|
const firedCount = new Map();
|
|
2549
2926
|
for (const event of rendezvous)
|
|
2550
2927
|
firedCount.set(event, 0);
|
|
2928
|
+
// Per-machine enablement, collected while the state space is explored: a rendezvous
|
|
2929
|
+
// that never fires needs to say *which* machine never had it enabled, not just that
|
|
2930
|
+
// the handshake did not happen.
|
|
2931
|
+
const everEnabled = new Map();
|
|
2932
|
+
const enabledStates = new Map();
|
|
2933
|
+
const coEnabledNodes = new Map();
|
|
2934
|
+
for (const event of rendezvous) {
|
|
2935
|
+
everEnabled.set(event, new Set());
|
|
2936
|
+
enabledStates.set(event, models.map(() => []));
|
|
2937
|
+
coEnabledNodes.set(event, 0);
|
|
2938
|
+
}
|
|
2551
2939
|
const c1Findings = [];
|
|
2552
2940
|
while (queue.length > 0) {
|
|
2553
2941
|
const node = queue.shift();
|
|
@@ -2562,12 +2950,66 @@ export function runCompositionVerification(machinesInput, options = {}) {
|
|
|
2562
2950
|
}
|
|
2563
2951
|
const moves = compositionMoves(node);
|
|
2564
2952
|
const allTerminal = node.runtimes.every((runtime, i) => isTerminal(models[i], runtime.state));
|
|
2953
|
+
// Enablement census for C2: which machines could fire each rendezvous here.
|
|
2954
|
+
for (const event of rendezvous) {
|
|
2955
|
+
const declaring = [];
|
|
2956
|
+
for (let i = 0; i < models.length; i++)
|
|
2957
|
+
if (machineEventSets[i].has(event))
|
|
2958
|
+
declaring.push(i);
|
|
2959
|
+
if (declaring.length === 0)
|
|
2960
|
+
continue;
|
|
2961
|
+
let allEnabled = declaring.length >= 2;
|
|
2962
|
+
for (const i of declaring) {
|
|
2963
|
+
const enabled = !isTerminal(models[i], node.runtimes[i].state) && stepRuntime(models[i], node.runtimes[i], event).length > 0;
|
|
2964
|
+
if (!enabled) {
|
|
2965
|
+
allEnabled = false;
|
|
2966
|
+
continue;
|
|
2967
|
+
}
|
|
2968
|
+
everEnabled.get(event)?.add(i);
|
|
2969
|
+
const states = enabledStates.get(event)?.[i];
|
|
2970
|
+
if (states !== undefined && !states.includes(node.runtimes[i].state) && states.length < 8)
|
|
2971
|
+
states.push(node.runtimes[i].state);
|
|
2972
|
+
}
|
|
2973
|
+
if (allEnabled)
|
|
2974
|
+
coEnabledNodes.set(event, (coEnabledNodes.get(event) ?? 0) + 1);
|
|
2975
|
+
}
|
|
2565
2976
|
if (moves.length === 0 && !allTerminal) {
|
|
2977
|
+
// Why nobody can advance, per machine: its state, its alphabet, and for every event
|
|
2978
|
+
// either the rendezvous partner that is not ready or the transition that does not fire.
|
|
2979
|
+
const perMachine = node.runtimes.map((runtime, i) => {
|
|
2980
|
+
const terminal = isTerminal(models[i], runtime.state);
|
|
2981
|
+
const blocked = terminal ? [] : allEvents(models[i]).map((event) => {
|
|
2982
|
+
const enabled = stepRuntime(models[i], runtime, event).length > 0;
|
|
2983
|
+
// A rendezvous event is not fireable just because this machine has a transition:
|
|
2984
|
+
// it needs every declaring machine enabled at the same time. Check that first, or
|
|
2985
|
+
// the deadlock census would report "enabled" for a handshake nobody can complete.
|
|
2986
|
+
if (!rendezvous.has(event))
|
|
2987
|
+
return { event, reason: enabled ? 'enabled' : 'no-enabled-transition' };
|
|
2988
|
+
const declaring = [];
|
|
2989
|
+
for (let j = 0; j < models.length; j++)
|
|
2990
|
+
if (machineEventSets[j].has(event))
|
|
2991
|
+
declaring.push(j);
|
|
2992
|
+
if (declaring.length < 2)
|
|
2993
|
+
return { event, reason: 'rendezvous-needs-partner' };
|
|
2994
|
+
const partnersReady = declaring.every((j) => !isTerminal(models[j], node.runtimes[j].state)
|
|
2995
|
+
&& stepRuntime(models[j], node.runtimes[j], event).length > 0);
|
|
2996
|
+
if (!partnersReady)
|
|
2997
|
+
return { event, reason: 'rendezvous-partner-not-ready' };
|
|
2998
|
+
return { event, reason: enabled ? 'enabled' : 'no-enabled-transition' };
|
|
2999
|
+
});
|
|
3000
|
+
return { index: i, state: runtime.state, terminal, blocked };
|
|
3001
|
+
});
|
|
3002
|
+
const reasons = perMachine
|
|
3003
|
+
.filter((machine) => !machine.terminal)
|
|
3004
|
+
.map((machine) => 'machine ' + String(machine.index) + ' at ' + machine.state + ': ' + (machine.blocked.length === 0
|
|
3005
|
+
? 'empty event alphabet'
|
|
3006
|
+
: machine.blocked.map((entry) => entry.event + ' (' + entry.reason + ')').join(', ')));
|
|
2566
3007
|
c1Findings.push({
|
|
2567
3008
|
code: 'C1_COMPOSITION_DEADLOCK',
|
|
2568
3009
|
severity: 'error',
|
|
2569
3010
|
message: 'Composition deadlock: no machine can advance from (' + node.runtimes.map((runtime) => runtime.state).join(', ') + ') while at least one is not terminal.',
|
|
2570
|
-
|
|
3011
|
+
detail: 'Shortest counterexample (breadth-first): ' + String(node.path.length) + ' step(s) to reach it. ' + reasons.join(' | '),
|
|
3012
|
+
evidence: { steps: node.path, states: node.runtimes.map((runtime) => runtime.state), depth: node.path.length, perMachine, reasons },
|
|
2571
3013
|
});
|
|
2572
3014
|
continue;
|
|
2573
3015
|
}
|
|
@@ -2589,22 +3031,71 @@ export function runCompositionVerification(machinesInput, options = {}) {
|
|
|
2589
3031
|
if (!allEventsSet.has(event))
|
|
2590
3032
|
continue;
|
|
2591
3033
|
if ((firedCount.get(event) ?? 0) === 0) {
|
|
3034
|
+
// Why the handshake never happens: who declares it, who ever had it enabled, and
|
|
3035
|
+
// whether the enabling states could ever coincide.
|
|
3036
|
+
const machines = models.map((model, index) => ({
|
|
3037
|
+
index,
|
|
3038
|
+
declares: machineEventSets[index].has(event),
|
|
3039
|
+
everEnabled: everEnabled.get(event)?.has(index) === true,
|
|
3040
|
+
enabledAt: enabledStates.get(event)?.[index] ?? [],
|
|
3041
|
+
}));
|
|
3042
|
+
const declaring = machines.filter((machine) => machine.declares);
|
|
3043
|
+
const neverEnabled = declaring.filter((machine) => !machine.everEnabled);
|
|
3044
|
+
const coEnabled = coEnabledNodes.get(event) ?? 0;
|
|
3045
|
+
let reason;
|
|
3046
|
+
if (declaring.length < 2) {
|
|
3047
|
+
reason = 'only ' + String(declaring.length) + ' machine(s) declare it (machine ' + declaring.map((machine) => machine.index).join(', ') + '); a handshake needs at least two';
|
|
3048
|
+
}
|
|
3049
|
+
else if (neverEnabled.length > 0) {
|
|
3050
|
+
reason = 'machine ' + neverEnabled.map((machine) => machine.index).join(', ') + ' declares it but never has it enabled (no transition, or the guard never holds)';
|
|
3051
|
+
}
|
|
3052
|
+
else {
|
|
3053
|
+
reason = 'every declaring machine enables it somewhere, but never at the same time (' + String(coEnabled) + ' composite state(s) had all of them ready)';
|
|
3054
|
+
}
|
|
3055
|
+
// A truncated exploration cannot prove that a handshake never happens: it may simply
|
|
3056
|
+
// not have reached the composite state where both machines are ready.
|
|
3057
|
+
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.' : '';
|
|
2592
3058
|
c2Findings.push({
|
|
2593
3059
|
code: 'C2_RENDEZVOUS_NEVER_FIRES',
|
|
2594
3060
|
severity: 'warning',
|
|
2595
|
-
message: 'Rendezvous event ' + event + ' can never fire:
|
|
3061
|
+
message: 'Rendezvous event ' + event + ' can never fire: ' + reason + '.' + caveat,
|
|
3062
|
+
detail: machines.map((machine) => 'machine ' + String(machine.index) + (machine.declares ? ' declares' : ' does not declare')
|
|
3063
|
+
+ (machine.everEnabled ? ', enabled at ' + (machine.enabledAt.length === 0 ? 'no visited state' : machine.enabledAt.join('/')) : ', never enabled')).join(' | '),
|
|
3064
|
+
evidence: { event, machines, declaring: declaring.map((machine) => machine.index), coEnabledNodes: coEnabled, reason, truncated },
|
|
2596
3065
|
});
|
|
2597
3066
|
}
|
|
2598
3067
|
}
|
|
2599
3068
|
const errors = c1Findings.length;
|
|
2600
3069
|
const warnings = c2Findings.length;
|
|
3070
|
+
const c1Detail = c1Findings.length > 0
|
|
3071
|
+
? 'Composition deadlocks: ' + String(c1Findings.length)
|
|
3072
|
+
: truncated
|
|
3073
|
+
? 'No composition deadlock found, but the search was truncated at ' + String(maxStates) + ' composite states: absence is not proven'
|
|
3074
|
+
: 'No composition deadlock reachable';
|
|
3075
|
+
const c2Detail = c2Findings.length > 0
|
|
3076
|
+
? 'Rendezvous warnings: ' + String(c2Findings.length)
|
|
3077
|
+
: truncated
|
|
3078
|
+
? 'All rendezvous events fired, but the search was truncated at ' + String(maxStates) + ' composite states'
|
|
3079
|
+
: 'All rendezvous events can fire';
|
|
2601
3080
|
const checks = [
|
|
2602
|
-
checkResult('C1', 'Composition Deadlock', c1Findings,
|
|
2603
|
-
checkResult('C2', 'Rendezvous Sync', c2Findings,
|
|
3081
|
+
checkResult('C1', 'Composition Deadlock', c1Findings, c1Detail),
|
|
3082
|
+
checkResult('C2', 'Rendezvous Sync', c2Findings, c2Detail),
|
|
2604
3083
|
];
|
|
3084
|
+
const nextSteps = [];
|
|
3085
|
+
if (errors > 0)
|
|
3086
|
+
nextSteps.push('Resolve the error findings first: the composition deadlocks at a reachable state, so at least one machine is missing an exit or a handshake partner.');
|
|
3087
|
+
if (warnings > 0)
|
|
3088
|
+
nextSteps.push('Review the warning findings: a rendezvous event that can never fire means the handshake is declared but unreachable.');
|
|
3089
|
+
nextSteps.push('Re-run the composition after the change; every machine must be able to advance, or explain why it is terminal.');
|
|
2605
3090
|
return {
|
|
2606
3091
|
ok: errors === 0,
|
|
3092
|
+
ran: true,
|
|
3093
|
+
...verdictOf(errors, warnings, c1Findings[0]?.code ?? c2Findings[0]?.code),
|
|
3094
|
+
schema: REPORT_SCHEMAS.compose,
|
|
3095
|
+
hashSpec,
|
|
3096
|
+
hashes: { hashSpec, machines: hashes },
|
|
2607
3097
|
summary: { machineCount: models.length, machines: machineSummary, compositeStates, errors, warnings, truncated },
|
|
2608
3098
|
checks,
|
|
3099
|
+
nextSteps,
|
|
2609
3100
|
};
|
|
2610
3101
|
}
|