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.
Files changed (62) hide show
  1. package/README.en-US.md +102 -81
  2. package/README.md +88 -76
  3. package/lib/automaton-tool.js +69 -0
  4. package/lib/automaton.js +327 -0
  5. package/lib/compose-tool.js +11 -2
  6. package/lib/concurrency-tool.js +6 -1
  7. package/lib/data-engine.js +1 -1
  8. package/lib/data-tool.js +18 -5
  9. package/lib/design-tool.js +21 -8
  10. package/lib/edit-tool.js +111 -0
  11. package/lib/editor.js +293 -0
  12. package/lib/engine.js +8 -1
  13. package/lib/export-tool.js +6 -1
  14. package/lib/index.js +4 -0
  15. package/lib/model-file.js +48 -0
  16. package/lib/presentation.js +81 -0
  17. package/lib/report-diff-tool.js +6 -1
  18. package/lib/report-view.js +92 -15
  19. package/lib/routing.js +85 -0
  20. package/lib/scan.js +547 -0
  21. package/lib/structure-tool.js +34 -1
  22. package/lib/structure.js +28 -3
  23. package/lib/tool.js +19 -4
  24. package/lib/types/automaton-tool.d.ts +2 -0
  25. package/lib/types/automaton.d.ts +57 -0
  26. package/lib/types/edit-tool.d.ts +2 -0
  27. package/lib/types/editor.d.ts +46 -0
  28. package/lib/types/engine.d.ts +1 -0
  29. package/lib/types/model-file.d.ts +7 -0
  30. package/lib/types/presentation.d.ts +59 -0
  31. package/lib/types/report-view.d.ts +8 -1
  32. package/lib/types/routing.d.ts +31 -0
  33. package/lib/types/scan.d.ts +37 -0
  34. package/lib/types/structure.d.ts +16 -0
  35. package/lib/uml-tool.js +16 -6
  36. package/package.json +8 -3
  37. package/skills/model-everything/SKILL.md +51 -79
  38. package/skills/model-everything/references/automata.md +102 -0
  39. package/skills/model-everything-interleaving/SKILL.zh-CN.md +60 -60
  40. package/skills/model-everything-report-reading/SKILL.md +137 -137
  41. package/src/automaton-tool.ts +73 -0
  42. package/src/automaton.ts +375 -0
  43. package/src/compose-tool.ts +13 -3
  44. package/src/concurrency-tool.ts +7 -1
  45. package/src/data-engine.ts +1 -1
  46. package/src/data-tool.ts +19 -5
  47. package/src/design-tool.ts +74 -60
  48. package/src/edit-tool.ts +119 -0
  49. package/src/editor.ts +297 -0
  50. package/src/engine.ts +8 -1
  51. package/src/export-tool.ts +87 -81
  52. package/src/index.ts +4 -0
  53. package/src/model-file.ts +49 -0
  54. package/src/presentation.ts +125 -0
  55. package/src/report-diff-tool.ts +7 -1
  56. package/src/report-view.ts +534 -451
  57. package/src/routing.ts +96 -0
  58. package/src/scan.ts +564 -0
  59. package/src/structure-tool.ts +33 -1
  60. package/src/structure.ts +37 -3
  61. package/src/tool.ts +19 -4
  62. package/src/uml-tool.ts +240 -230
