dsh-model-everything 0.0.0-stage → 0.1.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 (130) hide show
  1. package/LICENSE +21 -0
  2. package/README.en-US.md +319 -0
  3. package/README.md +319 -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 +76 -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 +79 -0
  22. package/lib/design-engine.js +1149 -0
  23. package/lib/design-tool.js +55 -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/option-shape.js +62 -0
  32. package/lib/report-diff-tool.js +63 -0
  33. package/lib/report-view.js +408 -0
  34. package/lib/structure-tool.js +91 -0
  35. package/lib/structure.js +711 -0
  36. package/lib/tool.js +85 -0
  37. package/lib/types/baseline.d.ts +65 -0
  38. package/lib/types/compose-tool.d.ts +7 -0
  39. package/lib/types/concurrency-tool.d.ts +8 -0
  40. package/lib/types/concurrency.d.ts +37 -0
  41. package/lib/types/data-engine.d.ts +211 -0
  42. package/lib/types/data-tool.d.ts +10 -0
  43. package/lib/types/design-engine.d.ts +215 -0
  44. package/lib/types/design-tool.d.ts +11 -0
  45. package/lib/types/engine.d.ts +494 -0
  46. package/lib/types/export-tool.d.ts +7 -0
  47. package/lib/types/exporters.d.ts +37 -0
  48. package/lib/types/gate.d.ts +91 -0
  49. package/lib/types/granularity.d.ts +106 -0
  50. package/lib/types/index.d.ts +45 -0
  51. package/lib/types/json-value.d.ts +21 -0
  52. package/lib/types/option-shape.d.ts +22 -0
  53. package/lib/types/report-diff-tool.d.ts +8 -0
  54. package/lib/types/report-view.d.ts +43 -0
  55. package/lib/types/structure-tool.d.ts +10 -0
  56. package/lib/types/structure.d.ts +152 -0
  57. package/lib/types/tool.d.ts +11 -0
  58. package/lib/types/uml-communication.d.ts +109 -0
  59. package/lib/types/uml-composite.d.ts +107 -0
  60. package/lib/types/uml-interaction-overview.d.ts +91 -0
  61. package/lib/types/uml-profile.d.ts +112 -0
  62. package/lib/types/uml-structure.d.ts +53 -0
  63. package/lib/types/uml-syntax.d.ts +296 -0
  64. package/lib/types/uml-timing.d.ts +152 -0
  65. package/lib/types/uml-tool.d.ts +12 -0
  66. package/lib/types/uml-usecase.d.ts +58 -0
  67. package/lib/types/uml.d.ts +303 -0
  68. package/lib/uml-communication.js +353 -0
  69. package/lib/uml-composite.js +442 -0
  70. package/lib/uml-interaction-overview.js +277 -0
  71. package/lib/uml-profile.js +373 -0
  72. package/lib/uml-structure.js +306 -0
  73. package/lib/uml-syntax.js +545 -0
  74. package/lib/uml-timing.js +974 -0
  75. package/lib/uml-tool.js +213 -0
  76. package/lib/uml-usecase.js +532 -0
  77. package/lib/uml.js +2110 -0
  78. package/locale/en.json +6 -0
  79. package/locale/zh.json +6 -0
  80. package/package.json +124 -6
  81. package/skills/model-everything/SKILL.md +482 -0
  82. package/skills/model-everything/references/dsh-model-schema.md +437 -0
  83. package/skills/model-everything/references/gap-routing-guide.md +42 -0
  84. package/skills/model-everything/references/hash-spec.md +152 -0
  85. package/skills/model-everything/references/logic-verification-guide.md +422 -0
  86. package/skills/model-everything-datamodel/SKILL.md +130 -0
  87. package/skills/model-everything-datamodel/references/data-model-guide.md +84 -0
  88. package/skills/model-everything-datamodel/references/data-model-schema.md +128 -0
  89. package/skills/model-everything-design/SKILL.md +115 -0
  90. package/skills/model-everything-interleaving/SKILL.md +63 -0
  91. package/skills/model-everything-interleaving/SKILL.zh-CN.md +60 -0
  92. package/skills/model-everything-interleaving/references/concurrency-risk-guide.md +63 -0
  93. package/skills/model-everything-report-reading/SKILL.md +137 -0
  94. package/skills/model-everything-report-reading/references/reading-a-report.md +166 -0
  95. package/skills/model-everything-report-reading/references/reading-a-report.mmd +59 -0
  96. package/skills/model-everything-structure/SKILL.md +96 -0
  97. package/skills/model-everything-structure/references/structure-review-guide.md +201 -0
  98. package/skills/model-everything-uml/SKILL.md +60 -0
  99. package/skills/model-everything-uml/references/uml-modeling-guide.md +551 -0
  100. package/src/baseline.ts +288 -0
  101. package/src/compose-tool.ts +78 -0
  102. package/src/concurrency-tool.ts +44 -0
  103. package/src/concurrency.ts +254 -0
  104. package/src/data-engine.ts +1208 -0
  105. package/src/data-tool.ts +83 -0
  106. package/src/design-engine.ts +1263 -0
  107. package/src/design-tool.ts +60 -0
  108. package/src/engine.ts +4564 -0
  109. package/src/export-tool.ts +81 -0
  110. package/src/exporters.ts +399 -0
  111. package/src/gate.ts +330 -0
  112. package/src/granularity.ts +312 -0
  113. package/src/index.ts +289 -0
  114. package/src/json-value.ts +20 -0
  115. package/src/option-shape.ts +64 -0
  116. package/src/report-diff-tool.ts +67 -0
  117. package/src/report-view.ts +451 -0
  118. package/src/structure-tool.ts +93 -0
  119. package/src/structure.ts +786 -0
  120. package/src/tool.ts +94 -0
  121. package/src/uml-communication.ts +428 -0
  122. package/src/uml-composite.ts +520 -0
  123. package/src/uml-interaction-overview.ts +321 -0
  124. package/src/uml-profile.ts +439 -0
  125. package/src/uml-structure.ts +317 -0
  126. package/src/uml-syntax.ts +764 -0
  127. package/src/uml-timing.ts +1106 -0
  128. package/src/uml-tool.ts +230 -0
  129. package/src/uml-usecase.ts +566 -0
  130. package/src/uml.ts +2139 -0
