dsh-model-everything 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en-US.md +81 -73
- package/README.md +67 -68
- package/lib/automaton-tool.js +69 -0
- package/lib/automaton.js +327 -0
- package/lib/compose-tool.js +6 -1
- package/lib/concurrency-tool.js +6 -1
- package/lib/data-tool.js +6 -1
- package/lib/design-tool.js +7 -2
- package/lib/edit-tool.js +111 -0
- package/lib/editor.js +293 -0
- package/lib/engine.js +7 -0
- package/lib/export-tool.js +6 -1
- package/lib/index.js +4 -0
- package/lib/presentation.js +81 -0
- package/lib/report-diff-tool.js +6 -1
- package/lib/report-view.js +62 -10
- package/lib/routing.js +85 -0
- package/lib/scan.js +547 -0
- package/lib/structure-tool.js +34 -1
- package/lib/structure.js +28 -3
- package/lib/tool.js +6 -0
- package/lib/types/automaton-tool.d.ts +2 -0
- package/lib/types/automaton.d.ts +57 -0
- package/lib/types/edit-tool.d.ts +2 -0
- package/lib/types/editor.d.ts +46 -0
- package/lib/types/engine.d.ts +1 -0
- package/lib/types/presentation.d.ts +59 -0
- package/lib/types/routing.d.ts +31 -0
- package/lib/types/scan.d.ts +37 -0
- package/lib/types/structure.d.ts +16 -0
- package/lib/uml-tool.js +16 -6
- package/package.json +4 -3
- package/skills/model-everything/SKILL.md +6 -2
- package/skills/model-everything/references/automata.md +102 -0
- package/src/automaton-tool.ts +73 -0
- package/src/automaton.ts +375 -0
- package/src/compose-tool.ts +12 -6
- package/src/concurrency-tool.ts +7 -1
- package/src/data-tool.ts +7 -1
- package/src/design-tool.ts +74 -68
- package/src/edit-tool.ts +119 -0
- package/src/editor.ts +297 -0
- package/src/engine.ts +7 -0
- package/src/export-tool.ts +87 -81
- package/src/index.ts +4 -0
- package/src/presentation.ts +125 -0
- package/src/report-diff-tool.ts +7 -1
- package/src/report-view.ts +53 -2
- package/src/routing.ts +96 -0
- package/src/scan.ts +564 -0
- package/src/structure-tool.ts +33 -1
- package/src/structure.ts +37 -3
- package/src/tool.ts +6 -0
- package/src/uml-tool.ts +240 -230
package/lib/automaton.js
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Automata as a verification target: the claims a state machine cannot express.
|
|
3
|
+
*
|
|
4
|
+
* A state machine in `model_verify` answers "can this configuration be reached". An automaton answers a
|
|
5
|
+
* different question: what language does this accept, and is that language contained in, or equal to,
|
|
6
|
+
* another one. That is the shape of a protocol conformance claim - "every run the implementation allows
|
|
7
|
+
* is a run the specification allows" - and it is not settled by reading either of the two machines.
|
|
8
|
+
*
|
|
9
|
+
* Scope, stated because it is what the report is allowed to claim:
|
|
10
|
+
*
|
|
11
|
+
* - finite words over an explicit alphabet; NFAs are determinised first;
|
|
12
|
+
* - the empty language, universality, reachability, dead states, and the relation between two automata;
|
|
13
|
+
* - not: liveness, fairness, timing, or infinite words. A Büchi or timed automaton is a different family.
|
|
14
|
+
*/
|
|
15
|
+
import { createHash } from 'node:crypto';
|
|
16
|
+
import { canonicalJson } from './engine.js';
|
|
17
|
+
import { OptionShapeError } from './option-shape.js';
|
|
18
|
+
const SINK = '\u0000sink';
|
|
19
|
+
/** Validate the shape, so a refusal says which field is wrong rather than throwing later. */
|
|
20
|
+
export function validateAutomaton(input) {
|
|
21
|
+
const errors = [];
|
|
22
|
+
const value = input;
|
|
23
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
24
|
+
return { ok: false, errors: ['automaton must be an object with states, alphabet, transitions, init and accepting.'] };
|
|
25
|
+
}
|
|
26
|
+
const states = value.states;
|
|
27
|
+
if (!Array.isArray(states) || states.length === 0 || states.some((state) => typeof state !== 'string' || state === '')) {
|
|
28
|
+
errors.push('states: must be a non-empty array of non-empty strings');
|
|
29
|
+
}
|
|
30
|
+
const alphabet = value.alphabet;
|
|
31
|
+
if (!Array.isArray(alphabet) || alphabet.length === 0 || alphabet.some((symbol) => typeof symbol !== 'string' || symbol === '')) {
|
|
32
|
+
errors.push('alphabet: must be a non-empty array of non-empty strings');
|
|
33
|
+
}
|
|
34
|
+
const init = value.init;
|
|
35
|
+
if (typeof init !== 'string' || init === '')
|
|
36
|
+
errors.push('init: must be a non-empty string');
|
|
37
|
+
const accepting = value.accepting;
|
|
38
|
+
if (!Array.isArray(accepting) || accepting.some((state) => typeof state !== 'string')) {
|
|
39
|
+
errors.push('accepting: must be an array of state ids');
|
|
40
|
+
}
|
|
41
|
+
const transitions = value.transitions;
|
|
42
|
+
if (!Array.isArray(transitions))
|
|
43
|
+
errors.push('transitions: must be an array of {from, symbol, to}');
|
|
44
|
+
if (errors.length > 0)
|
|
45
|
+
return { ok: false, errors };
|
|
46
|
+
const known = new Set(states);
|
|
47
|
+
if (typeof init === 'string' && !known.has(init))
|
|
48
|
+
errors.push(`init: names "${init}", which is not a declared state`);
|
|
49
|
+
for (const state of accepting) {
|
|
50
|
+
if (!known.has(state))
|
|
51
|
+
errors.push(`accepting: names "${state}", which is not a declared state`);
|
|
52
|
+
}
|
|
53
|
+
const symbols = new Set(alphabet);
|
|
54
|
+
for (const [index, raw] of transitions.entries()) {
|
|
55
|
+
const transition = raw;
|
|
56
|
+
if (transition === null || typeof transition !== 'object') {
|
|
57
|
+
errors.push(`transitions[${index}]: must be an object`);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (typeof transition.from !== 'string' || !known.has(transition.from))
|
|
61
|
+
errors.push(`transitions[${index}].from: "${String(transition.from)}" is not a declared state`);
|
|
62
|
+
if (typeof transition.to !== 'string' || !known.has(transition.to))
|
|
63
|
+
errors.push(`transitions[${index}].to: "${String(transition.to)}" is not a declared state`);
|
|
64
|
+
if (typeof transition.symbol !== 'string' || !symbols.has(transition.symbol))
|
|
65
|
+
errors.push(`transitions[${index}].symbol: "${String(transition.symbol)}" is not in the alphabet`);
|
|
66
|
+
}
|
|
67
|
+
if (errors.length > 0)
|
|
68
|
+
return { ok: false, errors };
|
|
69
|
+
return {
|
|
70
|
+
ok: true,
|
|
71
|
+
automaton: {
|
|
72
|
+
states: states,
|
|
73
|
+
alphabet: alphabet,
|
|
74
|
+
transitions: transitions.map((transition) => ({ from: transition.from, symbol: transition.symbol, to: transition.to })),
|
|
75
|
+
init: init,
|
|
76
|
+
accepting: accepting,
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/** Determinise by subset construction, adding one explicit sink so a word never stops early. */
|
|
81
|
+
function determinise(automaton, symbols) {
|
|
82
|
+
const index = new Map();
|
|
83
|
+
const subsets = [];
|
|
84
|
+
const delta = [];
|
|
85
|
+
const accept = [];
|
|
86
|
+
const accepting = new Set(automaton.accepting);
|
|
87
|
+
const byFromAndSymbol = new Map();
|
|
88
|
+
for (const transition of automaton.transitions) {
|
|
89
|
+
const key = transition.from + '\u0000' + transition.symbol;
|
|
90
|
+
byFromAndSymbol.set(key, [...(byFromAndSymbol.get(key) ?? []), transition.to]);
|
|
91
|
+
}
|
|
92
|
+
const intern = (label, acceptingHere) => {
|
|
93
|
+
const key = label.join('\u0000');
|
|
94
|
+
const existing = index.get(key);
|
|
95
|
+
if (existing !== undefined)
|
|
96
|
+
return existing;
|
|
97
|
+
const id = subsets.length;
|
|
98
|
+
index.set(key, id);
|
|
99
|
+
subsets.push(label);
|
|
100
|
+
accept.push(acceptingHere);
|
|
101
|
+
delta.push([]);
|
|
102
|
+
return id;
|
|
103
|
+
};
|
|
104
|
+
const start = intern([automaton.init], accepting.has(automaton.init));
|
|
105
|
+
// Each subset is expanded exactly once: a subset reached again - a self-loop, or a second path into
|
|
106
|
+
// the same set - only needs its edge recorded, not another pass through its transitions.
|
|
107
|
+
const pending = [start];
|
|
108
|
+
const expanded = new Set([start]);
|
|
109
|
+
while (pending.length > 0) {
|
|
110
|
+
const current = pending.shift();
|
|
111
|
+
for (const [symbolIndex, symbol] of symbols.entries()) {
|
|
112
|
+
const next = new Set();
|
|
113
|
+
for (const state of subsets[current]) {
|
|
114
|
+
for (const target of byFromAndSymbol.get(state + '\u0000' + symbol) ?? [])
|
|
115
|
+
next.add(target);
|
|
116
|
+
}
|
|
117
|
+
const label = [...next].sort();
|
|
118
|
+
const acceptingNext = label.some((state) => accepting.has(state));
|
|
119
|
+
const id = intern(label.length === 0 ? [SINK] : label, acceptingNext);
|
|
120
|
+
delta[current][symbolIndex] = id;
|
|
121
|
+
if (!expanded.has(id)) {
|
|
122
|
+
expanded.add(id);
|
|
123
|
+
pending.push(id);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return { start, accept, delta, labels: subsets.map((subset) => (subset[0] === SINK ? [] : subset)) };
|
|
128
|
+
}
|
|
129
|
+
/** The product search stopped at its cap, so the relation is undecided rather than true. */
|
|
130
|
+
const UNDECIDED = Symbol('undecided');
|
|
131
|
+
/**
|
|
132
|
+
* Shortest word accepted by `a` but not by `b`, when one exists.
|
|
133
|
+
*
|
|
134
|
+
* The product has at most |a| x |b| states, which is fine for the machines this family is for and can
|
|
135
|
+
* still explode on two large ones. The cap turns that into "undecided", because a search that stopped
|
|
136
|
+
* is not a proof that the languages are equal.
|
|
137
|
+
*/
|
|
138
|
+
function difference(first, second, symbols, limit = 10_000) {
|
|
139
|
+
const seen = new Set();
|
|
140
|
+
const queue = [{ left: first.start, right: second.start, word: [] }];
|
|
141
|
+
seen.add(first.start + ':' + second.start);
|
|
142
|
+
let expanded = 0;
|
|
143
|
+
while (queue.length > 0) {
|
|
144
|
+
if (++expanded > limit)
|
|
145
|
+
return UNDECIDED;
|
|
146
|
+
const node = queue.shift();
|
|
147
|
+
if (first.accept[node.left] && !second.accept[node.right])
|
|
148
|
+
return node.word;
|
|
149
|
+
for (const [symbolIndex, symbol] of symbols.entries()) {
|
|
150
|
+
const left = first.delta[node.left][symbolIndex];
|
|
151
|
+
const right = second.delta[node.right][symbolIndex];
|
|
152
|
+
const key = left + ':' + right;
|
|
153
|
+
if (seen.has(key))
|
|
154
|
+
continue;
|
|
155
|
+
seen.add(key);
|
|
156
|
+
queue.push({ left, right, word: [...node.word, symbol] });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
/** Every state that can be reached from the start. */
|
|
162
|
+
function reachable(dfa, symbols) {
|
|
163
|
+
const seen = new Array(dfa.delta.length).fill(false);
|
|
164
|
+
seen[dfa.start] = true;
|
|
165
|
+
const queue = [dfa.start];
|
|
166
|
+
while (queue.length > 0) {
|
|
167
|
+
const current = queue.shift();
|
|
168
|
+
for (let symbolIndex = 0; symbolIndex < symbols.length; symbolIndex++) {
|
|
169
|
+
const next = dfa.delta[current][symbolIndex];
|
|
170
|
+
if (!seen[next]) {
|
|
171
|
+
seen[next] = true;
|
|
172
|
+
queue.push(next);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return seen;
|
|
177
|
+
}
|
|
178
|
+
/** Every state from which an accepting state can be reached. */
|
|
179
|
+
function productive(dfa, symbols) {
|
|
180
|
+
const reverse = new Map();
|
|
181
|
+
for (let state = 0; state < dfa.delta.length; state++) {
|
|
182
|
+
for (let symbolIndex = 0; symbolIndex < symbols.length; symbolIndex++) {
|
|
183
|
+
const target = dfa.delta[state][symbolIndex];
|
|
184
|
+
reverse.set(target, [...(reverse.get(target) ?? []), state]);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const seen = new Array(dfa.delta.length).fill(false);
|
|
188
|
+
const queue = [];
|
|
189
|
+
for (let state = 0; state < dfa.accept.length; state++) {
|
|
190
|
+
if (dfa.accept[state]) {
|
|
191
|
+
seen[state] = true;
|
|
192
|
+
queue.push(state);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
while (queue.length > 0) {
|
|
196
|
+
const current = queue.shift();
|
|
197
|
+
for (const previous of reverse.get(current) ?? []) {
|
|
198
|
+
if (!seen[previous]) {
|
|
199
|
+
seen[previous] = true;
|
|
200
|
+
queue.push(previous);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return seen;
|
|
205
|
+
}
|
|
206
|
+
/** Determinism, as declared by the input rather than guessed: a repeated (from, symbol) pair. */
|
|
207
|
+
function isDeterministic(automaton) {
|
|
208
|
+
const seen = new Set();
|
|
209
|
+
for (const transition of automaton.transitions) {
|
|
210
|
+
const key = transition.from + '\u0000' + transition.symbol;
|
|
211
|
+
if (seen.has(key))
|
|
212
|
+
return false;
|
|
213
|
+
seen.add(key);
|
|
214
|
+
}
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
export function verifyAutomaton(input, options = {}) {
|
|
218
|
+
const parsed = validateAutomaton(input);
|
|
219
|
+
if (!parsed.ok) {
|
|
220
|
+
throw new OptionShapeError('AUTOMATON_INVALID', 'the automaton is not well formed: ' + parsed.errors.join('; '));
|
|
221
|
+
}
|
|
222
|
+
const automaton = parsed.automaton;
|
|
223
|
+
const symbols = [...automaton.alphabet].sort();
|
|
224
|
+
const findings = [];
|
|
225
|
+
const dfa = determinise(automaton, symbols);
|
|
226
|
+
const deterministic = isDeterministic(automaton);
|
|
227
|
+
const reach = reachable(dfa, symbols);
|
|
228
|
+
const canAccept = productive(dfa, symbols);
|
|
229
|
+
if (!deterministic) {
|
|
230
|
+
findings.push({ code: 'AUT003_NONDETERMINISTIC', severity: 'info', message: `the automaton is not deterministic: ${String(dfa.delta.length)} subset states after determinisation, ${String(automaton.states.length)} declared. The analysis used the deterministic form.` });
|
|
231
|
+
}
|
|
232
|
+
// A state no word can reach is dead weight, and a state from which no accepting state is reachable
|
|
233
|
+
// accepts nothing: the first is a modelling smell, the second is usually a defect in the intended language.
|
|
234
|
+
for (const [index, label] of dfa.labels.entries()) {
|
|
235
|
+
if (label.length === 0)
|
|
236
|
+
continue;
|
|
237
|
+
if (label.length === 1 && !reach[index]) {
|
|
238
|
+
findings.push({ code: 'AUT001_UNREACHABLE_STATE', severity: 'warning', message: `state "${label[0]}" cannot be reached from init` });
|
|
239
|
+
}
|
|
240
|
+
if (!dfa.accept[index] && reach[index] && !canAccept[index]) {
|
|
241
|
+
findings.push({ code: 'AUT002_TRAP_STATE', severity: 'warning', message: `state "${label[0]}" is reachable but can never reach an accepting state` });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
for (const state of automaton.states) {
|
|
245
|
+
if (!dfa.labels.some((label) => label.includes(state))) {
|
|
246
|
+
if (!findings.some((finding) => finding.code === 'AUT001_UNREACHABLE_STATE' && finding.message.includes(`"${state}"`))) {
|
|
247
|
+
findings.push({ code: 'AUT001_UNREACHABLE_STATE', severity: 'warning', message: `state "${state}" cannot be reached from init` });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const acceptingReachable = reach.some((value, index) => value && dfa.accept[index]);
|
|
252
|
+
if (!acceptingReachable) {
|
|
253
|
+
findings.push({ code: 'AUT004_EMPTY_LANGUAGE', severity: 'warning', message: 'the automaton accepts nothing: no accepting state is reachable from init' });
|
|
254
|
+
}
|
|
255
|
+
let witness;
|
|
256
|
+
let otherForHash;
|
|
257
|
+
const relation = options.relation ?? 'equivalent';
|
|
258
|
+
if (options.other !== undefined) {
|
|
259
|
+
const otherParsed = validateAutomaton(options.other);
|
|
260
|
+
if (!otherParsed.ok) {
|
|
261
|
+
throw new OptionShapeError('AUTOMATON_INVALID', 'the second automaton is not well formed: ' + otherParsed.errors.join('; '));
|
|
262
|
+
}
|
|
263
|
+
const other = otherParsed.automaton;
|
|
264
|
+
otherForHash = other;
|
|
265
|
+
if (other.alphabet.length !== automaton.alphabet.length || !other.alphabet.every((symbol) => automaton.alphabet.includes(symbol))) {
|
|
266
|
+
findings.push({ code: 'AUT005_ALPHABET_MISMATCH', severity: 'error', message: 'the two automata do not share an alphabet, so a language relation between them is not defined', detail: `first: ${automaton.alphabet.join(', ')}; second: ${other.alphabet.join(', ')}` });
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
const second = determinise(other, symbols);
|
|
270
|
+
const missing = difference(dfa, second, symbols);
|
|
271
|
+
const extra = difference(second, dfa, symbols);
|
|
272
|
+
if (missing === UNDECIDED || extra === UNDECIDED) {
|
|
273
|
+
findings.push({ code: 'AUT009_UNDECIDED', severity: 'warning', message: 'the product search stopped at its state cap before deciding the relation, so equality is neither proven nor refuted', detail: 'the product of two automata can be exponential; reduce one of them, or decide the relation with a tool that searches symbolically' });
|
|
274
|
+
}
|
|
275
|
+
else if (relation === 'equivalent') {
|
|
276
|
+
if (missing !== undefined || extra !== undefined) {
|
|
277
|
+
const word = missing ?? extra;
|
|
278
|
+
witness = { word: word ?? [], reason: missing !== undefined ? 'accepted by the first automaton, rejected by the second' : 'accepted by the second automaton, rejected by the first' };
|
|
279
|
+
findings.push({ code: 'AUT006_NOT_EQUIVALENT', severity: 'error', message: 'the two automata accept different languages', detail: `shortest witness: ${word === undefined || word.length === 0 ? 'the empty word' : word.join(' ')}; ${witness.reason}` });
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
else if (relation === 'subset') {
|
|
283
|
+
if (missing !== undefined) {
|
|
284
|
+
witness = { word: missing, reason: 'accepted by the first automaton, rejected by the second' };
|
|
285
|
+
findings.push({ code: 'AUT007_NOT_SUBSET', severity: 'error', message: 'the first automaton accepts a word the second rejects', detail: `shortest witness: ${missing.length === 0 ? 'the empty word' : missing.join(' ')}` });
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
else if (extra !== undefined) {
|
|
289
|
+
witness = { word: extra, reason: 'accepted by the second automaton, rejected by the first' };
|
|
290
|
+
findings.push({ code: 'AUT008_NOT_SUPERSET', severity: 'error', message: 'the second automaton accepts a word the first rejects', detail: `shortest witness: ${extra.length === 0 ? 'the empty word' : extra.join(' ')}` });
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
const errors = findings.filter((finding) => finding.severity === 'error').length;
|
|
295
|
+
const warnings = findings.filter((finding) => finding.severity === 'warning').length;
|
|
296
|
+
const verdict = errors > 0 ? 'fail' : warnings > 0 ? 'pass_with_findings' : 'pass';
|
|
297
|
+
const reason = errors > 0
|
|
298
|
+
? `${String(errors)} error finding(s) (first: ${findings.find((finding) => finding.severity === 'error')?.code ?? 'unknown'})`
|
|
299
|
+
: warnings > 0
|
|
300
|
+
? `no error findings; ${String(warnings)} warning finding(s)`
|
|
301
|
+
: 'every check passed';
|
|
302
|
+
const nextSteps = [];
|
|
303
|
+
if (findings.some((finding) => finding.code === 'AUT004_EMPTY_LANGUAGE'))
|
|
304
|
+
nextSteps.push('An automaton that accepts nothing usually has a transition or an accepting set wrong; check that the accepting set is reachable from init.');
|
|
305
|
+
if (witness !== undefined)
|
|
306
|
+
nextSteps.push('The witness is the shortest word that separates the two languages; replay it through both automata to see which transition makes the difference.');
|
|
307
|
+
if (!deterministic)
|
|
308
|
+
nextSteps.push('The analysis determinised the input; if nondeterminism was accidental, the subset count is the tell.');
|
|
309
|
+
nextSteps.push('This decides finite-word languages only. Liveness, fairness and timing are not in scope: those need a Büchi or timed model, which this family does not build.');
|
|
310
|
+
return {
|
|
311
|
+
ok: true,
|
|
312
|
+
ran: true,
|
|
313
|
+
verdict,
|
|
314
|
+
verdictReason: reason,
|
|
315
|
+
schema: 'model-everything/automaton/v1',
|
|
316
|
+
automaton: { states: automaton.states.length, transitions: automaton.transitions.length, alphabet: automaton.alphabet.length, deterministic },
|
|
317
|
+
findings,
|
|
318
|
+
...(witness === undefined ? {} : { witness }),
|
|
319
|
+
...(findings.some((finding) => finding.code === 'AUT009_UNDECIDED') ? { truncated: true } : {}),
|
|
320
|
+
hashes: { automaton: hashAutomaton(automaton), ...(otherForHash === undefined ? {} : { other: hashAutomaton(otherForHash) }) },
|
|
321
|
+
nextSteps,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
const createHashHex = (text) => createHash('sha256').update(text).digest('hex');
|
|
325
|
+
function hashAutomaton(automaton) {
|
|
326
|
+
return createHashHex(canonicalJson({ states: [...automaton.states].sort(), alphabet: [...automaton.alphabet].sort(), transitions: automaton.transitions.map((t) => `${t.from}\u0000${t.symbol}\u0000${t.to}`).sort(), init: automaton.init, accepting: [...automaton.accepting].sort() }));
|
|
327
|
+
}
|
package/lib/compose-tool.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
2
2
|
import { runCompositionVerification } from './engine.js';
|
|
3
|
+
import { reportCard, reportResultView } from './presentation.js';
|
|
3
4
|
import { OptionShapeError, optionRefusal, requireArrayOption } from './option-shape.js';
|
|
4
5
|
import { renderReportText } from './report-view.js';
|
|
5
6
|
export const COMPOSE_TOOL_NAME = 'model_compose_verify';
|
|
@@ -51,7 +52,11 @@ export const modelComposeTool = defineTool({
|
|
|
51
52
|
render(args, value) {
|
|
52
53
|
return [{ type: 'text', text: renderReportText(value, { full: args.reportView === 'full', check: args.check }) }];
|
|
53
54
|
},
|
|
54
|
-
|
|
55
|
+
// The card a UI shows, separated from the text the model reads.
|
|
56
|
+
presentationMeta(_args, value) {
|
|
57
|
+
return reportCard(value, COMPOSE_TOOL_NAME);
|
|
58
|
+
},
|
|
59
|
+
}, presentResult: reportResultView(COMPOSE_TOOL_NAME),
|
|
55
60
|
timeoutMs: 10_000,
|
|
56
61
|
isConcurrencySafe: () => true,
|
|
57
62
|
async execute(args) {
|
package/lib/concurrency-tool.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
2
2
|
import { runConcurrencyScan } from './concurrency.js';
|
|
3
3
|
import { renderReportText } from './report-view.js';
|
|
4
|
+
import { reportCard, reportResultView } from './presentation.js';
|
|
4
5
|
export const INTERLEAVING_SCAN_TOOL_NAME = 'model_interleaving_scan';
|
|
5
6
|
/**
|
|
6
7
|
* Model-visible DSH tool that mines design documents/plans for concurrency-related
|
|
@@ -31,7 +32,11 @@ export const modelConcurrencyScanTool = defineTool({
|
|
|
31
32
|
render(args, value) {
|
|
32
33
|
return [{ type: 'text', text: renderReportText(value, { full: args.reportView === 'full' }) }];
|
|
33
34
|
},
|
|
34
|
-
|
|
35
|
+
// The card a UI shows, separated from the text the model reads.
|
|
36
|
+
presentationMeta(_args, value) {
|
|
37
|
+
return reportCard(value, INTERLEAVING_SCAN_TOOL_NAME);
|
|
38
|
+
},
|
|
39
|
+
}, presentResult: reportResultView(INTERLEAVING_SCAN_TOOL_NAME),
|
|
35
40
|
timeoutMs: 10_000,
|
|
36
41
|
isConcurrencySafe: () => true,
|
|
37
42
|
async execute(args) {
|
package/lib/data-tool.js
CHANGED
|
@@ -3,6 +3,7 @@ import { runDataVerification, DATA_ENGINE_SCHEMA_VERSION } from './data-engine.j
|
|
|
3
3
|
import { oneOf } from './model-file.js';
|
|
4
4
|
import { optionRefusal, requireArrayOption, requireObjectOption } from './option-shape.js';
|
|
5
5
|
import { renderReportText } from './report-view.js';
|
|
6
|
+
import { reportCard, reportResultView } from './presentation.js';
|
|
6
7
|
export const DATAMODEL_VERIFY_TOOL_NAME = 'model_datamodel_verify';
|
|
7
8
|
/**
|
|
8
9
|
* Model-visible DSH tool wrapping the bundled data-model verification engine.
|
|
@@ -60,7 +61,11 @@ export const modelDataModelVerifyTool = defineTool({
|
|
|
60
61
|
render(args, value) {
|
|
61
62
|
return [{ type: 'text', text: renderReportText(value, { full: args.reportView === 'full', check: args.check }) }];
|
|
62
63
|
},
|
|
63
|
-
|
|
64
|
+
// The card a UI shows, separated from the text the model reads.
|
|
65
|
+
presentationMeta(_args, value) {
|
|
66
|
+
return reportCard(value, DATAMODEL_VERIFY_TOOL_NAME);
|
|
67
|
+
},
|
|
68
|
+
}, presentResult: reportResultView(DATAMODEL_VERIFY_TOOL_NAME),
|
|
64
69
|
timeoutMs: 10_000,
|
|
65
70
|
isConcurrencySafe: () => true,
|
|
66
71
|
async execute(args) {
|
package/lib/design-tool.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
2
2
|
import { REPORT_SCHEMAS } from './engine.js';
|
|
3
|
+
import { reportCard, reportResultView } from './presentation.js';
|
|
3
4
|
import { oneOf } from './model-file.js';
|
|
4
5
|
import { runDesignReview } from './design-engine.js';
|
|
5
6
|
import { renderReportText } from './report-view.js';
|
|
@@ -15,7 +16,7 @@ export const DESIGN_TOOL_NAME = 'model_design_review';
|
|
|
15
16
|
*/
|
|
16
17
|
export const designTool = defineTool({
|
|
17
18
|
name: DESIGN_TOOL_NAME,
|
|
18
|
-
description: 'Hold a design to the contracts it declares about itself: layering, responsibility ownership, substitutability, interface width. For auditing a dependency diagram against a matrix use `uml_structure_verify`; this one decides the declarations the design makes about itself and reports the shape of its graph. Violations of a declaration decide `verdict`: DES1 cycle, DES2 layer order, DES3 boundary leak, DES5 unplaced node, DES6 missing required dependency, DES7 substitutability, DES9 split interface. Signals are facts, not defects: DES4 load-bearing element, DES8 uncalled operation, DES10 responsibility gap, DES11 abstraction on a concrete type. A check with nothing to hold the design to is `skip`, not `pass`. `facts` has ranks, fan-in/out, components, density, cyclomatic complexity, and the fewest edges whose removal makes the graph acyclic (exact for a small cyclic part, an upper bound past that: `feedbackArcsExact` says which). Read `verdict`, never `ok`.',
|
|
19
|
+
description: 'Hold a design to the contracts it declares about itself: layering, responsibility ownership, substitutability, interface width. For auditing a dependency diagram against a matrix use `uml_structure_verify`; this one decides the declarations the design makes about itself and reports the shape of its graph. The split matters when a reader has a fact and a rule in one hand: which edges actually exist is the graph tool (it can scan a source tree with `repoPath`), and whether those edges were allowed is this one. Violations of a declaration decide `verdict`: DES1 cycle, DES2 layer order, DES3 boundary leak, DES5 unplaced node, DES6 missing required dependency, DES7 substitutability, DES9 split interface. Signals are facts, not defects: DES4 load-bearing element, DES8 uncalled operation, DES10 responsibility gap, DES11 abstraction on a concrete type. A check with nothing to hold the design to is `skip`, not `pass`. `facts` has ranks, fan-in/out, components, density, cyclomatic complexity, and the fewest edges whose removal makes the graph acyclic (exact for a small cyclic part, an upper bound past that: `feedbackArcsExact` says which). Read `verdict`, never `ok`.',
|
|
19
20
|
parameters: {
|
|
20
21
|
model: {
|
|
21
22
|
type: 'json',
|
|
@@ -43,7 +44,11 @@ export const designTool = defineTool({
|
|
|
43
44
|
render(args, value) {
|
|
44
45
|
return [{ type: 'text', text: renderReportText(value, { full: args.reportView === 'full', check: args.check }) }];
|
|
45
46
|
},
|
|
46
|
-
|
|
47
|
+
// The card a UI shows, separated from the text the model reads.
|
|
48
|
+
presentationMeta(_args, value) {
|
|
49
|
+
return reportCard(value, DESIGN_TOOL_NAME);
|
|
50
|
+
},
|
|
51
|
+
}, presentResult: reportResultView(DESIGN_TOOL_NAME),
|
|
47
52
|
timeoutMs: 10_000,
|
|
48
53
|
isConcurrencySafe: () => true,
|
|
49
54
|
async execute(args) {
|
package/lib/edit-tool.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `model_edit`: change a model file by naming the change, then see what the engine now says.
|
|
3
|
+
*
|
|
4
|
+
* The loop this replaces is edit-and-re-verify: hand back the whole model, get the whole report,
|
|
5
|
+
* repeat. Here the file holds the model, an operation holds the change, and the report carries the
|
|
6
|
+
* structural diff plus the difference in findings - so one call answers "what did I change, and did
|
|
7
|
+
* it fix anything".
|
|
8
|
+
*
|
|
9
|
+
* The description is written for the decision, not the schema: when this tool pays, when another
|
|
10
|
+
* one does, and what it refuses.
|
|
11
|
+
*/
|
|
12
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
13
|
+
import { diffReports } from './baseline.js';
|
|
14
|
+
import { applyEdits, readModelFile } from './editor.js';
|
|
15
|
+
import { REPORT_SCHEMAS, runVerification } from './engine.js';
|
|
16
|
+
import { reportCard, reportResultView } from './presentation.js';
|
|
17
|
+
import { optionRefusal } from './option-shape.js';
|
|
18
|
+
import { renderReportText } from './report-view.js';
|
|
19
|
+
export const MODEL_EDIT_TOOL_NAME = 'model_edit';
|
|
20
|
+
export const modelEditTool = defineTool({
|
|
21
|
+
name: MODEL_EDIT_TOOL_NAME,
|
|
22
|
+
description: 'Change a model file by naming one change, then see what the engine now decides. Use it to fix a model over several rounds: write the model to a file once, then add a state, tighten a guard, drop a transition and let the report show whether the finding went away. ' +
|
|
23
|
+
'It answers with the structural diff and, unless you set verify: false, the difference in findings - new errors, gone findings, before and after verdict - so one call replaces edit-then-verify-then-compare. ' +
|
|
24
|
+
'It refuses an edit that would leave an invalid model and writes nothing, so the file is always verifiable. ' +
|
|
25
|
+
'Do not use it to build a model from nothing (write the file or verify inline first), to check work that is not a state machine (`model_datamodel_verify`, `model_design_review`), or to judge the design as a whole (`model_verify` reports; this tool only compares its own edit).',
|
|
26
|
+
parameters: {
|
|
27
|
+
modelPath: {
|
|
28
|
+
type: 'string',
|
|
29
|
+
required: true,
|
|
30
|
+
description: 'Path to the model file to edit. It must already exist; the edit is applied to a copy and written back atomically.',
|
|
31
|
+
},
|
|
32
|
+
ops: {
|
|
33
|
+
type: 'json',
|
|
34
|
+
required: true,
|
|
35
|
+
description: 'Ordered list of operations, applied as one batch. Compact form: {addState: "c"} · {removeState: "c"} · {addTransition: ["from", "event", "to"]} · {removeTransition: ["from", "event"]} · {updateTransition: ["from", "event", {guard: ...}]} · {set: ["/path", value]} · {remove: "/path"}. Long form ({op: ..., state/transition/path/value}) works too, and addState/addTransition take the full object when you need terminal, guard, updates or cost.',
|
|
36
|
+
},
|
|
37
|
+
reportView: {
|
|
38
|
+
type: 'string',
|
|
39
|
+
enum: ['digest', 'full'],
|
|
40
|
+
description: 'digest (default) prints the changes and the report delta; full prints the structured result, which report_diff needs.',
|
|
41
|
+
},
|
|
42
|
+
allowInvalid: {
|
|
43
|
+
type: 'boolean',
|
|
44
|
+
description: 'Write the batch even when the result does not validate. Use it only for an edit that needs two calls with a broken model in between; the reply then carries validationErrors, and the next verification will refuse the file.',
|
|
45
|
+
},
|
|
46
|
+
verify: {
|
|
47
|
+
type: 'boolean',
|
|
48
|
+
description: 'Run the engine on the model before and after the edit and return the difference in findings. Default true; pass false for a batch of edits where only the final state matters.',
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
output: {
|
|
52
|
+
schema: {
|
|
53
|
+
type: 'json',
|
|
54
|
+
description: 'the edit result: structural changes, the new verdict, and the report delta when verify ran.',
|
|
55
|
+
},
|
|
56
|
+
render(args, value) {
|
|
57
|
+
return [{ type: 'text', text: renderReportText(value, { full: args.reportView === 'full' }) }];
|
|
58
|
+
},
|
|
59
|
+
// The card a UI shows, separated from the text the model reads.
|
|
60
|
+
presentationMeta(_args, value) {
|
|
61
|
+
return reportCard(value, MODEL_EDIT_TOOL_NAME);
|
|
62
|
+
},
|
|
63
|
+
}, presentResult: reportResultView(MODEL_EDIT_TOOL_NAME),
|
|
64
|
+
timeoutMs: 20_000,
|
|
65
|
+
isConcurrencySafe: () => false,
|
|
66
|
+
async execute(args) {
|
|
67
|
+
try {
|
|
68
|
+
const before = readModelFile(args.modelPath).model;
|
|
69
|
+
const verify = args.verify !== false;
|
|
70
|
+
const beforeReport = verify ? runVerification(before) : undefined;
|
|
71
|
+
const { model, changes, valid, errors } = applyEdits(args.modelPath, args.ops, { allowInvalid: args.allowInvalid === true });
|
|
72
|
+
const afterReport = verify ? runVerification(model) : undefined;
|
|
73
|
+
// A model that did not decide anything (checksRun 0 - the engine refused its shape) has no
|
|
74
|
+
// findings to compare. Diffing it would raise, and it would raise after the file was written,
|
|
75
|
+
// so the reply would claim nothing ran while the edit had landed. Say there is no delta instead.
|
|
76
|
+
const decided = (report) => (report?.summary?.checksRun ?? 0) > 0;
|
|
77
|
+
const compare = beforeReport !== undefined && afterReport !== undefined && decided(beforeReport) && decided(afterReport);
|
|
78
|
+
const delta = compare ? diffReports(beforeReport, afterReport) : undefined;
|
|
79
|
+
const noDelta = verify && !compare ? (decided(beforeReport) ? 'the edited model did not decide anything, so there is no after-report to compare' : 'the model before the edit did not decide anything, so there is no before-report to compare') : undefined;
|
|
80
|
+
const verdict = afterReport?.verdict ?? 'pass';
|
|
81
|
+
const reason = (afterReport === undefined ? 'edit applied, not verified: ' : '') +
|
|
82
|
+
changes.map((change) => `${change.kind} ${change.what}${change.detail === '' ? '' : ' (' + change.detail + ')'}`).join('; ') +
|
|
83
|
+
(delta === undefined ? (noDelta === undefined ? '' : ` — ${noDelta}`) : ` — verdict ${String(beforeReport?.verdict ?? '?')} -> ${String(delta.currentVerdict ?? '?')}`);
|
|
84
|
+
return {
|
|
85
|
+
ok: true,
|
|
86
|
+
ran: true,
|
|
87
|
+
schema: REPORT_SCHEMAS.edit,
|
|
88
|
+
verdict,
|
|
89
|
+
verdictReason: reason,
|
|
90
|
+
changes,
|
|
91
|
+
...(valid ? {} : { modelValid: false, validationErrors: errors }),
|
|
92
|
+
// `undefined` is not lossless JSON: with verify off there is no hash to report, so the field
|
|
93
|
+
// is omitted rather than filled with nothing.
|
|
94
|
+
...(afterReport === undefined ? {} : { hashes: { hashSpec: afterReport.hashSpec, modelHash: afterReport.modelHash } }),
|
|
95
|
+
...(beforeReport === undefined ? {} : { before: { verdict: beforeReport.verdict, modelHash: beforeReport.modelHash } }),
|
|
96
|
+
...(afterReport === undefined ? {} : { after: { verdict: afterReport.verdict, errors: afterReport.summary?.errors, warnings: afterReport.summary?.warnings } }),
|
|
97
|
+
...(delta === undefined ? {} : { reportDelta: delta }),
|
|
98
|
+
nextSteps: delta === undefined
|
|
99
|
+
? [noDelta === undefined
|
|
100
|
+
? 'The file now holds the edited model. Run `model_verify` with its `modelPath` when the edits are done.'
|
|
101
|
+
: 'The edit is on disk. Run `model_verify` on the file: it now decides whether this is a valid model.']
|
|
102
|
+
: delta.added.length === 0 && delta.removed.length === 0
|
|
103
|
+
? ['The edit moved nothing in the findings. Change the part of the model the finding names, or report the finding as unresolved.']
|
|
104
|
+
: [],
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
return optionRefusal(error, 'EDIT_OPTION_INVALID');
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
});
|