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