dsh-model-everything 0.0.0-stage → 0.2.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 (133) hide show
  1. package/LICENSE +21 -0
  2. package/README.en-US.md +332 -0
  3. package/README.md +332 -3
  4. package/cordis.patch.yml +20 -0
  5. package/examples/README.md +48 -0
  6. package/examples/control-dispatcher.json +113 -0
  7. package/examples/control-dispatcher.overbudget.json +113 -0
  8. package/examples/ecommerce-data-model.json +185 -0
  9. package/examples/order-state-machine.after.json +116 -0
  10. package/examples/order-state-machine.before.json +101 -0
  11. package/examples/user-migration.after.json +29 -0
  12. package/examples/user-migration.before.json +29 -0
  13. package/gate/locale/en.json +6 -0
  14. package/gate/locale/zh.json +6 -0
  15. package/gate/package.json +5 -0
  16. package/lib/baseline.js +249 -0
  17. package/lib/compose-tool.js +80 -0
  18. package/lib/concurrency-tool.js +40 -0
  19. package/lib/concurrency.js +206 -0
  20. package/lib/data-engine.js +1141 -0
  21. package/lib/data-tool.js +87 -0
  22. package/lib/design-engine.js +1149 -0
  23. package/lib/design-tool.js +63 -0
  24. package/lib/engine.js +4343 -0
  25. package/lib/export-tool.js +78 -0
  26. package/lib/exporters.js +434 -0
  27. package/lib/gate.js +274 -0
  28. package/lib/granularity.js +237 -0
  29. package/lib/index.js +237 -0
  30. package/lib/json-value.js +18 -0
  31. package/lib/model-file.js +48 -0
  32. package/lib/option-shape.js +62 -0
  33. package/lib/report-diff-tool.js +63 -0
  34. package/lib/report-view.js +433 -0
  35. package/lib/structure-tool.js +91 -0
  36. package/lib/structure.js +711 -0
  37. package/lib/tool.js +94 -0
  38. package/lib/types/baseline.d.ts +65 -0
  39. package/lib/types/compose-tool.d.ts +7 -0
  40. package/lib/types/concurrency-tool.d.ts +8 -0
  41. package/lib/types/concurrency.d.ts +37 -0
  42. package/lib/types/data-engine.d.ts +211 -0
  43. package/lib/types/data-tool.d.ts +10 -0
  44. package/lib/types/design-engine.d.ts +215 -0
  45. package/lib/types/design-tool.d.ts +11 -0
  46. package/lib/types/engine.d.ts +494 -0
  47. package/lib/types/export-tool.d.ts +7 -0
  48. package/lib/types/exporters.d.ts +37 -0
  49. package/lib/types/gate.d.ts +91 -0
  50. package/lib/types/granularity.d.ts +106 -0
  51. package/lib/types/index.d.ts +45 -0
  52. package/lib/types/json-value.d.ts +21 -0
  53. package/lib/types/model-file.d.ts +7 -0
  54. package/lib/types/option-shape.d.ts +22 -0
  55. package/lib/types/report-diff-tool.d.ts +8 -0
  56. package/lib/types/report-view.d.ts +50 -0
  57. package/lib/types/structure-tool.d.ts +10 -0
  58. package/lib/types/structure.d.ts +152 -0
  59. package/lib/types/tool.d.ts +11 -0
  60. package/lib/types/uml-communication.d.ts +109 -0
  61. package/lib/types/uml-composite.d.ts +107 -0
  62. package/lib/types/uml-interaction-overview.d.ts +91 -0
  63. package/lib/types/uml-profile.d.ts +112 -0
  64. package/lib/types/uml-structure.d.ts +53 -0
  65. package/lib/types/uml-syntax.d.ts +296 -0
  66. package/lib/types/uml-timing.d.ts +152 -0
  67. package/lib/types/uml-tool.d.ts +12 -0
  68. package/lib/types/uml-usecase.d.ts +58 -0
  69. package/lib/types/uml.d.ts +303 -0
  70. package/lib/uml-communication.js +353 -0
  71. package/lib/uml-composite.js +442 -0
  72. package/lib/uml-interaction-overview.js +277 -0
  73. package/lib/uml-profile.js +373 -0
  74. package/lib/uml-structure.js +306 -0
  75. package/lib/uml-syntax.js +545 -0
  76. package/lib/uml-timing.js +974 -0
  77. package/lib/uml-tool.js +213 -0
  78. package/lib/uml-usecase.js +532 -0
  79. package/lib/uml.js +2110 -0
  80. package/locale/en.json +6 -0
  81. package/locale/zh.json +6 -0
  82. package/package.json +128 -6
  83. package/skills/model-everything/SKILL.md +450 -0
  84. package/skills/model-everything/references/dsh-model-schema.md +437 -0
  85. package/skills/model-everything/references/gap-routing-guide.md +42 -0
  86. package/skills/model-everything/references/hash-spec.md +152 -0
  87. package/skills/model-everything/references/logic-verification-guide.md +422 -0
  88. package/skills/model-everything-datamodel/SKILL.md +130 -0
  89. package/skills/model-everything-datamodel/references/data-model-guide.md +84 -0
  90. package/skills/model-everything-datamodel/references/data-model-schema.md +128 -0
  91. package/skills/model-everything-design/SKILL.md +115 -0
  92. package/skills/model-everything-interleaving/SKILL.md +63 -0
  93. package/skills/model-everything-interleaving/SKILL.zh-CN.md +60 -0
  94. package/skills/model-everything-interleaving/references/concurrency-risk-guide.md +63 -0
  95. package/skills/model-everything-report-reading/SKILL.md +137 -0
  96. package/skills/model-everything-report-reading/references/reading-a-report.md +166 -0
  97. package/skills/model-everything-report-reading/references/reading-a-report.mmd +59 -0
  98. package/skills/model-everything-structure/SKILL.md +96 -0
  99. package/skills/model-everything-structure/references/structure-review-guide.md +201 -0
  100. package/skills/model-everything-uml/SKILL.md +60 -0
  101. package/skills/model-everything-uml/references/uml-modeling-guide.md +551 -0
  102. package/src/baseline.ts +288 -0
  103. package/src/compose-tool.ts +82 -0
  104. package/src/concurrency-tool.ts +44 -0
  105. package/src/concurrency.ts +254 -0
  106. package/src/data-engine.ts +1208 -0
  107. package/src/data-tool.ts +91 -0
  108. package/src/design-engine.ts +1263 -0
  109. package/src/design-tool.ts +68 -0
  110. package/src/engine.ts +4564 -0
  111. package/src/export-tool.ts +81 -0
  112. package/src/exporters.ts +399 -0
  113. package/src/gate.ts +330 -0
  114. package/src/granularity.ts +312 -0
  115. package/src/index.ts +289 -0
  116. package/src/json-value.ts +20 -0
  117. package/src/model-file.ts +49 -0
  118. package/src/option-shape.ts +64 -0
  119. package/src/report-diff-tool.ts +67 -0
  120. package/src/report-view.ts +483 -0
  121. package/src/structure-tool.ts +93 -0
  122. package/src/structure.ts +786 -0
  123. package/src/tool.ts +103 -0
  124. package/src/uml-communication.ts +428 -0
  125. package/src/uml-composite.ts +520 -0
  126. package/src/uml-interaction-overview.ts +321 -0
  127. package/src/uml-profile.ts +439 -0
  128. package/src/uml-structure.ts +317 -0
  129. package/src/uml-syntax.ts +764 -0
  130. package/src/uml-timing.ts +1106 -0
  131. package/src/uml-tool.ts +230 -0
  132. package/src/uml-usecase.ts +566 -0
  133. package/src/uml.ts +2139 -0
