dsh-model-everything 0.2.0 → 0.4.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 (54) hide show
  1. package/README.en-US.md +104 -76
  2. package/README.md +84 -71
  3. package/lib/automaton-tool.js +70 -0
  4. package/lib/automaton.js +328 -0
  5. package/lib/compose-tool.js +7 -0
  6. package/lib/concurrency-tool.js +7 -0
  7. package/lib/data-tool.js +7 -0
  8. package/lib/design-tool.js +8 -1
  9. package/lib/edit-tool.js +113 -0
  10. package/lib/editor.js +293 -0
  11. package/lib/engine.js +7 -0
  12. package/lib/export-tool.js +8 -0
  13. package/lib/index.js +4 -0
  14. package/lib/presentation.js +309 -0
  15. package/lib/report-diff-tool.js +7 -0
  16. package/lib/report-view.js +62 -10
  17. package/lib/routing.js +85 -0
  18. package/lib/scan.js +547 -0
  19. package/lib/structure-tool.js +35 -1
  20. package/lib/structure.js +28 -3
  21. package/lib/tool.js +7 -0
  22. package/lib/types/automaton-tool.d.ts +2 -0
  23. package/lib/types/automaton.d.ts +57 -0
  24. package/lib/types/edit-tool.d.ts +2 -0
  25. package/lib/types/editor.d.ts +46 -0
  26. package/lib/types/engine.d.ts +1 -0
  27. package/lib/types/presentation.d.ts +164 -0
  28. package/lib/types/routing.d.ts +31 -0
  29. package/lib/types/scan.d.ts +37 -0
  30. package/lib/types/structure.d.ts +16 -0
  31. package/lib/uml-tool.js +21 -6
  32. package/package.json +6 -3
  33. package/skills/model-everything/SKILL.md +6 -2
  34. package/skills/model-everything/references/automata.md +102 -0
  35. package/src/automaton-tool.ts +74 -0
  36. package/src/automaton.ts +376 -0
  37. package/src/compose-tool.ts +13 -5
  38. package/src/concurrency-tool.ts +8 -0
  39. package/src/data-tool.ts +8 -0
  40. package/src/design-tool.ts +76 -68
  41. package/src/edit-tool.ts +121 -0
  42. package/src/editor.ts +297 -0
  43. package/src/engine.ts +7 -0
  44. package/src/export-tool.ts +90 -81
  45. package/src/index.ts +4 -0
  46. package/src/presentation.ts +365 -0
  47. package/src/report-diff-tool.ts +8 -0
  48. package/src/report-view.ts +53 -2
  49. package/src/routing.ts +96 -0
  50. package/src/scan.ts +564 -0
  51. package/src/structure-tool.ts +34 -1
  52. package/src/structure.ts +37 -3
  53. package/src/tool.ts +7 -0
  54. package/src/uml-tool.ts +245 -230
