dsh-logicprobe 0.9.0 → 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.js +14 -0
- package/lib/data-engine.js +42 -8
- package/lib/engine.js +379 -79
- package/lib/export-tool.js +27 -2
- package/lib/granularity.js +237 -0
- package/lib/index.js +63 -41
- 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 +138 -1
- 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 +84 -58
- package/skills/logicprobe/references/dsh-model-schema.md +81 -10
- 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 +27 -250
- 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.ts +23 -0
- package/src/data-engine.ts +51 -9
- package/src/engine.ts +441 -67
- package/src/export-tool.ts +73 -48
- package/src/granularity.ts +312 -0
- package/src/index.ts +63 -41
- 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 +3 -3
- package/src/uml-tool.ts +38 -8
- package/src/uml.ts +275 -24
- package/skills/logicprobe/references/logicprobe-engine.py +0 -4943
- 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;
|
|
@@ -458,70 +603,70 @@ export function validateModel(input) {
|
|
|
458
603
|
rejectUnknownKeys(narrative, MODEL_KEYS.narrative, narrativePath);
|
|
459
604
|
const stateIds = new Set(Array.isArray(root.states) ? root.states.map((state) => state.id) : []);
|
|
460
605
|
const eventIds = new Set(Array.isArray(root.transitions) ? root.transitions.map((transition) => transition.event) : []);
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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)');
|
|
464
614
|
}
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
bad(narrativePath + '.states', 'references unknown state ' + id);
|
|
469
|
-
if (typeof description !== 'string' || description.length === 0)
|
|
470
|
-
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');
|
|
471
618
|
}
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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
|
+
}
|
|
475
626
|
}
|
|
476
627
|
}
|
|
477
|
-
if (
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
else {
|
|
481
|
-
for (const [id, description] of Object.entries(narrative.events)) {
|
|
482
|
-
if (!eventIds.has(id))
|
|
483
|
-
bad(narrativePath + '.events', 'references unknown event ' + id);
|
|
484
|
-
if (typeof description !== 'string' || description.length === 0)
|
|
485
|
-
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');
|
|
486
631
|
}
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
+
}
|
|
490
639
|
}
|
|
491
640
|
}
|
|
492
|
-
if (
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
if (!seen.has(key)) {
|
|
522
|
-
const sep = key.indexOf('|');
|
|
523
|
-
bad(narrativePath + '.scenarios', 'missing scenario for (' + key.slice(0, sep) + ', ' + key.slice(sep + 1) + ')');
|
|
524
|
-
}
|
|
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
|
+
});
|
|
525
670
|
}
|
|
526
671
|
}
|
|
527
672
|
}
|
|
@@ -750,7 +895,7 @@ function checkResult(id, name, findings, detail) {
|
|
|
750
895
|
};
|
|
751
896
|
}
|
|
752
897
|
// ---------------------------------------------------------------------------
|
|
753
|
-
// S1-
|
|
898
|
+
// S1-S8
|
|
754
899
|
// ---------------------------------------------------------------------------
|
|
755
900
|
function S1_reachability(model, exploration) {
|
|
756
901
|
const reached = new Set(exploration.reachable.map((runtime) => runtime.state));
|
|
@@ -1184,7 +1329,7 @@ function S7_invariants(model, options) {
|
|
|
1184
1329
|
return checkResult('S7', 'Invariant Validity', findings, violations.length === 0 ? 'All invariants hold' : 'Invariant violations: ' + violations.length);
|
|
1185
1330
|
}
|
|
1186
1331
|
// ---------------------------------------------------------------------------
|
|
1187
|
-
// A1-
|
|
1332
|
+
// A1-A14
|
|
1188
1333
|
// ---------------------------------------------------------------------------
|
|
1189
1334
|
function A1_unexpectedEvents(model) {
|
|
1190
1335
|
const events = allEvents(model);
|
|
@@ -2546,20 +2691,32 @@ export function runVerification(input, options = {}) {
|
|
|
2546
2691
|
maxStates: options.maxStates ?? DEFAULT_MAX_STATES,
|
|
2547
2692
|
maxPermutationEvents: options.maxPermutationEvents ?? DEFAULT_MAX_PERMUTATION_EVENTS,
|
|
2548
2693
|
};
|
|
2694
|
+
const hashSpec = options.hashSpec ?? DEFAULT_HASH_SPEC;
|
|
2695
|
+
const metadataKeys = metadataKeysOf(input);
|
|
2696
|
+
const metadata = metadataKeys.length === 0 ? {} : { metadataKeys };
|
|
2549
2697
|
const validation = validateModel(input);
|
|
2550
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
|
+
};
|
|
2551
2707
|
return {
|
|
2552
2708
|
ok: false,
|
|
2709
|
+
ran: true,
|
|
2710
|
+
...verdictOfFindings(findings),
|
|
2711
|
+
schema: REPORT_SCHEMAS.verify,
|
|
2553
2712
|
schemaVersion: 1,
|
|
2713
|
+
hashSpec,
|
|
2554
2714
|
modelHash: '',
|
|
2715
|
+
hashes: { hashSpec, modelHash: '' },
|
|
2716
|
+
...metadata,
|
|
2555
2717
|
summary: { states: 0, transitions: 0, errors: validation.errors.length, warnings: 0, checksRun: 0 },
|
|
2556
|
-
checks: [
|
|
2557
|
-
|
|
2558
|
-
name: 'Model Validation',
|
|
2559
|
-
status: 'fail',
|
|
2560
|
-
detail: 'Model schema validation failed: ' + validation.errors.length + ' errors',
|
|
2561
|
-
findings: validation.errors.map((message) => ({ code: 'MODEL_INVALID', severity: 'error', message })),
|
|
2562
|
-
}],
|
|
2718
|
+
checks: [modelCheck],
|
|
2719
|
+
nextSteps: verificationNextSteps([modelCheck], undefined),
|
|
2563
2720
|
};
|
|
2564
2721
|
}
|
|
2565
2722
|
const model = validation.model;
|
|
@@ -2592,10 +2749,18 @@ export function runVerification(input, options = {}) {
|
|
|
2592
2749
|
if (options.beforeModel !== undefined) {
|
|
2593
2750
|
const beforeValidation = validateModel(options.beforeModel);
|
|
2594
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];
|
|
2595
2754
|
return {
|
|
2596
2755
|
ok: false,
|
|
2756
|
+
ran: true,
|
|
2757
|
+
...verdictOfFindings(allFindings),
|
|
2758
|
+
schema: REPORT_SCHEMAS.verify,
|
|
2597
2759
|
schemaVersion: 1,
|
|
2598
|
-
|
|
2760
|
+
hashSpec,
|
|
2761
|
+
modelHash: modelHash(model, hashSpec),
|
|
2762
|
+
hashes: { hashSpec, modelHash: modelHash(model, hashSpec), afterModelHash: modelHash(model, hashSpec) },
|
|
2763
|
+
...metadata,
|
|
2599
2764
|
summary: {
|
|
2600
2765
|
states: model.states.length,
|
|
2601
2766
|
transitions: model.transitions.length,
|
|
@@ -2611,9 +2776,10 @@ export function runVerification(input, options = {}) {
|
|
|
2611
2776
|
name: 'Before Model Validation',
|
|
2612
2777
|
status: 'fail',
|
|
2613
2778
|
detail: 'Before model schema validation failed: ' + beforeValidation.errors.length + ' errors',
|
|
2614
|
-
findings:
|
|
2779
|
+
findings: beforeFindings,
|
|
2615
2780
|
},
|
|
2616
2781
|
],
|
|
2782
|
+
nextSteps: verificationNextSteps(checks, narrativeCoverageOf(model)),
|
|
2617
2783
|
};
|
|
2618
2784
|
}
|
|
2619
2785
|
const before = beforeValidation.model;
|
|
@@ -2624,10 +2790,22 @@ export function runVerification(input, options = {}) {
|
|
|
2624
2790
|
const errors = checks.reduce((sum, check) => sum + check.findings.filter((finding) => finding.severity === 'error').length, 0);
|
|
2625
2791
|
const warnings = checks.reduce((sum, check) => sum + check.findings.filter((finding) => finding.severity === 'warning').length, 0);
|
|
2626
2792
|
const coverageNotes = computeCoverageNotes(model);
|
|
2793
|
+
const narrativeCoverage = narrativeCoverageOf(model);
|
|
2627
2794
|
return {
|
|
2628
2795
|
ok: true,
|
|
2796
|
+
ran: true,
|
|
2797
|
+
...verdictOfFindings(checks.flatMap((check) => check.findings)),
|
|
2798
|
+
schema: REPORT_SCHEMAS.verify,
|
|
2629
2799
|
schemaVersion: 1,
|
|
2630
|
-
|
|
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 }),
|
|
2631
2809
|
summary: {
|
|
2632
2810
|
states: model.states.length,
|
|
2633
2811
|
transitions: model.transitions.length,
|
|
@@ -2637,6 +2815,7 @@ export function runVerification(input, options = {}) {
|
|
|
2637
2815
|
truncated: exploration.truncated,
|
|
2638
2816
|
},
|
|
2639
2817
|
checks,
|
|
2818
|
+
nextSteps: verificationNextSteps(checks, narrativeCoverage),
|
|
2640
2819
|
...(model.narrative === undefined ? {} : { narrative: model.narrative }),
|
|
2641
2820
|
...(comparison === undefined ? {} : { comparison }),
|
|
2642
2821
|
...(coverageNotes.length === 0 ? {} : { coverageNotes }),
|
|
@@ -2654,6 +2833,7 @@ export function runVerification(input, options = {}) {
|
|
|
2654
2833
|
* least one is not terminal) and C2 rendezvous that can never fire.
|
|
2655
2834
|
*/
|
|
2656
2835
|
export function runCompositionVerification(machinesInput, options = {}) {
|
|
2836
|
+
const hashSpec = options.hashSpec ?? DEFAULT_HASH_SPEC;
|
|
2657
2837
|
const models = [];
|
|
2658
2838
|
const hashes = [];
|
|
2659
2839
|
const modelFindings = [];
|
|
@@ -2664,7 +2844,7 @@ export function runCompositionVerification(machinesInput, options = {}) {
|
|
|
2664
2844
|
}
|
|
2665
2845
|
else {
|
|
2666
2846
|
models.push(validation.model);
|
|
2667
|
-
hashes.push(modelHash(validation.model));
|
|
2847
|
+
hashes.push(modelHash(validation.model, hashSpec));
|
|
2668
2848
|
}
|
|
2669
2849
|
});
|
|
2670
2850
|
const machineSummary = models.map((m, i) => ({ modelHash: hashes[i] ?? '', states: m.states.length, transitions: m.transitions.length }));
|
|
@@ -2674,8 +2854,14 @@ export function runCompositionVerification(machinesInput, options = {}) {
|
|
|
2674
2854
|
}
|
|
2675
2855
|
return {
|
|
2676
2856
|
ok: false,
|
|
2857
|
+
ran: true,
|
|
2858
|
+
...verdictOfFindings(modelFindings),
|
|
2859
|
+
schema: REPORT_SCHEMAS.compose,
|
|
2860
|
+
hashSpec,
|
|
2861
|
+
hashes: { hashSpec, machines: hashes },
|
|
2677
2862
|
summary: { machineCount: models.length, machines: machineSummary, compositeStates: 0, errors: modelFindings.length, warnings: 0, truncated: false },
|
|
2678
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.'],
|
|
2679
2865
|
};
|
|
2680
2866
|
}
|
|
2681
2867
|
const rendezvous = new Set(options.rendezvous ?? []);
|
|
@@ -2739,6 +2925,17 @@ export function runCompositionVerification(machinesInput, options = {}) {
|
|
|
2739
2925
|
const firedCount = new Map();
|
|
2740
2926
|
for (const event of rendezvous)
|
|
2741
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
|
+
}
|
|
2742
2939
|
const c1Findings = [];
|
|
2743
2940
|
while (queue.length > 0) {
|
|
2744
2941
|
const node = queue.shift();
|
|
@@ -2753,12 +2950,66 @@ export function runCompositionVerification(machinesInput, options = {}) {
|
|
|
2753
2950
|
}
|
|
2754
2951
|
const moves = compositionMoves(node);
|
|
2755
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
|
+
}
|
|
2756
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(', ')));
|
|
2757
3007
|
c1Findings.push({
|
|
2758
3008
|
code: 'C1_COMPOSITION_DEADLOCK',
|
|
2759
3009
|
severity: 'error',
|
|
2760
3010
|
message: 'Composition deadlock: no machine can advance from (' + node.runtimes.map((runtime) => runtime.state).join(', ') + ') while at least one is not terminal.',
|
|
2761
|
-
|
|
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 },
|
|
2762
3013
|
});
|
|
2763
3014
|
continue;
|
|
2764
3015
|
}
|
|
@@ -2780,22 +3031,71 @@ export function runCompositionVerification(machinesInput, options = {}) {
|
|
|
2780
3031
|
if (!allEventsSet.has(event))
|
|
2781
3032
|
continue;
|
|
2782
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.' : '';
|
|
2783
3058
|
c2Findings.push({
|
|
2784
3059
|
code: 'C2_RENDEZVOUS_NEVER_FIRES',
|
|
2785
3060
|
severity: 'warning',
|
|
2786
|
-
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 },
|
|
2787
3065
|
});
|
|
2788
3066
|
}
|
|
2789
3067
|
}
|
|
2790
3068
|
const errors = c1Findings.length;
|
|
2791
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';
|
|
2792
3080
|
const checks = [
|
|
2793
|
-
checkResult('C1', 'Composition Deadlock', c1Findings,
|
|
2794
|
-
checkResult('C2', 'Rendezvous Sync', c2Findings,
|
|
3081
|
+
checkResult('C1', 'Composition Deadlock', c1Findings, c1Detail),
|
|
3082
|
+
checkResult('C2', 'Rendezvous Sync', c2Findings, c2Detail),
|
|
2795
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.');
|
|
2796
3090
|
return {
|
|
2797
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 },
|
|
2798
3097
|
summary: { machineCount: models.length, machines: machineSummary, compositeStates, errors, warnings, truncated },
|
|
2799
3098
|
checks,
|
|
3099
|
+
nextSteps,
|
|
2800
3100
|
};
|
|
2801
3101
|
}
|