dsh-model-everything 0.1.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 +102 -81
- package/README.md +88 -76
- package/lib/automaton-tool.js +69 -0
- package/lib/automaton.js +327 -0
- package/lib/compose-tool.js +11 -2
- package/lib/concurrency-tool.js +6 -1
- package/lib/data-engine.js +1 -1
- package/lib/data-tool.js +18 -5
- package/lib/design-tool.js +21 -8
- package/lib/edit-tool.js +111 -0
- package/lib/editor.js +293 -0
- package/lib/engine.js +8 -1
- package/lib/export-tool.js +6 -1
- package/lib/index.js +4 -0
- package/lib/model-file.js +48 -0
- package/lib/presentation.js +81 -0
- package/lib/report-diff-tool.js +6 -1
- package/lib/report-view.js +92 -15
- 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 +19 -4
- 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/model-file.d.ts +7 -0
- package/lib/types/presentation.d.ts +59 -0
- package/lib/types/report-view.d.ts +8 -1
- 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 +8 -3
- package/skills/model-everything/SKILL.md +51 -79
- package/skills/model-everything/references/automata.md +102 -0
- package/skills/model-everything-interleaving/SKILL.zh-CN.md +60 -60
- package/skills/model-everything-report-reading/SKILL.md +137 -137
- package/src/automaton-tool.ts +73 -0
- package/src/automaton.ts +375 -0
- package/src/compose-tool.ts +13 -3
- package/src/concurrency-tool.ts +7 -1
- package/src/data-engine.ts +1 -1
- package/src/data-tool.ts +19 -5
- package/src/design-tool.ts +74 -60
- package/src/edit-tool.ts +119 -0
- package/src/editor.ts +297 -0
- package/src/engine.ts +8 -1
- package/src/export-tool.ts +87 -81
- package/src/index.ts +4 -0
- package/src/model-file.ts +49 -0
- package/src/presentation.ts +125 -0
- package/src/report-diff-tool.ts +7 -1
- package/src/report-view.ts +534 -451
- 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 +19 -4
- package/src/uml-tool.ts +240 -230
package/src/edit-tool.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
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
|
+
|
|
14
|
+
import { diffReports } from './baseline.js'
|
|
15
|
+
import { applyEdits, readModelFile } from './editor.js'
|
|
16
|
+
import { REPORT_SCHEMAS, runVerification } from './engine.js'
|
|
17
|
+
import { reportCard, reportResultView } from './presentation.js'
|
|
18
|
+
import type { JsonValue } from './json-value.js'
|
|
19
|
+
import { optionRefusal } from './option-shape.js'
|
|
20
|
+
import { renderReportText } from './report-view.js'
|
|
21
|
+
|
|
22
|
+
export const MODEL_EDIT_TOOL_NAME = 'model_edit'
|
|
23
|
+
|
|
24
|
+
export const modelEditTool = defineTool({
|
|
25
|
+
name: MODEL_EDIT_TOOL_NAME,
|
|
26
|
+
description:
|
|
27
|
+
'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. ' +
|
|
28
|
+
'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. ' +
|
|
29
|
+
'It refuses an edit that would leave an invalid model and writes nothing, so the file is always verifiable. ' +
|
|
30
|
+
'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).',
|
|
31
|
+
parameters: {
|
|
32
|
+
modelPath: {
|
|
33
|
+
type: 'string',
|
|
34
|
+
required: true,
|
|
35
|
+
description: 'Path to the model file to edit. It must already exist; the edit is applied to a copy and written back atomically.',
|
|
36
|
+
},
|
|
37
|
+
ops: {
|
|
38
|
+
type: 'json',
|
|
39
|
+
required: true,
|
|
40
|
+
description:
|
|
41
|
+
'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.',
|
|
42
|
+
},
|
|
43
|
+
reportView: {
|
|
44
|
+
type: 'string',
|
|
45
|
+
enum: ['digest', 'full'],
|
|
46
|
+
description: 'digest (default) prints the changes and the report delta; full prints the structured result, which report_diff needs.',
|
|
47
|
+
},
|
|
48
|
+
allowInvalid: {
|
|
49
|
+
type: 'boolean',
|
|
50
|
+
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.',
|
|
51
|
+
},
|
|
52
|
+
verify: {
|
|
53
|
+
type: 'boolean',
|
|
54
|
+
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.',
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
output: {
|
|
58
|
+
schema: {
|
|
59
|
+
type: 'json',
|
|
60
|
+
description: 'the edit result: structural changes, the new verdict, and the report delta when verify ran.',
|
|
61
|
+
},
|
|
62
|
+
render(args, value) {
|
|
63
|
+
return [{ type: 'text' as const, text: renderReportText(value, { full: args.reportView === 'full' }) }]
|
|
64
|
+
},
|
|
65
|
+
// The card a UI shows, separated from the text the model reads.
|
|
66
|
+
presentationMeta(_args, value) {
|
|
67
|
+
return reportCard(value, MODEL_EDIT_TOOL_NAME) as unknown as JsonValue
|
|
68
|
+
},
|
|
69
|
+
}, presentResult: reportResultView(MODEL_EDIT_TOOL_NAME),
|
|
70
|
+
|
|
71
|
+
timeoutMs: 20_000,
|
|
72
|
+
isConcurrencySafe: () => false,
|
|
73
|
+
async execute(args) {
|
|
74
|
+
try {
|
|
75
|
+
const before = readModelFile(args.modelPath).model
|
|
76
|
+
const verify = args.verify !== false
|
|
77
|
+
const beforeReport = verify ? runVerification(before) : undefined
|
|
78
|
+
const { model, changes, valid, errors } = applyEdits(args.modelPath, args.ops, { allowInvalid: args.allowInvalid === true })
|
|
79
|
+
const afterReport = verify ? runVerification(model) : undefined
|
|
80
|
+
// A model that did not decide anything (checksRun 0 - the engine refused its shape) has no
|
|
81
|
+
// findings to compare. Diffing it would raise, and it would raise after the file was written,
|
|
82
|
+
// so the reply would claim nothing ran while the edit had landed. Say there is no delta instead.
|
|
83
|
+
const decided = (report: typeof beforeReport): boolean => (report?.summary?.checksRun ?? 0) > 0
|
|
84
|
+
const compare = beforeReport !== undefined && afterReport !== undefined && decided(beforeReport) && decided(afterReport)
|
|
85
|
+
const delta = compare ? diffReports(beforeReport, afterReport) : undefined
|
|
86
|
+
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
|
|
87
|
+
const verdict = afterReport?.verdict ?? 'pass'
|
|
88
|
+
const reason =
|
|
89
|
+
(afterReport === undefined ? 'edit applied, not verified: ' : '') +
|
|
90
|
+
changes.map((change) => `${change.kind} ${change.what}${change.detail === '' ? '' : ' (' + change.detail + ')'}`).join('; ') +
|
|
91
|
+
(delta === undefined ? (noDelta === undefined ? '' : ` — ${noDelta}`) : ` — verdict ${String(beforeReport?.verdict ?? '?')} -> ${String(delta.currentVerdict ?? '?')}`)
|
|
92
|
+
return {
|
|
93
|
+
ok: true,
|
|
94
|
+
ran: true,
|
|
95
|
+
schema: REPORT_SCHEMAS.edit,
|
|
96
|
+
verdict,
|
|
97
|
+
verdictReason: reason,
|
|
98
|
+
changes,
|
|
99
|
+
...(valid ? {} : { modelValid: false, validationErrors: errors }),
|
|
100
|
+
// `undefined` is not lossless JSON: with verify off there is no hash to report, so the field
|
|
101
|
+
// is omitted rather than filled with nothing.
|
|
102
|
+
...(afterReport === undefined ? {} : { hashes: { hashSpec: afterReport.hashSpec, modelHash: afterReport.modelHash } }),
|
|
103
|
+
...(beforeReport === undefined ? {} : { before: { verdict: beforeReport.verdict, modelHash: beforeReport.modelHash } }),
|
|
104
|
+
...(afterReport === undefined ? {} : { after: { verdict: afterReport.verdict, errors: afterReport.summary?.errors, warnings: afterReport.summary?.warnings } }),
|
|
105
|
+
...(delta === undefined ? {} : { reportDelta: delta }),
|
|
106
|
+
nextSteps:
|
|
107
|
+
delta === undefined
|
|
108
|
+
? [noDelta === undefined
|
|
109
|
+
? 'The file now holds the edited model. Run `model_verify` with its `modelPath` when the edits are done.'
|
|
110
|
+
: 'The edit is on disk. Run `model_verify` on the file: it now decides whether this is a valid model.']
|
|
111
|
+
: delta.added.length === 0 && delta.removed.length === 0
|
|
112
|
+
? ['The edit moved nothing in the findings. Change the part of the model the finding names, or report the finding as unresolved.']
|
|
113
|
+
: [],
|
|
114
|
+
} as unknown as JsonValue
|
|
115
|
+
} catch (error) {
|
|
116
|
+
return optionRefusal(error, 'EDIT_OPTION_INVALID') as unknown as JsonValue
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
})
|
package/src/editor.ts
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Applying edits to a model file: the delta side of the verification loop.
|
|
3
|
+
*
|
|
4
|
+
* A model is the largest thing any of these tools takes. Handing it back whole on every
|
|
5
|
+
* iteration is what makes a four-round fix cost four models. Here an edit names one change,
|
|
6
|
+
* the file carries the rest, and the caller gets the structural diff plus - when asked - the
|
|
7
|
+
* difference in what the engine decided, in the same call.
|
|
8
|
+
*
|
|
9
|
+
* Every operation works on a copy and the result is validated before anything is written. A
|
|
10
|
+
* refused edit leaves the file exactly as it was: an invalid model on disk would make the next
|
|
11
|
+
* verification spend a round reporting that, which is the cost this tool exists to remove.
|
|
12
|
+
*/
|
|
13
|
+
import { readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
14
|
+
import { resolve } from 'node:path'
|
|
15
|
+
|
|
16
|
+
import { validateModel } from './engine.js'
|
|
17
|
+
import { OptionShapeError } from './option-shape.js'
|
|
18
|
+
|
|
19
|
+
type Json = Record<string, unknown>
|
|
20
|
+
|
|
21
|
+
export interface EditOperation {
|
|
22
|
+
op: string
|
|
23
|
+
[key: string]: unknown
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ModelChange {
|
|
27
|
+
kind: 'added' | 'removed' | 'updated'
|
|
28
|
+
what: string
|
|
29
|
+
detail: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const CLONE = (value: unknown): unknown => JSON.parse(JSON.stringify(value)) as unknown
|
|
33
|
+
|
|
34
|
+
/** Read the model at `path`; a missing file is a refusal, not an empty model. */
|
|
35
|
+
export function readModelFile(path: unknown): { model: Json; full: string } {
|
|
36
|
+
if (typeof path !== 'string' || path.trim() === '') {
|
|
37
|
+
throw new OptionShapeError('EDIT_OPTION_INVALID', 'modelPath must be a non-empty path to a JSON file.')
|
|
38
|
+
}
|
|
39
|
+
const full = resolve(path)
|
|
40
|
+
let text: string
|
|
41
|
+
try {
|
|
42
|
+
text = readFileSync(full, 'utf8')
|
|
43
|
+
} catch (error) {
|
|
44
|
+
const reason = error instanceof Error ? error.message : String(error)
|
|
45
|
+
throw new OptionShapeError('EDIT_OPTION_INVALID', `modelPath could not be read: ${reason}. Edits apply to a file that already exists; model_verify can create the model inline first, or write it yourself.`)
|
|
46
|
+
}
|
|
47
|
+
let parsed: unknown
|
|
48
|
+
try {
|
|
49
|
+
parsed = JSON.parse(text.replace(/^\uFEFF/, ''))
|
|
50
|
+
} catch (error) {
|
|
51
|
+
const reason = error instanceof Error ? error.message : String(error)
|
|
52
|
+
throw new OptionShapeError('EDIT_OPTION_INVALID', `modelPath is not valid JSON: ${reason}.`)
|
|
53
|
+
}
|
|
54
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
55
|
+
throw new OptionShapeError('EDIT_OPTION_INVALID', 'modelPath must hold a model object, not a list or a scalar.')
|
|
56
|
+
}
|
|
57
|
+
return { model: parsed as Json, full }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function statesOf(model: Json): Json[] {
|
|
61
|
+
return Array.isArray(model.states) ? (model.states as Json[]) : []
|
|
62
|
+
}
|
|
63
|
+
function transitionsOf(model: Json): Json[] {
|
|
64
|
+
return Array.isArray(model.transitions) ? (model.transitions as Json[]) : []
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function requireString(value: unknown, name: string): string {
|
|
68
|
+
if (typeof value !== 'string' || value === '') throw new OptionShapeError('EDIT_OPTION_INVALID', `${name} must be a non-empty string.`)
|
|
69
|
+
return value
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** JSON pointer, so anything without a named operation still has a way to be edited. */
|
|
73
|
+
function pointerParts(path: unknown): string[] {
|
|
74
|
+
const text = requireString(path, 'path')
|
|
75
|
+
if (!text.startsWith('/')) throw new OptionShapeError('EDIT_OPTION_INVALID', `path must be a JSON pointer starting with "/", got ${JSON.stringify(text)}.`)
|
|
76
|
+
return text.split('/').slice(1).map((part) => part.replace(/~1/g, '/').replace(/~0/g, '~'))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function atPointer(model: Json, parts: string[], create: boolean): { parent: Json; key: string } {
|
|
80
|
+
let node: Json = model
|
|
81
|
+
for (const part of parts.slice(0, -1)) {
|
|
82
|
+
const next = (node as Record<string, unknown>)[part]
|
|
83
|
+
if (next === undefined) {
|
|
84
|
+
if (!create) throw new OptionShapeError('EDIT_OPTION_INVALID', `path does not exist at "${part}".`)
|
|
85
|
+
const created: Json = {}
|
|
86
|
+
;(node as Record<string, unknown>)[part] = created
|
|
87
|
+
node = created
|
|
88
|
+
} else if (next !== null && typeof next === 'object') {
|
|
89
|
+
node = next as Json
|
|
90
|
+
} else {
|
|
91
|
+
throw new OptionShapeError('EDIT_OPTION_INVALID', `path runs through "${part}", which is not an object.`)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return { parent: node, key: parts[parts.length - 1] ?? '' }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Accept the compact spelling of an operation as well as the long one.
|
|
99
|
+
*
|
|
100
|
+
* An operation is a small thing - add a state, wire a transition - and the long form spends more
|
|
101
|
+
* tokens on key names than on the change. Five operations cost 76 tokens spelled out and 39 in the
|
|
102
|
+
* compact form, and a batch of twenty is where that difference starts to matter.
|
|
103
|
+
*
|
|
104
|
+
* {addState: "c"} {addState: {id: "c", terminal: true}}
|
|
105
|
+
* {removeState: "c"}
|
|
106
|
+
* {addTransition: ["a", "reset", "c"]} {addTransition: {from, event, to, guard?, ...}}
|
|
107
|
+
* {removeTransition: ["a", "go"]} {updateTransition: ["a", "go", {guard: ...}]}
|
|
108
|
+
* {set: ["/init", "a"]} {remove: "/init"}
|
|
109
|
+
*
|
|
110
|
+
* The long form is unchanged and is what the errors talk about.
|
|
111
|
+
*/
|
|
112
|
+
export function normalizeOperation(raw: unknown): EditOperation {
|
|
113
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
114
|
+
throw new OptionShapeError('EDIT_OPTION_INVALID', 'every op must be an object.')
|
|
115
|
+
}
|
|
116
|
+
const value = raw as Record<string, unknown>
|
|
117
|
+
if (typeof value.op === 'string') return value as EditOperation
|
|
118
|
+
const keys = Object.keys(value)
|
|
119
|
+
if (keys.length !== 1) {
|
|
120
|
+
throw new OptionShapeError('EDIT_OPTION_INVALID', `an operation names exactly one op, got ${JSON.stringify(keys)}.`)
|
|
121
|
+
}
|
|
122
|
+
const name = keys[0]
|
|
123
|
+
const argument = value[name]
|
|
124
|
+
if (name === 'addState' || name === 'removeState') {
|
|
125
|
+
if (typeof argument === 'string') {
|
|
126
|
+
return name === 'addState' ? { op: name, state: { id: argument } } : { op: name, id: argument }
|
|
127
|
+
}
|
|
128
|
+
if (name === 'addState' && argument !== null && typeof argument === 'object') return { op: name, state: argument }
|
|
129
|
+
throw new OptionShapeError('EDIT_OPTION_INVALID', `${name} takes a state id, or a state object for addState.`)
|
|
130
|
+
}
|
|
131
|
+
if (name === 'addTransition' || name === 'removeTransition' || name === 'updateTransition') {
|
|
132
|
+
if (Array.isArray(argument)) {
|
|
133
|
+
if (name === 'addTransition' && argument.length === 3) {
|
|
134
|
+
const [from, event, to] = argument
|
|
135
|
+
return { op: name, transition: { from, event, to } }
|
|
136
|
+
}
|
|
137
|
+
if (name === 'removeTransition' && argument.length === 2) {
|
|
138
|
+
const [from, event] = argument
|
|
139
|
+
return { op: name, from, event }
|
|
140
|
+
}
|
|
141
|
+
if (name === 'updateTransition' && argument.length === 3) {
|
|
142
|
+
const [from, event, patch] = argument
|
|
143
|
+
return { op: name, from, event, patch }
|
|
144
|
+
}
|
|
145
|
+
throw new OptionShapeError('EDIT_OPTION_INVALID', `${name} takes ${name === 'updateTransition' ? '["from", "event", {patch}]' : name === 'addTransition' ? '["from", "event", "to"]' : '["from", "event"]'}.`)
|
|
146
|
+
}
|
|
147
|
+
if (name === 'addTransition' && argument !== null && typeof argument === 'object') return { op: name, transition: argument }
|
|
148
|
+
throw new OptionShapeError('EDIT_OPTION_INVALID', `${name} takes a list, or a transition object for addTransition.`)
|
|
149
|
+
}
|
|
150
|
+
if (name === 'set') {
|
|
151
|
+
if (Array.isArray(argument) && argument.length === 2) return { op: 'set', path: argument[0], value: argument[1] }
|
|
152
|
+
if (argument !== null && typeof argument === 'object' && 'path' in (argument as Record<string, unknown>)) {
|
|
153
|
+
const { path, value } = argument as Record<string, unknown>
|
|
154
|
+
return { op: 'set', path, value }
|
|
155
|
+
}
|
|
156
|
+
throw new OptionShapeError('EDIT_OPTION_INVALID', 'set takes ["/path", value] or {path, value}.')
|
|
157
|
+
}
|
|
158
|
+
if (name === 'remove') {
|
|
159
|
+
if (typeof argument === 'string') return { op: 'remove', path: argument }
|
|
160
|
+
throw new OptionShapeError('EDIT_OPTION_INVALID', 'remove takes a JSON pointer string.')
|
|
161
|
+
}
|
|
162
|
+
throw new OptionShapeError('EDIT_OPTION_INVALID', `unknown op ${JSON.stringify(name)}. Known ops: addState, removeState, addTransition, removeTransition, updateTransition, set, remove.`)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Apply one operation to a model copy, returning the changes it made. */
|
|
166
|
+
export function applyOperation(model: Json, operation: EditOperation): ModelChange[] {
|
|
167
|
+
const op = requireString(operation.op, 'op')
|
|
168
|
+
const changes: ModelChange[] = []
|
|
169
|
+
if (op === 'addState') {
|
|
170
|
+
const state = operation.state as Json | undefined
|
|
171
|
+
const id = requireString(state?.id, 'state.id')
|
|
172
|
+
if (statesOf(model).some((entry) => entry.id === id)) {
|
|
173
|
+
throw new OptionShapeError('EDIT_OPTION_INVALID', `state "${id}" already exists; use update on it instead of adding it twice.`)
|
|
174
|
+
}
|
|
175
|
+
model.states = [...statesOf(model), state]
|
|
176
|
+
changes.push({ kind: 'added', what: 'state ' + id, detail: state?.terminal === true ? 'terminal' : '' })
|
|
177
|
+
return changes
|
|
178
|
+
}
|
|
179
|
+
if (op === 'removeState') {
|
|
180
|
+
const id = requireString(operation.id, 'id')
|
|
181
|
+
const before = statesOf(model)
|
|
182
|
+
if (!before.some((entry) => entry.id === id)) throw new OptionShapeError('EDIT_OPTION_INVALID', `no state "${id}" to remove.`)
|
|
183
|
+
model.states = before.filter((entry) => entry.id !== id)
|
|
184
|
+
const dropped = transitionsOf(model).filter((entry) => entry.from === id || entry.to === id)
|
|
185
|
+
model.transitions = transitionsOf(model).filter((entry) => entry.from !== id && entry.to !== id)
|
|
186
|
+
changes.push({ kind: 'removed', what: 'state ' + id, detail: dropped.length > 0 ? `and ${String(dropped.length)} transition(s) that used it` : '' })
|
|
187
|
+
return changes
|
|
188
|
+
}
|
|
189
|
+
if (op === 'addTransition') {
|
|
190
|
+
const transition = operation.transition as Json | undefined
|
|
191
|
+
const from = requireString(transition?.from, 'transition.from')
|
|
192
|
+
const event = requireString(transition?.event, 'transition.event')
|
|
193
|
+
const to = requireString(transition?.to, 'transition.to')
|
|
194
|
+
if (transitionsOf(model).some((entry) => entry.from === from && entry.event === event)) {
|
|
195
|
+
throw new OptionShapeError('EDIT_OPTION_INVALID', `a transition for ${from} + ${event} already exists; update it instead of adding another.`)
|
|
196
|
+
}
|
|
197
|
+
model.transitions = [...transitionsOf(model), transition]
|
|
198
|
+
changes.push({ kind: 'added', what: `transition ${from} + ${event}`, detail: 'to ' + to })
|
|
199
|
+
return changes
|
|
200
|
+
}
|
|
201
|
+
if (op === 'removeTransition') {
|
|
202
|
+
const from = requireString(operation.from, 'from')
|
|
203
|
+
const event = requireString(operation.event, 'event')
|
|
204
|
+
const before = transitionsOf(model)
|
|
205
|
+
const kept = before.filter((entry) => !(entry.from === from && entry.event === event))
|
|
206
|
+
if (kept.length === before.length) throw new OptionShapeError('EDIT_OPTION_INVALID', `no transition for ${from} + ${event} to remove.`)
|
|
207
|
+
model.transitions = kept
|
|
208
|
+
changes.push({ kind: 'removed', what: `transition ${from} + ${event}`, detail: '' })
|
|
209
|
+
return changes
|
|
210
|
+
}
|
|
211
|
+
if (op === 'updateTransition') {
|
|
212
|
+
const from = requireString(operation.from, 'from')
|
|
213
|
+
const event = requireString(operation.event, 'event')
|
|
214
|
+
const patch = operation.patch as Json | undefined
|
|
215
|
+
if (patch === undefined || typeof patch !== 'object' || Array.isArray(patch)) {
|
|
216
|
+
throw new OptionShapeError('EDIT_OPTION_INVALID', 'patch must be an object of the fields to replace.')
|
|
217
|
+
}
|
|
218
|
+
const list = transitionsOf(model)
|
|
219
|
+
const index = list.findIndex((entry) => entry.from === from && entry.event === event)
|
|
220
|
+
if (index < 0) throw new OptionShapeError('EDIT_OPTION_INVALID', `no transition for ${from} + ${event} to update.`)
|
|
221
|
+
list[index] = { ...list[index], ...patch }
|
|
222
|
+
model.transitions = list
|
|
223
|
+
changes.push({ kind: 'updated', what: `transition ${from} + ${event}`, detail: Object.keys(patch).join(', ') })
|
|
224
|
+
return changes
|
|
225
|
+
}
|
|
226
|
+
if (op === 'set') {
|
|
227
|
+
const parts = pointerParts(operation.path)
|
|
228
|
+
if (parts.length === 0) throw new OptionShapeError('EDIT_OPTION_INVALID', 'path must name something inside the model.')
|
|
229
|
+
const { parent, key } = atPointer(model, parts, true)
|
|
230
|
+
parent[key] = CLONE(operation.value)
|
|
231
|
+
changes.push({ kind: 'updated', what: 'set ' + String(operation.path), detail: '' })
|
|
232
|
+
return changes
|
|
233
|
+
}
|
|
234
|
+
if (op === 'remove') {
|
|
235
|
+
const parts = pointerParts(operation.path)
|
|
236
|
+
if (parts.length === 0) throw new OptionShapeError('EDIT_OPTION_INVALID', 'path must name something inside the model.')
|
|
237
|
+
const { parent, key } = atPointer(model, parts, false)
|
|
238
|
+
if (!(key in parent)) throw new OptionShapeError('EDIT_OPTION_INVALID', `nothing at ${String(operation.path)} to remove.`)
|
|
239
|
+
delete parent[key]
|
|
240
|
+
changes.push({ kind: 'removed', what: 'remove ' + String(operation.path), detail: '' })
|
|
241
|
+
return changes
|
|
242
|
+
}
|
|
243
|
+
throw new OptionShapeError('EDIT_OPTION_INVALID', `unknown op ${JSON.stringify(op)}. Known ops: addState, removeState, addTransition, removeTransition, updateTransition, set, remove.`)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Validate the edited model and write it atomically. A refused edit leaves the file untouched. */
|
|
247
|
+
export interface EditOutcome {
|
|
248
|
+
model: Json
|
|
249
|
+
changes: ModelChange[]
|
|
250
|
+
full: string
|
|
251
|
+
/** Whether the model as written validates. False only when the caller allowed it. */
|
|
252
|
+
valid: boolean
|
|
253
|
+
errors: string[]
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function applyEdits(path: unknown, operations: unknown, options: { allowInvalid?: boolean } = {}): EditOutcome {
|
|
257
|
+
if (!Array.isArray(operations) || operations.length === 0) {
|
|
258
|
+
throw new OptionShapeError('EDIT_OPTION_INVALID', 'ops must be a non-empty array of operations.')
|
|
259
|
+
}
|
|
260
|
+
const { model: original, full } = readModelFile(path)
|
|
261
|
+
const next = CLONE(original) as Json
|
|
262
|
+
const changes: ModelChange[] = []
|
|
263
|
+
const normalized = (operations as unknown[]).map((raw, index) => {
|
|
264
|
+
try {
|
|
265
|
+
return normalizeOperation(raw)
|
|
266
|
+
} catch (error) {
|
|
267
|
+
const reason = error instanceof Error ? error.message : String(error)
|
|
268
|
+
throw new OptionShapeError('EDIT_OPTION_INVALID', `operation ${String(index + 1)} was refused: ${reason}`)
|
|
269
|
+
}
|
|
270
|
+
})
|
|
271
|
+
for (const [index, raw] of normalized.entries()) {
|
|
272
|
+
try {
|
|
273
|
+
changes.push(...applyOperation(next, raw))
|
|
274
|
+
} catch (error) {
|
|
275
|
+
const reason = error instanceof Error ? error.message : String(error)
|
|
276
|
+
throw new OptionShapeError('EDIT_OPTION_INVALID', `operation ${String(index + 1)} (${String((raw as EditOperation).op)}) was refused: ${reason}`)
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
const validation = validateModel(next)
|
|
280
|
+
if (!validation.ok && options.allowInvalid !== true) {
|
|
281
|
+
// Name the operation the batch first stops validating at, not just the symptom: a caller that
|
|
282
|
+
// reads "a transition to an unknown state" still has to find which entry introduced it.
|
|
283
|
+
let firstBad = -1
|
|
284
|
+
const partial = CLONE(original) as Json
|
|
285
|
+
for (const [index, raw] of (operations as EditOperation[]).entries()) {
|
|
286
|
+
try { applyOperation(partial, raw) } catch { firstBad = index; break }
|
|
287
|
+
if (!validateModel(partial).ok) { firstBad = index; break }
|
|
288
|
+
}
|
|
289
|
+
const where = firstBad < 0 ? '' : ` Operation ${String(firstBad + 1)} (${String((operations as EditOperation[])[firstBad]?.op)}) is where it first stops validating.`
|
|
290
|
+
throw new OptionShapeError('EDIT_RESULT_INVALID', 'the batch would leave an invalid model, so nothing was written: ' + validation.errors.join('; ') + '.' + where + ' Fix the list, or pass allowInvalid to write it anyway and continue in a second call.')
|
|
291
|
+
}
|
|
292
|
+
const text = JSON.stringify(validation.ok ? validation.model : next, null, 2) + '\n'
|
|
293
|
+
const temporary = full + '.tmp'
|
|
294
|
+
writeFileSync(temporary, text, 'utf8')
|
|
295
|
+
renameSync(temporary, full)
|
|
296
|
+
return { model: next, changes, full, valid: validation.ok, errors: validation.ok ? [] : validation.errors }
|
|
297
|
+
}
|
package/src/engine.ts
CHANGED
|
@@ -291,7 +291,7 @@ function verificationNextSteps(checks: CheckResult[], coverage: NarrativeCoverag
|
|
|
291
291
|
// named check looked at the model. A list of failures does not say that, and neither does `pass`.
|
|
292
292
|
const skipped = checks.filter((check) => check.status === 'skip').map((check) => check.name + ' (' + check.id + ')')
|
|
293
293
|
if (skipped.length > 0) {
|
|
294
|
-
steps.push('
|
|
294
|
+
steps.push('Not checked: ' + skipped.join(', ') + ' examined nothing — silence there is not a pass. Declare what each needs, or treat the model as unverified on those points.')
|
|
295
295
|
}
|
|
296
296
|
// Named checks above, bare codes here. That is deliberate: each warning is a different kind of thing,
|
|
297
297
|
// so there is no single name to give the set, and the sentence points at where each one IS explained
|
|
@@ -300,6 +300,12 @@ function verificationNextSteps(checks: CheckResult[], coverage: NarrativeCoverag
|
|
|
300
300
|
if (coverage !== undefined && !narrativeComplete(coverage)) {
|
|
301
301
|
steps.push('Complete the narrative (states ' + coverage.states + ', events ' + coverage.events + ', scenarios ' + coverage.scenarios + ') — a partial narrative is valid, but a reader still has to re-derive the missing symbols.')
|
|
302
302
|
}
|
|
303
|
+
// A truncated search is the one outcome where "look harder" is the wrong advice: the wall is
|
|
304
|
+
// combinatorial, and raising maxStates buys a constant factor. Name the ladder instead, so the reader
|
|
305
|
+
// knows this is not a bug to fix but a model to reduce, or a different tool to use.
|
|
306
|
+
if (hasUndecidedFinding(checks)) {
|
|
307
|
+
steps.push('The search stopped before deciding, so the undecided checks are neither proven nor refuted. Do not just raise maxStates: first reduce the model (drop variables the property does not read, collapse symmetric states, order independent events), and if it still explodes, move to a tool that does not enumerate every state - SPIN for partial-order reduction, NuSMV/nuXMV for symbolic search, CBMC for bounded SAT. This plugin establishes no abstraction and reads no program source, so a property that needs one is outside it.')
|
|
308
|
+
}
|
|
303
309
|
// The range is dropped: the code that would be reported back is already named in `findings`, and a
|
|
304
310
|
// reader who has not seen a report yet learns nothing from four digits.
|
|
305
311
|
steps.push('Re-run with beforeModel/stateMapping after the change to prove the behaviour did not regress: behaviour preserved, invariants continuous, the state mapping consistent. Then compare the two reports with `report_diff` rather than reading them side by side.')
|
|
@@ -479,6 +485,7 @@ export const REPORT_SCHEMAS = {
|
|
|
479
485
|
granularity: 'model-everything/granularity/v1',
|
|
480
486
|
design: 'model-everything/design/v1',
|
|
481
487
|
baseline: 'model-everything/baseline/v1',
|
|
488
|
+
edit: 'model-everything/edit/v1',
|
|
482
489
|
} as const
|
|
483
490
|
|
|
484
491
|
export type ReportSchema = typeof REPORT_SCHEMAS[keyof typeof REPORT_SCHEMAS]
|
package/src/export-tool.ts
CHANGED
|
@@ -1,81 +1,87 @@
|
|
|
1
|
-
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
|
-
import type { JsonValue } from './json-value.js'
|
|
3
|
-
import { REPORT_SCHEMAS, DEFAULT_HASH_SPEC, modelHash, refusalVerdict, validateModel, verdictOf } from './engine.js'
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
warnings,
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
1
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
|
+
import type { JsonValue } from './json-value.js'
|
|
3
|
+
import { REPORT_SCHEMAS, DEFAULT_HASH_SPEC, modelHash, refusalVerdict, validateModel, verdictOf } from './engine.js'
|
|
4
|
+
import { reportCard, reportResultView } from './presentation.js'
|
|
5
|
+
import { ExportError, exportModel, type ExportFormat } from './exporters.js'
|
|
6
|
+
import { renderReportText } from './report-view.js'
|
|
7
|
+
|
|
8
|
+
/** The model hash of an export's input, when the input validates (the success path always does). */
|
|
9
|
+
function exportHashes(model: unknown): { hashSpec: typeof DEFAULT_HASH_SPEC; modelHash?: string } {
|
|
10
|
+
const validation = validateModel(model)
|
|
11
|
+
return validation.ok
|
|
12
|
+
? { hashSpec: DEFAULT_HASH_SPEC, modelHash: modelHash(validation.model) }
|
|
13
|
+
: { hashSpec: DEFAULT_HASH_SPEC }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const EXPORT_TOOL_NAME = 'model_export'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* DSH tool wrapping the external-tool exporters: turn a LogicModelV1 machine into
|
|
20
|
+
* native input for the tool this plugin routes to (UPPAAL / TLA+ / PRISM / SPIN).
|
|
21
|
+
* v1 translates the core machine; unrepresentable invariants become warnings.
|
|
22
|
+
*/
|
|
23
|
+
export const modelExportTool = defineTool({
|
|
24
|
+
name: EXPORT_TOOL_NAME,
|
|
25
|
+
description:
|
|
26
|
+
'Translate a LogicModelV1 into the input language of an external checker - UPPAAL, TLA+, PRISM or SPIN - for a property this plugin does not decide itself (symbolic model checking, unbounded induction, expected reward). format is one of uppaal (.xta + queries), tla (TLC spec + safety property), prism (.pm + .pctl), spin (Promela + ltl). This tool does not verify anything: for the checks it can decide use `model_verify`. Booleans are exported as 0/1 integers; invariants the target cannot express are returned as warnings, never silently dropped. Returns { ok, format, primary, extras?, warnings? } or { ok: false, error } when the model is invalid or the PRISM enumeration is too large.',
|
|
27
|
+
parameters: {
|
|
28
|
+
model: {
|
|
29
|
+
type: 'json',
|
|
30
|
+
required: true,
|
|
31
|
+
description: 'LogicModelV1 state-machine model to export.',
|
|
32
|
+
},
|
|
33
|
+
format: {
|
|
34
|
+
type: 'string',
|
|
35
|
+
required: true,
|
|
36
|
+
enum: ['uppaal', 'tla', 'prism', 'spin'],
|
|
37
|
+
description: 'Target tool format.',
|
|
38
|
+
},
|
|
39
|
+
reportView: {
|
|
40
|
+
type: 'string',
|
|
41
|
+
enum: ['digest', 'full'],
|
|
42
|
+
description: 'digest (default) prints the generated file verbatim; full prints the structured result as JSON.',
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
output: {
|
|
46
|
+
schema: {
|
|
47
|
+
type: 'json',
|
|
48
|
+
description: 'Export result with generated file content.',
|
|
49
|
+
},
|
|
50
|
+
render(args, value) {
|
|
51
|
+
return [{ type: 'text' as const, text: renderReportText(value, { full: args.reportView === 'full' }) }]
|
|
52
|
+
},
|
|
53
|
+
// The card a UI shows, separated from the text the model reads.
|
|
54
|
+
presentationMeta(_args, value) {
|
|
55
|
+
return reportCard(value, EXPORT_TOOL_NAME) as unknown as JsonValue
|
|
56
|
+
},
|
|
57
|
+
}, presentResult: reportResultView(EXPORT_TOOL_NAME),
|
|
58
|
+
|
|
59
|
+
timeoutMs: 10_000,
|
|
60
|
+
isConcurrencySafe: () => true,
|
|
61
|
+
async execute(args) {
|
|
62
|
+
try {
|
|
63
|
+
const result = exportModel(args.model, args.format as ExportFormat)
|
|
64
|
+
const warnings = result.warnings ?? []
|
|
65
|
+
const nextSteps: string[] = []
|
|
66
|
+
if (warnings.length > 0) nextSteps.push('The target notation cannot express some invariants; the warnings name each one — verify them in the target tool rather than assuming they carried over.')
|
|
67
|
+
nextSteps.push('Run the exported file through its own tool (verify/compile) before trusting the translation; this plugin guarantees the encoding, not the target checker\'s verdict.')
|
|
68
|
+
return {
|
|
69
|
+
ok: true,
|
|
70
|
+
ran: true,
|
|
71
|
+
...verdictOf(0, warnings.length),
|
|
72
|
+
schema: REPORT_SCHEMAS.export,
|
|
73
|
+
format: result.format,
|
|
74
|
+
primary: result.primary,
|
|
75
|
+
...(result.extras === undefined ? {} : { extras: result.extras }),
|
|
76
|
+
hashes: exportHashes(args.model),
|
|
77
|
+
warnings,
|
|
78
|
+
nextSteps,
|
|
79
|
+
} as unknown as JsonValue
|
|
80
|
+
} catch (error) {
|
|
81
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
82
|
+
// The exporter's own refusals carry their code: a limit of the encoding is not a defect in the model.
|
|
83
|
+
const errorCode = error instanceof ExportError ? error.code : 'MODEL_INVALID'
|
|
84
|
+
return { ok: false, ran: false, ...refusalVerdict(message), schema: REPORT_SCHEMAS.export, errorCode, error: message } as unknown as JsonValue
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
})
|
package/src/index.ts
CHANGED
|
@@ -39,6 +39,8 @@ import { umlRenderTool, umlParseTool, umlReviewTool, umlExplainLabelsTool } from
|
|
|
39
39
|
import { umlStructureTool } from './structure-tool.js'
|
|
40
40
|
import { designTool } from './design-tool.js'
|
|
41
41
|
import { reportDiffTool } from './report-diff-tool.js'
|
|
42
|
+
import { modelEditTool } from './edit-tool.js'
|
|
43
|
+
import { modelAutomatonTool } from './automaton-tool.js'
|
|
42
44
|
import { ENGINE_SCHEMA_VERSION } from './engine.js'
|
|
43
45
|
|
|
44
46
|
// DSH 0.1.7-alpha.1 (session format v4) retires the shared
|
|
@@ -245,6 +247,8 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
245
247
|
ctx.effect(() => tools.register(umlStructureTool), 'dsh-model-everything: structure tool')
|
|
246
248
|
ctx.effect(() => tools.register(designTool), 'dsh-model-everything: design review tool')
|
|
247
249
|
ctx.effect(() => tools.register(reportDiffTool), 'dsh-model-everything: report diff tool')
|
|
250
|
+
ctx.effect(() => tools.register(modelEditTool), 'dsh-model-everything: model edit tool')
|
|
251
|
+
ctx.effect(() => tools.register(modelAutomatonTool), 'dsh-model-everything: automaton tool')
|
|
248
252
|
toolRegistered = true
|
|
249
253
|
dataToolRegistered = true
|
|
250
254
|
concurrencyToolRegistered = true
|