@@ -0,0 +1,309 @@
1
+ import { routeFor } from './routing.js';
2
+ const SHORT = 160;
3
+ function shorten(text, limit = SHORT) {
4
+ const flat = text.replace(/\s+/g, ' ').trim();
5
+ return flat.length <= limit ? flat : flat.slice(0, limit - 1).trimEnd() + '…';
6
+ }
7
+ function record(value) {
8
+ return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
9
+ }
10
+ function array(value) {
11
+ return Array.isArray(value) ? value : [];
12
+ }
13
+ /**
14
+ * Project a report into the card payload.
15
+ *
16
+ * Read defensively: this runs on replay too, and a payload that once had a shape may come back with a
17
+ * field missing. A card that throws would take the whole session view with it.
18
+ */
19
+ export function reportCard(value, family) {
20
+ const report = record(value) ?? {};
21
+ const findings = array(report.findings)
22
+ .map((entry) => record(entry))
23
+ .filter((entry) => entry !== undefined);
24
+ // Two shapes live under one payload: the families with a top-level `findings` array, and the verify
25
+ // family, whose findings sit inside `checks` and whose counts live in `summary`. Reading only the first
26
+ // shape made a failing verification card say "0 error(s)" while its own verdict said fail.
27
+ const violations = array(report.violations);
28
+ const signals = array(report.signals);
29
+ const facts = array(report.facts);
30
+ if (findings.length === 0 && violations.length > 0) {
31
+ // The design family keeps three levels apart on purpose: a violation decides the verdict, a signal is a
32
+ // structural fact, and a fact is a measurement. Only the violations are findings here, or the card would
33
+ // show a signal with an error mark.
34
+ for (const entry of violations) {
35
+ const finding = record(entry);
36
+ if (finding !== undefined)
37
+ findings.push({ ...finding, severity: 'error' });
38
+ }
39
+ }
40
+ else if (findings.length === 0 && violations.length === 0 && array(report.violations).length === 0 && !Array.isArray(report.violations)) {
41
+ for (const check of array(report.checks)) {
42
+ for (const entry of array(record(check)?.findings)) {
43
+ const finding = record(entry);
44
+ if (finding !== undefined)
45
+ findings.push(finding);
46
+ }
47
+ }
48
+ }
49
+ const severity = (entry) => String(entry.severity ?? 'info');
50
+ const errors = findings.filter((entry) => severity(entry) === 'error');
51
+ const warnings = findings.filter((entry) => severity(entry) === 'warning');
52
+ const summary = record(report.summary);
53
+ const summaryErrors = typeof summary?.errors === 'number' ? summary.errors : undefined;
54
+ const summaryWarnings = typeof summary?.warnings === 'number' ? summary.warnings : undefined;
55
+ const checksRun = typeof summary?.checksRun === 'number'
56
+ ? summary.checksRun
57
+ : array(report.checks).length > 0
58
+ ? array(report.checks).length
59
+ : undefined;
60
+ const first = errors[0] ?? warnings[0];
61
+ const next = array(report.nextSteps).map((step) => String(step))[0];
62
+ const route = routeFor(family);
63
+ const deltaSummary = record(record(report.reportDelta)?.summary) ?? record(report.summary);
64
+ const delta = deltaSummary === undefined
65
+ ? undefined
66
+ : {
67
+ added: Number(deltaSummary.added ?? 0),
68
+ removed: Number(deltaSummary.removed ?? 0),
69
+ changed: Number(deltaSummary.changed ?? 0),
70
+ addedErrors: Number(deltaSummary.addedErrors ?? 0),
71
+ };
72
+ const changes = array(report.changes)
73
+ .map((entry) => record(entry))
74
+ .filter((entry) => entry !== undefined)
75
+ .map((entry) => ({ kind: String(entry.kind ?? '?'), what: String(entry.what ?? ''), ...(typeof entry.detail === 'string' && entry.detail !== '' ? { detail: entry.detail } : {}) }));
76
+ const firstAddedRaw = (array(report.added).map((entry) => record(entry)).find((entry) => entry !== undefined && String(entry.severity ?? '') === 'error')
77
+ ?? array(report.added).map((entry) => record(entry)).find((entry) => entry !== undefined));
78
+ return {
79
+ ...(typeof report.verdict === 'string' ? { verdict: report.verdict } : {}),
80
+ ...(typeof report.verdictReason === 'string' ? { verdictReason: shorten(report.verdictReason) } : {}),
81
+ errors: summaryErrors ?? errors.length,
82
+ warnings: summaryWarnings ?? warnings.length,
83
+ ...(checksRun === undefined ? {} : { checksRun }),
84
+ first: first === undefined
85
+ ? null
86
+ : { code: String(first.code ?? 'UNKNOWN'), severity: severity(first), message: shorten(String(first.message ?? '')) },
87
+ next: next === undefined ? null : shorten(next),
88
+ family,
89
+ ...(route === undefined ? {} : { route: { owns: route.owns, instead: route.notFor.tool } }),
90
+ ...(changes.length === 0 ? {} : { changes }),
91
+ ...(delta === undefined ? {} : { delta }),
92
+ ...(firstAddedRaw === undefined ? {} : { firstAdded: { code: String(firstAddedRaw.code ?? '?'), severity: String(firstAddedRaw.severity ?? 'info'), message: shorten(String(firstAddedRaw.message ?? '')) } }),
93
+ ...(typeof report.currentVerdict === 'string' ? { currentVerdict: report.currentVerdict } : {}),
94
+ ...(typeof summary?.nodes === 'number' && typeof summary?.edges === 'number'
95
+ ? { graph: { nodes: summary.nodes, edges: summary.edges, cycles: Number(summary.cycles ?? 0), isolated: Number(summary.isolated ?? 0), dangling: Number(summary.dangling ?? 0) } }
96
+ : {}),
97
+ ...(violations.length === 0 && signals.length === 0 && facts.length === 0
98
+ ? {}
99
+ : { levels: { violations: violations.length, signals: signals.length, facts: facts.length, skipped: array(report.checksSkipped).length } }),
100
+ ...(typeof summary?.absoluteClaims === 'number'
101
+ ? { claims: { absolute: summary.absoluteClaims, keywords: Number(summary.keywords ?? 0), families: claimFamilies(findings) } }
102
+ : {}),
103
+ };
104
+ }
105
+ /** The card body: four lines, in the order a reader asks the questions. */
106
+ export function cardBlocks(card) {
107
+ const lines = [];
108
+ const head = card.verdict === undefined ? card.family : card.verdict.toUpperCase();
109
+ const counts = card.checksRun === undefined
110
+ ? `${String(card.errors)} error(s), ${String(card.warnings)} warning(s)`
111
+ : `${String(card.checksRun)} checks · ${String(card.errors)} error(s) · ${String(card.warnings)} warning(s)`;
112
+ lines.push(head + ' — ' + counts);
113
+ if (card.verdictReason !== undefined)
114
+ lines.push(card.verdictReason);
115
+ if (card.first != null) {
116
+ // A finding with no message prints its code and stops: a colon with nothing after it reads as a
117
+ // truncated message rather than as an absent one.
118
+ const mark = card.first.severity === 'error' ? '✗ ' : '⚠ ';
119
+ lines.push(mark + card.first.code + (card.first.message.trim() === '' ? '' : ': ' + card.first.message));
120
+ }
121
+ if (card.next != null)
122
+ lines.push('next: ' + card.next);
123
+ if (card.source !== undefined) {
124
+ lines.push('');
125
+ const shown = card.source.lines.length;
126
+ lines.push(card.source.label + (card.source.total > shown ? ' (first ' + String(shown) + ' of ' + String(card.source.total) + ' lines)' : ''));
127
+ lines.push(...card.source.lines);
128
+ }
129
+ return [{ type: 'text', text: lines.join('\n') }];
130
+ }
131
+ /**
132
+ * `presentResult` for a report tool: render the card from the persisted payload, and fall back to the
133
+ * raw content by returning undefined when there is none.
134
+ */
135
+ /** The last path segment, for a title. No `node:path` here: this runs on replay too. */
136
+ function lastSegment(path) {
137
+ const parts = path.split(/[\\/]/).filter((part) => part !== '' && part !== '.');
138
+ return parts[parts.length - 1] ?? path;
139
+ }
140
+ /**
141
+ * The pending card for a report tool: a title naming the artifact, and the file to follow.
142
+ *
143
+ * From the arguments only, because a UI calls this during streaming and again on replay; the file has not
144
+ * been opened at that point and must not be.
145
+ */
146
+ /**
147
+ * The card payload with the artifact previewed.
148
+ *
149
+ * A translation and a rendering both produce text a reader wants to see the shape of, and neither should
150
+ * paste the whole file into a card: the preview is bounded, and it says how much it left out. The report
151
+ * keeps the artifact in full for whoever needs it.
152
+ */
153
+ export function cardWithSource(value, family, text, label, limit = 14) {
154
+ const card = reportCard(value, family);
155
+ if (text === undefined || text.trim() === '')
156
+ return card;
157
+ const all = text.split(String.fromCharCode(10));
158
+ const lines = all.slice(0, limit);
159
+ return { ...card, source: { label, lines, total: all.length } };
160
+ }
161
+ export function reportCallView(family) {
162
+ return (args) => {
163
+ const a = record(args) ?? {};
164
+ const path = ['modelPath', 'repoPath', 'path']
165
+ .map((key) => a[key])
166
+ .find((value) => typeof value === 'string' && value !== '');
167
+ const kind = family === 'model_edit' ? 'edit' : family.startsWith('uml_') ? 'search' : 'other';
168
+ const title = path === undefined ? family : family + ' ' + lastSegment(path);
169
+ return { card: 'generic', title, kind, ...(path === undefined ? {} : { locations: [{ path }] }) };
170
+ };
171
+ }
172
+ /**
173
+ * The card for `model_edit`: what changed, and what the change did to the findings.
174
+ *
175
+ * Both lists are already in the report, so this is a rendering of the same value rather than a second
176
+ * computation - the rule every card here follows.
177
+ */
178
+ export function editCardBlocks(card) {
179
+ const changes = card.changes ?? [];
180
+ const lines = [];
181
+ const head = card.verdict === undefined ? card.family : card.verdict.toUpperCase();
182
+ lines.push(head + (changes.length === 0 ? ' — no change was applied' : ' — ' + String(changes.length) + ' change(s)'));
183
+ for (const change of changes.slice(0, 6)) {
184
+ lines.push('· ' + change.kind + ' ' + change.what + (change.detail === undefined ? '' : ' (' + change.detail + ')'));
185
+ }
186
+ if (changes.length > 6)
187
+ lines.push('· … ' + String(changes.length - 6) + ' more in the report');
188
+ if (card.delta !== undefined) {
189
+ lines.push('findings: added ' + String(card.delta.added) + ' (' + String(card.delta.addedErrors) + ' error) · removed ' + String(card.delta.removed) + ' · changed ' + String(card.delta.changed));
190
+ }
191
+ if (card.next != null)
192
+ lines.push('next: ' + card.next);
193
+ return [{ type: 'text', text: lines.join('\n') }];
194
+ }
195
+ /**
196
+ * The card for `report_diff`: does this change add anything, read as counts and the first added error.
197
+ *
198
+ * A diff's `verdict` is about the delta, so the card leads with the delta and never with the current
199
+ * report's own verdict - the two differ exactly when a clean change lands on an already-failing report.
200
+ */
201
+ export function diffCardBlocks(card) {
202
+ const delta = card.delta;
203
+ const lines = [];
204
+ const head = card.verdict === undefined ? card.family : card.verdict.toUpperCase();
205
+ lines.push(head + ' — added ' + String(delta?.added ?? 0) + ' (' + String(delta?.addedErrors ?? 0) + ' error) · removed ' + String(delta?.removed ?? 0) + ' · changed ' + String(delta?.changed ?? 0));
206
+ if (card.currentVerdict !== undefined)
207
+ lines.push('the current report on its own: ' + card.currentVerdict);
208
+ if (card.firstAdded !== undefined)
209
+ lines.push('✗ ' + card.firstAdded.code + ': ' + card.firstAdded.message);
210
+ if ((delta?.added ?? 0) === 0)
211
+ lines.push('nothing was added: this change is accepted by the "no new findings" rule');
212
+ if (card.next != null)
213
+ lines.push('next: ' + card.next);
214
+ return [{ type: 'text', text: lines.join('\n') }];
215
+ }
216
+ /** `presentResult` for `model_edit`, falling back to the generic card when the payload has no changes. */
217
+ export function editResultView(family) {
218
+ return (_args, result) => {
219
+ const card = record(result.meta);
220
+ if (card === undefined || card === null)
221
+ return undefined;
222
+ const changeCount = card.changes === undefined ? '' : ' · ' + String(card.changes.length) + ' change(s)';
223
+ return { card: 'generic', title: family + ' ' + (card.verdict ?? '') + changeCount, content: editCardBlocks(card) };
224
+ };
225
+ }
226
+ export function reportResultView(family) {
227
+ return (_args, result) => {
228
+ const card = record(result.meta);
229
+ if (card === undefined || card === null)
230
+ return undefined;
231
+ if (card.verdict === undefined && card.next == null && card.first == null)
232
+ return undefined;
233
+ return { card: 'generic', title: family + ' ' + (card.verdict ?? ''), content: cardBlocks(card) };
234
+ };
235
+ }
236
+ /** How many findings per family, for the scan card: the reader wants the shape, not the list. */
237
+ function claimFamilies(findings) {
238
+ const counts = new Map();
239
+ for (const finding of findings) {
240
+ const family = typeof finding.family === 'string' ? finding.family : 'other';
241
+ counts.set(family, (counts.get(family) ?? 0) + 1);
242
+ }
243
+ return [...counts.entries()].sort((a, b) => b[1] - a[1]).map(([family, count]) => ({ family, count }));
244
+ }
245
+ /** The card for `uml_structure_verify`: the graph's shape first, then the first finding. */
246
+ export function structureCardBlocks(card) {
247
+ const lines = [];
248
+ const head = card.verdict === undefined ? card.family : card.verdict.toUpperCase();
249
+ lines.push(head + ' — ' + String(card.errors) + ' error(s) · ' + String(card.warnings) + ' warning(s)');
250
+ if (card.graph !== undefined) {
251
+ lines.push('graph: ' + String(card.graph.nodes) + ' nodes · ' + String(card.graph.edges) + ' edges · ' + String(card.graph.cycles) + ' cycle(s) · ' + String(card.graph.isolated) + ' isolated');
252
+ }
253
+ if (card.first != null)
254
+ lines.push((card.first.severity === 'error' ? '✗ ' : '⚠ ') + card.first.code + ': ' + card.first.message);
255
+ if (card.next != null)
256
+ lines.push('next: ' + card.next);
257
+ return [{ type: 'text', text: lines.join(String.fromCharCode(10)) }];
258
+ }
259
+ /** The card for `model_design_review`: violations, then signals, then facts - never one number. */
260
+ export function designCardBlocks(card) {
261
+ const lines = [];
262
+ const head = card.verdict === undefined ? card.family : card.verdict.toUpperCase();
263
+ lines.push(head + ' — ' + String(card.errors) + ' violation(s) · ' + String(card.warnings) + ' signal(s)');
264
+ if (card.levels !== undefined) {
265
+ lines.push('levels: ' + String(card.levels.violations) + ' violation · ' + String(card.levels.signals) + ' signal · ' + String(card.levels.facts) + ' fact · ' + String(card.levels.skipped) + ' check(s) skipped');
266
+ }
267
+ if (card.first != null)
268
+ lines.push('✗ ' + card.first.code + ': ' + card.first.message);
269
+ if (card.levels !== undefined && card.levels.skipped > 0)
270
+ lines.push('a skipped check examined nothing: silence there is not a pass');
271
+ if (card.next != null)
272
+ lines.push('next: ' + card.next);
273
+ return [{ type: 'text', text: lines.join(String.fromCharCode(10)) }];
274
+ }
275
+ /** The card for `model_interleaving_scan`: claims by family, and the reminder that nothing was proven. */
276
+ export function scanCardBlocks(card) {
277
+ const lines = [];
278
+ const head = card.verdict === undefined ? card.family : card.verdict.toUpperCase();
279
+ lines.push(head + ' — ' + String(card.errors) + ' absolute claim(s) · ' + String(card.warnings) + ' risk(s)');
280
+ if (card.claims !== undefined) {
281
+ const families = card.claims.families.map((entry) => entry.family + ' ' + String(entry.count)).join(' · ');
282
+ lines.push('claims: ' + String(card.claims.absolute) + ' absolute, ' + String(card.claims.keywords) + ' keyword(s) hit' + (families === '' ? '' : ' · ' + families));
283
+ }
284
+ if (card.first != null)
285
+ lines.push('✗ ' + card.first.code + ': ' + card.first.message);
286
+ lines.push('this plugin routed the claims; it proved none of them');
287
+ return [{ type: 'text', text: lines.join(String.fromCharCode(10)) }];
288
+ }
289
+ /** One `presentResult` per card body, since the shapes differ but the fallback does not. */
290
+ export function familyResultView(family, body) {
291
+ return (_args, result) => {
292
+ const card = record(result.meta);
293
+ if (card === undefined || card === null)
294
+ return undefined;
295
+ return { card: 'generic', title: family + ' ' + (card.verdict ?? ''), content: body(card) };
296
+ };
297
+ }
298
+ export const structureResultView = familyResultView('uml_structure_verify', structureCardBlocks);
299
+ export const designResultView = familyResultView('model_design_review', designCardBlocks);
300
+ export const scanResultView = familyResultView('model_interleaving_scan', scanCardBlocks);
301
+ /** `presentResult` for `report_diff`: the delta leads, never the current report's own verdict. */
302
+ export function diffResultView(family) {
303
+ return (_args, result) => {
304
+ const card = record(result.meta);
305
+ if (card === undefined || card === null)
306
+ return undefined;
307
+ return { card: 'generic', title: family + ' ' + (card.verdict ?? ''), content: diffCardBlocks(card) };
308
+ };
309
+ }
@@ -1,6 +1,7 @@
1
1
  import { defineTool } from '@deepseek-ai/dsh-tools';
