dsh-logicprobe 1.3.0 → 1.4.1
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 +22 -1
- package/README.md +16 -1
- package/lib/engine.js +320 -25
- package/lib/types/uml.d.ts +293 -293
- package/lib/uml-tool.js +153 -153
- package/lib/uml.js +14 -1
- package/package.json +11 -9
- package/skills/logicprobe/SKILL.md +50 -1
- package/skills/logicprobe/references/dsh-model-schema.md +20 -3
- package/skills/logicprobe-concurrency/SKILL.md +2 -0
- package/skills/logicprobe-datamodel/SKILL.md +2 -0
- package/skills/logicprobe-structure/SKILL.md +2 -0
- package/skills/logicprobe-uml/SKILL.md +2 -0
- package/src/engine.ts +325 -27
- package/src/uml.ts +15 -1
package/README.en-US.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
<p align="center"><strong>English</strong> · <a href="README.md">中文</a></p>
|
|
4
4
|
|
|
5
|
+
> **Status**: this plugin checks claims about designs, code and plans. It continues
|
|
6
|
+
> as `dsh-model-everything`, which covers the same claim, behaviour, structure, UML,
|
|
7
|
+
> data-model, concurrency, export and report-comparison ground, and adds a design
|
|
8
|
+
> review engine (layering, boundaries, substitutability, interface width, graph
|
|
9
|
+
> shape), a report-reading skill, label explanation, and an optional session-start
|
|
10
|
+
> routing component. It is not an API twin: the UML capability is split across tools
|
|
11
|
+
> where this plugin has one `logicprobe_uml`. It is published as
|
|
12
|
+
> [`dsh-model-everything`](https://github.com/AmethystLuna/dsh-model-everything)
|
|
13
|
+
> ([npm](https://www.npmjs.com/package/dsh-model-everything)). Existing installs
|
|
14
|
+
> keep working. Installation, mount, boot, tool call and uninstall were measured on
|
|
15
|
+
> 0.2.1-rc.2 and 0.2.1-rc.1; per-release evidence is in
|
|
16
|
+
> [DSH-COMPATIBILITY.md](DSH-COMPATIBILITY.md).
|
|
17
|
+
|
|
5
18
|
[](https://github.com/hashgraph-online/hol-guard)
|
|
6
19
|
|
|
7
20
|
For an unverified design, failure is only a matter of time. However complex the code, model it and run it — then it becomes clear.
|
|
@@ -10,6 +23,14 @@ logicprobe checks a claim against the thing it describes. Facts go against the s
|
|
|
10
23
|
|
|
11
24
|
**Cross-platform**: works with Claude Code, Codex CLI, Cursor, Kimi CLI, OpenCode, and ZCode. Built on the [Agent Skills](https://agentskills.io) open standard.
|
|
12
25
|
|
|
26
|
+
> **Use case diagrams and the other UML families**: this plugin models state machines
|
|
27
|
+
> and flows only. A use case diagram (`:Actor: --> (UseCase)`), a class, component or
|
|
28
|
+
> deployment diagram is **refused by name** instead of being misread as a state machine.
|
|
29
|
+
> For render/parse/review across all fourteen UML 2.5 diagram families, use
|
|
30
|
+
> [dsh-model-everything](https://github.com/AmethystLuna/dsh-model-everything), which
|
|
31
|
+
> carries this plugin's whole diagram capability (notation syntax engine, capability
|
|
32
|
+
> matrix, use case and structure family modules).
|
|
33
|
+
|
|
13
34
|
## What It Does
|
|
14
35
|
|
|
15
36
|
| Phase | What |
|
|
@@ -292,7 +313,7 @@ Skills are invoked with `$logicprobe`. See `.zcode/INSTALL.md`.
|
|
|
292
313
|
## Requirements
|
|
293
314
|
|
|
294
315
|
- Host: Claude Code v2.1+ / Codex CLI latest / Cursor 2.5+ / Kimi CLI latest / OpenCode latest / ZCode 3.0+
|
|
295
|
-
- DeepSeek Harness (dsh): dev preview, declared support for `>= 0.1.0-rc.7`. The latest round measured install, mount, boot and uninstall on 0.2.1-
|
|
316
|
+
- DeepSeek Harness (dsh): dev preview, declared support for `>= 0.1.0-rc.7`. The latest round measured install, mount, boot and uninstall on 0.2.1-rc.2 and 0.2.1-rc.1. The earlier round measured 0.1.5-rc.2 through 0.2.0-rc.2. Per-release evidence is in [DSH-COMPATIBILITY.md](DSH-COMPATIBILITY.md).
|
|
296
317
|
- The Web Plugins-page "Gate injection" switch requires **dsh ≥ 0.1.7-alpha.1**, because its settings service must be able to project live fields. On older dsh the plugin still loads and still injects. The switch is simply absent, with no error.
|
|
297
318
|
- Python 3.8+ (standard library), needed to run the checks outside dsh. The manual mode needs no dependencies.
|
|
298
319
|
|
package/README.md
CHANGED
|
@@ -2,12 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
<p align="center"><a href="README.en-US.md">English</a> · <strong>中文</strong></p>
|
|
4
4
|
|
|
5
|
+
> **状态**:本插件核验设计、代码与计划中的声称。它继续以 `dsh-model-everything`
|
|
6
|
+
> 的形式发展,同样覆盖声称、行为、结构、UML、数据模型、并发、导出与报告对比,
|
|
7
|
+
> 并加上设计原则审查(分层、边界、可替换性、接口宽度、图形状)、报告阅读技能、
|
|
8
|
+
> 标签口径解释和可选的会话启动路由。它不是本插件的 API 镜像:UML 能力拆在多个
|
|
9
|
+
> 工具里,本插件只有一个 `logicprobe_uml`。它已发布为
|
|
10
|
+
> [`dsh-model-everything`](https://github.com/AmethystLuna/dsh-model-everything)
|
|
11
|
+
> ([npm](https://www.npmjs.com/package/dsh-model-everything))。现有安装继续可用。
|
|
12
|
+
> 0.2.1-rc.2 与 0.2.1-rc.1 上已实测安装、挂载、启动、工具调用与卸载;逐版本证据见
|
|
13
|
+
> [DSH-COMPATIBILITY.md](DSH-COMPATIBILITY.md)。
|
|
14
|
+
|
|
5
15
|
没验证过的设计,翻车只是时间问题;再复杂的代码,建模再跑就清楚了。
|
|
6
16
|
|
|
7
17
|
logicprobe 核验声称与实物是否一致。事实类对着源码查。行为类跑模型:状态机、协议、组合、数据模型、迁移。图按依赖图读,单层或多层。改动前后用基线对比。验不了的维度明说,并指出该用哪个工具。
|
|
8
18
|
|
|
9
19
|
**跨平台**:支持 Claude Code、Codex CLI、Cursor、Kimi CLI、OpenCode、ZCode。技能基于 [Agent Skills](https://agentskills.io) 开放标准。
|
|
10
20
|
|
|
21
|
+
> **用例图与其它 UML 图族**:本插件只建模状态机与流程。用例图(`:Actor: --> (UseCase)`)、类图、
|
|
22
|
+
> 组件图、部署图等会被**按名拒绝**,不会被误读成状态机。需要完整 14 类 UML 2.5 图族的
|
|
23
|
+
> render/parse/review 时,用 [dsh-model-everything](https://github.com/AmethystLuna/dsh-model-everything):
|
|
24
|
+
> 它承接了本插件的全部图能力(记号语法引擎、能力矩阵、用例与结构族模块)。
|
|
25
|
+
|
|
11
26
|
## 功能
|
|
12
27
|
|
|
13
28
|
| 阶段 | 内容 |
|
|
@@ -290,7 +305,7 @@ cp -r logicprobe/skills/* .zcode/skills/
|
|
|
290
305
|
## 环境要求
|
|
291
306
|
|
|
292
307
|
- 宿主:Claude Code v2.1+ / Codex CLI 最新 / Cursor 2.5+ / Kimi CLI 最新 / OpenCode 最新 / ZCode 3.0+
|
|
293
|
-
- DeepSeek Harness (dsh):dev preview,声明支持 `>= 0.1.0-rc.7`。最新一轮在 0.2.1-
|
|
308
|
+
- DeepSeek Harness (dsh):dev preview,声明支持 `>= 0.1.0-rc.7`。最新一轮在 0.2.1-rc.2 与 0.2.1-rc.1 上实测了安装、挂载、启动与卸载;更早一轮实测覆盖 0.1.5-rc.2 到 0.2.0-rc.2。逐版本证据见 [DSH-COMPATIBILITY.md](DSH-COMPATIBILITY.md)。
|
|
294
309
|
- Web 端的「Gate 注入」开关需要 **dsh ≥ 0.1.7-alpha.1**,因为设置服务必须能投影即时字段。更早的 dsh 上插件照常加载、照常注入,只是开关不出现,也不报错。
|
|
295
310
|
- Python 3.8+(标准库),在 dsh 之外运行验证所需;手动兜底模式不需要任何依赖。
|
|
296
311
|
|
package/lib/engine.js
CHANGED
|
@@ -68,6 +68,18 @@ function refusalGuidance(errors) {
|
|
|
68
68
|
}
|
|
69
69
|
const DEFAULT_MAX_STATES = 10_000;
|
|
70
70
|
const DEFAULT_MAX_PERMUTATION_EVENTS = 5;
|
|
71
|
+
/**
|
|
72
|
+
* Budget for the leads-to walk, as a multiple of `maxStates`. That walk is depth-first
|
|
73
|
+
* over run states, so `maxStates` does not bound it the way it bounds the breadth-first
|
|
74
|
+
* explorations: an `inc`/`dec` counter whose updates are not pinned by a guard has an
|
|
75
|
+
* unbounded reachable set, every run state is distinct, and the walk never revisits one.
|
|
76
|
+
* The budget is shared across every start state of a single check, so a wide search
|
|
77
|
+
* space cannot multiply it by the number of starts.
|
|
78
|
+
*/
|
|
79
|
+
const LEADS_TO_WALK_FACTOR = 20;
|
|
80
|
+
function leadsToWalkBudget(options) {
|
|
81
|
+
return Math.max(1_000, options.maxStates * LEADS_TO_WALK_FACTOR);
|
|
82
|
+
}
|
|
71
83
|
function stableStringify(value) {
|
|
72
84
|
if (value === null || typeof value !== 'object')
|
|
73
85
|
return JSON.stringify(value);
|
|
@@ -158,6 +170,15 @@ export function verdictOfFindings(findings) {
|
|
|
158
170
|
const warnings = findings.filter((finding) => finding.severity === 'warning');
|
|
159
171
|
return verdictOf(errors.length, warnings.length, errors[0]?.code);
|
|
160
172
|
}
|
|
173
|
+
/**
|
|
174
|
+
* Finding codes that mean "the search stopped before deciding", as opposed to "the
|
|
175
|
+
* model is wrong". They must reach `summary.truncated`, or a caller reading only that
|
|
176
|
+
* flag would take an unfinished search for a clean one.
|
|
177
|
+
*/
|
|
178
|
+
const UNDECIDED_CODES = new Set(['S7_UNDECIDED', 'A7_UNDECIDED', 'A9_LEADS_TO_UNDECIDED', 'D2_UNDECIDED']);
|
|
179
|
+
function hasUndecidedFinding(checks) {
|
|
180
|
+
return checks.some((check) => check.findings.some((finding) => UNDECIDED_CODES.has(finding.code)));
|
|
181
|
+
}
|
|
161
182
|
/**
|
|
162
183
|
* Paths of every `_`-prefixed key in the input, sorted (`_source`, `_verified`,
|
|
163
184
|
* `states[0]._note`; a metadata key's own children are not listed, since the subtree is
|
|
@@ -1059,6 +1080,108 @@ function guardLeaves(model) {
|
|
|
1059
1080
|
function collectGuardConstants(model) {
|
|
1060
1081
|
return guardLeaves(model).filter((leaf) => typeof leaf.value === 'number').map((leaf) => leaf.value);
|
|
1061
1082
|
}
|
|
1083
|
+
/** How a variable value prints in a message. Shared by every finding that quotes one. */
|
|
1084
|
+
function varValueLabel(value) {
|
|
1085
|
+
return typeof value === 'boolean' ? (value ? 'true' : 'false') : String(value);
|
|
1086
|
+
}
|
|
1087
|
+
/** Deterministic value order: booleans first, then numbers ascending. */
|
|
1088
|
+
function compareVarValues(a, b) {
|
|
1089
|
+
const rank = (value) => typeof value === 'number' ? [1, value] : [0, value ? 1 : 0];
|
|
1090
|
+
const [rankA, numberA] = rank(a);
|
|
1091
|
+
const [rankB, numberB] = rank(b);
|
|
1092
|
+
return rankA === rankB ? numberA - numberB : rankA - rankB;
|
|
1093
|
+
}
|
|
1094
|
+
/**
|
|
1095
|
+
* Values each variable can actually hold, as an over-approximation: `init` plus every
|
|
1096
|
+
* `set` constant the model writes. `null` means "unknown", which is what an `inc` or
|
|
1097
|
+
* `dec` update produces — the reachable set is then unbounded and nothing can be
|
|
1098
|
+
* concluded from it.
|
|
1099
|
+
*
|
|
1100
|
+
* The union is taken across the whole model rather than per state, so the answer can
|
|
1101
|
+
* only be too large, never too small. A guard this function calls dead is therefore
|
|
1102
|
+
* dead in every state, while a live guard may still be unreachable in a particular one.
|
|
1103
|
+
*/
|
|
1104
|
+
function attainableValues(model) {
|
|
1105
|
+
const attainable = new Map();
|
|
1106
|
+
for (const variable of model.variables ?? [])
|
|
1107
|
+
attainable.set(variable.name, new Set([variable.init]));
|
|
1108
|
+
for (const transition of model.transitions) {
|
|
1109
|
+
for (const update of transition.updates ?? []) {
|
|
1110
|
+
if (!attainable.has(update.variable))
|
|
1111
|
+
continue;
|
|
1112
|
+
if (update.op === 'inc' || update.op === 'dec') {
|
|
1113
|
+
attainable.set(update.variable, null);
|
|
1114
|
+
continue;
|
|
1115
|
+
}
|
|
1116
|
+
const values = attainable.get(update.variable);
|
|
1117
|
+
if (values === null || values === undefined)
|
|
1118
|
+
continue;
|
|
1119
|
+
values.add(update.value ?? 0);
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
return attainable;
|
|
1123
|
+
}
|
|
1124
|
+
/**
|
|
1125
|
+
* Guarded transitions whose guard no reachable variable value can satisfy: the branch
|
|
1126
|
+
* is dead code, and a model that relies on it is not the model the author wrote. This
|
|
1127
|
+
* is the dual of S6_INCOMPLETE_GUARD — a group that never reaches a branch, rather
|
|
1128
|
+
* than one that falls through every branch.
|
|
1129
|
+
*
|
|
1130
|
+
* The test runs over {@link attainableValues}, not over the declared domain: a guard
|
|
1131
|
+
* on a variable the model only ever sets to one of its declared values is satisfiable
|
|
1132
|
+
* on paper and dead in practice, which is exactly the defect worth reporting.
|
|
1133
|
+
*/
|
|
1134
|
+
function deadGuardFindings(model) {
|
|
1135
|
+
const attainable = attainableValues(model);
|
|
1136
|
+
const findings = [];
|
|
1137
|
+
for (const [key, group] of groupTransitions(model)) {
|
|
1138
|
+
const [from, event] = key.split('|');
|
|
1139
|
+
for (const transition of group) {
|
|
1140
|
+
if (transition.guard === undefined)
|
|
1141
|
+
continue;
|
|
1142
|
+
const names = [...new Set(guardVariables(transition.guard))].filter((name) => attainable.has(name)).sort();
|
|
1143
|
+
if (names.length === 0)
|
|
1144
|
+
continue;
|
|
1145
|
+
const domains = [];
|
|
1146
|
+
let known = true;
|
|
1147
|
+
for (const name of names) {
|
|
1148
|
+
const values = attainable.get(name);
|
|
1149
|
+
if (values === undefined || values === null || values.size === 0) {
|
|
1150
|
+
known = false;
|
|
1151
|
+
break;
|
|
1152
|
+
}
|
|
1153
|
+
domains.push(values);
|
|
1154
|
+
}
|
|
1155
|
+
if (!known)
|
|
1156
|
+
continue;
|
|
1157
|
+
let size = 1;
|
|
1158
|
+
for (const values of domains)
|
|
1159
|
+
size *= values.size;
|
|
1160
|
+
if (size > 10_000)
|
|
1161
|
+
continue;
|
|
1162
|
+
const assignment = {};
|
|
1163
|
+
const satisfiable = (index) => {
|
|
1164
|
+
if (index === names.length)
|
|
1165
|
+
return evalGuard(transition.guard, assignment);
|
|
1166
|
+
for (const value of [...domains[index]].sort(compareVarValues)) {
|
|
1167
|
+
assignment[names[index]] = value;
|
|
1168
|
+
if (satisfiable(index + 1))
|
|
1169
|
+
return true;
|
|
1170
|
+
}
|
|
1171
|
+
return false;
|
|
1172
|
+
};
|
|
1173
|
+
if (satisfiable(0))
|
|
1174
|
+
continue;
|
|
1175
|
+
findings.push({
|
|
1176
|
+
code: 'S6_DEAD_GUARD',
|
|
1177
|
+
severity: 'warning',
|
|
1178
|
+
message: from + ' + ' + event + ' has a guard that can never hold: ' + names.map((name) => name + ' is only ever ' + [...attainable.get(name)].sort(compareVarValues).map(varValueLabel).join('/')).join(', ') + '. The branch is dead.',
|
|
1179
|
+
evidence: { guard: transition.guard, attainable: names.map((name) => ({ variable: name, values: [...attainable.get(name)].sort(compareVarValues).map(varValueLabel) })) },
|
|
1180
|
+
});
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
return findings;
|
|
1184
|
+
}
|
|
1062
1185
|
function analyzeGuards(model) {
|
|
1063
1186
|
const determinism = [];
|
|
1064
1187
|
const completeness = [];
|
|
@@ -1111,6 +1234,7 @@ function analyzeGuards(model) {
|
|
|
1111
1234
|
if (truncated)
|
|
1112
1235
|
break;
|
|
1113
1236
|
}
|
|
1237
|
+
completeness.push(...deadGuardFindings(model));
|
|
1114
1238
|
return { determinism, completeness, truncated };
|
|
1115
1239
|
}
|
|
1116
1240
|
function S4_determinism(model) {
|
|
@@ -1119,7 +1243,11 @@ function S4_determinism(model) {
|
|
|
1119
1243
|
}
|
|
1120
1244
|
function S6_guardCompleteness(model) {
|
|
1121
1245
|
const analysis = analyzeGuards(model);
|
|
1122
|
-
|
|
1246
|
+
const dead = analysis.completeness.filter((finding) => finding.code === 'S6_DEAD_GUARD').length;
|
|
1247
|
+
const detail = analysis.completeness.length === 0
|
|
1248
|
+
? 'All guard branches defined'
|
|
1249
|
+
: dead === analysis.completeness.length ? 'Dead guard branches: ' + dead : 'Guard findings: ' + analysis.completeness.length;
|
|
1250
|
+
return checkResult('S6', 'Guard Completeness', analysis.completeness, detail);
|
|
1123
1251
|
}
|
|
1124
1252
|
function S5_eventCompleteness(model) {
|
|
1125
1253
|
const events = allEvents(model);
|
|
@@ -1253,12 +1381,17 @@ function pathPropertyViolation(model, options, invariant) {
|
|
|
1253
1381
|
if (invariant.kind === 'leads-to') {
|
|
1254
1382
|
const targets = new Set(leadsToTargets(invariant));
|
|
1255
1383
|
const label = leadsToTargetLabel(invariant);
|
|
1384
|
+
const budget = leadsToWalkBudget(options);
|
|
1385
|
+
const walk = { remaining: budget };
|
|
1256
1386
|
for (const runtime of reachableRuntimeStates(model, options)) {
|
|
1257
1387
|
if (runtime.state !== invariant.from)
|
|
1258
1388
|
continue;
|
|
1259
|
-
const
|
|
1260
|
-
if (
|
|
1261
|
-
return { invariant, path:
|
|
1389
|
+
const outcome = findLeadsToBadPath(model, runtime, targets, label, walk);
|
|
1390
|
+
if (outcome.undecided) {
|
|
1391
|
+
return { invariant, path: [], reason: 'the walk spent its budget of ' + budget + ' steps without finding a run that avoids ' + label + ', or proving that none exists', undecided: true };
|
|
1392
|
+
}
|
|
1393
|
+
if (outcome.violation !== undefined)
|
|
1394
|
+
return { invariant, path: outcome.violation.path, reason: outcome.violation.reason };
|
|
1262
1395
|
}
|
|
1263
1396
|
return undefined;
|
|
1264
1397
|
}
|
|
@@ -1335,14 +1468,22 @@ function runInvariants(model, options, includePathProperties = false) {
|
|
|
1335
1468
|
}
|
|
1336
1469
|
function S7_invariants(model, options) {
|
|
1337
1470
|
const violations = runInvariants(model, options);
|
|
1338
|
-
const findings = violations.map((violation) =>
|
|
1471
|
+
const findings = violations.map((violation) => violation.undecided ? {
|
|
1472
|
+
code: 'S7_UNDECIDED',
|
|
1473
|
+
severity: 'warning',
|
|
1474
|
+
message: 'Invariant "' + violation.invariant.id + '" (' + violation.invariant.description + ') could not be decided: ' + violation.reason + '. Absence is not proven.',
|
|
1475
|
+
evidence: { invariant: violation.invariant },
|
|
1476
|
+
} : {
|
|
1339
1477
|
code: 'S7_INVARIANT_VIOLATION',
|
|
1340
1478
|
severity: 'error',
|
|
1341
1479
|
message: 'Invariant "' + violation.invariant.id + '" (' + violation.invariant.description + ') violated: ' + violation.reason,
|
|
1342
1480
|
path: violation.path,
|
|
1343
1481
|
evidence: { invariant: violation.invariant },
|
|
1344
|
-
})
|
|
1345
|
-
|
|
1482
|
+
});
|
|
1483
|
+
const undecided = findings.some((finding) => finding.code === 'S7_UNDECIDED');
|
|
1484
|
+
return checkResult('S7', 'Invariant Validity', findings, violations.length === 0
|
|
1485
|
+
? 'All invariants hold'
|
|
1486
|
+
: undecided && findings.every((finding) => finding.code === 'S7_UNDECIDED') ? 'Invariants undecided: the search budget ran out' : 'Invariant violations: ' + violations.length);
|
|
1346
1487
|
}
|
|
1347
1488
|
// ---------------------------------------------------------------------------
|
|
1348
1489
|
// A1-A14
|
|
@@ -1709,6 +1850,14 @@ function A7_shortestViolations(model, options) {
|
|
|
1709
1850
|
// found instead of claiming minimality.
|
|
1710
1851
|
const violations = runInvariants(model, options, true);
|
|
1711
1852
|
const findings = violations.map((violation) => {
|
|
1853
|
+
if (violation.undecided) {
|
|
1854
|
+
return {
|
|
1855
|
+
code: 'A7_UNDECIDED',
|
|
1856
|
+
severity: 'warning',
|
|
1857
|
+
message: 'Invariant "' + violation.invariant.id + '" could not be decided: ' + violation.reason + '. Absence is not proven, so there is no shortest counterexample to report.',
|
|
1858
|
+
evidence: { invariant: violation.invariant.id },
|
|
1859
|
+
};
|
|
1860
|
+
}
|
|
1712
1861
|
const pathLength = violation.path.length;
|
|
1713
1862
|
const message = isPathPropertyInvariant(violation.invariant)
|
|
1714
1863
|
? 'Invariant "' + violation.invariant.id + '" violated: ' + violation.reason
|
|
@@ -1891,7 +2040,12 @@ function D2_invariantContinuity(before, after, options, mapping) {
|
|
|
1891
2040
|
? pathPropertyViolation(after, options, mapped)
|
|
1892
2041
|
: shortestViolationForInvariant(after, options, mapped);
|
|
1893
2042
|
if (violation !== undefined) {
|
|
1894
|
-
findings.push({
|
|
2043
|
+
findings.push(violation.undecided ? {
|
|
2044
|
+
code: 'D2_UNDECIDED',
|
|
2045
|
+
severity: 'warning',
|
|
2046
|
+
message: 'BEFORE invariant "' + invariant.id + '" could not be re-decided against AFTER: ' + violation.reason + '. Absence is not proven.',
|
|
2047
|
+
evidence: { beforeInvariant: invariant, afterInvariant: mapped },
|
|
2048
|
+
} : {
|
|
1895
2049
|
code: 'D2_INVARIANT_REGRESSION',
|
|
1896
2050
|
severity: 'error',
|
|
1897
2051
|
message: 'BEFORE invariant "' + invariant.id + '" no longer holds in AFTER: ' + violation.reason,
|
|
@@ -2046,6 +2200,109 @@ function A8_idempotentReplay(model, exploration) {
|
|
|
2046
2200
|
}
|
|
2047
2201
|
return checkResult('A8', 'Idempotent Replay', findings, findings.length === 0 ? 'Idempotent events are replay-safe' : 'Idempotent replay findings: ' + findings.length);
|
|
2048
2202
|
}
|
|
2203
|
+
/**
|
|
2204
|
+
* Whether the guard proves `variable` stays at or below `bound` wherever it holds.
|
|
2205
|
+
*
|
|
2206
|
+
* Deliberately conservative in one direction only: a guard this function cannot read
|
|
2207
|
+
* answers `false`, so an unreadable guard is reported as "not proven" rather than as
|
|
2208
|
+
* "enforced". A conjunction bounds the value if any conjunct does; a disjunction has to
|
|
2209
|
+
* bound it on every branch; a negation is never reasoned about.
|
|
2210
|
+
*/
|
|
2211
|
+
function guardImpliesUpperBound(guard, variable, bound) {
|
|
2212
|
+
if (guard === undefined)
|
|
2213
|
+
return false;
|
|
2214
|
+
if ('variable' in guard) {
|
|
2215
|
+
if (guard.variable !== variable || typeof guard.value !== 'number')
|
|
2216
|
+
return false;
|
|
2217
|
+
if (guard.op === '<')
|
|
2218
|
+
return guard.value - 1 <= bound;
|
|
2219
|
+
if (guard.op === '<=' || guard.op === '==')
|
|
2220
|
+
return guard.value <= bound;
|
|
2221
|
+
return false;
|
|
2222
|
+
}
|
|
2223
|
+
if ('all' in guard)
|
|
2224
|
+
return guard.all.some((child) => guardImpliesUpperBound(child, variable, bound));
|
|
2225
|
+
if ('any' in guard)
|
|
2226
|
+
return guard.any.every((child) => guardImpliesUpperBound(child, variable, bound));
|
|
2227
|
+
return false;
|
|
2228
|
+
}
|
|
2229
|
+
/** Mirror of {@link guardImpliesUpperBound} for the lower bound. */
|
|
2230
|
+
function guardImpliesLowerBound(guard, variable, bound) {
|
|
2231
|
+
if (guard === undefined)
|
|
2232
|
+
return false;
|
|
2233
|
+
if ('variable' in guard) {
|
|
2234
|
+
if (guard.variable !== variable || typeof guard.value !== 'number')
|
|
2235
|
+
return false;
|
|
2236
|
+
if (guard.op === '>')
|
|
2237
|
+
return guard.value + 1 >= bound;
|
|
2238
|
+
if (guard.op === '>=' || guard.op === '==')
|
|
2239
|
+
return guard.value >= bound;
|
|
2240
|
+
return false;
|
|
2241
|
+
}
|
|
2242
|
+
if ('all' in guard)
|
|
2243
|
+
return guard.all.some((child) => guardImpliesLowerBound(child, variable, bound));
|
|
2244
|
+
if ('any' in guard)
|
|
2245
|
+
return guard.any.every((child) => guardImpliesLowerBound(child, variable, bound));
|
|
2246
|
+
return false;
|
|
2247
|
+
}
|
|
2248
|
+
/**
|
|
2249
|
+
* Whether the model's own declarations about a variable hold against what its
|
|
2250
|
+
* transitions do. `inc` and `dec` are never clamped to `min`/`max` — the declared
|
|
2251
|
+
* range is a property to check, not a bound on the search — so a counter whose guard
|
|
2252
|
+
* does not pin it ranges past its own declaration, and the reachable set is unbounded
|
|
2253
|
+
* however narrow the declaration looks.
|
|
2254
|
+
*
|
|
2255
|
+
* Reporting the declaration rather than silently clamping is deliberate. Clamping would
|
|
2256
|
+
* delete the very states a range violation is witnessed by, turning a counterexample
|
|
2257
|
+
* into a false pass.
|
|
2258
|
+
*/
|
|
2259
|
+
function declaredDomainFindings(model) {
|
|
2260
|
+
const findings = [];
|
|
2261
|
+
for (const transition of model.transitions) {
|
|
2262
|
+
for (const update of transition.updates ?? []) {
|
|
2263
|
+
const variable = (model.variables ?? []).find((entry) => entry.name === update.variable);
|
|
2264
|
+
if (variable === undefined)
|
|
2265
|
+
continue;
|
|
2266
|
+
const delta = typeof update.value === 'number' ? update.value : 1;
|
|
2267
|
+
const at = transition.from + ' + ' + transition.event;
|
|
2268
|
+
if (update.op === 'inc' && variable.max !== undefined && !guardImpliesUpperBound(transition.guard, variable.name, variable.max - delta)) {
|
|
2269
|
+
findings.push({
|
|
2270
|
+
code: 'S8_DOMAIN_NOT_ENFORCED',
|
|
2271
|
+
severity: 'warning',
|
|
2272
|
+
message: at + ' increments ' + variable.name + ', but ' + variable.name + ' declares max ' + variable.max + ' and no guard keeps it there: the declared range is not a bound on reachable values.',
|
|
2273
|
+
evidence: { variable: variable.name, declaredMax: variable.max, transition: at },
|
|
2274
|
+
});
|
|
2275
|
+
}
|
|
2276
|
+
if (update.op === 'dec' && variable.min !== undefined && !guardImpliesLowerBound(transition.guard, variable.name, variable.min + delta)) {
|
|
2277
|
+
findings.push({
|
|
2278
|
+
code: 'S8_DOMAIN_NOT_ENFORCED',
|
|
2279
|
+
severity: 'warning',
|
|
2280
|
+
message: at + ' decrements ' + variable.name + ', but ' + variable.name + ' declares min ' + variable.min + ' and no guard keeps it there: the declared range is not a bound on reachable values.',
|
|
2281
|
+
evidence: { variable: variable.name, declaredMin: variable.min, transition: at },
|
|
2282
|
+
});
|
|
2283
|
+
}
|
|
2284
|
+
if (update.op === 'set' && typeof update.value === 'number') {
|
|
2285
|
+
if (variable.min !== undefined && update.value < variable.min) {
|
|
2286
|
+
findings.push({
|
|
2287
|
+
code: 'S8_DOMAIN_SET_OUTSIDE',
|
|
2288
|
+
severity: 'error',
|
|
2289
|
+
message: at + ' sets ' + variable.name + ' to ' + update.value + ', below its declared min ' + variable.min + '.',
|
|
2290
|
+
evidence: { variable: variable.name, value: update.value, declaredMin: variable.min },
|
|
2291
|
+
});
|
|
2292
|
+
}
|
|
2293
|
+
if (variable.max !== undefined && update.value > variable.max) {
|
|
2294
|
+
findings.push({
|
|
2295
|
+
code: 'S8_DOMAIN_SET_OUTSIDE',
|
|
2296
|
+
severity: 'error',
|
|
2297
|
+
message: at + ' sets ' + variable.name + ' to ' + update.value + ', above its declared max ' + variable.max + '.',
|
|
2298
|
+
evidence: { variable: variable.name, value: update.value, declaredMax: variable.max },
|
|
2299
|
+
});
|
|
2300
|
+
}
|
|
2301
|
+
}
|
|
2302
|
+
}
|
|
2303
|
+
}
|
|
2304
|
+
return findings;
|
|
2305
|
+
}
|
|
2049
2306
|
function S8_monotonicVariables(model) {
|
|
2050
2307
|
const findings = [];
|
|
2051
2308
|
for (const variable of model.variables ?? []) {
|
|
@@ -2067,7 +2324,8 @@ function S8_monotonicVariables(model) {
|
|
|
2067
2324
|
}
|
|
2068
2325
|
}
|
|
2069
2326
|
}
|
|
2070
|
-
|
|
2327
|
+
findings.push(...declaredDomainFindings(model));
|
|
2328
|
+
return checkResult('S8', 'Variable Declarations', findings, findings.length === 0 ? 'Variable declarations are respected' : 'Variable declaration findings: ' + findings.length);
|
|
2071
2329
|
}
|
|
2072
2330
|
/**
|
|
2073
2331
|
* Target states of a `leads-to` invariant. The schema accepts one state id or a
|
|
@@ -2100,16 +2358,39 @@ function leadsToTargetLabel(invariant) {
|
|
|
2100
2358
|
* (two branches rejoining) is acyclic and must pass, while a genuine cycle must not.
|
|
2101
2359
|
* A run is identified by its state plus its variable values, so the same state id
|
|
2102
2360
|
* with different values is a different node.
|
|
2361
|
+
*
|
|
2362
|
+
* Two properties keep the walk from taking the host down, and both are load-bearing:
|
|
2363
|
+
* the walk is **budgeted**, and it stores a **parent link** per node instead of a copy
|
|
2364
|
+
* of the path. Without the budget an unbounded counter makes the run graph infinite, so
|
|
2365
|
+
* the three colours above never repeat a key and the walk never returns; without the
|
|
2366
|
+
* parent link every frame retains its own copy of the path, which grows the walk's
|
|
2367
|
+
* memory quadratically in its depth. Either one alone turns "the model is wrong" into
|
|
2368
|
+
* an out-of-memory abort, which is the failure this function must not have.
|
|
2369
|
+
*
|
|
2370
|
+
* An exhausted budget is reported as `undecided`, never as `undefined`: a walk that
|
|
2371
|
+
* did not finish has not proven that every run reaches a target.
|
|
2103
2372
|
*/
|
|
2104
|
-
function findLeadsToBadPath(model, start, targets, label) {
|
|
2373
|
+
function findLeadsToBadPath(model, start, targets, label, walk) {
|
|
2105
2374
|
if (targets.has(start.state))
|
|
2106
|
-
return
|
|
2375
|
+
return { undecided: false };
|
|
2107
2376
|
const GRAY = 1;
|
|
2108
2377
|
const BLACK = 2;
|
|
2378
|
+
const pathOf = (frame) => {
|
|
2379
|
+
const steps = [];
|
|
2380
|
+
let node = frame;
|
|
2381
|
+
while (node !== undefined && node.step !== undefined) {
|
|
2382
|
+
steps.push(node.step);
|
|
2383
|
+
node = node.parent;
|
|
2384
|
+
}
|
|
2385
|
+
return steps.reverse();
|
|
2386
|
+
};
|
|
2109
2387
|
const color = new Map();
|
|
2110
|
-
const stack = [{ runtime: start,
|
|
2388
|
+
const stack = [{ runtime: start, parent: undefined, step: undefined, nexts: undefined, index: 0 }];
|
|
2111
2389
|
color.set(runtimeKey(start), GRAY);
|
|
2112
2390
|
while (stack.length > 0) {
|
|
2391
|
+
if (walk.remaining <= 0)
|
|
2392
|
+
return { undecided: true };
|
|
2393
|
+
walk.remaining -= 1;
|
|
2113
2394
|
const frame = stack[stack.length - 1];
|
|
2114
2395
|
if (frame.nexts === undefined) {
|
|
2115
2396
|
const nexts = [];
|
|
@@ -2119,7 +2400,7 @@ function findLeadsToBadPath(model, start, targets, label) {
|
|
|
2119
2400
|
}
|
|
2120
2401
|
frame.nexts = nexts;
|
|
2121
2402
|
if (nexts.length === 0)
|
|
2122
|
-
return { path: frame
|
|
2403
|
+
return { violation: { path: pathOf(frame), reason: 'Dead end before target ' + label }, undecided: false };
|
|
2123
2404
|
}
|
|
2124
2405
|
if (frame.index >= frame.nexts.length) {
|
|
2125
2406
|
color.set(runtimeKey(frame.runtime), BLACK);
|
|
@@ -2134,39 +2415,53 @@ function findLeadsToBadPath(model, start, targets, label) {
|
|
|
2134
2415
|
const key = runtimeKey(next);
|
|
2135
2416
|
const seen = color.get(key);
|
|
2136
2417
|
if (seen === GRAY)
|
|
2137
|
-
return { path: [...frame
|
|
2418
|
+
return { violation: { path: [...pathOf(frame), step], reason: 'Cycle avoids target ' + label }, undecided: false };
|
|
2138
2419
|
if (seen === BLACK)
|
|
2139
2420
|
continue;
|
|
2140
2421
|
color.set(key, GRAY);
|
|
2141
|
-
stack.push({ runtime: next,
|
|
2422
|
+
stack.push({ runtime: next, parent: frame, step, nexts: undefined, index: 0 });
|
|
2142
2423
|
}
|
|
2143
2424
|
// Every branch either reached a target or rejoined a branch that did.
|
|
2144
|
-
return
|
|
2425
|
+
return { undecided: false };
|
|
2145
2426
|
}
|
|
2146
|
-
function A9_leadsTo(model, exploration) {
|
|
2427
|
+
function A9_leadsTo(model, exploration, options) {
|
|
2147
2428
|
const findings = [];
|
|
2148
2429
|
for (const invariant of model.invariants ?? []) {
|
|
2149
2430
|
if (invariant.kind !== 'leads-to')
|
|
2150
2431
|
continue;
|
|
2151
2432
|
const targets = new Set(leadsToTargets(invariant));
|
|
2152
2433
|
const label = leadsToTargetLabel(invariant);
|
|
2434
|
+
const budget = leadsToWalkBudget(options);
|
|
2435
|
+
const walk = { remaining: budget };
|
|
2153
2436
|
for (const runtime of exploration.reachable) {
|
|
2154
2437
|
if (runtime.state !== invariant.from)
|
|
2155
2438
|
continue;
|
|
2156
|
-
const
|
|
2157
|
-
if (
|
|
2439
|
+
const outcome = findLeadsToBadPath(model, runtime, targets, label, walk);
|
|
2440
|
+
if (outcome.undecided) {
|
|
2441
|
+
findings.push({
|
|
2442
|
+
code: 'A9_LEADS_TO_UNDECIDED',
|
|
2443
|
+
severity: 'warning',
|
|
2444
|
+
message: 'Leads-to invariant "' + invariant.id + '" is undecided: the walk spent its budget of ' + budget + ' steps from ' + invariant.from + ' without finding a run that avoids ' + label + ', or proving that none exists. Absence is not proven.',
|
|
2445
|
+
evidence: { invariant },
|
|
2446
|
+
});
|
|
2447
|
+
break;
|
|
2448
|
+
}
|
|
2449
|
+
if (outcome.violation !== undefined) {
|
|
2158
2450
|
findings.push({
|
|
2159
2451
|
code: 'A9_LEADS_TO_VIOLATION',
|
|
2160
2452
|
severity: 'error',
|
|
2161
|
-
message: 'Leads-to invariant "' + invariant.id + '" violated from ' + invariant.from + ': ' +
|
|
2162
|
-
path:
|
|
2453
|
+
message: 'Leads-to invariant "' + invariant.id + '" violated from ' + invariant.from + ': ' + outcome.violation.reason,
|
|
2454
|
+
path: outcome.violation.path,
|
|
2163
2455
|
evidence: { invariant },
|
|
2164
2456
|
});
|
|
2165
2457
|
break;
|
|
2166
2458
|
}
|
|
2167
2459
|
}
|
|
2168
2460
|
}
|
|
2169
|
-
|
|
2461
|
+
const undecided = findings.some((finding) => finding.code === 'A9_LEADS_TO_UNDECIDED');
|
|
2462
|
+
return checkResult('A9', 'Leads-To', findings, findings.length === 0
|
|
2463
|
+
? 'All leads-to invariants hold'
|
|
2464
|
+
: undecided ? 'Leads-to undecided: the search budget ran out' : 'Leads-to findings: ' + findings.length);
|
|
2170
2465
|
}
|
|
2171
2466
|
function findSequenceViolation(model, options, invariant) {
|
|
2172
2467
|
const events = invariant.events;
|
|
@@ -2754,7 +3049,7 @@ export function runVerification(input, options = {}) {
|
|
|
2754
3049
|
A6_resourceInjection(model),
|
|
2755
3050
|
A7_shortestViolations(model, normalized),
|
|
2756
3051
|
A8_idempotentReplay(model, exploration),
|
|
2757
|
-
A9_leadsTo(model, exploration),
|
|
3052
|
+
A9_leadsTo(model, exploration, normalized),
|
|
2758
3053
|
A10_sequenceOrder(model, normalized),
|
|
2759
3054
|
A11_atomicity(model, normalized),
|
|
2760
3055
|
A12_budget(model, normalized),
|
|
@@ -2783,7 +3078,7 @@ export function runVerification(input, options = {}) {
|
|
|
2783
3078
|
errors: beforeValidation.errors.length,
|
|
2784
3079
|
warnings: 0,
|
|
2785
3080
|
checksRun: checks.length + 1,
|
|
2786
|
-
truncated: exploration.truncated,
|
|
3081
|
+
truncated: exploration.truncated || hasUndecidedFinding(checks),
|
|
2787
3082
|
},
|
|
2788
3083
|
checks: [
|
|
2789
3084
|
...checks,
|
|
@@ -2838,7 +3133,7 @@ export function runVerification(input, options = {}) {
|
|
|
2838
3133
|
errors,
|
|
2839
3134
|
warnings,
|
|
2840
3135
|
checksRun: checks.length,
|
|
2841
|
-
truncated: exploration.truncated,
|
|
3136
|
+
truncated: exploration.truncated || hasUndecidedFinding(checks),
|
|
2842
3137
|
},
|
|
2843
3138
|
checks,
|
|
2844
3139
|
nextSteps: verificationNextSteps(checks, narrativeCoverage),
|