@@ -0,0 +1,375 @@
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
+
17
+ import { canonicalJson } from './engine.js'
18
+ import { OptionShapeError } from './option-shape.js'
19
+
20
+ export interface AutomatonTransition {
21
+ from: string
22
+ symbol: string
23
+ to: string
24
+ }
25
+
26
+ export interface Automaton {
27
+ states: string[]
28
+ alphabet: string[]
29
+ transitions: AutomatonTransition[]
30
+ init: string
31
+ accepting: string[]
32
+ }
33
+
34
+ export interface AutomatonFinding {
35
+ code: string
36
+ severity: 'error' | 'warning' | 'info'
37
+ message: string
38
+ detail?: string
39
+ }
40
+
41
+ /** One deterministic automaton, with an explicit sink so every word has exactly one run. */
42
+ interface Dfa {
43
+ start: number
44
+ accept: boolean[]
45
+ /** delta[state][symbolIndex] = next state */
46
+ delta: number[][]
47
+ /** Which machine state each index came from, for readable witnesses and findings. */
48
+ labels: string[][]
49
+ }
50
+
51
+ const SINK = '\u0000sink'
52
+
53
+ /** Validate the shape, so a refusal says which field is wrong rather than throwing later. */
54
+ export function validateAutomaton(input: unknown): { ok: true; automaton: Automaton } | { ok: false; errors: string[] } {
55
+ const errors: string[] = []
56
+ const value = input as Record<string, unknown> | null
57
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
58
+ return { ok: false, errors: ['automaton must be an object with states, alphabet, transitions, init and accepting.'] }
59
+ }
60
+ const states = value.states
61
+ if (!Array.isArray(states) || states.length === 0 || states.some((state) => typeof state !== 'string' || state === '')) {
62
+ errors.push('states: must be a non-empty array of non-empty strings')
63
+ }
64
+ const alphabet = value.alphabet
65
+ if (!Array.isArray(alphabet) || alphabet.length === 0 || alphabet.some((symbol) => typeof symbol !== 'string' || symbol === '')) {
66
+ errors.push('alphabet: must be a non-empty array of non-empty strings')
67
+ }
68
+ const init = value.init
69
+ if (typeof init !== 'string' || init === '') errors.push('init: must be a non-empty string')
70
+ const accepting = value.accepting
71
+ if (!Array.isArray(accepting) || accepting.some((state) => typeof state !== 'string')) {
72
+ errors.push('accepting: must be an array of state ids')
73
+ }
74
+ const transitions = value.transitions
75
+ if (!Array.isArray(transitions)) errors.push('transitions: must be an array of {from, symbol, to}')
76
+ if (errors.length > 0) return { ok: false, errors }
77
+
78
+ const known = new Set(states as string[])
79
+ if (typeof init === 'string' && !known.has(init)) errors.push(`init: names "${init}", which is not a declared state`)
80
+ for (const state of accepting as string[]) {
81
+ if (!known.has(state)) errors.push(`accepting: names "${state}", which is not a declared state`)
82
+ }
83
+ const symbols = new Set(alphabet as string[])
84
+ for (const [index, raw] of (transitions as unknown[]).entries()) {
85
+ const transition = raw as Record<string, unknown> | null
86
+ if (transition === null || typeof transition !== 'object') {
87
+ errors.push(`transitions[${index}]: must be an object`)
88
+ continue
89
+ }
90
+ if (typeof transition.from !== 'string' || !known.has(transition.from)) errors.push(`transitions[${index}].from: "${String(transition.from)}" is not a declared state`)
91
+ if (typeof transition.to !== 'string' || !known.has(transition.to)) errors.push(`transitions[${index}].to: "${String(transition.to)}" is not a declared state`)
92
+ if (typeof transition.symbol !== 'string' || !symbols.has(transition.symbol)) errors.push(`transitions[${index}].symbol: "${String(transition.symbol)}" is not in the alphabet`)
93
+ }
94
+ if (errors.length > 0) return { ok: false, errors }
95
+ return {
96
+ ok: true,
97
+ automaton: {
98
+ states: states as string[],
99
+ alphabet: alphabet as string[],
100
+ transitions: (transitions as AutomatonTransition[]).map((transition) => ({ from: transition.from, symbol: transition.symbol, to: transition.to })),
101
+ init: init as string,
102
+ accepting: accepting as string[],
103
+ },
104
+ }
105
+ }
106
+
107
+ /** Determinise by subset construction, adding one explicit sink so a word never stops early. */
108
+ function determinise(automaton: Automaton, symbols: string[]): Dfa {
109
+ const index = new Map<string, number>()
110
+ const subsets: string[][] = []
111
+ const delta: number[][] = []
112
+ const accept: boolean[] = []
113
+ const accepting = new Set(automaton.accepting)
114
+ const byFromAndSymbol = new Map<string, string[]>()
115
+ for (const transition of automaton.transitions) {
116
+ const key = transition.from + '\u0000' + transition.symbol
117
+ byFromAndSymbol.set(key, [...(byFromAndSymbol.get(key) ?? []), transition.to])
118
+ }
119
+
120
+ const intern = (label: string[], acceptingHere: boolean): number => {
121
+ const key = label.join('\u0000')
122
+ const existing = index.get(key)
123
+ if (existing !== undefined) return existing
124
+ const id = subsets.length
125
+ index.set(key, id)
126
+ subsets.push(label)
127
+ accept.push(acceptingHere)
128
+ delta.push([])
129
+ return id
130
+ }
131
+
132
+ const start = intern([automaton.init], accepting.has(automaton.init))
133
+ // Each subset is expanded exactly once: a subset reached again - a self-loop, or a second path into
134
+ // the same set - only needs its edge recorded, not another pass through its transitions.
135
+ const pending = [start]
136
+ const expanded = new Set<number>([start])
137
+ while (pending.length > 0) {
138
+ const current = pending.shift() as number
139
+ for (const [symbolIndex, symbol] of symbols.entries()) {
140
+ const next = new Set<string>()
141
+ for (const state of subsets[current]) {
142
+ for (const target of byFromAndSymbol.get(state + '\u0000' + symbol) ?? []) next.add(target)
143
+ }
144
+ const label = [...next].sort()
145
+ const acceptingNext = label.some((state) => accepting.has(state))
146
+ const id = intern(label.length === 0 ? [SINK] : label, acceptingNext)
147
+ delta[current][symbolIndex] = id
148
+ if (!expanded.has(id)) {
149
+ expanded.add(id)
150
+ pending.push(id)
151
+ }
152
+ }
153
+ }
154
+ return { start, accept, delta, labels: subsets.map((subset) => (subset[0] === SINK ? [] : subset)) }
155
+ }
156
+
157
+ /** The product search stopped at its cap, so the relation is undecided rather than true. */
158
+ const UNDECIDED = Symbol('undecided')
159
+
160
+ /**
161
+ * Shortest word accepted by `a` but not by `b`, when one exists.
162
+ *
163
+ * The product has at most |a| x |b| states, which is fine for the machines this family is for and can
164
+ * still explode on two large ones. The cap turns that into "undecided", because a search that stopped
165
+ * is not a proof that the languages are equal.
166
+ */
167
+ function difference(first: Dfa, second: Dfa, symbols: string[], limit = 10_000): string[] | undefined | typeof UNDECIDED {
168
+ const seen = new Set<string>()
169
+ const queue: { left: number; right: number; word: string[] }[] = [{ left: first.start, right: second.start, word: [] }]
170
+ seen.add(first.start + ':' + second.start)
171
+ let expanded = 0
172
+ while (queue.length > 0) {
173
+ if (++expanded > limit) return UNDECIDED
174
+ const node = queue.shift() as { left: number; right: number; word: string[] }
175
+ if (first.accept[node.left] && !second.accept[node.right]) return node.word
176
+ for (const [symbolIndex, symbol] of symbols.entries()) {
177
+ const left = first.delta[node.left][symbolIndex]
178
+ const right = second.delta[node.right][symbolIndex]
179
+ const key = left + ':' + right
180
+ if (seen.has(key)) continue
181
+ seen.add(key)
182
+ queue.push({ left, right, word: [...node.word, symbol] })
183
+ }
184
+ }
185
+ return undefined
186
+ }
187
+
188
+ /** Every state that can be reached from the start. */
189
+ function reachable(dfa: Dfa, symbols: string[]): boolean[] {
190
+ const seen = new Array<boolean>(dfa.delta.length).fill(false)
191
+ seen[dfa.start] = true
192
+ const queue = [dfa.start]
193
+ while (queue.length > 0) {
194
+ const current = queue.shift() as number
195
+ for (let symbolIndex = 0; symbolIndex < symbols.length; symbolIndex++) {
196
+ const next = dfa.delta[current][symbolIndex]
197
+ if (!seen[next]) {
198
+ seen[next] = true
199
+ queue.push(next)
200
+ }
201
+ }
202
+ }
203
+ return seen
204
+ }
205
+
206
+ /** Every state from which an accepting state can be reached. */
207
+ function productive(dfa: Dfa, symbols: string[]): boolean[] {
208
+ const reverse = new Map<number, number[]>()
209
+ for (let state = 0; state < dfa.delta.length; state++) {
210
+ for (let symbolIndex = 0; symbolIndex < symbols.length; symbolIndex++) {
211
+ const target = dfa.delta[state][symbolIndex]
212
+ reverse.set(target, [...(reverse.get(target) ?? []), state])
213
+ }
214
+ }
215
+ const seen = new Array<boolean>(dfa.delta.length).fill(false)
216
+ const queue: number[] = []
217
+ for (let state = 0; state < dfa.accept.length; state++) {
218
+ if (dfa.accept[state]) {
219
+ seen[state] = true
220
+ queue.push(state)
221
+ }
222
+ }
223
+ while (queue.length > 0) {
224
+ const current = queue.shift() as number
225
+ for (const previous of reverse.get(current) ?? []) {
226
+ if (!seen[previous]) {
227
+ seen[previous] = true
228
+ queue.push(previous)
229
+ }
230
+ }
231
+ }
232
+ return seen
233
+ }
234
+
235
+ export type AutomatonRelation = 'equivalent' | 'subset' | 'superset'
236
+
237
+ export interface AutomatonReport {
238
+ ok: boolean
239
+ ran: boolean
240
+ verdict: 'pass' | 'pass_with_findings' | 'fail'
241
+ verdictReason: string
242
+ schema: string
243
+ automaton: { states: number; transitions: number; alphabet: number; deterministic: boolean }
244
+ findings: AutomatonFinding[]
245
+ /** Set when the relation fails: the shortest word that breaks it. */
246
+ witness?: { word: string[]; reason: string }
247
+ /** True when the product search stopped before deciding, so `pass` is not a proof of equality. */
248
+ truncated?: boolean
249
+ hashes: { automaton: string; other?: string }
250
+ nextSteps: string[]
251
+ }
252
+
253
+ /** Determinism, as declared by the input rather than guessed: a repeated (from, symbol) pair. */
254
+ function isDeterministic(automaton: Automaton): boolean {
255
+ const seen = new Set<string>()
256
+ for (const transition of automaton.transitions) {
257
+ const key = transition.from + '\u0000' + transition.symbol
258
+ if (seen.has(key)) return false
259
+ seen.add(key)
260
+ }
261
+ return true
262
+ }
263
+
264
+ export function verifyAutomaton(input: unknown, options: { other?: unknown; relation?: AutomatonRelation } = {}): AutomatonReport {
265
+ const parsed = validateAutomaton(input)
266
+ if (!parsed.ok) {
267
+ throw new OptionShapeError('AUTOMATON_INVALID', 'the automaton is not well formed: ' + parsed.errors.join('; '))
268
+ }
269
+ const automaton = parsed.automaton
270
+ const symbols = [...automaton.alphabet].sort()
271
+ const findings: AutomatonFinding[] = []
272
+ const dfa = determinise(automaton, symbols)
273
+ const deterministic = isDeterministic(automaton)
274
+
275
+ const reach = reachable(dfa, symbols)
276
+ const canAccept = productive(dfa, symbols)
277
+ if (!deterministic) {
278
+ 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.` })
279
+ }
280
+ // A state no word can reach is dead weight, and a state from which no accepting state is reachable
281
+ // accepts nothing: the first is a modelling smell, the second is usually a defect in the intended language.
282
+ for (const [index, label] of dfa.labels.entries()) {
283
+ if (label.length === 0) continue
284
+ if (label.length === 1 && !reach[index]) {
285
+ findings.push({ code: 'AUT001_UNREACHABLE_STATE', severity: 'warning', message: `state "${label[0]}" cannot be reached from init` })
286
+ }
287
+ if (!dfa.accept[index] && reach[index] && !canAccept[index]) {
288
+ findings.push({ code: 'AUT002_TRAP_STATE', severity: 'warning', message: `state "${label[0]}" is reachable but can never reach an accepting state` })
289
+ }
290
+ }
291
+ for (const state of automaton.states) {
292
+ if (!dfa.labels.some((label) => label.includes(state))) {
293
+ if (!findings.some((finding) => finding.code === 'AUT001_UNREACHABLE_STATE' && finding.message.includes(`"${state}"`))) {
294
+ findings.push({ code: 'AUT001_UNREACHABLE_STATE', severity: 'warning', message: `state "${state}" cannot be reached from init` })
295
+ }
296
+ }
297
+ }
298
+
299
+ const acceptingReachable = reach.some((value, index) => value && dfa.accept[index])
300
+ if (!acceptingReachable) {
301
+ findings.push({ code: 'AUT004_EMPTY_LANGUAGE', severity: 'warning', message: 'the automaton accepts nothing: no accepting state is reachable from init' })
302
+ }
303
+
304
+ let witness: AutomatonReport['witness']
305
+ let otherForHash: Automaton | undefined
306
+ const relation = options.relation ?? 'equivalent'
307
+ if (options.other !== undefined) {
308
+ const otherParsed = validateAutomaton(options.other)
309
+ if (!otherParsed.ok) {
310
+ throw new OptionShapeError('AUTOMATON_INVALID', 'the second automaton is not well formed: ' + otherParsed.errors.join('; '))
311
+ }
312
+ const other = otherParsed.automaton
313
+ otherForHash = other
314
+ if (other.alphabet.length !== automaton.alphabet.length || !other.alphabet.every((symbol) => automaton.alphabet.includes(symbol))) {
315
+ 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(', ')}` })
316
+ } else {
317
+ const second = determinise(other, symbols)
318
+ const missing = difference(dfa, second, symbols)
319
+ const extra = difference(second, dfa, symbols)
320
+ if (missing === UNDECIDED || extra === UNDECIDED) {
321
+ 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' })
322
+ } else
323
+ if (relation === 'equivalent') {
324
+ if (missing !== undefined || extra !== undefined) {
325
+ const word = missing ?? extra
326
+ witness = { word: word ?? [], reason: missing !== undefined ? 'accepted by the first automaton, rejected by the second' : 'accepted by the second automaton, rejected by the first' }
327
+ 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}` })
328
+ }
329
+ } else if (relation === 'subset') {
330
+ if (missing !== undefined) {
331
+ witness = { word: missing, reason: 'accepted by the first automaton, rejected by the second' }
332
+ 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(' ')}` })
333
+ }
334
+ } else if (extra !== undefined) {
335
+ witness = { word: extra, reason: 'accepted by the second automaton, rejected by the first' }
336
+ 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(' ')}` })
337
+ }
338
+ }
339
+ }
340
+
341
+ const errors = findings.filter((finding) => finding.severity === 'error').length
342
+ const warnings = findings.filter((finding) => finding.severity === 'warning').length
343
+ const verdict = errors > 0 ? 'fail' : warnings > 0 ? 'pass_with_findings' : 'pass'
344
+ const reason = errors > 0
345
+ ? `${String(errors)} error finding(s) (first: ${findings.find((finding) => finding.severity === 'error')?.code ?? 'unknown'})`
346
+ : warnings > 0
347
+ ? `no error findings; ${String(warnings)} warning finding(s)`
348
+ : 'every check passed'
349
+
350
+ const nextSteps: string[] = []
351
+ if (findings.some((finding) => finding.code === 'AUT004_EMPTY_LANGUAGE')) 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.')
352
+ if (witness !== undefined) 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.')
353
+ if (!deterministic) nextSteps.push('The analysis determinised the input; if nondeterminism was accidental, the subset count is the tell.')
354
+ 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.')
355
+
356
+ return {
357
+ ok: true,
358
+ ran: true,
359
+ verdict,
360
+ verdictReason: reason,
361
+ schema: 'model-everything/automaton/v1',
362
+ automaton: { states: automaton.states.length, transitions: automaton.transitions.length, alphabet: automaton.alphabet.length, deterministic },
363
+ findings,
364
+ ...(witness === undefined ? {} : { witness }),
365
+ ...(findings.some((finding) => finding.code === 'AUT009_UNDECIDED') ? { truncated: true } : {}),
366
+ hashes: { automaton: hashAutomaton(automaton), ...(otherForHash === undefined ? {} : { other: hashAutomaton(otherForHash) }) },
367
+ nextSteps,
368
+ }
369
+ }
370
+
371
+ const createHashHex = (text: string): string => createHash('sha256').update(text).digest('hex')
372
+
373
+ function hashAutomaton(automaton: Automaton): string {
374
+ 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() }))
375
+ }
@@ -1,6 +1,7 @@
1
1
  import { defineTool } from '@deepseek-ai/dsh-tools'
2
2
  import type { JsonValue } from './json-value.js'
3
- import { runCompositionVerification } from './engine.js'
3
+ import { runCompositionVerification } from './engine.js'
4
+ import { reportCard, reportResultView } from './presentation.js'
4
5
  import type { SharedAccess, SharedLocation } from './engine.js'
5
6
  import { OptionShapeError, optionRefusal, requireArrayOption } from './option-shape.js'
6
7
  import { renderReportText } from './report-view.js'
@@ -38,6 +39,10 @@ export const modelComposeTool = defineTool({
38
39
  type: 'integer',
39
40
  description: 'Maximum composite states to explore. Default 10000.',
40
41
  },
42
+ check: {
43
+ type: 'string',
44
+ description: 'Print one check in full when the digest truncated it. Pass the check id the CHECKS line lists (for example A9); the per-check budget no longer applies, and an id this report does not carry is answered with the ids that it does.',
45
+ },
41
46
  reportView: {
42
47
  type: 'string',
43
48
  enum: ['digest', 'full'],
@@ -50,9 +55,14 @@ export const modelComposeTool = defineTool({
50
55
  description: 'Composition verification report with C1/C2 findings and, when shared locations are declared, C3/C4/C5.',
51
56
  },
52
57
  render(args, value) {
53
- return [{ type: 'text' as const, text: renderReportText(value, { full: args.reportView === 'full' }) }]
58
+ return [{ type: 'text' as const, text: renderReportText(value, { full: args.reportView === 'full', check: args.check }) }]
54
59
  },
55
- },
60
+ // The card a UI shows, separated from the text the model reads.
61
+ presentationMeta(_args, value) {
62
+ return reportCard(value, COMPOSE_TOOL_NAME) as unknown as JsonValue
63
+ },
64
+ }, presentResult: reportResultView(COMPOSE_TOOL_NAME),
65
+
56
66
  timeoutMs: 10_000,
57
67
  isConcurrencySafe: () => true,
58
68
  async execute(args) {
@@ -2,6 +2,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
2
2
  import type { JsonValue } from './json-value.js'
3
3
  import { runConcurrencyScan } from './concurrency.js'
4
4
  import { renderReportText } from './report-view.js'
5
+ import { reportCard, reportResultView } from './presentation.js'
5
6
 
6
7
  export const INTERLEAVING_SCAN_TOOL_NAME = 'model_interleaving_scan'
7
8
 
@@ -35,7 +36,12 @@ export const modelConcurrencyScanTool = defineTool({
35
36
  render(args, value) {
36
37
  return [{ type: 'text' as const, text: renderReportText(value, { full: args.reportView === 'full' }) }]
37
38
  },
38
- },
39
+ // The card a UI shows, separated from the text the model reads.
40
+ presentationMeta(_args, value) {
41
+ return reportCard(value, INTERLEAVING_SCAN_TOOL_NAME) as unknown as JsonValue
42
+ },
43
+ }, presentResult: reportResultView(INTERLEAVING_SCAN_TOOL_NAME),
44
+
39
45
  timeoutMs: 10_000,
40
46
  isConcurrencySafe: () => true,
41
47
  async execute(args) {
@@ -1200,7 +1200,7 @@ function dataNextSteps(checks: CheckResult[]): string[] {
1200
1200
  // check looked at the model. `pass` does not say that, and neither does a list of the failures.
1201
1201
  const skipped = checks.filter((check) => check.status === 'skip').map((check) => check.name + ' (' + check.id + ')')
1202
1202
  if (skipped.length > 0) {
1203
- steps.push('These examined nothing, so their silence is not a result: ' + skipped.join(', ') + '. Declare what each one would need, or accept that the model is unverified on those points.')
1203
+ 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.')
1204
1204
  }
1205
1205
  if (warningCodes.length > 0) steps.push('Review the warning findings (' + warningCodes.join(', ') + '): a nullable field or a missing index is a decision, not silence.')
1206
1206
  steps.push('Re-run with beforeModel plus the mapping/copy/migration options after the change to prove the data behaviour did not regress: migration coverage, copy consistency, rollback symmetry, and the before/after comparison. Then compare the two reports with `report_diff` rather than reading them side by side.')
package/src/data-tool.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import { defineTool } from '@deepseek-ai/dsh-tools'
2
2
  import type { JsonValue } from './json-value.js'
3
3
  import { runDataVerification, DATA_ENGINE_SCHEMA_VERSION } from './data-engine.js'
4
+ import { oneOf } from './model-file.js'
4
5
  import { optionRefusal, requireArrayOption, requireObjectOption } from './option-shape.js'
5
6
  import { renderReportText } from './report-view.js'
7
+ import { reportCard, reportResultView } from './presentation.js'
6
8
 
7
9
  export const DATAMODEL_VERIFY_TOOL_NAME = 'model_datamodel_verify'
8
10
 
@@ -19,8 +21,11 @@ export const modelDataModelVerifyTool = defineTool({
19
21
  parameters: {
20
22
  model: {
21
23
  type: 'json',
22
- required: true,
23
- description: 'DataModelV1 data model to verify.',
24
+ description: 'DataModelV1 data model to verify. Pass this, or modelPath, not both.',
25
+ },
26
+ modelPath: {
27
+ type: 'string',
28
+ description: 'Path to a JSON file holding the model, instead of inlining it. Formatting does not matter: whitespace, a BOM and pretty printing all parse. Relative paths resolve against the process working directory, which is the session workspace.',
24
29
  },
25
30
  beforeModel: {
26
31
  type: 'json',
@@ -42,6 +47,10 @@ export const modelDataModelVerifyTool = defineTool({
42
47
  type: 'json',
43
48
  description: 'Optional backup/restore pairs for DA7 rollback symmetry.',
44
49
  },
50
+ check: {
51
+ type: 'string',
52
+ description: 'Print one check in full when the digest truncated it. Pass the check id the CHECKS line lists (for example A9); the per-check budget no longer applies, and an id this report does not carry is answered with the ids that it does.',
53
+ },
45
54
  reportView: {
46
55
  type: 'string',
47
56
  enum: ['digest', 'full'],
@@ -54,9 +63,14 @@ export const modelDataModelVerifyTool = defineTool({
54
63
  description: 'model-everything-datamodel verification report with summary and per-check findings.',
55
64
  },
56
65
  render(args, value) {
57
- return [{ type: 'text' as const, text: renderReportText(value, { full: args.reportView === 'full' }) }]
66
+ return [{ type: 'text' as const, text: renderReportText(value, { full: args.reportView === 'full', check: args.check }) }]
58
67
  },
59
- },
68
+ // The card a UI shows, separated from the text the model reads.
69
+ presentationMeta(_args, value) {
70
+ return reportCard(value, DATAMODEL_VERIFY_TOOL_NAME) as unknown as JsonValue
71
+ },
72
+ }, presentResult: reportResultView(DATAMODEL_VERIFY_TOOL_NAME),
73
+
60
74
  timeoutMs: 10_000,
61
75
  isConcurrencySafe: () => true,
62
76
  async execute(args) {
@@ -67,7 +81,7 @@ export const modelDataModelVerifyTool = defineTool({
67
81
  requireArrayOption(args.copyPairs, 'copyPairs', 'DATA_OPTION_INVALID')
68
82
  requireArrayOption(args.migrationMappings, 'migrationMappings', 'DATA_OPTION_INVALID')
69
83
  requireArrayOption(args.backupPairs, 'backupPairs', 'DATA_OPTION_INVALID')
70
- return runDataVerification(args.model, {
84
+ return runDataVerification(oneOf(args.model, args.modelPath, 'model', 'modelPath', 'DATA_OPTION_INVALID'), {
71
85
  beforeModel: args.beforeModel,
72
86
  fieldMapping: args.fieldMapping as Record<string, string> | undefined,
73
87
  copyPairs: args.copyPairs as Array<{ id: string; sourceEntity: string; targetEntity: string; mapping: Record<string, string> }> | undefined,
@@ -1,60 +1,74 @@
1
- import { defineTool } from '@deepseek-ai/dsh-tools'
2
- import type { JsonValue } from './json-value.js'
3
- import { REPORT_SCHEMAS } from './engine.js'
4
- import { runDesignReview } from './design-engine.js'
5
- import { renderReportText } from './report-view.js'
6
-
7
- export const DESIGN_TOOL_NAME = 'model_design_review'
8
-
9
- /**
10
- * Model-visible DSH tool that reviews a *design*: the shape of its dependency graph, and the
11
- * contracts the design states about itself.
12
- *
13
- * This is not `model_verify`. That one asks whether a machine can misbehave; this one asks
14
- * whether a design keeps its own declarations - which layer may depend on which, which
15
- * responsibility owns which dependency, which subtype may refuse which event. Two engines,
16
- * two questions, two verdicts that mean different things.
17
- */
18
- export const designTool = defineTool({
19
- name: DESIGN_TOOL_NAME,
20
- description:
21
- '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`.',
22
- parameters: {
23
- model: {
24
- type: 'json',
25
- required: true,
26
- description:
27
- 'DesignModelV1: {schemaVersion: 1, architecture: {nodes: [ids], edges: [[from, to]]}, types?: [{name, stereotype, extends?, implements?, operations?, transitions?, forbids?, responsibilities?, dependsOn?, uses?, layer?}], declare?: {layers?, boundaries?, allowedEdges?, requiredEdges?}}. The schema is closed: an unknown key is refused rather than ignored. `types` is what makes the principle checks possible - with no `extends` there is no substitutability to decide, with no `uses` no interface can be called fat, and no `responsibilities` means nothing to compare the dependencies against.',
28
- },
29
- reportView: {
30
- type: 'string',
31
- enum: ['digest', 'full'],
32
- description: 'digest (default) prints the readable body; full prints the structured report, which report_diff needs.',
33
- },
34
- },
35
- output: {
36
- schema: {
37
- type: 'json',
38
- description: 'Design review report: every check with its status, `violations` (they decide the verdict), `signals`, `facts` (graph features), summary and next steps.',
39
- },
40
- render(args, value) {
41
- return [{ type: 'text' as const, text: renderReportText(value, { full: args.reportView === 'full' }) }]
42
- },
43
- },
44
- timeoutMs: 10_000,
45
- isConcurrencySafe: () => true,
46
- async execute(args) {
47
- if (args.model === undefined) {
48
- return {
49
- ok: false,
50
- ran: false,
51
- verdict: 'fail',
52
- verdictReason: 'the tool did not run: pass `model`, a DesignModelV1 with schemaVersion, architecture and the declarations to review it against',
53
- schema: REPORT_SCHEMAS.design,
54
- errorCode: 'DESIGN_INPUT',
55
- error: 'design review needs `model`',
56
- } as unknown as JsonValue
57
- }
58
- return runDesignReview(args.model) as unknown as JsonValue
59
- },
60
- })
1
+ import { defineTool } from '@deepseek-ai/dsh-tools'
2
+ import type { JsonValue } from './json-value.js'
3
+ import { REPORT_SCHEMAS } from './engine.js'
4
+ import { reportCard, reportResultView } from './presentation.js'
5
+ import { oneOf } from './model-file.js'
6
+ import { runDesignReview } from './design-engine.js'
7
+ import { renderReportText } from './report-view.js'
8
+
9
+ export const DESIGN_TOOL_NAME = 'model_design_review'
10
+
11
+ /**
12
+ * Model-visible DSH tool that reviews a *design*: the shape of its dependency graph, and the
13
+ * contracts the design states about itself.
14
+ *
15
+ * This is not `model_verify`. That one asks whether a machine can misbehave; this one asks
16
+ * whether a design keeps its own declarations - which layer may depend on which, which
17
+ * responsibility owns which dependency, which subtype may refuse which event. Two engines,
18
+ * two questions, two verdicts that mean different things.
19
+ */
20
+ export const designTool = defineTool({
21
+ name: DESIGN_TOOL_NAME,
22
+ description:
23
+ '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`.',
24
+ parameters: {
25
+ model: {
26
+ type: 'json',
27
+ description:
28
+ 'DesignModelV1: {schemaVersion: 1, architecture: {nodes: [ids], edges: [[from, to]]}, types?: [{name, stereotype, extends?, implements?, operations?, transitions?, forbids?, responsibilities?, dependsOn?, uses?, layer?}], declare?: {layers?, boundaries?, allowedEdges?, requiredEdges?}}. The schema is closed: an unknown key is refused rather than ignored. `types` is what makes the principle checks possible - with no `extends` there is no substitutability to decide, with no `uses` no interface can be called fat, and no `responsibilities` means nothing to compare the dependencies against.',
29
+ },
30
+ modelPath: {
31
+ type: 'string',
32
+ description: 'Path to a JSON file holding the model, instead of inlining it. Formatting does not matter: whitespace, a BOM and pretty printing all parse. Relative paths resolve against the process working directory, which is the session workspace.',
33
+ },
34
+ check: {
35
+ type: 'string',
36
+ description: 'Print one check in full when the digest truncated it. Pass the check id the CHECKS line lists (for example A9); the per-check budget no longer applies, and an id this report does not carry is answered with the ids that it does.',
37
+ },
38
+ reportView: {
39
+ type: 'string',
40
+ enum: ['digest', 'full'],
41
+ description: 'digest (default) prints the readable body; full prints the structured report, which report_diff needs.',
42
+ },
43
+ },
44
+ output: {
45
+ schema: {
46
+ type: 'json',
47
+ description: 'Design review report: every check with its status, `violations` (they decide the verdict), `signals`, `facts` (graph features), summary and next steps.',
48
+ },
49
+ render(args, value) {
50
+ return [{ type: 'text' as const, text: renderReportText(value, { full: args.reportView === 'full', check: args.check }) }]
51
+ },
52
+ // The card a UI shows, separated from the text the model reads.
53
+ presentationMeta(_args, value) {
54
+ return reportCard(value, DESIGN_TOOL_NAME) as unknown as JsonValue
55
+ },
56
+ }, presentResult: reportResultView(DESIGN_TOOL_NAME),
57
+
58
+ timeoutMs: 10_000,
59
+ isConcurrencySafe: () => true,
60
+ async execute(args) {
61
+ if (args.model === undefined && args.modelPath === undefined) {
62
+ return {
63
+ ok: false,
64
+ ran: false,
65
+ verdict: 'fail',
66
+ verdictReason: 'the tool did not run: pass `model` (a DesignModelV1 with schemaVersion, architecture and the declarations) or `modelPath` (a JSON file holding one)',
67
+ schema: REPORT_SCHEMAS.design,
68
+ errorCode: 'DESIGN_INPUT',
69
+ error: 'design review needs `model` or `modelPath`',
70
+ } as unknown as JsonValue
71
+ }
72
+ return runDesignReview(oneOf(args.model, args.modelPath, 'model', 'modelPath', 'DESIGN_OPTION_INVALID')) as unknown as JsonValue
73
+ },
74
+ })