2
2
  import { diffReports } from './baseline.js';
3
3
  import { refusalVerdict } from './engine.js';
4
+ import { diffResultView, reportCallView, reportCard } from './presentation.js';
4
5
  import { renderReportText } from './report-view.js';
5
6
  export const REPORT_DIFF_TOOL_NAME = 'report_diff';
6
7
  /**
@@ -49,7 +50,13 @@ export const reportDiffTool = defineTool({
49
50
  render(args, value) {
50
51
  return [{ type: 'text', text: renderReportText(value, { full: args.reportView === 'full' }) }];
51
52
  },
53
+ // The card a UI shows, separated from the text the model reads.
54
+ presentationMeta(_args, value) {
55
+ return reportCard(value, REPORT_DIFF_TOOL_NAME);
56
+ },
52
57
  },
58
+ presentCall: reportCallView(REPORT_DIFF_TOOL_NAME),
59
+ presentResult: diffResultView(REPORT_DIFF_TOOL_NAME),
53
60
  timeoutMs: 10_000,
54
61
  isConcurrencySafe: () => true,
55
62
  async execute(args) {
@@ -138,6 +138,30 @@ function headerLines(report, options) {
138
138
  ok === undefined || verdict === undefined ? '' : `ok=${String(ok)}`,
139
139
  ].filter((bit) => bit !== '');
140
140
  lines.push(bits.join(' '));
141
+ // A graph report answers "which module is the hub" in one line: counting incoming edges off the graph by
142
+ // hand is where a correct graph still produced a wrong answer, in both conditions of the round that asked.
143
+ const degreeSummary = record(report.summary);
144
+ const hubs = array(degreeSummary?.hubs).map((entry) => record(entry)).filter((entry) => entry !== undefined);
145
+ if (hubs.length > 0) {
146
+ const counts = degreeSummary?.nodes !== undefined && degreeSummary?.edges !== undefined ? ` ${String(degreeSummary.nodes)} modules, ${String(degreeSummary.edges)} edges` : '';
147
+ lines.push(`GRAPH${counts}`);
148
+ lines.push(' ' + hubs.map((entry) => `${String(entry.id)} (${String(entry.dependents)} dependents)`).join(', '));
149
+ const leaves = array(degreeSummary?.leaves).map((leaf) => String(leaf));
150
+ if (leaves.length > 0)
151
+ lines.push(' imports nothing: ' + leaves.join(', '));
152
+ }
153
+ // A scan has to say what it covered, or a model that cannot see the boundary recounts the tree by hand
154
+ // and spends more than the tool saved. The numbers are the boundary: files read, references resolved.
155
+ const scan = record(report.scan);
156
+ if (scan !== undefined) {
157
+ const files = String(scan.files ?? '?');
158
+ const unresolved = Number(scan.unresolved ?? 0);
159
+ const languages = array(scan.languages).map((entry) => String(entry));
160
+ lines.push(`SCAN ${files} files read, ${String(unresolved)} unresolved${languages.length === 0 ? '' : ', ' + languages.join(', ')}`);
161
+ lines.push(unresolved === 0
162
+ ? ' this is the whole tree: every relative reference resolved, so a recount by hand cannot find more'
163
+ : ' the unresolved lines above are outside this tree; count nothing twice');
164
+ }
141
165
  // A refusal carries no model to hash, and the field is an empty string rather than absent, so it rendered
142
166
  // as `modelHash=…` - which reads as a truncated hash rather than as nothing to hash. An empty value means
143
167
  // the same thing as a missing one here.
@@ -204,6 +228,29 @@ function footerLines(report) {
204
228
  return lines;
205
229
  }
206
230
  /** A `checks[]` report (verify, compose, datamodel): silence pass, expand the rest. */