@@ -0,0 +1,451 @@
1
+ /**
2
+ * The report body: the text a model reads for a this plugin result.
3
+ *
4
+ * Why this is not `JSON.stringify(value, null, 2)` any more: the structured report is a
5
+ * contract (schema, verdict, hashes, check ids, finding codes, evidence — a finding's
6
+ * identity is `check|code|<evidence + path>`, so `report_diff` and the test
7
+ * suite read the object, never this text). The object is unchanged and still reaches the
8
+ * client and the file path; this module only decides what the *model* pays for.
9
+ *
10
+ * Measured on a real 37,167-character report: 33.1% indentation, 21.7% repeated key names,
11
+ * 12.7% punctuation; 105 findings carried 47 distinct facts. So the work this module does —
12
+ * silencing passing checks, grouping repeated (state, event) facts, collapsing identical
13
+ * lines, dropping warning evidence — is work the model would otherwise have to redo by
14
+ * reading every finding. Everything a consumer does not need to act on is handled here.
15
+ *
16
+ * Determinism: the same report always renders to the same text (first-seen order, stable
17
+ * check order, no clock, no randomness), so two runs over the same input stay comparable.
18
+ *
19
+ * @module model-everything/report-view
20
+ */
21
+ import type { JsonValue } from './json-value.js'
22
+
23
+ /** Rendering limits. Defaults are the shipped behaviour; tests pin them. */
24
+ export interface ReportViewOptions {
25
+ /** Maximum finding lines per check. Error findings are never dropped, only warnings are capped. */
26
+ maxFindingsPerCheck?: number
27
+ /** Evidence is inlined for `error` findings only, truncated to this many characters. */
28
+ maxEvidenceChars?: number
29
+ /** Include passing checks' bodies (default false: a passing check carries no actionable finding). */
30
+ includePassing?: boolean
31
+ /**
32
+ * Print the whole structured report instead of the body. This is the escape hatch for a
33
+ * caller that must hand the report to another tool verbatim — `report_diff`
34
+ * matches on check + code + evidence, so a body cannot stand in for the object.
35
+ */
36
+ full?: boolean
37
+ }
38
+
39
+ const DEFAULTS = { maxFindingsPerCheck: 24, maxEvidenceChars: 160, includePassing: false, full: false } as const
40
+
41
+ type Record_ = Record<string, unknown>
42
+
43
+ const record = (value: unknown): Record_ | undefined =>
44
+ typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record_) : undefined
45
+ const array = (value: unknown): unknown[] => (Array.isArray(value) ? value : [])
46
+ const text = (value: unknown): string | undefined => (typeof value === 'string' ? value : undefined)
47
+ const count = (value: unknown): number | undefined => (typeof value === 'number' && Number.isFinite(value) ? value : undefined)
48
+
49
+ /** One normalized finding. `evidence` is kept because a finding's identity depends on it. */
50
+ export interface ViewFinding {
51
+ code: string
52
+ severity: string
53
+ message: string
54
+ evidence?: Record_
55
+ file?: string
56
+ line?: number
57
+ check?: string
58
+ }
59
+
60
+ /** Normalize one raw finding. Unknown fields are ignored; a missing message renders as the code. */
61
+ export function toViewFinding(value: unknown, check?: string): ViewFinding {
62
+ const raw = record(value) ?? {}
63
+ const code = text(raw.code) ?? 'FINDING'
64
+ const file = text(raw.file)
65
+ const line = count(raw.line)
66
+ return {
67
+ code,
68
+ severity: text(raw.severity) ?? 'warning',
69
+ message: text(raw.message) ?? code,
70
+ ...(record(raw.evidence) === undefined ? {} : { evidence: record(raw.evidence) as Record_ }),
71
+ ...(file === undefined ? {} : { file }),
72
+ ...(line === undefined ? {} : { line }),
73
+ ...(check === undefined ? {} : { check }),
74
+ }
75
+ }
76
+
77
+ /**
78
+ * The fact a finding states, as a comparable key. The verify/datamodel families report the
79
+ * same (state, event) fact from more than one check, so the key ignores which check said it;
80
+ * anything else falls back to code + message.
81
+ */
82
+ export function factKey(finding: ViewFinding): string {
83
+ const stateEvent = /^(\S+) silently ignores event (\S+)/.exec(finding.message)
84
+ if (stateEvent !== null) return `event-in-state|${stateEvent[1] ?? ''}|${stateEvent[2] ?? ''}`
85
+ const eventState = /^Event (\S+) in state (\S+) /.exec(finding.message)
86
+ if (eventState !== null) return `event-in-state|${eventState[2] ?? ''}|${eventState[1] ?? ''}`
87
+ return `message|${finding.code}|${finding.message}`
88
+ }
89
+
90
+ /** `[severity/CODE] message` plus, for errors, a compact evidence tail. */
91
+ function findingLine(finding: ViewFinding, options: Required<ReportViewOptions>): string {
92
+ const where = finding.file === undefined ? '' : ` (${finding.file}${finding.line === undefined ? '' : ':' + String(finding.line)})`
93
+ const evidence =
94
+ finding.evidence === undefined || finding.severity !== 'error'
95
+ ? ''
96
+ : ' evidence=' + truncate(JSON.stringify(finding.evidence), options.maxEvidenceChars)
97
+ return ` [${finding.severity}/${finding.code}] ${finding.message}${where}${evidence}`
98
+ }
99
+
100
+ function truncate(value: string, limit: number): string {
101
+ return value.length <= limit ? value : value.slice(0, limit - 1) + '…'
102
+ }
103
+
104
+ /** Collapse identical lines into `line (xN)`, keeping first-seen order. */
105
+ function collapse(lines: string[]): string[] {
106
+ const order: string[] = []
107
+ const seen = new Map<string, number>()
108
+ for (const line of lines) {
109
+ if (!seen.has(line)) order.push(line)
110
+ seen.set(line, (seen.get(line) ?? 0) + 1)
111
+ }
112
+ return order.map((line) => (seen.get(line) === 1 ? line : `${line} (x${String(seen.get(line))})`))
113
+ }
114
+
115
+ /**
116
+ * Body lines for one group of findings. Findings that restate a fact an earlier check already
117
+ * reported are dropped (only their count survives), because re-reading them is pure cost;
118
+ * `error` findings are never dropped.
119
+ */
120
+ function findingLines(findings: ViewFinding[], options: Required<ReportViewOptions>, seenFacts: Set<string>): string[] {
121
+ const lines: string[] = []
122
+ let repeats = 0
123
+ const fresh: ViewFinding[] = []
124
+ const addedHere = new Set<string>()
125
+ for (const finding of findings) {
126
+ const key = factKey(finding)
127
+ // A fact another check already reported is dropped here (only its count survives), but a
128
+ // fact repeated inside this same group is not a cross-check repeat: it collapses below
129
+ // into one line with a multiplier, which keeps the count visible where it belongs.
130
+ if (finding.severity !== 'error' && seenFacts.has(key) && !addedHere.has(key)) {
131
+ repeats += 1
132
+ continue
133
+ }
134
+ addedHere.add(key)
135
+ seenFacts.add(key)
136
+ fresh.push(finding)
137
+ }
138
+ const stateGroups = new Map<string, { severity: string; code: string; events: string[] }>()
139
+ const singles: ViewFinding[] = []
140
+ for (const finding of fresh) {
141
+ const match = /^(\S+) silently ignores event (\S+)/.exec(finding.message) ?? /^Event (\S+) in state (\S+) /.exec(finding.message)
142
+ if (match === null) {
143
+ singles.push(finding)
144
+ continue
145
+ }
146
+ // `event in state` puts the state second; `state ignores event` puts it first.
147
+ const state = / silently ignores event /.test(finding.message) ? match[1] ?? '' : match[2] ?? ''
148
+ const event = / silently ignores event /.test(finding.message) ? match[2] ?? '' : match[1] ?? ''
149
+ const group = stateGroups.get(state) ?? { severity: finding.severity, code: finding.code, events: [] }
150
+ group.events.push(event)
151
+ stateGroups.set(state, group)
152
+ }
153
+ for (const [state, group] of stateGroups) {
154
+ lines.push(` ${state} (${String(group.events.length)}) [${group.severity}/${group.code}]: ${group.events.join(', ')}`)
155
+ }
156
+ const rendered = collapse(singles.map((finding) => findingLine(finding, options)))
157
+ const budget = options.maxFindingsPerCheck
158
+ const kept: string[] = []
159
+ let dropped = 0
160
+ for (const line of [...lines, ...rendered]) {
161
+ if (kept.length < budget || line.includes('/error]')) kept.push(line)
162
+ else dropped += 1
163
+ }
164
+ if (dropped > 0) kept.push(` +${String(dropped)} more warning line(s) — ask for this check by id to see them all`)
165
+ if (repeats > 0) kept.push(` (${String(repeats)} finding(s) here repeat facts already reported above)`)
166
+ return kept
167
+ }
168
+
169
+ /** `ok`/`verdict` header fields, shared by every report family. */
170
+ function headerLines(report: Record_, options: Required<ReportViewOptions>): string[] {
171
+ const schema = text(report.schema) ?? 'model-everything/report'
172
+ const verdict = text(report.verdict)
173
+ const ok = report.ok
174
+ const summary = record(report.summary) ?? {}
175
+ const lines: string[] = []
176
+ const bits = [
177
+ `${schema}`,
178
+ verdict === undefined ? `ok=${String(ok)}` : `verdict=${verdict}`,
179
+ text(report.verdictReason) ?? '',
180
+ ok === undefined || verdict === undefined ? '' : `ok=${String(ok)}`,
181
+ ].filter((bit) => bit !== '')
182
+ lines.push(bits.join(' '))
183
+ // A refusal carries no model to hash, and the field is an empty string rather than absent, so it rendered
184
+ // as `modelHash=…` - which reads as a truncated hash rather than as nothing to hash. An empty value means
185
+ // the same thing as a missing one here.
186
+ const hashText = text(report.modelHash) ?? text(record(report.hashes)?.modelHash)
187
+ const hash = hashText === '' ? undefined : hashText
188
+ // The scheme belongs beside the digest: a hex string with no spec cannot be reproduced, and two hashes cannot
189
+ // be compared across report versions without it.
190
+ const hashSpec = text(report.hashSpec) ?? text(record(report.hashes)?.hashSpec)
191
+ const hashBits = hash === undefined ? [] : [`modelHash=${hash.slice(0, 12)}…`, ...(hashSpec === undefined ? [] : [`hashSpec=${hashSpec}`])]
192
+ const counts = ['states', 'transitions', 'errors', 'warnings', 'checksRun']
193
+ .map((key) => (count(summary[key]) === undefined ? '' : `${key}=${String(count(summary[key]))}`))
194
+ .filter((bit) => bit !== '')
195
+ if (counts.length > 0 || hashBits.length > 0) lines[0] += ' ' + [...hashBits, ...counts].join(' ')
196
+ if (report.ran === false) lines.push('note: the tool did not run (the engine refused the input) — the fields below describe the refusal.')
197
+ // `truncated` is the flag that says a search stopped at its budget. It reached the object and never the body, so
198
+ // the one reader who sees only the body - the model - read an unfinished search as a complete one. It belongs
199
+ // at the top, before the inventory: a reader who stops after the first line still learns the report is partial.
200
+ if (summary.truncated === true) {
201
+ lines.push('LIMIT the searches stopped at their budget, so part of this report is undecided rather than clean — the *_UNDECIDED findings name what.')
202
+ }
203
+ // A replaced or clamped bound is the reason the header's numbers are not the ones the caller asked for.
204
+ for (const note of array(summary.boundNotes).map(String).filter((note) => note !== '')) lines.push(`BOUND ${note}`)
205
+ // Coverage arrives as "described/total" strings, so reading only numbers dropped every value the engine sends
206
+ // and the line never appeared. Take both shapes.
207
+ const coverage = record(report.narrativeCoverage)
208
+ if (coverage !== undefined) {
209
+ const parts = Object.entries(coverage)
210
+ .map(([key, value]) => {
211
+ const shown = count(value) ?? text(value)
212
+ return shown === undefined ? undefined : `${key} ${String(shown)}`
213
+ })
214
+ .filter((part): part is string => part !== undefined)
215
+ if (parts.length > 0) lines.push(`NARRATIVE ${parts.join(' / ')} documented`)
216
+ }
217
+ // The schema ignores `_`-prefixed keys as annotation metadata and the report echoes their paths. Naming them
218
+ // here makes the ignoring auditable; dropping them made "the schema ignored your field" invisible.
219
+ const metadata = array(report.metadataKeys).map(String).filter((key) => key !== '')
220
+ if (metadata.length > 0) lines.push(`METADATA _-prefixed keys the schema ignored: ${metadata.join(', ')}`)
221
+ void options
222
+ return lines
223
+ }
224
+
225
+ /** Trailing lines: notes about what was not checked, then the next steps. */
226
+ function footerLines(report: Record_): string[] {
227
+ const lines: string[] = []
228
+ const notes = array(report.coverageNotes).map(String).filter((note) => note !== '')
229
+ if (notes.length > 0) lines.push(`COVERAGE ${notes.join(' ')}`)
230
+ const steps = array(report.nextSteps).map(String).filter((step) => step !== '')
231
+ for (const step of steps) lines.push(`NEXT ${step}`)
232
+ const comparison = record(report.comparison)
233
+ if (comparison !== undefined) {
234
+ const parts = Object.entries(comparison)
235
+ .filter(([, value]) => count(value) !== undefined || typeof value === 'string')
236
+ .map(([key, value]) => `${key}=${String(value)}`)
237
+ if (parts.length > 0) lines.push(`COMPARISON ${parts.join(' ')}`)
238
+ }
239
+ return lines
240
+ }
241
+
242
+ /** A `checks[]` report (verify, compose, datamodel): silence pass, expand the rest. */
243
+ function renderChecksReport(report: Record_, options: Required<ReportViewOptions>): string[] {
244
+ const checks = array(report.checks).map((value) => record(value) ?? {})
245
+ const byStatus = (status: string): string[] =>
246
+ checks.filter((check) => text(check.status) === status).map((check) => text(check.id) ?? '?')
247
+ /**
248
+ * Whether a failing check carries an error or only warnings.
249
+ *
250
+ * `status` is `fail` for any check with a finding, which is the right field for a machine and a
251
+ * misleading word for a reader: a report can say `verdict=pass_with_findings ... no error findings` on
252
+ * one line and `fail S5 A1 A3` on the next, where all three hold warnings only. The word `fail` beside a
253
+ * verdict that says nothing failed is the misreading the reading skill warns about, so the inventory
254
+ * distinguishes them and leaves the status field alone.
255
+ */
256
+ const failingKind = (check: Record_): 'error' | 'warn' => {
257
+ const findings = array(check.findings).map((value) => record(value) ?? {})
258
+ return findings.some((finding) => text(finding.severity) === 'error') ? 'error' : 'warn'
259
+ }
260
+ const failing = checks.filter((check) => text(check.status) === 'fail')
261
+ const inventory = [
262
+ ['pass', byStatus('pass')],
263
+ ['fail', failing.filter((check) => failingKind(check) === 'error').map((check) => text(check.id) ?? '?')],
264
+ ['warn', failing.filter((check) => failingKind(check) === 'warn').map((check) => text(check.id) ?? '?')],
265
+ ['skip', byStatus('skip')],
266
+ ]
267
+ .filter(([, ids]) => (ids as string[]).length > 0)
268
+ .map(([status, ids]) => `${String(status)} ${(ids as string[]).join(' ')}`)
269
+ .join(' | ')
270
+ const lines = [...headerLines(report, options), `CHECKS ${inventory}`]
271
+ const seenFacts = new Set<string>()
272
+ for (const check of checks) {
273
+ const status = text(check.status) ?? 'skip'
274
+ const findings = array(check.findings).map((finding) => toViewFinding(finding, text(check.id)))
275
+ if (status === 'pass' && !options.includePassing) continue
276
+ const body = findingLines(findings, options, seenFacts)
277
+ if (status !== 'fail' && body.length === 0) continue
278
+ lines.push('')
279
+ // The section header says what the inventory said: `fail` for a check carrying an error, `warn` for one
280
+ // carrying warnings only. Printing FAIL here while the CHECKS line grouped the same check under `warn`
281
+ // put the misreading one line below where it had just been removed.
282
+ const label = status === 'fail' ? (failingKind(check) === 'error' ? 'FAIL' : 'WARN') : status.toUpperCase()
283
+ lines.push(
284
+ `${label} ${text(check.id) ?? '?'} ${text(check.name) ?? ''} — ${text(check.detail) ?? ''} (${String(findings.length)} finding(s))`.replace(/ +— +$/, ''),
285
+ )
286
+ lines.push(...body)
287
+ }
288
+ return [...lines, ...wrapFooter(report)]
289
+ }
290
+
291
+ /** A flat `findings[]` report (uml review, structure, granularity, concurrency). */
292
+ function renderFindingsReport(report: Record_, options: Required<ReportViewOptions>): string[] {
293
+ const findings = array(report.findings).map((finding) => toViewFinding(finding))
294
+ const errors = findings.filter((finding) => finding.severity === 'error').length
295
+ const warnings = findings.length - errors
296
+ const byCode = new Map<string, number>()
297
+ for (const finding of findings) byCode.set(finding.code, (byCode.get(finding.code) ?? 0) + 1)
298
+ // A report can carry notes beside its findings: `uml_parse` puts what the parser could not read in `warnings`, and
299
+ // the verdict counts them. The body printed neither, so a report with one parser note said "1 warning finding(s)"
300
+ // above "FINDINGS none — nothing was reported". They are printed now, and the empty-roster sentence stops claiming
301
+ // nothing was reported when something was.
302
+ const notes = array(report.warnings).map(String).filter((note) => note !== '')
303
+ const lines = [
304
+ ...headerLines(report, options),
305
+ ...parseBodyLines(report),
306
+ // An empty roster says so in one word. `0 total (0 error, 0 warning):` with a dangling colon read like a
307
+ // line that had lost its content.
308
+ findings.length === 0
309
+ ? (notes.length === 0
310
+ ? 'FINDINGS none — nothing was reported'
311
+ : `FINDINGS none — ${String(notes.length)} note(s) under WARNING say what could not be carried`)
312
+ : `FINDINGS ${String(findings.length)} total (${String(errors)} error, ${String(warnings)} warning): ${[...byCode].map(([code, n]) => `${code} x${String(n)}`).join(' | ')}`,
313
+ ]
314
+ const body = findingLines(findings, options, new Set<string>())
315
+ if (body.length > 0) lines.push('', ...body)
316
+ if (notes.length > 0) {
317
+ lines.push('')
318
+ for (const note of notes) lines.push(`WARNING ${truncate(note, options.maxEvidenceChars * 2)}`)
319
+ }
320
+ return [...lines, ...wrapFooter(report)]
321
+ }
322
+
323
+ /**
324
+ * A report that carries a model rather than findings about one (`uml_parse`).
325
+ *
326
+ * The reader asked to read the diagram into a model, and the body printed neither the model nor how the text was
327
+ * read, so the one reader who only has the body got `FINDINGS none` as the entire answer. The model is printed as
328
+ * compact JSON on one line: it is data the caller feeds to `model_verify`, not prose.
329
+ */
330
+ function parseBodyLines(report: Record_): string[] {
331
+ // Only a parse report: a review report carries a model too, but the caller already has it and the review is about
332
+ // what the diagram says. Printing it there would be the same body for two different questions.
333
+ if (text(report.action) !== 'parse') return []
334
+ const model = record(report.model)
335
+ if (model === undefined) return []
336
+ const where = ['notation', 'kind', 'family']
337
+ .map((key) => (text(report[key]) === undefined ? undefined : `${key}=${String(text(report[key]))}`))
338
+ .filter((part): part is string => part !== undefined)
339
+ return [...(where.length === 0 ? [] : [`PARSE ${where.join(' ')}`]), `MODEL ${JSON.stringify(model)}`]
340
+ }
341
+
342
+ /** A baseline diff report: what a change added, removed or changed. */
343
+ function renderDeltaReport(report: Record_, options: Required<ReportViewOptions>): string[] {
344
+ const lines = headerLines(report, options)
345
+ const delta = record(report.delta) ?? report
346
+ const section = (key: string): ViewFinding[] => array(delta[key]).map((finding) => toViewFinding(finding))
347
+ for (const key of ['added', 'removed']) {
348
+ const findings = section(key)
349
+ if (findings.length === 0) continue
350
+ lines.push('', `${key.toUpperCase()} (${String(findings.length)})`)
351
+ lines.push(...findingLines(findings, options, new Set<string>()))
352
+ }
353
+ // `changed` items are not findings: each carries the two sides of a finding that kept its identity and moved.
354
+ // Reading them with `toViewFinding` invented `severity: warning` for an item that has no severity, and dropped
355
+ // both messages - so a warning that escalated to an error rendered as `[warning/CODE] CODE`, which says the
356
+ // opposite of what happened.
357
+ const changes = array(delta.changed)
358
+ if (changes.length > 0) {
359
+ lines.push('', `CHANGED (${String(changes.length)})`)
360
+ for (const raw of changes) {
361
+ const change = record(raw) ?? {}
362
+ const before = record(change.before) ?? {}
363
+ const after = record(change.after) ?? {}
364
+ const beforeSeverity = text(before.severity)
365
+ const afterSeverity = text(after.severity)
366
+ const beforeMessage = text(before.message)
367
+ const afterMessage = text(after.message)
368
+ const parts: string[] = []
369
+ if (beforeSeverity !== afterSeverity) parts.push(`severity ${String(beforeSeverity)} → ${String(afterSeverity)}`)
370
+ if (beforeMessage !== afterMessage) {
371
+ parts.push(`message "${truncate(String(beforeMessage), options.maxEvidenceChars)}" → "${truncate(String(afterMessage), options.maxEvidenceChars)}"`)
372
+ }
373
+ lines.push(` ${text(change.code) ?? '?'} ${parts.length === 0 ? 'reported differently' : parts.join('; ')}`)
374
+ }
375
+ }
376
+ const current = text(report.currentVerdict) ?? text(record(report.current)?.verdict)
377
+ if (current !== undefined) lines.push(`CURRENT verdict=${current}`)
378
+ return [...lines, ...wrapFooter(report)]
379
+ }
380
+
381
+ /** A payload result (a rendered diagram, an exported file): print the payload, not JSON of it. */
382
+ function renderPayloadReport(report: Record_, options: Required<ReportViewOptions>): string[] {
383
+ const lines = headerLines(report, options)
384
+ const primary = text(report.diagram) ?? text(report.primary)
385
+ if (primary !== undefined) lines.push('', primary.trimEnd())
386
+ const extras = record(report.extras)
387
+ for (const [name, content] of Object.entries(extras ?? {})) {
388
+ const body = text(content)
389
+ if (body !== undefined) lines.push('', `--- ${name} ---`, body.trimEnd())
390
+ }
391
+ const warnings = array(report.warnings).map(String).filter((warning) => warning !== '')
392
+ for (const warning of warnings) lines.push(`WARNING ${warning}`)
393
+ return [...lines, ...wrapFooter(report)]
394
+ }
395
+
396
+ function wrapFooter(report: Record_): string[] {
397
+ const footer = footerLines(report)
398
+ return footer.length === 0 ? [] : ['', ...footer]
399
+ }
400
+
401
+ /**
402
+ * A refusal, or a shape this module does not recognize: state the reason first, then the
403
+ * remaining fields compactly so nothing is lost, without paying for indentation.
404
+ */
405
+ function renderFallbackReport(report: Record_, options: Required<ReportViewOptions>): string[] {
406
+ const lines = headerLines(report, options)
407
+ const message = text(report.error)
408
+ if (message !== undefined) {
409
+ const code = text(report.errorCode)
410
+ lines.push(`ERROR ${message}${code === undefined ? '' : ` [${code}]`}`)
411
+ }
412
+ const rest: Record_ = { ...report }
413
+ delete rest.error
414
+ delete rest.errorCode
415
+ lines.push('', JSON.stringify(rest))
416
+ return [...lines, ...wrapFooter(report)]
417
+ }
418
+
419
+ /**
420
+ * Render any this plugin result as the text a model reads.
421
+ *
422
+ * Order of recognition: a `checks[]` report, a flat `findings[]` report, a baseline delta,
423
+ * a payload result (`diagram`/`primary`), and finally compact JSON so an unrecognized shape
424
+ * is still readable rather than silently empty.
425
+ */
426
+ export function renderReportText(value: unknown, options: ReportViewOptions = {}): string {
427
+ const resolved: Required<ReportViewOptions> = { ...DEFAULTS, ...options }
428
+ if (resolved.full) return JSON.stringify(value as JsonValue, null, 2)
429
+ const report = record(value)
430
+ if (report === undefined) return JSON.stringify(value as JsonValue) ?? String(value)
431
+ const hasChecks = array(report.checks).some((check) => record(check) !== undefined)
432
+ // A report that CARRIES a findings list is a findings report even when the list is empty. Routing on
433
+ // length sent a clean report to the unknown-shape fallback, which printed the whole JSON - so the default
434
+ // view cost more than the `full` escape hatch it exists to be cheaper than.
435
+ const hasFindings = Array.isArray(report.findings)
436
+ // A delta is recognised by its shape, not by whether anything moved: a clean comparison is the case a baseline
437
+ // is used for most often, and requiring a non-empty list sent exactly that case to the unknown-shape fallback,
438
+ // which printed the whole object as JSON.
439
+ const isDelta = Array.isArray(report.added) || Array.isArray(report.removed) || Array.isArray(report.changed) || typeof report.currentVerdict === 'string'
440
+ const isPayload = typeof report.diagram === 'string' || typeof report.primary === 'string'
441
+ const lines = hasChecks
442
+ ? renderChecksReport(report, resolved)
443
+ : isDelta
444
+ ? renderDeltaReport(report, resolved)
445
+ : hasFindings
446
+ ? renderFindingsReport(report, resolved)
447
+ : isPayload
448
+ ? renderPayloadReport(report, resolved)
449
+ : renderFallbackReport(report, resolved)
450
+ return lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd()
451
+ }
@@ -0,0 +1,93 @@
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 { reviewStructure } from './structure.js'
5
+ import { reviewGranularity, type GranularityDiagram } from './granularity.js'
6
+ import { OptionShapeError, optionRefusal, requireArrayOption } from './option-shape.js'
7
+ import { renderReportText } from './report-view.js'
8
+
9
+ export const STRUCTURE_TOOL_NAME = 'uml_structure_verify'
10
+
11
+ /**
12
+ * Model-visible DSH tool that audits a *structure* diagram — components, packages,
13
+ * classes, deployment nodes and the dependency arrows between them — against an
14
+ * optional dependency matrix. A structure diagram is not a state machine: this tool
15
+ * reads the graph the diagram actually draws, instead of refusing it (which is what
16
+ * `uml action=parse` does) or pretending the arrows are state transitions
17
+ * (which is what a false `ok` used to hide).
18
+ */
19
+ export const umlStructureTool = defineTool({
20
+ name: STRUCTURE_TOOL_NAME,
21
+ description:
22
+ 'Audit a structure diagram as a dependency graph. Takes PlantUML component, package, class or deployment text, or a Mermaid class diagram. Reports UML020 isolated node, UML021 dangling reference, UML022 cycle (shortest path), UML023 disallowed edge and UML024 layer violation against a matrix, UML025 missing required edge, and UML026 when no node is declared. The matrix is {rules: [{id, source, allow?, deny?, require?}], layers: [{name, members}], default: "allow"|"deny"}. Ids and members accept globs (`*`, `?`). Deny beats allow, and layers run top-first, so downward edges pass and upward ones are UML024. With a matrix, every edge appears in `edgeVerdicts` with the rules it matched. Pass `diagrams` instead of `diagram` to check several levels at once: each child must refine its parent. This audits the diagram, not the code: reconcile it with a source-side scan. Read `verdict`, not `ok`. For state or activity diagrams use `uml` and `model_verify`.',
23
+ parameters: {
24
+ diagram: {
25
+ type: 'string',
26
+ description: 'Structure-diagram source text: PlantUML component/package/class/deployment, or a Mermaid class diagram. Omit when `diagrams` is given.',
27
+ },
28
+ diagrams: {
29
+ type: 'json',
30
+ description: 'Multi-granularity mode: an array of {name, diagram, parent?} levels. Each diagram is checked on its own AND against its declared parent (UML028 unknown parent, UML029 refinement violation, UML030 parent cycle), with per-pair node/edge counts.',
31
+ },
32
+ notation: {
33
+ type: 'string',
34
+ enum: ['auto', 'plantuml', 'mermaid'],
35
+ description: 'Diagram language. Default auto (detected from the text).',
36
+ },
37
+ matrix: {
38
+ type: 'json',
39
+ description: 'Optional dependency matrix: {rules: [{id, source, allow?, deny?, require?}], layers: [{name, members}], default: "allow"|"deny"}. Every edge is judged against it and the matched rule ids are reported.',
40
+ },
41
+ reportView: {
42
+ type: 'string',
43
+ enum: ['digest', 'full'],
44
+ description: 'digest (default) prints the readable body; full prints the structured report, which report_diff needs.',
45
+ },
46
+ },
47
+ output: {
48
+ schema: {
49
+ type: 'json',
50
+ description: 'Structure review report: graph (nodes/edges), the findings with their codes, per-edge matrix verdicts, summary and next steps.',
51
+ },
52
+ render(args, value) {
53
+ return [{ type: 'text' as const, text: renderReportText(value, { full: args.reportView === 'full' }) }]
54
+ },
55
+ },
56
+ timeoutMs: 10_000,
57
+ isConcurrencySafe: () => true,
58
+ async execute(args) {
59
+ if (args.diagrams !== undefined) {
60
+ try {
61
+ // `diagrams` was read with `.map` behind no guard, and a level's `diagram` with `.split`, so a string
62
+ // or a nameless level escaped as a raw TypeError. The level is the unit a caller writes by hand, so a
63
+ // missing field has to be named with its position.
64
+ requireArrayOption(args.diagrams, 'diagrams', 'STRUCTURE_OPTION_INVALID')
65
+ for (const [index, level] of (args.diagrams as unknown[]).entries()) {
66
+ if (level === null || typeof level !== 'object' || Array.isArray(level)) continue
67
+ const entry = level as Record<string, unknown>
68
+ if (typeof entry.diagram !== 'string') {
69
+ throw new OptionShapeError('STRUCTURE_OPTION_INVALID', 'diagrams[' + String(index) + '].diagram is ' + (entry.diagram === undefined ? 'missing' : typeof entry.diagram) + ', not a string. Every level needs the diagram text it audits.')
70
+ }
71
+ if (typeof entry.name !== 'string' || entry.name === '') {
72
+ throw new OptionShapeError('STRUCTURE_OPTION_INVALID', 'diagrams[' + String(index) + '].name is ' + (entry.name === undefined ? 'missing' : 'not a non-empty string') + '. The name is how a refinement violation says which level it means.')
73
+ }
74
+ }
75
+ return reviewGranularity({
76
+ diagrams: args.diagrams as unknown as GranularityDiagram[],
77
+ notation: (args.notation ?? 'auto') as 'auto' | 'plantuml' | 'mermaid',
78
+ ...(args.matrix === undefined ? {} : { matrix: args.matrix }),
79
+ }) as unknown as JsonValue
80
+ } catch (error) {
81
+ return optionRefusal(error, 'STRUCTURE_OPTION_INVALID') as unknown as JsonValue
82
+ }
83
+ }
84
+ if (args.diagram === undefined) {
85
+ return { ok: false, ran: false, verdict: 'fail', verdictReason: 'the tool did not run: pass `diagram` (one structure diagram) or `diagrams` (several levels with parents)', schema: REPORT_SCHEMAS.structure, errorCode: 'UML_INPUT', error: 'structure review needs `diagram` or `diagrams`' } as unknown as JsonValue
86
+ }
87
+ return reviewStructure({
88
+ diagram: args.diagram,
89
+ notation: (args.notation ?? 'auto') as 'auto' | 'plantuml' | 'mermaid',
90
+ ...(args.matrix === undefined ? {} : { matrix: args.matrix }),
91
+ }) as unknown as JsonValue
92
+ },
93
+ })