dsh-logicprobe 0.6.8 → 0.7.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 +5 -2
- package/README.md +5 -2
- package/lib/client.js +212 -0
- package/lib/engine.js +127 -2
- package/lib/index.js +342 -304
- package/lib/tool.js +1 -1
- package/lib/types/engine.d.ts +22 -0
- package/lib/types/index.d.ts +60 -54
- package/package.json +32 -16
- package/skills/logicprobe/references/__pycache__/logicprobe-engine.cpython-310.pyc +0 -0
- package/skills/logicprobe/references/dsh-model-schema.md +322 -266
- package/skills/logicprobe/references/logicprobe-engine.py +3169 -3028
- package/src/client.js +212 -0
- package/src/engine.ts +2714 -2568
- package/src/index.ts +56 -5
- package/src/tool.ts +61 -61
package/README.en-US.md
CHANGED
|
@@ -182,7 +182,8 @@ Skills are invoked with `$logicprobe`. See `.zcode/INSTALL.md` for details.
|
|
|
182
182
|
## Requirements
|
|
183
183
|
|
|
184
184
|
- Claude Code v2.1+ / Codex CLI latest / Cursor 2.5+ / Kimi CLI latest / OpenCode latest / ZCode 3.0+
|
|
185
|
-
- DeepSeek Harness (dsh): dev preview —
|
|
185
|
+
- DeepSeek Harness (dsh): dev preview — supports `>= 0.1.0-rc.7` (the standing declaration; this round re-measured 0.1.5-rc.2 / 0.1.5-rc.3 / 0.1.6-alpha.2 / 0.1.7-alpha.1 / 0.1.7-alpha.2 / 0.1.7-rc.1 / 0.1.7-rc.2 / 0.2.0-rc.1 / 0.2.0-rc.2 — per-release evidence in [DSH-COMPATIBILITY.md](DSH-COMPATIBILITY.md))
|
|
186
|
+
- The Web Plugins-page "Gate injection" switch requires **dsh ≥ 0.1.7-alpha.1** — its settings service must project live fields. On older dsh the plugin still loads and still injects, with the switch simply absent and **no error**: below schemastery 3.18.3 the field degrades to an ordinary boolean, and a settings service without `whileServed` makes the client half register nothing.
|
|
186
187
|
- Python 3.6+ optional (only for the automated harness; manual fallback mode requires none)
|
|
187
188
|
|
|
188
189
|
## Configuration
|
|
@@ -195,6 +196,8 @@ In DeepSeek Harness, the bundle registers the `logicprobe_verify` tool (through
|
|
|
195
196
|
| `gateContent` | string | built-in gate text | Override the text injected into the first model step. |
|
|
196
197
|
| `interaction` | `ask` \| `auto` \| `follow-approval` | `follow-approval` | Model-confirmation policy. `follow-approval` resolves to `auto` when the session approval policy is `never`. |
|
|
197
198
|
|
|
199
|
+
The switch is editable live in the dsh Web GUI: sidebar **Plugins** → this plugin's card → "Gate injection". It takes effect without a profile restart and controls only the injected text — turning it off leaves the skills and the verification tools registered. The same card also carries a coarser row switch: turning that off unmounts the whole row (skills, tools, and this switch go with it). Persistent overrides still go through the profile patch below.
|
|
200
|
+
|
|
198
201
|
To change it, override the row by id in your profile's `cordis.patch.yml`:
|
|
199
202
|
|
|
200
203
|
```yaml
|
|
@@ -239,7 +242,7 @@ npm run build
|
|
|
239
242
|
|
|
240
243
|
Test chain:
|
|
241
244
|
|
|
242
|
-
- `npm run test:engine` — state-machine / data-model engine regression (`tests/engine`, `tests/data-engine`, `tests/concurrency`, `tests/apply-smoke`, `tests/exporters`, `tests/external`) plus byte-for-byte Python parity (`tests/python/run.mjs`: the same fixtures are compared between the TS engine and `skills/logicprobe/references/logicprobe-engine.py` across reports / composition / exporter output; auto-SKIP when Python is absent)
|
|
245
|
+
- `npm run test:engine` — state-machine / data-model engine regression (`tests/engine`, `tests/data-engine`, `tests/concurrency`, `tests/apply-smoke`, `tests/dsh-client-half`, `tests/exporters`, `tests/external`) plus byte-for-byte Python parity (`tests/python/run.mjs`: the same fixtures are compared between the TS engine and `skills/logicprobe/references/logicprobe-engine.py` across reports / composition / exporter output; auto-SKIP when Python is absent)
|
|
243
246
|
- `npm run test:full` — `tests/full-suite.mjs` combined end-to-end suite
|
|
244
247
|
- `npm run test:python` — Python parity only (build + `tests/python/run.mjs`)
|
|
245
248
|
- Trigger tests are under `tests/skill-triggering/`: `bash tests/skill-triggering/run-all.sh`
|
package/README.md
CHANGED
|
@@ -181,7 +181,8 @@ cp -r logicprobe/skills/* .zcode/skills/
|
|
|
181
181
|
## 环境要求
|
|
182
182
|
|
|
183
183
|
- Claude Code v2.1+ / Codex CLI 最新 / Cursor 2.5+ / Kimi CLI 最新 / OpenCode 最新 / ZCode 3.0+
|
|
184
|
-
- DeepSeek Harness (dsh): dev preview —
|
|
184
|
+
- DeepSeek Harness (dsh): dev preview — 支持 `>= 0.1.0-rc.7`(沿用既有声明;本轮实测覆盖 0.1.5-rc.2 / 0.1.5-rc.3 / 0.1.6-alpha.2 / 0.1.7-alpha.1 / 0.1.7-alpha.2 / 0.1.7-rc.1 / 0.1.7-rc.2 / 0.2.0-rc.1 / 0.2.0-rc.2,逐版本证据见 [DSH-COMPATIBILITY.md](DSH-COMPATIBILITY.md))
|
|
185
|
+
- Web 端的「Gate 注入」开关需要 **dsh ≥ 0.1.7-alpha.1**(设置服务必须能投影即时字段)。更早的 dsh 上插件照常加载、照常注入,只是开关不出现、**也不报错**:schemastery 早于 3.18.3 时该字段退化为普通布尔值;设置服务没有 `whileServed` 时客户端半侧不注册任何东西。
|
|
185
186
|
- Python 3.6+ 可选(仅自动验证工具需要;手动兜底模式无需任何依赖)
|
|
186
187
|
|
|
187
188
|
## 配置
|
|
@@ -194,6 +195,8 @@ cp -r logicprobe/skills/* .zcode/skills/
|
|
|
194
195
|
| `gateContent` | string | 内置 gate 文本 | 覆盖注入到首轮模型上下文中的文本。 |
|
|
195
196
|
| `interaction` | `ask` \| `auto` \| `follow-approval` | `follow-approval` | 模型确认策略;`follow-approval` 在会话 approval policy 为 `never` 时解析为 `auto`。 |
|
|
196
197
|
|
|
198
|
+
在 dsh Web GUI 里可以直接改这个开关:侧边栏 **插件** → 本插件卡片 → 「Gate 注入」。它实时生效,不必重启 profile,而且只管注入的那段文本——关掉后 skills 与验证工具照常注册。同一张卡片上还有一个更粗粒度的行开关:关掉它会整行卸载插件(技能、工具和这个开关一起消失)。要持久化覆盖,仍按下面的 profile patch 写。
|
|
199
|
+
|
|
197
200
|
在 profile 的 `cordis.patch.yml` 中按 row id 覆盖:
|
|
198
201
|
|
|
199
202
|
```yaml
|
|
@@ -237,7 +240,7 @@ npm run build
|
|
|
237
240
|
|
|
238
241
|
测试链:
|
|
239
242
|
|
|
240
|
-
- `npm run test:engine` — 状态机/数据模型引擎回归(`tests/engine`、`tests/data-engine`、`tests/concurrency`、`tests/apply-smoke`、`tests/exporters`、`tests/external`)+ Python 逐字节一致性对照(`tests/python/run.mjs`:同一批 fixture 在 TS 引擎与 `skills/logicprobe/references/logicprobe-engine.py` 之间比对报告/组合/导出产物;无 Python 时自动 SKIP)
|
|
243
|
+
- `npm run test:engine` — 状态机/数据模型引擎回归(`tests/engine`、`tests/data-engine`、`tests/concurrency`、`tests/apply-smoke`、`tests/dsh-client-half`、`tests/exporters`、`tests/external`)+ Python 逐字节一致性对照(`tests/python/run.mjs`:同一批 fixture 在 TS 引擎与 `skills/logicprobe/references/logicprobe-engine.py` 之间比对报告/组合/导出产物;无 Python 时自动 SKIP)
|
|
241
244
|
- `npm run test:full` — `tests/full-suite.mjs` 端到端合并套件
|
|
242
245
|
- `npm run test:python` — 仅 Python parity(构建 + `tests/python/run.mjs`)
|
|
243
246
|
- 触发测试位于 `tests/skill-triggering/`:`bash tests/skill-triggering/run-all.sh`
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* logicprobe — browser half: the gate-injection switch on the dsh Web client's
|
|
3
|
+
* Plugins page.
|
|
4
|
+
*
|
|
5
|
+
* The Plugins page (`@deepseek-ai/dsh-client-ui-plugin-manager`) owns the
|
|
6
|
+
* sidebar **Plugins** entry and declares the slots a bundle's own configuration
|
|
7
|
+
* registers into. This module contributes one `plugins.bundle.config` entry,
|
|
8
|
+
* keyed by this package's npm name, so the switch renders on logicprobe's own
|
|
9
|
+
* page between its description and its rows.
|
|
10
|
+
*
|
|
11
|
+
* Why the switch writes through `configForms` rather than reaching for the
|
|
12
|
+
* profile file: dsh's settings service exposes only the Config fields declared
|
|
13
|
+
* `.volatile()`, and it rejects a write to any other path. `enabled` is such a
|
|
14
|
+
* field (see `src/index.ts`), so flipping the switch is an ordinary
|
|
15
|
+
* revision-fenced settings write that the running Host picks up in place — the
|
|
16
|
+
* injection there re-reads the reference on every model step.
|
|
17
|
+
*
|
|
18
|
+
* Shape: this is a prebuilt module-system bundle, not a source module. It calls
|
|
19
|
+
* `window.__ModuleLoader__.load({ id, factory })` with this package's resolved
|
|
20
|
+
* npm name, and `factory` returns the cordis plugin face. Only the client
|
|
21
|
+
* baseline is requested (`react` and
|
|
22
|
+
* `@deepseek-ai/dsh-client-ui-primitives`); every other capability arrives
|
|
23
|
+
* through cordis `inject`. `scripts/build-client.mjs` publishes this file
|
|
24
|
+
* verbatim as `lib/client.js`.
|
|
25
|
+
*
|
|
26
|
+
* @module dsh-logicprobe/client
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
window.__ModuleLoader__.load({
|
|
30
|
+
id: 'dsh-logicprobe',
|
|
31
|
+
factory: (require) => {
|
|
32
|
+
const React = require('react')
|
|
33
|
+
const { Button, Switch } = require('@deepseek-ai/dsh-client-ui-primitives')
|
|
34
|
+
|
|
35
|
+
/** Settings namespace: the Loader entry id this bundle's patch declares. */
|
|
36
|
+
const NS = 'logicprobe'
|
|
37
|
+
/** `plugins.bundle.config` key: the bundle's npm package name. */
|
|
38
|
+
const PACKAGE = 'dsh-logicprobe'
|
|
39
|
+
/** This page's dictionary namespace. */
|
|
40
|
+
const LOCALE_NS = 'logicprobe.plugins'
|
|
41
|
+
/** The Config field the switch writes inside the namespace's section. */
|
|
42
|
+
const FIELD = 'enabled'
|
|
43
|
+
|
|
44
|
+
/** English copy. */
|
|
45
|
+
const en = {
|
|
46
|
+
title: 'Gate injection',
|
|
47
|
+
label: 'Inject the gate text',
|
|
48
|
+
hint: 'Folds the claim-verification doctrine into the first model step of every session. Turning it off leaves the skills and the verification tools registered — only the injected text is dropped.',
|
|
49
|
+
overridden: 'Overridden',
|
|
50
|
+
reset: 'Reset to default',
|
|
51
|
+
readOnly: 'This deployment stores settings read-only.',
|
|
52
|
+
unavailable: 'This plugin is not loaded, so it cannot be configured right now.',
|
|
53
|
+
saveFailed: 'The deployment did not accept that value; the switch shows what is stored.',
|
|
54
|
+
}
|
|
55
|
+
/** Simplified Chinese copy. */
|
|
56
|
+
const zh = {
|
|
57
|
+
title: 'Gate 注入',
|
|
58
|
+
label: '注入 gate 文本',
|
|
59
|
+
hint: '把 claim 核查铁律折进每个会话的第一个模型步。关掉后 skills 与验证工具仍然注册,只是不再注入那段提示文本。',
|
|
60
|
+
overridden: '已覆盖',
|
|
61
|
+
reset: '恢复默认',
|
|
62
|
+
readOnly: '本部署的设置为只读。',
|
|
63
|
+
unavailable: '该插件当前未加载,暂时无法配置。',
|
|
64
|
+
saveFailed: '本部署没有接受这个值,开关显示的是已存下的状态。',
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Required cordis services. */
|
|
68
|
+
const inject = ['slots', 'locale', 'configForms']
|
|
69
|
+
|
|
70
|
+
const GROUP = { display: 'flex', flexDirection: 'column', gap: '8px' }
|
|
71
|
+
const TITLE = { margin: 0, fontSize: '14px', fontWeight: '500', lineHeight: '22px' }
|
|
72
|
+
const ROW = { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '16px' }
|
|
73
|
+
const LABEL = { fontSize: '13px', lineHeight: '20px' }
|
|
74
|
+
const NOTE = { margin: 0, fontSize: '12px', lineHeight: '18px', color: 'var(--dsw-alias-label-tertiary)' }
|
|
75
|
+
const FAILED = { margin: 0, fontSize: '12px', lineHeight: '18px', color: 'var(--dsw-alias-state-error-primary)' }
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Whether a settings-layer value carries this field, which is what marks it
|
|
79
|
+
* overridden: an override equal to the default is still an override.
|
|
80
|
+
* @param layer - the raw user layer the form snapshot carries.
|
|
81
|
+
* @returns whether the layer holds the field.
|
|
82
|
+
*/
|
|
83
|
+
function carries(layer) {
|
|
84
|
+
return layer !== null && typeof layer === 'object' && Object.prototype.hasOwnProperty.call(layer, FIELD)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Render the gate-injection switch, or the note saying why it cannot render.
|
|
89
|
+
* @param props - the page's `t` seat, the bound form snapshot hook, and the write actions.
|
|
90
|
+
* @returns the body of this bundle's configuration section.
|
|
91
|
+
*/
|
|
92
|
+
function InjectionCard(props) {
|
|
93
|
+
const t = props.t
|
|
94
|
+
const state = props.useInjectionForm((snapshot) => snapshot)
|
|
95
|
+
const [pending, setPending] = React.useState(false)
|
|
96
|
+
const [failed, setFailed] = React.useState(false)
|
|
97
|
+
|
|
98
|
+
/** Run one settings write and report a refusal or a transport failure. */
|
|
99
|
+
const write = (run) => {
|
|
100
|
+
setPending(true)
|
|
101
|
+
setFailed(false)
|
|
102
|
+
Promise.resolve(run()).then(
|
|
103
|
+
(accepted) => {
|
|
104
|
+
setPending(false)
|
|
105
|
+
setFailed(accepted === false)
|
|
106
|
+
},
|
|
107
|
+
() => {
|
|
108
|
+
setPending(false)
|
|
109
|
+
setFailed(true)
|
|
110
|
+
},
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (state.status !== 'ready') {
|
|
115
|
+
return React.createElement('p', { style: NOTE }, t('unavailable'))
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const section = state.value !== null && typeof state.value === 'object' ? state.value : {}
|
|
119
|
+
// The schema default is `true`; only an explicit false means off.
|
|
120
|
+
const checked = section[FIELD] !== false
|
|
121
|
+
const overridden = carries(state.user)
|
|
122
|
+
const locked = state.writable !== true || pending
|
|
123
|
+
|
|
124
|
+
const children = [
|
|
125
|
+
React.createElement('h4', { key: 'title', style: TITLE }, t('title')),
|
|
126
|
+
React.createElement('div', { key: 'row', style: ROW }, [
|
|
127
|
+
React.createElement('span', { key: 'label', style: LABEL }, t('label')),
|
|
128
|
+
React.createElement(Switch, {
|
|
129
|
+
key: 'switch',
|
|
130
|
+
checked,
|
|
131
|
+
disabled: locked,
|
|
132
|
+
label: t('label'),
|
|
133
|
+
onChange: (next) => write(() => props.setEnabled(next)),
|
|
134
|
+
}),
|
|
135
|
+
]),
|
|
136
|
+
React.createElement('p', { key: 'hint', style: NOTE }, state.writable === true ? t('hint') : t('readOnly')),
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
if (overridden) {
|
|
140
|
+
children.push(
|
|
141
|
+
React.createElement('div', { key: 'overridden', style: ROW }, [
|
|
142
|
+
React.createElement('span', { key: 'badge', style: NOTE }, t('overridden')),
|
|
143
|
+
React.createElement(
|
|
144
|
+
Button,
|
|
145
|
+
{
|
|
146
|
+
key: 'reset',
|
|
147
|
+
variant: 'outline',
|
|
148
|
+
size: 'sm',
|
|
149
|
+
disabled: locked,
|
|
150
|
+
onClick: () => write(() => props.resetEnabled()),
|
|
151
|
+
},
|
|
152
|
+
t('reset'),
|
|
153
|
+
),
|
|
154
|
+
]),
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (failed) {
|
|
159
|
+
children.push(React.createElement('p', { key: 'failed', style: FAILED, role: 'alert' }, t('saveFailed')))
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return React.createElement('div', { style: GROUP }, children)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Mount the switch while the Host serves logicprobe's settings namespace.
|
|
167
|
+
* @param ctx - the browser plugin context.
|
|
168
|
+
*/
|
|
169
|
+
function apply(ctx) {
|
|
170
|
+
ctx.effect(() => ctx.locale.register(LOCALE_NS, { zh, en }), 'dsh-logicprobe: dictionaries')
|
|
171
|
+
// `whileServed` is the registration barrier that keeps this page alive only
|
|
172
|
+
// while the Host serves the namespace. Hosts predating it (measured: dsh
|
|
173
|
+
// 0.1.5-rc.3 and 0.1.6-alpha.2) have no live settings field to offer at all,
|
|
174
|
+
// so there is nothing to register — and calling it there would throw during
|
|
175
|
+
// this plugin's own activation, which the Web boot audit then reports as a
|
|
176
|
+
// failed client entry. Degrade to no page instead.
|
|
177
|
+
if (typeof ctx.configForms.whileServed !== 'function') return
|
|
178
|
+
// The page renders the section only for a bundle whose package name is in
|
|
179
|
+
// its configuration ledger, and the ledger follows this registration. The
|
|
180
|
+
// registration in turn waits for the namespace to be served, so a profile
|
|
181
|
+
// whose logicprobe row is switched off shows no trace of the switch.
|
|
182
|
+
ctx.effect(
|
|
183
|
+
() =>
|
|
184
|
+
ctx.configForms.whileServed([NS], () => {
|
|
185
|
+
const form = ctx.configForms.get(NS)
|
|
186
|
+
const source = {
|
|
187
|
+
getSnapshot: () => form.getSnapshot(),
|
|
188
|
+
subscribe: (listener) => form.subscribe(listener),
|
|
189
|
+
}
|
|
190
|
+
return ctx.slots.inject('plugins.bundle.config', () =>
|
|
191
|
+
ctx.slots.register(
|
|
192
|
+
{
|
|
193
|
+
name: 'plugins.bundle.config',
|
|
194
|
+
key: PACKAGE,
|
|
195
|
+
locale: LOCALE_NS,
|
|
196
|
+
inject: () => ({
|
|
197
|
+
hooks: { injectionForm: source },
|
|
198
|
+
setEnabled: (next) => form.set(FIELD, next),
|
|
199
|
+
resetEnabled: () => form.unset(FIELD),
|
|
200
|
+
}),
|
|
201
|
+
},
|
|
202
|
+
InjectionCard,
|
|
203
|
+
),
|
|
204
|
+
)
|
|
205
|
+
}),
|
|
206
|
+
'dsh-logicprobe: gate-injection switch',
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return { inject, apply }
|
|
211
|
+
},
|
|
212
|
+
})
|
package/lib/engine.js
CHANGED
|
@@ -1,5 +1,40 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
export const ENGINE_SCHEMA_VERSION = 1;
|
|
3
|
+
export function isStateGuard(when) {
|
|
4
|
+
return 'state' in when;
|
|
5
|
+
}
|
|
6
|
+
/** Allowed keys per invariant kind — the schema is closed, so anything else is a typo or an unsupported feature. */
|
|
7
|
+
const INVARIANT_KEYS = {
|
|
8
|
+
'never-states': new Set(['id', 'description', 'kind', 'states']),
|
|
9
|
+
'var-in-range': new Set(['id', 'description', 'kind', 'variable', 'min', 'max', 'when']),
|
|
10
|
+
'event-before-state': new Set(['id', 'description', 'kind', 'event', 'state']),
|
|
11
|
+
'leads-to': new Set(['id', 'description', 'kind', 'from', 'to']),
|
|
12
|
+
'sequence': new Set(['id', 'description', 'kind', 'events']),
|
|
13
|
+
'atomicity': new Set(['id', 'description', 'kind', 'events', 'commit', 'rollback']),
|
|
14
|
+
'budget': new Set(['id', 'description', 'kind', 'budget']),
|
|
15
|
+
'probability': new Set(['id', 'description', 'kind', 'target', 'op', 'p']),
|
|
16
|
+
};
|
|
17
|
+
const KNOWN_INVARIANT_KINDS = new Set(Object.keys(INVARIANT_KEYS));
|
|
18
|
+
/**
|
|
19
|
+
* Declared keys per model part. The model schema is closed: a key that is not
|
|
20
|
+
* declared is a typo or an unsupported feature, and silently ignoring it can make
|
|
21
|
+
* the engine report a property that the model does not actually have (a mistyped
|
|
22
|
+
* guard variable reads as an always-false guard, which prunes real paths and can
|
|
23
|
+
* yield a false "no deadlock"). Rejecting is the only safe option for a verifier.
|
|
24
|
+
*/
|
|
25
|
+
const MODEL_KEYS = {
|
|
26
|
+
root: new Set(['schemaVersion', 'init', 'states', 'transitions', 'variables', 'invariants', 'concurrentPairs', 'boundaryChecks', 'resourcePairs', 'idempotentEvents', 'tickEvents', 'narrative']),
|
|
27
|
+
state: new Set(['id', 'terminal', 'onEntry', 'onExit', 'maxTicks']),
|
|
28
|
+
transition: new Set(['from', 'event', 'to', 'guard', 'updates', 'cost', 'weight']),
|
|
29
|
+
update: new Set(['variable', 'op', 'value']),
|
|
30
|
+
variable: new Set(['name', 'kind', 'init', 'min', 'max', 'monotonic']),
|
|
31
|
+
// LogicModelV1 boundary checks are (variable, values). The data-model engine has
|
|
32
|
+
// its own (entity, field) shape and its own validator — the two must not be mixed.
|
|
33
|
+
boundaryCheck: new Set(['variable', 'values']),
|
|
34
|
+
resourcePair: new Set(['resource', 'acquireEvent', 'releaseEvent', 'failEvent']),
|
|
35
|
+
narrative: new Set(['states', 'events', 'scenarios']),
|
|
36
|
+
scenario: new Set(['from', 'event', 'scenario']),
|
|
37
|
+
};
|
|
3
38
|
const DEFAULT_MAX_STATES = 10_000;
|
|
4
39
|
const DEFAULT_MAX_PERMUTATION_EVENTS = 5;
|
|
5
40
|
function stableStringify(value) {
|
|
@@ -22,6 +57,13 @@ export function validateModel(input) {
|
|
|
22
57
|
return { ok: false, errors: ['model: must be an object'] };
|
|
23
58
|
}
|
|
24
59
|
const root = input;
|
|
60
|
+
const rejectUnknownKeys = (value, allowed, path) => {
|
|
61
|
+
const unexpected = Object.keys(value).filter((key) => !allowed.has(key));
|
|
62
|
+
if (unexpected.length > 0) {
|
|
63
|
+
bad(path + '.' + unexpected[0], 'unknown field (allowed: ' + [...allowed].join(', ') + ')');
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
rejectUnknownKeys(root, MODEL_KEYS.root, 'model');
|
|
25
67
|
if (root.schemaVersion !== 1)
|
|
26
68
|
bad('schemaVersion', 'must be 1');
|
|
27
69
|
if (typeof root.init !== 'string' || root.init.length === 0)
|
|
@@ -37,6 +79,7 @@ export function validateModel(input) {
|
|
|
37
79
|
return;
|
|
38
80
|
}
|
|
39
81
|
const state = entry;
|
|
82
|
+
rejectUnknownKeys(state, MODEL_KEYS.state, 'states[' + index + ']');
|
|
40
83
|
if (typeof state.id !== 'string' || state.id.length === 0)
|
|
41
84
|
bad('states[' + index + '].id', 'must be a non-empty string');
|
|
42
85
|
else if (seen.has(state.id))
|
|
@@ -75,6 +118,7 @@ export function validateModel(input) {
|
|
|
75
118
|
return;
|
|
76
119
|
}
|
|
77
120
|
const transition = entry;
|
|
121
|
+
rejectUnknownKeys(transition, MODEL_KEYS.transition, path);
|
|
78
122
|
if (typeof transition.from !== 'string' || transition.from.length === 0)
|
|
79
123
|
bad(path + '.from', 'must be a non-empty string');
|
|
80
124
|
else if (!stateIds.has(transition.from))
|
|
@@ -98,6 +142,7 @@ export function validateModel(input) {
|
|
|
98
142
|
return;
|
|
99
143
|
}
|
|
100
144
|
const record = update;
|
|
145
|
+
rejectUnknownKeys(record, MODEL_KEYS.update, updatePath);
|
|
101
146
|
if (typeof record.variable !== 'string' || record.variable.length === 0)
|
|
102
147
|
bad(updatePath + '.variable', 'must be a non-empty string');
|
|
103
148
|
if (record.op !== 'set' && record.op !== 'inc' && record.op !== 'dec')
|
|
@@ -135,6 +180,7 @@ export function validateModel(input) {
|
|
|
135
180
|
return;
|
|
136
181
|
}
|
|
137
182
|
const variable = entry;
|
|
183
|
+
rejectUnknownKeys(variable, MODEL_KEYS.variable, path);
|
|
138
184
|
if (typeof variable.name !== 'string' || variable.name.length === 0)
|
|
139
185
|
bad(path + '.name', 'must be a non-empty string');
|
|
140
186
|
else if (variableNames.has(variable.name))
|
|
@@ -168,6 +214,10 @@ export function validateModel(input) {
|
|
|
168
214
|
else if (!variableNames.has(name))
|
|
169
215
|
bad(path, 'references unknown variable ' + name);
|
|
170
216
|
};
|
|
217
|
+
const invariantStateIds = new Set(Array.isArray(root.states) ? root.states.map((state) => state.id) : []);
|
|
218
|
+
// Reference sets for invariant targets: denormalising a kind's target into the
|
|
219
|
+
// report makes a mistyped id look authoritative, so every reference must resolve.
|
|
220
|
+
const invariantEventIds = new Set(Array.isArray(root.transitions) ? root.transitions.map((transition) => transition.event) : []);
|
|
171
221
|
if (root.invariants !== undefined) {
|
|
172
222
|
if (!Array.isArray(root.invariants))
|
|
173
223
|
bad('invariants', 'must be an array');
|
|
@@ -183,6 +233,17 @@ export function validateModel(input) {
|
|
|
183
233
|
bad(path + '.id', 'must be a non-empty string');
|
|
184
234
|
if (typeof invariant.description !== 'string')
|
|
185
235
|
bad(path + '.description', 'must be a string');
|
|
236
|
+
// Reject keys the selected kind does not declare instead of silently ignoring
|
|
237
|
+
// them: an ignored field (e.g. a `when` scope on a kind that cannot honor it)
|
|
238
|
+
// still shows up in the echoed report and reads as if it took effect. Kinds an
|
|
239
|
+
// older reader may not know are skipped so they still fail on `.kind` alone.
|
|
240
|
+
if (KNOWN_INVARIANT_KINDS.has(invariant.kind)) {
|
|
241
|
+
const allowed = INVARIANT_KEYS[invariant.kind];
|
|
242
|
+
const unexpected = Object.keys(invariant).filter((key) => !allowed.has(key));
|
|
243
|
+
if (unexpected.length > 0) {
|
|
244
|
+
bad(path + '.' + unexpected[0], 'unknown field for kind ' + String(invariant.kind) + ' (allowed: ' + [...allowed].join(', ') + ')');
|
|
245
|
+
}
|
|
246
|
+
}
|
|
186
247
|
if (invariant.kind === 'never-states') {
|
|
187
248
|
if (!Array.isArray(invariant.states) || invariant.states.length === 0)
|
|
188
249
|
bad(path + '.states', 'must be a non-empty array');
|
|
@@ -190,6 +251,8 @@ export function validateModel(input) {
|
|
|
190
251
|
invariant.states.forEach((state, stateIndex) => {
|
|
191
252
|
if (typeof state !== 'string' || state.length === 0)
|
|
192
253
|
bad(path + '.states[' + stateIndex + ']', 'must be a non-empty string');
|
|
254
|
+
else if (!invariantStateIds.has(state))
|
|
255
|
+
bad(path + '.states[' + stateIndex + ']', 'references unknown state ' + state);
|
|
193
256
|
});
|
|
194
257
|
}
|
|
195
258
|
else if (invariant.kind === 'var-in-range') {
|
|
@@ -198,18 +261,57 @@ export function validateModel(input) {
|
|
|
198
261
|
bad(path + '.min', 'must be a number');
|
|
199
262
|
if (invariant.max !== undefined && typeof invariant.max !== 'number')
|
|
200
263
|
bad(path + '.max', 'must be a number');
|
|
264
|
+
if (invariant.min === undefined && invariant.max === undefined)
|
|
265
|
+
bad(path, 'requires min or max (a range with neither bound is vacuous)');
|
|
266
|
+
if (invariant.when !== undefined) {
|
|
267
|
+
const when = invariant.when;
|
|
268
|
+
if (typeof when !== 'object' || when === null || Array.isArray(when)) {
|
|
269
|
+
bad(path + '.when', 'must be an object');
|
|
270
|
+
}
|
|
271
|
+
else if ('state' in when) {
|
|
272
|
+
const scope = when;
|
|
273
|
+
for (const key of Object.keys(scope)) {
|
|
274
|
+
if (key !== 'state')
|
|
275
|
+
bad(path + '.when.' + key, 'unknown field for a state scope (allowed: state)');
|
|
276
|
+
}
|
|
277
|
+
const state = scope.state;
|
|
278
|
+
if (typeof state !== 'string' || state.length === 0)
|
|
279
|
+
bad(path + '.when.state', 'must be a non-empty string');
|
|
280
|
+
else if (!invariantStateIds.has(state))
|
|
281
|
+
bad(path + '.when.state', 'references unknown state ' + state);
|
|
282
|
+
}
|
|
283
|
+
else {
|
|
284
|
+
validateGuard(when, path + '.when', errors, bad);
|
|
285
|
+
for (const variable of guardVariables(when)) {
|
|
286
|
+
if (variable === invariant.variable) {
|
|
287
|
+
bad(path + '.when', 'must not reference the constrained variable ' + invariant.variable + ' (it can mask its own violation)');
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
validateVariableRef(variable, path + '.when');
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
201
295
|
}
|
|
202
296
|
else if (invariant.kind === 'event-before-state') {
|
|
203
297
|
if (typeof invariant.event !== 'string' || invariant.event.length === 0)
|
|
204
298
|
bad(path + '.event', 'must be a non-empty string');
|
|
299
|
+
else if (!invariantEventIds.has(invariant.event))
|
|
300
|
+
bad(path + '.event', 'references unknown event ' + invariant.event);
|
|
205
301
|
if (typeof invariant.state !== 'string' || invariant.state.length === 0)
|
|
206
302
|
bad(path + '.state', 'must be a non-empty string');
|
|
303
|
+
else if (!invariantStateIds.has(invariant.state))
|
|
304
|
+
bad(path + '.state', 'references unknown state ' + invariant.state);
|
|
207
305
|
}
|
|
208
306
|
else if (invariant.kind === 'leads-to') {
|
|
209
307
|
if (typeof invariant.from !== 'string' || invariant.from.length === 0)
|
|
210
308
|
bad(path + '.from', 'must be a non-empty string');
|
|
309
|
+
else if (!invariantStateIds.has(invariant.from))
|
|
310
|
+
bad(path + '.from', 'references unknown state ' + invariant.from);
|
|
211
311
|
if (typeof invariant.to !== 'string' || invariant.to.length === 0)
|
|
212
312
|
bad(path + '.to', 'must be a non-empty string');
|
|
313
|
+
else if (!invariantStateIds.has(invariant.to))
|
|
314
|
+
bad(path + '.to', 'references unknown state ' + invariant.to);
|
|
213
315
|
}
|
|
214
316
|
else if (invariant.kind === 'sequence') {
|
|
215
317
|
if (!Array.isArray(invariant.events) || invariant.events.length === 0)
|
|
@@ -240,6 +342,8 @@ export function validateModel(input) {
|
|
|
240
342
|
else if (invariant.kind === 'probability') {
|
|
241
343
|
if (typeof invariant.target !== 'string' || invariant.target.length === 0)
|
|
242
344
|
bad(path + '.target', 'must be a non-empty string');
|
|
345
|
+
else if (!invariantStateIds.has(invariant.target))
|
|
346
|
+
bad(path + '.target', 'references unknown state ' + invariant.target);
|
|
243
347
|
if (invariant.op !== '>=' && invariant.op !== '<=' && invariant.op !== '>' && invariant.op !== '<')
|
|
244
348
|
bad(path + '.op', "must be one of '>=', '<=', '>', '<'");
|
|
245
349
|
if (typeof invariant.p !== 'number' || !Number.isFinite(invariant.p) || invariant.p < 0 || invariant.p > 1)
|
|
@@ -272,6 +376,7 @@ export function validateModel(input) {
|
|
|
272
376
|
return;
|
|
273
377
|
}
|
|
274
378
|
const check = entry;
|
|
379
|
+
rejectUnknownKeys(check, MODEL_KEYS.boundaryCheck, path);
|
|
275
380
|
validateVariableRef(check.variable, path + '.variable');
|
|
276
381
|
if (!Array.isArray(check.values) || check.values.some((value) => typeof value !== 'number'))
|
|
277
382
|
bad(path + '.values', 'must be an array of numbers');
|
|
@@ -306,6 +411,7 @@ export function validateModel(input) {
|
|
|
306
411
|
return;
|
|
307
412
|
}
|
|
308
413
|
const pair = entry;
|
|
414
|
+
rejectUnknownKeys(pair, MODEL_KEYS.resourcePair, path);
|
|
309
415
|
if (typeof pair.resource !== 'string' || pair.resource.length === 0)
|
|
310
416
|
bad(path + '.resource', 'must be a non-empty string');
|
|
311
417
|
if (typeof pair.acquireEvent !== 'string' || pair.acquireEvent.length === 0)
|
|
@@ -323,6 +429,7 @@ export function validateModel(input) {
|
|
|
323
429
|
}
|
|
324
430
|
else {
|
|
325
431
|
const narrative = root.narrative;
|
|
432
|
+
rejectUnknownKeys(narrative, MODEL_KEYS.narrative, narrativePath);
|
|
326
433
|
const stateIds = new Set(Array.isArray(root.states) ? root.states.map((state) => state.id) : []);
|
|
327
434
|
const eventIds = new Set(Array.isArray(root.transitions) ? root.transitions.map((transition) => transition.event) : []);
|
|
328
435
|
const fromEventGroups = new Set(Array.isArray(root.transitions) ? root.transitions.map((transition) => transition.from + '|' + transition.event) : []);
|
|
@@ -368,6 +475,7 @@ export function validateModel(input) {
|
|
|
368
475
|
return;
|
|
369
476
|
}
|
|
370
477
|
const scenario = entry;
|
|
478
|
+
rejectUnknownKeys(scenario, MODEL_KEYS.scenario, scenarioPath);
|
|
371
479
|
if (typeof scenario.from !== 'string' || scenario.from.length === 0)
|
|
372
480
|
bad(scenarioPath + '.from', 'must be a non-empty string');
|
|
373
481
|
else if (!stateIds.has(scenario.from))
|
|
@@ -845,10 +953,18 @@ function S5_eventCompleteness(model) {
|
|
|
845
953
|
}
|
|
846
954
|
return checkResult('S5', 'Event Completeness', findings, findings.length === 0 ? 'All states handle all relevant events' : findings.length + ' unhandled (state, event) pairs');
|
|
847
955
|
}
|
|
848
|
-
|
|
956
|
+
/** Whether a `var-in-range` `when` scope selects this runtime state. */
|
|
957
|
+
function whenScopeHolds(when, runtime) {
|
|
958
|
+
return isStateGuard(when) ? when.state === runtime.state : evalGuard(when, runtime.vars);
|
|
959
|
+
}
|
|
960
|
+
function invariantHolds(invariant, runtime, isInitial = false) {
|
|
849
961
|
if (invariant.kind === 'never-states')
|
|
850
962
|
return !invariant.states.includes(runtime.state);
|
|
851
963
|
if (invariant.kind === 'var-in-range') {
|
|
964
|
+
// The initial runtime state is checked unconditionally: a machine must never be
|
|
965
|
+
// able to escape the range simply by starting outside the scope.
|
|
966
|
+
if (!isInitial && invariant.when !== undefined && !whenScopeHolds(invariant.when, runtime))
|
|
967
|
+
return true;
|
|
852
968
|
const value = runtime.vars[invariant.variable];
|
|
853
969
|
if (typeof value !== 'number')
|
|
854
970
|
return false;
|
|
@@ -865,7 +981,7 @@ function shortestViolationForInvariant(model, options, invariant) {
|
|
|
865
981
|
return shortestEventBeforeStateViolation(model, options, invariant);
|
|
866
982
|
}
|
|
867
983
|
const init = initialState(model);
|
|
868
|
-
if (!invariantHolds(invariant, init)) {
|
|
984
|
+
if (!invariantHolds(invariant, init, true)) {
|
|
869
985
|
return { invariant, path: [], reason: 'Initial state violates the invariant.' };
|
|
870
986
|
}
|
|
871
987
|
const visited = new Set([runtimeKey(init)]);
|
|
@@ -1375,6 +1491,15 @@ function mapInvariantForComparison(invariant, mapping) {
|
|
|
1375
1491
|
state: mapStateId(mapping, invariant.state),
|
|
1376
1492
|
};
|
|
1377
1493
|
}
|
|
1494
|
+
if (invariant.kind === 'var-in-range') {
|
|
1495
|
+
const mapped = { ...invariant, id: invariant.id + ':before', description: invariant.description + ' (from BEFORE)' };
|
|
1496
|
+
// A state scope must follow the state rename, otherwise D2 reports a spurious
|
|
1497
|
+
// regression when the scope later refers to a state id that no longer exists.
|
|
1498
|
+
if (mapped.when !== undefined && isStateGuard(mapped.when)) {
|
|
1499
|
+
mapped.when = { state: mapStateId(mapping, mapped.when.state) };
|
|
1500
|
+
}
|
|
1501
|
+
return mapped;
|
|
1502
|
+
}
|
|
1378
1503
|
return { ...invariant, id: invariant.id + ':before', description: invariant.description + ' (from BEFORE)' };
|
|
1379
1504
|
}
|
|
1380
1505
|
function D2_invariantContinuity(before, after, options, mapping) {
|