231
+ /**
232
+ * An edit result: what changed in the model, then what changed in the findings.
233
+ *
234
+ * The two belong together because the second is the reason the first happened. The delta is
235
+ * rendered by the delta renderer rather than restated here, so an edit and a `report_diff` of the
236
+ * same two reports cannot drift apart.
237
+ */
238
+ function renderEditReport(report, options) {
239
+ const lines = headerLines(report, options);
240
+ const changes = array(report.changes).map((value) => record(value) ?? {});
241
+ const added = changes.filter((change) => text(change.kind) === 'added').length;
242
+ const removed = changes.filter((change) => text(change.kind) === 'removed').length;
243
+ const updated = changes.filter((change) => text(change.kind) === 'updated').length;
244
+ lines.push(`CHANGES ${String(added)} added, ${String(removed)} removed, ${String(updated)} updated`);
245
+ for (const change of changes) {
246
+ const detail = text(change.detail);
247
+ lines.push(` ${text(change.kind) ?? '?'} ${text(change.what) ?? '?'}${detail === undefined || detail === '' ? '' : ` — ${detail}`}`);
248
+ }
249
+ const delta = record(report.reportDelta);
250
+ if (delta !== undefined)
251
+ lines.push('', ...renderDeltaReport(delta, options));
252
+ return [...lines, ...wrapFooter(report)];
253
+ }
207
254
  function renderChecksReport(report, options) {
208
255
  const checks = array(report.checks).map((value) => record(value) ?? {});
209
256
  const byStatus = (status) => checks.filter((check) => text(check.status) === status).map((check) => text(check.id) ?? '?');
@@ -230,7 +277,8 @@ function renderChecksReport(report, options) {
230
277
  .filter(([, ids]) => ids.length > 0)
231
278
  .map(([status, ids]) => `${String(status)} ${ids.join(' ')}`)
232
279
  .join(' | ');
233
- const lines = [...headerLines(report, options), `CHECKS ${inventory}`];
280
+ const lines = [...headerLines(report, options)];
281
+ lines.push(`CHECKS ${inventory}`);
234
282
  const requested = options.check;
235
283
  const ids = checks.map((check) => text(check.id)).filter((id) => id !== undefined);
236
284
  const selected = requested === undefined ? checks : checks.filter((check) => text(check.id) === requested);
@@ -420,14 +468,18 @@ export function renderReportText(value, options = {}) {
420
468
  // which printed the whole object as JSON.
421
469
  const isDelta = Array.isArray(report.added) || Array.isArray(report.removed) || Array.isArray(report.changed) || typeof report.currentVerdict === 'string';
422
470
  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);
471
+ // An edit result names its changes; the findings it brings along are a delta, which already has a renderer.
472
+ const isEdit = Array.isArray(report.changes);
473
+ const lines = isEdit
474
+ ? renderEditReport(report, resolved)
475
+ : hasChecks
476
+ ? renderChecksReport(report, resolved)
477
+ : isDelta
478
+ ? renderDeltaReport(report, resolved)
479
+ : hasFindings
480
+ ? renderFindingsReport(report, resolved)
481
+ : isPayload
482
+ ? renderPayloadReport(report, resolved)
483
+ : renderFallbackReport(report, resolved);
432
484
  return lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd();
433
485
  }
package/lib/routing.js ADDED
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Which tool owns which question, in one table.
3
+ *
4
+ * Two consumers read this, and they used to read two different things: the result a model gets
5
+ * (`nextSteps` naming a sibling) and the toolset map a human reads (hand-written prose). That is how the
6
+ * map came to say "about ten thousand characters" while the descriptions measured twenty-nine: a fact
7
+ * stated twice drifts, and nothing fails when it does.
8
+ *
9
+ * So the table states the routing once. `routeFor` hands it to the card a UI draws; the toolset map
10
+ * generator reads the same table and refuses to write a map for a tool that is missing from it.
11
+ *
12
+ * What it is not: a decision. The tool a request should use is chosen by the model from the descriptions.
13
+ * This table says what each tool owns and which neighbour takes the case just outside that line, so a
14
+ * report can redirect without a reader having to remember the roster.
15
+ */
16
+ export const ROUTES = {
17
+ model_verify: {
18
+ owns: 'one machine: reachable states, invariants, deadlock, and a before/after regression',
19
+ notFor: { when: 'two or more machines must handshake or share a location', tool: 'model_compose_verify' },
20
+ },
21
+ model_compose_verify: {
22
+ owns: 'several machines run together: rendezvous, shared locations, lost updates',
23
+ notFor: { when: 'the question is about one machine on its own', tool: 'model_verify' },
24
+ },
25
+ model_automaton_verify: {
26
+ owns: 'a finite-word language, and its relation to another automaton',
27
+ notFor: { when: 'the question is reachability or deadlock inside one state machine', tool: 'model_verify' },
28
+ },
29
+ model_edit: {
30
+ owns: 'one editing round on a model file, answered with the structural diff and the change in findings',
31
+ notFor: { when: 'the edit is done and the model has to be decided again', tool: 'model_verify' },
32
+ },
33
+ model_datamodel_verify: {
34
+ owns: 'entities, keys, migrations, and whether the data survived them',
35
+ notFor: { when: 'the question is about behaviour over time rather than stored state', tool: 'model_verify' },
36
+ },
37
+ model_design_review: {
38
+ owns: "a design's own declarations: layering, responsibility, substitutability, interface width",
39
+ notFor: { when: 'the question is a fact about the graph - does this edge exist', tool: 'uml_structure_verify' },
40
+ },
41
+ model_interleaving_scan: {
42
+ owns: 'mining a document or plan for concurrency claims, and routing each to the analysis that decides it',
43
+ notFor: { when: 'the protocol itself has to be decided', tool: 'model_compose_verify' },
44
+ },
45
+ model_export: {
46
+ owns: 'a model translated into UPPAAL, TLA+, PRISM or SPIN for a property this plugin does not decide',
47
+ notFor: { when: 'the property can be decided here', tool: 'model_verify' },
48
+ },
49
+ report_diff: {
50
+ owns: 'what changed between two reports of the same family',
51
+ notFor: { when: 'the current report does not exist yet', tool: 'model_verify' },
52
+ },
53
+ uml_render: {
54
+ owns: 'drawing a model in the notation that can carry its family',
55
+ notFor: { when: 'the question is whether the modelling is sound', tool: 'uml_review' },
56
+ },
57
+ uml_parse: {
58
+ owns: 'reading a diagram back into the model its family declares',
59
+ notFor: { when: 'the question is whether the diagram says something sound', tool: 'uml_review' },
60
+ },
61
+ uml_review: {
62
+ owns: 'auditing the modelling: reachability, ambiguity, round-trip fidelity, family-specific defects',
63
+ notFor: { when: 'the claim is about behaviour rather than the drawing', tool: 'model_verify' },
64
+ },
65
+ uml_explain_labels: {
66
+ owns: 'the label spellings a notation accepts and the directives that pin the rest',
67
+ notFor: { when: 'a diagram has to be read rather than explained', tool: 'uml_parse' },
68
+ },
69
+ uml_structure_verify: {
70
+ owns: 'a dependency graph as a graph - cycles, layers, dangling edges - from a drawing or a source tree',
71
+ notFor: { when: 'the question is a rule the design declared for itself, not an edge that exists', tool: 'model_design_review' },
72
+ },
73
+ };
74
+ /** The route for a tool, or undefined when the tool has none. */
75
+ export function routeFor(tool) {
76
+ return ROUTES[tool];
77
+ }
78
+ /** Every tool a route names, for a caller that has to check the table against the registry. */
79
+ export function routedTools() {
80
+ return Object.keys(ROUTES);
81
+ }
82
+ /** The tools named as the owner of an adjacent case. */
83
+ export function siblingTools() {
84
+ return [...new Set(Object.values(ROUTES).map((route) => route.notFor.tool))].sort();
85
+ }