@@ -0,0 +1,288 @@
1
+ /**
2
+ * Report diffing: "did this slice add findings, or remove them?"
3
+ *
4
+ * The acceptance criterion of a refactoring slice is usually *not* "zero findings" —
5
+ * it is "no NEW findings, and none of the old ones got worse". Comparing two JSON
6
+ * reports by hand is how that gets fudged, so the comparison is code.
7
+ *
8
+ * A finding's identity is its machine-readable locator, never its prose:
9
+ * `check|code|<canonical evidence + path>`. Findings that carry neither evidence nor a
10
+ * path fall back to their message, because an engine that reports nothing structured
11
+ * has nothing stabler to match on. Two runs over the same input must produce zero
12
+ * added and zero removed findings.
13
+ *
14
+ * @module dsh-model-everything-baseline
15
+ */
16
+
17
+ import { canonicalJson, REPORT_SCHEMAS, verdictOf, type Finding, type Verdict, type VerdictSummary } from './engine.js'
18
+
19
+ /** A finding as it appears in any report family, after flattening. */
20
+ export interface ReportFinding extends Finding {
21
+ /** The check that emitted it (verify/datamodel: `S2`, `C1`, …); absent for flat families. */
22
+ check?: string
23
+ }
24
+
25
+ export interface BaselineChange {
26
+ identity: string
27
+ code: string
28
+ before: { severity: string; message: string }
29
+ after: { severity: string; message: string }
30
+ }
31
+
32
+ export interface BaselineReport {
33
+ ok: boolean
34
+ ran: boolean
35
+ verdict: Verdict
36
+ verdictReason: string
37
+ /** The versioned report contract this result follows. */
38
+ schema: string
39
+ /** The current report's own verdict, echoed so "no new findings" cannot hide a failing run. */
40
+ currentVerdict: Verdict
41
+ added: ReportFinding[]
42
+ removed: ReportFinding[]
43
+ changed: BaselineChange[]
44
+ summary: {
45
+ baseline: number
46
+ current: number
47
+ added: number
48
+ removed: number
49
+ changed: number
50
+ addedErrors: number
51
+ addedWarnings: number
52
+ }
53
+ nextSteps: string[]
54
+ }
55
+
56
+ /**
57
+ * The stable identity of a finding: check + file + code + a canonical locator. Prose is
58
+ * excluded so a reworded message reads as `changed`, not as a new finding.
59
+ */
60
+ export function findingIdentity(finding: ReportFinding): string {
61
+ const locator = canonicalJson({
62
+ ...(finding.evidence === undefined ? {} : { evidence: finding.evidence }),
63
+ ...(finding.path === undefined ? {} : { path: finding.path }),
64
+ })
65
+ const where = locator === '{}' ? 'message:' + finding.message : locator
66
+ const file = finding.file === undefined ? '' : finding.file + '|'
67
+ return (finding.check === undefined ? '' : finding.check + '|') + file + finding.code + '|' + where
68
+ }
69
+
70
+ /** Flatten a report of any family into one finding list (`checks[].findings` or `findings[]`). */
71
+ export function flattenReportFindings(report: unknown): ReportFinding[] {
72
+ if (report === null || typeof report !== 'object') return []
73
+ const record = report as Record<string, unknown>
74
+ const flat: ReportFinding[] = []
75
+ if (Array.isArray(record.checks)) {
76
+ for (const check of record.checks as Array<Record<string, unknown>>) {
77
+ const id = typeof check.id === 'string' ? check.id : undefined
78
+ for (const finding of (Array.isArray(check.findings) ? check.findings : []) as Finding[]) {
79
+ flat.push(id === undefined ? finding : { ...finding, check: id })
80
+ }
81
+ }
82
+ }
83
+ if (Array.isArray(record.findings)) {
84
+ for (const finding of record.findings as Finding[]) flat.push(finding)
85
+ }
86
+ return flat
87
+ }
88
+
89
+ /** The report's own verdict, whatever family it came from. */
90
+ function verdictOfReport(report: unknown): Verdict {
91
+ if (report === null || typeof report !== 'object') return 'fail'
92
+ const value = (report as Record<string, unknown>).verdict
93
+ return value === 'pass' || value === 'pass_with_findings' || value === 'fail' ? value : 'fail'
94
+ }
95
+
96
+ /**
97
+ * The delta's verdict, from the four ways a comparison can get worse or better.
98
+ *
99
+ * `escalated` is the one that used to be missing: a finding that keeps its identity and moves from warning to
100
+ * error lives in `changed`, not in `added`, so folding severities into `addedErrors` only left the comparison
101
+ * answering `pass` over a change that made the report strictly worse. A de-escalation is not a regression: it is
102
+ * still a change, so it is named, and the verdict stays `pass`.
103
+ */
104
+ function diffOf(added: number, removed: number, addedErrors: number, escalated: number, deescalated: number, firstCode?: string): VerdictSummary {
105
+ const errors = addedErrors + escalated
106
+ if (errors > 0) {
107
+ const bits: string[] = []
108
+ if (added > 0) bits.push(String(added) + ' new finding(s)')
109
+ if (escalated > 0) bits.push(String(escalated) + ' finding(s) escalated to error')
110
+ return {
111
+ verdict: 'fail',
112
+ // The count of errors is the sum: a reader must not have to add "new" and "escalated" in their head.
113
+ verdictReason: bits.join(', ') + ' (' + String(errors) + ' error)' + (firstCode === undefined ? '' : ' (first error: ' + firstCode + ')'),
114
+ }
115
+ }
116
+ if (added > 0) {
117
+ return {
118
+ verdict: 'pass_with_findings',
119
+ verdictReason: String(added) + ' new warning finding(s), no new errors' + (deescalated > 0 ? '; ' + String(deescalated) + ' finding(s) moved to a lower severity' : ''),
120
+ }
121
+ }
122
+ if (removed > 0) {
123
+ return { verdict: 'pass', verdictReason: 'no new findings; ' + String(removed) + ' finding(s) resolved' }
124
+ }
125
+ if (deescalated > 0) {
126
+ return { verdict: 'pass', verdictReason: 'no new findings; ' + String(deescalated) + ' finding(s) moved to a lower severity' }
127
+ }
128
+ return { verdict: 'pass', verdictReason: 'no new findings, nothing resolved' }
129
+ }
130
+
131
+ /**
132
+ * Compare a baseline report with the current one. The verdict describes the *delta*
133
+ * (a new error fails it), while `currentVerdict` keeps the absolute result visible.
134
+ */
135
+ /**
136
+ * Whether a value is a report this can compare.
137
+ *
138
+ * `diffReports(null, null)` answered `verdict: 'pass'`. With no inputs, every comparison found
139
+ * nothing to compare and the report said the two agree - the worst shape of a false green, because
140
+ * the absence of an input reads as agreement. A caller who passed the wrong variable would take
141
+ * that verdict at face value.
142
+ *
143
+ * The first fix refused only `null` and `undefined`, on the argument that a present-but-wrong value
144
+ * still carries `currentVerdict: 'fail'` and the delta is honestly empty. Running the installed
145
+ * plugin disproved that: `report_diff` with two `'junk'` strings answered `verdict: pass`, and
146
+ * `verdict` is the field a reader is told to act on. A mitigation in a field the reader is told to
147
+ * ignore is not a mitigation.
148
+ *
149
+ * The second fix required "an object", which moved the hole rather than closing it: `{}` is an object,
150
+ * has no findings, and two of them compare as agreement. A guard is only as deep as its requirement,
151
+ * so the requirement is now what the name promises - an object that carries at least one report field.
152
+ * A partial report still passes, because `findings` alone is one.
153
+ */
154
+ const REPORT_FIELDS = ['schema', 'verdict', 'ran', 'ok', 'checks', 'findings', 'summary', 'hashes', 'currentVerdict', 'added', 'removed']
155
+
156
+ function requireReport(value: unknown, role: string): Record<string, unknown> {
157
+ if (value === null || value === undefined) {
158
+ throw new Error(role + ' is ' + String(value) + '. A diff needs both sides: with no input at all, every comparison finds nothing and the report reads as agreement between two things that were never compared.')
159
+ }
160
+ if (typeof value !== 'object' || Array.isArray(value)) {
161
+ throw new Error(role + ' is a ' + typeof value + (Array.isArray(value) ? ' (an array)' : '') + ', not a report. A diff needs two report objects: two values that are not reports flatten to two empty finding lists, and the delta then reads as agreement between things that were never compared.')
162
+ }
163
+ const record = value as Record<string, unknown>
164
+ if (!REPORT_FIELDS.some((field) => field in record)) {
165
+ const keys = Object.keys(record)
166
+ throw new Error(role + ' is an object with no report fields' + (keys.length === 0 ? ' at all' : ' (' + keys.slice(0, 6).join(', ') + ')') + ', not a report. Two such values flatten to two empty finding lists, and the delta then reads as agreement between things that were never compared.')
167
+ }
168
+ return record
169
+ }
170
+
171
+ /**
172
+ * Why a report-shaped value still must not be compared, or `undefined` if it can be.
173
+ *
174
+ * Two shapes reach here legitimately and neither is a report about a model:
175
+ *
176
+ * - a REFUSAL (`ran: false`): the tool did not run, so there is nothing to compare against;
177
+ * - a report about an INVALID INPUT (`summary.checksRun === 0`): the engine ran, refused the input, and
178
+ * examined nothing. Its finding list is not empty because the model is clean, and comparing it against
179
+ * a real report would call every finding in that report resolved.
180
+ *
181
+ * `checksRun` is read only when the report carries it, because the families that report flat findings
182
+ * have no check roster and `compose` counts machines rather than checks.
183
+ */
184
+ function incomparableReason(record: Record<string, unknown>, role: string): string | undefined {
185
+ if (record.ran === false) {
186
+ return role + ' is a refusal: the fields describe why the tool did not run, so there is no report to compare'
187
+ }
188
+ const summary = record.summary
189
+ if (summary !== null && typeof summary === 'object' && (summary as Record<string, unknown>).checksRun === 0) {
190
+ return role + ' examined nothing (its summary says checksRun 0), so its empty finding list is not evidence of anything'
191
+ }
192
+ return undefined
193
+ }
194
+
195
+ /** The family a report belongs to, when it says so. Two different schemas cannot be compared. */
196
+ function familyOf(record: Record<string, unknown>): string | undefined {
197
+ return typeof record.schema === 'string' && record.schema !== '' ? record.schema : undefined
198
+ }
199
+
200
+ export function diffReports(baseline: unknown, current: unknown): BaselineReport {
201
+ const beforeRecord = requireReport(baseline, 'baseline')
202
+ const afterRecord = requireReport(current, 'current')
203
+ for (const [record, role] of [[beforeRecord, 'baseline'], [afterRecord, 'current']] as const) {
204
+ const reason = incomparableReason(record, role)
205
+ if (reason !== undefined) throw new Error(reason + '. A diff needs two runs that produced reports.')
206
+ }
207
+ const beforeFamily = familyOf(beforeRecord)
208
+ const afterFamily = familyOf(afterRecord)
209
+ if (beforeFamily !== undefined && afterFamily !== undefined && beforeFamily !== afterFamily) {
210
+ throw new Error('cannot compare a ' + beforeFamily + ' report with a ' + afterFamily + ' report: they do not share a finding vocabulary, so every finding on one side would read as resolved or added on the other. Compare two runs of the same tool.')
211
+ }
212
+ const before = flattenReportFindings(baseline)
213
+ const after = flattenReportFindings(current)
214
+ const beforeByIdentity = new Map<string, ReportFinding>()
215
+ for (const finding of before) beforeByIdentity.set(findingIdentity(finding), finding)
216
+ const seen = new Set<string>()
217
+ const added: ReportFinding[] = []
218
+ const changed: BaselineChange[] = []
219
+ for (const finding of after) {
220
+ const identity = findingIdentity(finding)
221
+ seen.add(identity)
222
+ const previous = beforeByIdentity.get(identity)
223
+ if (previous === undefined) {
224
+ added.push(finding)
225
+ continue
226
+ }
227
+ if (previous.severity !== finding.severity || previous.message !== finding.message) {
228
+ changed.push({
229
+ identity,
230
+ code: finding.code,
231
+ before: { severity: previous.severity, message: previous.message },
232
+ after: { severity: finding.severity, message: finding.message },
233
+ })
234
+ }
235
+ }
236
+ const removed = before.filter((finding) => !seen.has(findingIdentity(finding)))
237
+ const addedErrors = added.filter((finding) => finding.severity === 'error')
238
+ const addedWarnings = added.filter((finding) => finding.severity === 'warning')
239
+ // Severity movement lives in `changed`. Counted here, because the verdict is about whether the slice got worse,
240
+ // and a warning that became an error did - even though nothing was added.
241
+ const escalated = changed.filter((change) => change.before.severity !== 'error' && change.after.severity === 'error')
242
+ const deescalated = changed.filter((change) => change.before.severity === 'error' && change.after.severity !== 'error')
243
+ const delta = diffOf(added.length, removed.length, addedErrors.length, escalated.length, deescalated.length, addedErrors[0]?.code ?? escalated[0]?.code)
244
+
245
+ const nextSteps: string[] = []
246
+ if (addedErrors.length > 0) {
247
+ nextSteps.push('Fix or explicitly accept the ' + String(addedErrors.length) + ' new error finding(s) before calling this slice done: the baseline is what "not worse" is measured against.')
248
+ } else if (escalated.length > 0) {
249
+ nextSteps.push('A finding moved from warning to error (' + String(escalated.length) + '): the slice added nothing but still made the report worse, so the baseline is less strict than the run.')
250
+ } else if (added.length > 0) {
251
+ nextSteps.push('The new warnings are not failures, but they are new: decide per finding whether the change introduced them or the baseline was incomplete.')
252
+ } else {
253
+ nextSteps.push('No new findings: re-record the baseline with this report if the slice is accepted, so the next comparison starts from it.')
254
+ }
255
+ if (changed.length > 0) nextSteps.push('Review the ' + String(changed.length) + ' changed finding(s): the same locator now reports something different, which usually means the fix moved the problem.')
256
+ if (removed.length > 0) nextSteps.push(String(removed.length) + ' finding(s) from the baseline are gone — confirm they were fixed rather than renamed out of the report (a renamed check or a reworded message changes the identity).')
257
+ if (verdictOfReport(current) === 'fail') {
258
+ // The sentence has to match the delta it sits beside: "the delta being clean" is false when the delta failed,
259
+ // which is exactly the case an escalation now produces.
260
+ const deltaClean = delta.verdict === 'pass' || delta.verdict === 'pass_with_findings'
261
+ nextSteps.push(deltaClean
262
+ ? 'The current report still fails on its own terms; the delta being clean does not make the run clean.'
263
+ : 'The current report still fails on its own terms, and the delta already says the slice made it worse: settle both before accepting it.')
264
+ }
265
+
266
+ return {
267
+ ok: true,
268
+ ran: true,
269
+ ...delta,
270
+ schema: REPORT_SCHEMAS.baseline,
271
+ currentVerdict: verdictOfReport(current),
272
+ added,
273
+ removed,
274
+ changed,
275
+ summary: {
276
+ baseline: before.length,
277
+ current: after.length,
278
+ added: added.length,
279
+ removed: removed.length,
280
+ changed: changed.length,
281
+ addedErrors: addedErrors.length,
282
+ addedWarnings: addedWarnings.length,
283
+ },
284
+ nextSteps,
285
+ }
286
+ }
287
+
288
+ export type { VerdictSummary }
@@ -0,0 +1,82 @@
1
+ import { defineTool } from '@deepseek-ai/dsh-tools'
2
+ import type { JsonValue } from './json-value.js'
3
+ import { runCompositionVerification } from './engine.js'
4
+ import type { SharedAccess, SharedLocation } from './engine.js'
5
+ import { OptionShapeError, optionRefusal, requireArrayOption } from './option-shape.js'
6
+ import { renderReportText } from './report-view.js'
7
+
8
+ export const COMPOSE_TOOL_NAME = 'model_compose_verify'
9
+
10
+ /**
11
+ * DSH tool wrapping multi-machine composition verification:
12
+ * product-space reachability with rendezvous handshakes (C1 deadlock, C2 sync), and the shared
13
+ * locations the machines both touch (C3 declarations, C4 unordered write, C5 non-atomic update).
14
+ */
15
+ export const modelComposeTool = defineTool({
16
+ name: COMPOSE_TOOL_NAME,
17
+ description:
18
+ 'Check two or more LogicModelV1 machines together. Pass the models in `machines`. Pass `rendezvous` for handshake events: these fire only when every declaring machine is enabled at once, and a terminal machine stops taking part. Other events advance one machine, so a machine waiting on a handshake can take any number of its own steps first. There is no rate ratio and no fairness bound. Pass `shared` for locations two machines both touch and `accesses` to say which machine event reads or writes one: C3 reports a declaration that names something the composition does not have, C4 an unordered write to one location both machines write, and C5 a read-modify-write pair the model does not make atomic (the lost-update interleaving). Two accesses that name the same `protectedBy` lock are treated as exclusive; that the lock is held across the whole operation is a single-machine question this tool does not answer. Returns C1 deadlocks (no machine can advance, and one is not terminal) and C2 handshakes that never fire. Both name the reason per machine, and C1 gives the shortest counterexample. `maxStates` caps the search; a capped run says so. It decides what no single machine can show: a handshake that never fires, or a node where no machine may advance because of the others. One machine on its own is `model_verify`. Read `verdict` and `verdictReason`.',
19
+ parameters: {
20
+ machines: {
21
+ type: 'json',
22
+ required: true,
23
+ description: 'Array of LogicModelV1 state-machine models to compose (two or more).',
24
+ },
25
+ rendezvous: {
26
+ type: 'json',
27
+ description: 'Optional array of handshake event names shared by the machines.',
28
+ },
29
+ shared: {
30
+ type: 'json',
31
+ description: 'Optional array of shared locations: { id, initial? }. Without one, nothing is shared between the machines.',
32
+ },
33
+ accesses: {
34
+ type: 'json',
35
+ description: 'Optional array of { machine, event, location, kind: "read" | "write" | "read-write", protectedBy? }. A read-write is a read-modify-write: two of them that are not mutually exclusive lose an update.',
36
+ },
37
+ maxStates: {
38
+ type: 'integer',
39
+ description: 'Maximum composite states to explore. Default 10000.',
40
+ },
41
+ check: {
42
+ type: 'string',
43
+ 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.',
44
+ },
45
+ reportView: {
46
+ type: 'string',
47
+ enum: ['digest', 'full'],
48
+ description: 'digest (default) prints the readable body; full prints the structured report, which report_diff needs.',
49
+ },
50
+ },
51
+ output: {
52
+ schema: {
53
+ type: 'json',
54
+ description: 'Composition verification report with C1/C2 findings and, when shared locations are declared, C3/C4/C5.',
55
+ },
56
+ render(args, value) {
57
+ return [{ type: 'text' as const, text: renderReportText(value, { full: args.reportView === 'full', check: args.check }) }]
58
+ },
59
+ },
60
+ timeoutMs: 10_000,
61
+ isConcurrencySafe: () => true,
62
+ async execute(args) {
63
+ try {
64
+ // `machines` is declared `json`, so a string reached `machinesInput.forEach` as a raw TypeError. The
65
+ // array-ness is the tool's contract; check it here and refuse with a code that names the option.
66
+ requireArrayOption(args.machines, 'machines', 'COMPOSE_OPTION_INVALID')
67
+ if (args.machines !== undefined && (args.machines as unknown[]).length === 0) {
68
+ throw new OptionShapeError('COMPOSE_OPTION_INVALID', 'machines is an empty array. A composition needs at least two machines to compose; with none there is nothing to decide.')
69
+ }
70
+ if (args.shared !== undefined) requireArrayOption(args.shared, 'shared', 'COMPOSE_OPTION_INVALID')
71
+ if (args.accesses !== undefined) requireArrayOption(args.accesses, 'accesses', 'COMPOSE_OPTION_INVALID')
72
+ return runCompositionVerification(args.machines as unknown[], {
73
+ rendezvous: args.rendezvous as string[] | undefined,
74
+ shared: args.shared as SharedLocation[] | undefined,
75
+ accesses: args.accesses as SharedAccess[] | undefined,
76
+ maxStates: args.maxStates,
77
+ }) as unknown as JsonValue
78
+ } catch (error) {
79
+ return optionRefusal(error, 'COMPOSE_OPTION_INVALID') as unknown as JsonValue
80
+ }
81
+ },
82
+ })
@@ -0,0 +1,44 @@
1
+ import { defineTool } from '@deepseek-ai/dsh-tools'
2
+ import type { JsonValue } from './json-value.js'
3
+ import { runConcurrencyScan } from './concurrency.js'
4
+ import { renderReportText } from './report-view.js'
5
+
6
+ export const INTERLEAVING_SCAN_TOOL_NAME = 'model_interleaving_scan'
7
+
8
+ /**
9
+ * Model-visible DSH tool that mines design documents/plans for concurrency-related
10
+ * claims and risk keywords. It does not prove concurrency safety; it flags terms
11
+ * such as "thread-safe", "lock-free", "race condition", "atomic", "mutex", etc.,
12
+ * so the model can either provide dedicated evidence or mark the claim unverified.
13
+ */
14
+ export const modelConcurrencyScanTool = defineTool({
15
+ name: INTERLEAVING_SCAN_TOOL_NAME,
16
+ description:
17
+ 'Scan a document or plan text for claims about how work is executed: threads, synchronization, async tasks, interrupts, shared state. Use ONLY after confirming the target actually has concurrency requirements or behavior; if the target is purely sequential, do not call this tool. Findings carry a family (threads, synchronization, async, interrupts, shared-state) and an absolute claim is one a plan makes about safety rather than a risk it mentions. Returns keywords like thread-safe, lock-free, wait-free, data race, race condition, atomic, synchronized, mutex, semaphore, spinlock, shared variable, shared memory, reentrant, interrupt-safe, ISR, IRQ, NMI, critical section, disable_irq/enable_irq; async ones such as async, await, promise, event loop, callback, microtask, coroutine, fire-and-forget, backpressure, task queue; and their Chinese counterparts (线程安全, 无数据竞争, 无锁, 互斥, 原子, 并发, 临界区, 异步, 回调, 事件循环, 微任务, 协程, 背压 and so on). Absolute claims (thread-safe, lock-free, no data race, interrupt-safe, ISR-safe, non-blocking, never blocks) are flagged as errors requiring dedicated verification, and each carries suggestions naming the analysis to run instead, chosen by family: TSan/Helgrind/CBMC/TLA+ for threads, RTOS-aware analysis for interrupts, and TLA+/UPPAAL plus a runtime check for async, where a blocking call on the event loop, a missing await and a lock held across an await are invisible to any model. Text with no claim to mine is refused with `CONCURRENCY_NO_CLAIM` rather than reported as clean: a scan of nothing is not a clean bill of health, and that includes text in a vocabulary this scan does not match. This plugin does not prove concurrency safety, and this tool does not check a machine: to decide a protocol it mined, model it and call `model_verify`, or compose two of them with `model_compose_verify`.',
18
+ parameters: {
19
+ text: {
20
+ type: 'string',
21
+ required: true,
22
+ description: 'Document or plan text to scan for claims about threads, synchronization, async tasks, interrupts or shared state.',
23
+ },
24
+ reportView: {
25
+ type: 'string',
26
+ enum: ['digest', 'full'],
27
+ description: 'digest (default) prints the readable body; full prints the structured report, which report_diff needs.',
28
+ },
29
+ },
30
+ output: {
31
+ schema: {
32
+ type: 'json',
33
+ description: 'Concurrency scan report with findings and summary.',
34
+ },
35
+ render(args, value) {
36
+ return [{ type: 'text' as const, text: renderReportText(value, { full: args.reportView === 'full' }) }]
37
+ },
38
+ },
39
+ timeoutMs: 10_000,
40
+ isConcurrencySafe: () => true,
41
+ async execute(args) {
42
+ return runConcurrencyScan(args.text) as unknown as JsonValue
43
+ },
44
+ })