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 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 — verified on mainline 2026-08-14 (gate bundle loaded and injected in-session)
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 — 已实测 mainline 2026-08-14(gate bundle 加载并注入会话成功)
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
- function invariantHolds(invariant, runtime) {
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) {