dsh-model-everything 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.en-US.md +81 -73
  2. package/README.md +67 -68
  3. package/lib/automaton-tool.js +69 -0
  4. package/lib/automaton.js +327 -0
  5. package/lib/compose-tool.js +6 -1
  6. package/lib/concurrency-tool.js +6 -1
  7. package/lib/data-tool.js +6 -1
  8. package/lib/design-tool.js +7 -2
  9. package/lib/edit-tool.js +111 -0
  10. package/lib/editor.js +293 -0
  11. package/lib/engine.js +7 -0
  12. package/lib/export-tool.js +6 -1
  13. package/lib/index.js +4 -0
  14. package/lib/presentation.js +81 -0
  15. package/lib/report-diff-tool.js +6 -1
  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 +34 -1
  20. package/lib/structure.js +28 -3
  21. package/lib/tool.js +6 -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 +59 -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 +16 -6
  32. package/package.json +4 -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 +73 -0
  36. package/src/automaton.ts +375 -0
  37. package/src/compose-tool.ts +12 -6
  38. package/src/concurrency-tool.ts +7 -1
  39. package/src/data-tool.ts +7 -1
  40. package/src/design-tool.ts +74 -68
  41. package/src/edit-tool.ts +119 -0
  42. package/src/editor.ts +297 -0
  43. package/src/engine.ts +7 -0
  44. package/src/export-tool.ts +87 -81
  45. package/src/index.ts +4 -0
  46. package/src/presentation.ts +125 -0
  47. package/src/report-diff-tool.ts +7 -1
  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 +33 -1
  52. package/src/structure.ts +37 -3
  53. package/src/tool.ts +6 -0
  54. package/src/uml-tool.ts +240 -230
@@ -1,81 +1,87 @@
1
- import { defineTool } from '@deepseek-ai/dsh-tools'
2
- import type { JsonValue } from './json-value.js'
3
- import { REPORT_SCHEMAS, DEFAULT_HASH_SPEC, modelHash, refusalVerdict, validateModel, verdictOf } from './engine.js'
4
- import { ExportError, exportModel, type ExportFormat } from './exporters.js'
5
- import { renderReportText } from './report-view.js'
6
-
7
- /** The model hash of an export's input, when the input validates (the success path always does). */
8
- function exportHashes(model: unknown): { hashSpec: typeof DEFAULT_HASH_SPEC; modelHash?: string } {
9
- const validation = validateModel(model)
10
- return validation.ok
11
- ? { hashSpec: DEFAULT_HASH_SPEC, modelHash: modelHash(validation.model) }
12
- : { hashSpec: DEFAULT_HASH_SPEC }
13
- }
14
-
15
- export const EXPORT_TOOL_NAME = 'model_export'
16
-
17
- /**
18
- * DSH tool wrapping the external-tool exporters: turn a LogicModelV1 machine into
19
- * native input for the tool this plugin routes to (UPPAAL / TLA+ / PRISM / SPIN).
20
- * v1 translates the core machine; unrepresentable invariants become warnings.
21
- */
22
- export const modelExportTool = defineTool({
23
- name: EXPORT_TOOL_NAME,
24
- description:
25
- 'Translate a LogicModelV1 into the input language of an external checker - UPPAAL, TLA+, PRISM or SPIN - for a property this plugin does not decide itself (symbolic model checking, unbounded induction, expected reward). format is one of uppaal (.xta + queries), tla (TLC spec + safety property), prism (.pm + .pctl), spin (Promela + ltl). This tool does not verify anything: for the checks it can decide use `model_verify`. Booleans are exported as 0/1 integers; invariants the target cannot express are returned as warnings, never silently dropped. Returns { ok, format, primary, extras?, warnings? } or { ok: false, error } when the model is invalid or the PRISM enumeration is too large.',
26
- parameters: {
27
- model: {
28
- type: 'json',
29
- required: true,
30
- description: 'LogicModelV1 state-machine model to export.',
31
- },
32
- format: {
33
- type: 'string',
34
- required: true,
35
- enum: ['uppaal', 'tla', 'prism', 'spin'],
36
- description: 'Target tool format.',
37
- },
38
- reportView: {
39
- type: 'string',
40
- enum: ['digest', 'full'],
41
- description: 'digest (default) prints the generated file verbatim; full prints the structured result as JSON.',
42
- },
43
- },
44
- output: {
45
- schema: {
46
- type: 'json',
47
- description: 'Export result with generated file content.',
48
- },
49
- render(args, value) {
50
- return [{ type: 'text' as const, text: renderReportText(value, { full: args.reportView === 'full' }) }]
51
- },
52
- },
53
- timeoutMs: 10_000,
54
- isConcurrencySafe: () => true,
55
- async execute(args) {
56
- try {
57
- const result = exportModel(args.model, args.format as ExportFormat)
58
- const warnings = result.warnings ?? []
59
- const nextSteps: string[] = []
60
- if (warnings.length > 0) nextSteps.push('The target notation cannot express some invariants; the warnings name each one — verify them in the target tool rather than assuming they carried over.')
61
- nextSteps.push('Run the exported file through its own tool (verify/compile) before trusting the translation; this plugin guarantees the encoding, not the target checker\'s verdict.')
62
- return {
63
- ok: true,
64
- ran: true,
65
- ...verdictOf(0, warnings.length),
66
- schema: REPORT_SCHEMAS.export,
67
- format: result.format,
68
- primary: result.primary,
69
- ...(result.extras === undefined ? {} : { extras: result.extras }),
70
- hashes: exportHashes(args.model),
71
- warnings,
72
- nextSteps,
73
- } as unknown as JsonValue
74
- } catch (error) {
75
- const message = error instanceof Error ? error.message : String(error)
76
- // The exporter's own refusals carry their code: a limit of the encoding is not a defect in the model.
77
- const errorCode = error instanceof ExportError ? error.code : 'MODEL_INVALID'
78
- return { ok: false, ran: false, ...refusalVerdict(message), schema: REPORT_SCHEMAS.export, errorCode, error: message } as unknown as JsonValue
79
- }
80
- },
81
- })
1
+ import { defineTool } from '@deepseek-ai/dsh-tools'
2
+ import type { JsonValue } from './json-value.js'
3
+ import { REPORT_SCHEMAS, DEFAULT_HASH_SPEC, modelHash, refusalVerdict, validateModel, verdictOf } from './engine.js'
4
+ import { reportCard, reportResultView } from './presentation.js'
5
+ import { ExportError, exportModel, type ExportFormat } from './exporters.js'
6
+ import { renderReportText } from './report-view.js'
7
+
8
+ /** The model hash of an export's input, when the input validates (the success path always does). */
9
+ function exportHashes(model: unknown): { hashSpec: typeof DEFAULT_HASH_SPEC; modelHash?: string } {
10
+ const validation = validateModel(model)
11
+ return validation.ok
12
+ ? { hashSpec: DEFAULT_HASH_SPEC, modelHash: modelHash(validation.model) }
13
+ : { hashSpec: DEFAULT_HASH_SPEC }
14
+ }
15
+
16
+ export const EXPORT_TOOL_NAME = 'model_export'
17
+
18
+ /**
19
+ * DSH tool wrapping the external-tool exporters: turn a LogicModelV1 machine into
20
+ * native input for the tool this plugin routes to (UPPAAL / TLA+ / PRISM / SPIN).
21
+ * v1 translates the core machine; unrepresentable invariants become warnings.
22
+ */
23
+ export const modelExportTool = defineTool({
24
+ name: EXPORT_TOOL_NAME,
25
+ description:
26
+ 'Translate a LogicModelV1 into the input language of an external checker - UPPAAL, TLA+, PRISM or SPIN - for a property this plugin does not decide itself (symbolic model checking, unbounded induction, expected reward). format is one of uppaal (.xta + queries), tla (TLC spec + safety property), prism (.pm + .pctl), spin (Promela + ltl). This tool does not verify anything: for the checks it can decide use `model_verify`. Booleans are exported as 0/1 integers; invariants the target cannot express are returned as warnings, never silently dropped. Returns { ok, format, primary, extras?, warnings? } or { ok: false, error } when the model is invalid or the PRISM enumeration is too large.',
27
+ parameters: {
28
+ model: {
29
+ type: 'json',
30
+ required: true,
31
+ description: 'LogicModelV1 state-machine model to export.',
32
+ },
33
+ format: {
34
+ type: 'string',
35
+ required: true,
36
+ enum: ['uppaal', 'tla', 'prism', 'spin'],
37
+ description: 'Target tool format.',
38
+ },
39
+ reportView: {
40
+ type: 'string',
41
+ enum: ['digest', 'full'],
42
+ description: 'digest (default) prints the generated file verbatim; full prints the structured result as JSON.',
43
+ },
44
+ },
45
+ output: {
46
+ schema: {
47
+ type: 'json',
48
+ description: 'Export result with generated file content.',
49
+ },
50
+ render(args, value) {
51
+ return [{ type: 'text' as const, text: renderReportText(value, { full: args.reportView === 'full' }) }]
52
+ },
53
+ // The card a UI shows, separated from the text the model reads.
54
+ presentationMeta(_args, value) {
55
+ return reportCard(value, EXPORT_TOOL_NAME) as unknown as JsonValue
56
+ },
57
+ }, presentResult: reportResultView(EXPORT_TOOL_NAME),
58
+
59
+ timeoutMs: 10_000,
60
+ isConcurrencySafe: () => true,
61
+ async execute(args) {
62
+ try {
63
+ const result = exportModel(args.model, args.format as ExportFormat)
64
+ const warnings = result.warnings ?? []
65
+ const nextSteps: string[] = []
66
+ if (warnings.length > 0) nextSteps.push('The target notation cannot express some invariants; the warnings name each one — verify them in the target tool rather than assuming they carried over.')
67
+ nextSteps.push('Run the exported file through its own tool (verify/compile) before trusting the translation; this plugin guarantees the encoding, not the target checker\'s verdict.')
68
+ return {
69
+ ok: true,
70
+ ran: true,
71
+ ...verdictOf(0, warnings.length),
72
+ schema: REPORT_SCHEMAS.export,
73
+ format: result.format,
74
+ primary: result.primary,
75
+ ...(result.extras === undefined ? {} : { extras: result.extras }),
76
+ hashes: exportHashes(args.model),
77
+ warnings,
78
+ nextSteps,
79
+ } as unknown as JsonValue
80
+ } catch (error) {
81
+ const message = error instanceof Error ? error.message : String(error)
82
+ // The exporter's own refusals carry their code: a limit of the encoding is not a defect in the model.
83
+ const errorCode = error instanceof ExportError ? error.code : 'MODEL_INVALID'
84
+ return { ok: false, ran: false, ...refusalVerdict(message), schema: REPORT_SCHEMAS.export, errorCode, error: message } as unknown as JsonValue
85
+ }
86
+ },
87
+ })
package/src/index.ts CHANGED
@@ -39,6 +39,8 @@ import { umlRenderTool, umlParseTool, umlReviewTool, umlExplainLabelsTool } from
39
39
  import { umlStructureTool } from './structure-tool.js'
40
40
  import { designTool } from './design-tool.js'
41
41
  import { reportDiffTool } from './report-diff-tool.js'
42
+ import { modelEditTool } from './edit-tool.js'
43
+ import { modelAutomatonTool } from './automaton-tool.js'
42
44
  import { ENGINE_SCHEMA_VERSION } from './engine.js'
43
45
 
44
46
  // DSH 0.1.7-alpha.1 (session format v4) retires the shared
@@ -245,6 +247,8 @@ export function apply(ctx: Context, config: Config): void {
245
247
  ctx.effect(() => tools.register(umlStructureTool), 'dsh-model-everything: structure tool')
246
248
  ctx.effect(() => tools.register(designTool), 'dsh-model-everything: design review tool')
247
249
  ctx.effect(() => tools.register(reportDiffTool), 'dsh-model-everything: report diff tool')
250
+ ctx.effect(() => tools.register(modelEditTool), 'dsh-model-everything: model edit tool')
251
+ ctx.effect(() => tools.register(modelAutomatonTool), 'dsh-model-everything: automaton tool')
248
252
  toolRegistered = true
249
253
  dataToolRegistered = true
250
254
  concurrencyToolRegistered = true
@@ -0,0 +1,125 @@
1
+ /**
2
+ * What a UI shows for a report, separated from what the model reads.
3
+ *
4
+ * A report is written for a model: it names every check, every finding and the next step, because the
5
+ * model has to act on all of it. A reader wants four things instead - did it run, what does it decide,
6
+ * what is the first thing wrong, what to do next - and wants the rest folded away.
7
+ *
8
+ * Two host hooks carry that split:
9
+ *
10
+ * - `output.presentationMeta` projects a small JSON payload the host persists on the result; a client
11
+ * card narrows it without parsing prose.
12
+ * - `presentResult` returns the card body from that payload, so a UI that supports it shows the summary
13
+ * and a UI that does not falls back to the raw text. The model-facing content never changes.
14
+ *
15
+ * Both are pure and replayable: they read the persisted value, never the filesystem.
16
+ */
17
+ import type { ContentBlock } from '@deepseek-ai/dsh-llm'
18
+ import { routeFor } from './routing.js'
19
+ import type { ToolResultView } from '@deepseek-ai/dsh-tools'
20
+
21
+ /** One finding, as a card needs it: a code to name it and a sentence to read. */
22
+ export interface CardFinding {
23
+ code: string
24
+ severity: string
25
+ message: string
26
+ }
27
+
28
+ /** The payload a client card narrows. Small on purpose: a card is a summary, not a second report. */
29
+ export interface ReportCard {
30
+ verdict?: string
31
+ verdictReason?: string
32
+ errors: number
33
+ warnings: number
34
+ checksRun?: number
35
+ first: CardFinding | null
36
+ /** The one step a reader should take, already a sentence. */
37
+ next: string | null
38
+ /** Which family produced this, so a card can pick an icon and a colour without parsing codes. */
39
+ family: string
40
+ /** What this family owns, and where the case just outside it goes. One table, not a second prose copy. */
41
+ route?: { owns: string; instead: string }
42
+ }
43
+
44
+ const SHORT = 160
45
+
46
+ function shorten(text: string, limit = SHORT): string {
47
+ const flat = text.replace(/\s+/g, ' ').trim()
48
+ return flat.length <= limit ? flat : flat.slice(0, limit - 1).trimEnd() + '…'
49
+ }
50
+
51
+ function record(value: unknown): Record<string, unknown> | undefined {
52
+ return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : undefined
53
+ }
54
+
55
+ function array(value: unknown): unknown[] {
56
+ return Array.isArray(value) ? value : []
57
+ }
58
+
59
+ /**
60
+ * Project a report into the card payload.
61
+ *
62
+ * Read defensively: this runs on replay too, and a payload that once had a shape may come back with a
63
+ * field missing. A card that throws would take the whole session view with it.
64
+ */
65
+ export function reportCard(value: unknown, family: string): ReportCard {
66
+ const report = record(value) ?? {}
67
+ const findings = array(report.findings)
68
+ .map((entry) => record(entry))
69
+ .filter((entry): entry is Record<string, unknown> => entry !== undefined)
70
+ const severity = (entry: Record<string, unknown>): string => String(entry.severity ?? 'info')
71
+ const errors = findings.filter((entry) => severity(entry) === 'error')
72
+ const warnings = findings.filter((entry) => severity(entry) === 'warning')
73
+ const summary = record(report.summary)
74
+ const checksRun = typeof summary?.checksRun === 'number'
75
+ ? summary.checksRun
76
+ : array(report.checks).length > 0
77
+ ? array(report.checks).length
78
+ : undefined
79
+ const first = errors[0] ?? warnings[0]
80
+ const next = array(report.nextSteps).map((step) => String(step))[0]
81
+ return {
82
+ ...(typeof report.verdict === 'string' ? { verdict: report.verdict } : {}),
83
+ ...(typeof report.verdictReason === 'string' ? { verdictReason: shorten(report.verdictReason) } : {}),
84
+ errors: errors.length,
85
+ warnings: warnings.length,
86
+ ...(checksRun === undefined ? {} : { checksRun }),
87
+ first: first === undefined
88
+ ? null
89
+ : { code: String(first.code ?? 'UNKNOWN'), severity: severity(first), message: shorten(String(first.message ?? '')) },
90
+ next: next === undefined ? null : shorten(next),
91
+ family,
92
+ ...(routeFor(family) === undefined
93
+ ? {}
94
+ : { route: { owns: routeFor(family)?.owns ?? '', instead: routeFor(family)?.notFor.tool ?? '' } }),
95
+ }
96
+ }
97
+
98
+ /** The card body: four lines, in the order a reader asks the questions. */
99
+ export function cardBlocks(card: ReportCard): ContentBlock[] {
100
+ const lines: string[] = []
101
+ const head = card.verdict === undefined ? card.family : card.verdict.toUpperCase()
102
+ const counts = card.checksRun === undefined
103
+ ? `${String(card.errors)} error(s), ${String(card.warnings)} warning(s)`
104
+ : `${String(card.checksRun)} checks · ${String(card.errors)} error(s) · ${String(card.warnings)} warning(s)`
105
+ lines.push(head + ' — ' + counts)
106
+ if (card.verdictReason !== undefined) lines.push(card.verdictReason)
107
+ if (card.first !== null) {
108
+ lines.push((card.first.severity === 'error' ? '✗ ' : '⚠ ') + card.first.code + ': ' + card.first.message)
109
+ }
110
+ if (card.next !== null) lines.push('next: ' + card.next)
111
+ return [{ type: 'text', text: lines.join('\n') }]
112
+ }
113
+
114
+ /**
115
+ * `presentResult` for a report tool: render the card from the persisted payload, and fall back to the
116
+ * raw content by returning undefined when there is none.
117
+ */
118
+ export function reportResultView(family: string) {
119
+ return (_args: unknown, result: { content: ContentBlock[]; meta?: unknown }): ToolResultView | undefined => {
120
+ const card = record(result.meta) as unknown as ReportCard | undefined
121
+ if (card === undefined || card === null) return undefined
122
+ if (card.verdict === undefined && card.next === null && card.first === null) return undefined
123
+ return { card: 'generic', title: family + ' ' + (card.verdict ?? '') , content: cardBlocks(card) }
124
+ }
125
+ }
@@ -2,6 +2,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
2
2
  import type { JsonValue } from './json-value.js'
3
3
  import { diffReports } from './baseline.js'
4
4
  import { refusalVerdict } from './engine.js'
5
+ import { reportCard, reportResultView } from './presentation.js'
5
6
  import { renderReportText } from './report-view.js'
6
7
 
7
8
  export const REPORT_DIFF_TOOL_NAME = 'report_diff'
@@ -54,7 +55,12 @@ export const reportDiffTool = defineTool({
54
55
  render(args, value) {
55
56
  return [{ type: 'text' as const, text: renderReportText(value, { full: args.reportView === 'full' }) }]
56
57
  },
57
- },
58
+ // The card a UI shows, separated from the text the model reads.
59
+ presentationMeta(_args, value) {
60
+ return reportCard(value, REPORT_DIFF_TOOL_NAME) as unknown as JsonValue
61
+ },
62
+ }, presentResult: reportResultView(REPORT_DIFF_TOOL_NAME),
63
+
58
64
  timeoutMs: 10_000,
59
65
  isConcurrencySafe: () => true,
60
66
  async execute(args) {
@@ -199,6 +199,29 @@ function headerLines(report: Record_, options: Required<ReportViewOptions>): str
199
199
  ok === undefined || verdict === undefined ? '' : `ok=${String(ok)}`,
200
200
  ].filter((bit) => bit !== '')
201
201
  lines.push(bits.join(' '))
202
+ // A graph report answers "which module is the hub" in one line: counting incoming edges off the graph by
203
+ // hand is where a correct graph still produced a wrong answer, in both conditions of the round that asked.
204
+ const degreeSummary = record(report.summary)
205
+ const hubs = array(degreeSummary?.hubs).map((entry) => record(entry)).filter((entry) => entry !== undefined)
206
+ if (hubs.length > 0) {
207
+ const counts = degreeSummary?.nodes !== undefined && degreeSummary?.edges !== undefined ? ` ${String(degreeSummary.nodes)} modules, ${String(degreeSummary.edges)} edges` : ''
208
+ lines.push(`GRAPH${counts}`)
209
+ lines.push(' ' + hubs.map((entry) => `${String(entry.id)} (${String(entry.dependents)} dependents)`).join(', '))
210
+ const leaves = array(degreeSummary?.leaves).map((leaf) => String(leaf))
211
+ if (leaves.length > 0) lines.push(' imports nothing: ' + leaves.join(', '))
212
+ }
213
+ // A scan has to say what it covered, or a model that cannot see the boundary recounts the tree by hand
214
+ // and spends more than the tool saved. The numbers are the boundary: files read, references resolved.
215
+ const scan = record(report.scan)
216
+ if (scan !== undefined) {
217
+ const files = String(scan.files ?? '?')
218
+ const unresolved = Number(scan.unresolved ?? 0)
219
+ const languages = array(scan.languages).map((entry) => String(entry))
220
+ lines.push(`SCAN ${files} files read, ${String(unresolved)} unresolved${languages.length === 0 ? '' : ', ' + languages.join(', ')}`)
221
+ lines.push(unresolved === 0
222
+ ? ' this is the whole tree: every relative reference resolved, so a recount by hand cannot find more'
223
+ : ' the unresolved lines above are outside this tree; count nothing twice')
224
+ }
202
225
  // A refusal carries no model to hash, and the field is an empty string rather than absent, so it rendered
203
226
  // as `modelHash=…` - which reads as a truncated hash rather than as nothing to hash. An empty value means
204
227
  // the same thing as a missing one here.
@@ -259,6 +282,29 @@ function footerLines(report: Record_): string[] {
259
282
  }
260
283
 
261
284
  /** A `checks[]` report (verify, compose, datamodel): silence pass, expand the rest. */
285
+ /**
286
+ * An edit result: what changed in the model, then what changed in the findings.
287
+ *
288
+ * The two belong together because the second is the reason the first happened. The delta is
289
+ * rendered by the delta renderer rather than restated here, so an edit and a `report_diff` of the
290
+ * same two reports cannot drift apart.
291
+ */
292
+ function renderEditReport(report: Record_, options: Required<ReportViewOptions>): string[] {
293
+ const lines = headerLines(report, options)
294
+ const changes = array(report.changes).map((value) => record(value) ?? {})
295
+ const added = changes.filter((change) => text(change.kind) === 'added').length
296
+ const removed = changes.filter((change) => text(change.kind) === 'removed').length
297
+ const updated = changes.filter((change) => text(change.kind) === 'updated').length
298
+ lines.push(`CHANGES ${String(added)} added, ${String(removed)} removed, ${String(updated)} updated`)
299
+ for (const change of changes) {
300
+ const detail = text(change.detail)
301
+ lines.push(` ${text(change.kind) ?? '?'} ${text(change.what) ?? '?'}${detail === undefined || detail === '' ? '' : ` — ${detail}`}`)
302
+ }
303
+ const delta = record(report.reportDelta)
304
+ if (delta !== undefined) lines.push('', ...renderDeltaReport(delta, options))
305
+ return [...lines, ...wrapFooter(report)]
306
+ }
307
+
262
308
  function renderChecksReport(report: Record_, options: Required<ReportViewOptions>): string[] {
263
309
  const checks = array(report.checks).map((value) => record(value) ?? {})
264
310
  const byStatus = (status: string): string[] =>
@@ -286,7 +332,8 @@ function renderChecksReport(report: Record_, options: Required<ReportViewOptions
286
332
  .filter(([, ids]) => (ids as string[]).length > 0)
287
333
  .map(([status, ids]) => `${String(status)} ${(ids as string[]).join(' ')}`)
288
334
  .join(' | ')
289
- const lines = [...headerLines(report, options), `CHECKS ${inventory}`]
335
+ const lines = [...headerLines(report, options)]
336
+ lines.push(`CHECKS ${inventory}`)
290
337
  const requested = options.check
291
338
  const ids = checks.map((check) => text(check.id)).filter((id): id is string => id !== undefined)
292
339
  const selected = requested === undefined ? checks : checks.filter((check) => text(check.id) === requested)
@@ -470,7 +517,11 @@ export function renderReportText(value: unknown, options: ReportViewOptions = {}
470
517
  // which printed the whole object as JSON.
471
518
  const isDelta = Array.isArray(report.added) || Array.isArray(report.removed) || Array.isArray(report.changed) || typeof report.currentVerdict === 'string'
472
519
  const isPayload = typeof report.diagram === 'string' || typeof report.primary === 'string'
473
- const lines = hasChecks
520
+ // An edit result names its changes; the findings it brings along are a delta, which already has a renderer.
521
+ const isEdit = Array.isArray(report.changes)
522
+ const lines = isEdit
523
+ ? renderEditReport(report, resolved)
524
+ : hasChecks
474
525
  ? renderChecksReport(report, resolved)
475
526
  : isDelta
476
527
  ? renderDeltaReport(report, resolved)
package/src/routing.ts ADDED
@@ -0,0 +1,96 @@
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
+
17
+ export interface Route {
18
+ /** What this tool decides, in the words its report already uses. */
19
+ owns: string
20
+ /** The case just outside the line, and the tool that owns it. */
21
+ notFor: { when: string; tool: string }
22
+ }
23
+
24
+ export const ROUTES: Record<string, Route> = {
25
+ model_verify: {
26
+ owns: 'one machine: reachable states, invariants, deadlock, and a before/after regression',
27
+ notFor: { when: 'two or more machines must handshake or share a location', tool: 'model_compose_verify' },
28
+ },
29
+ model_compose_verify: {
30
+ owns: 'several machines run together: rendezvous, shared locations, lost updates',
31
+ notFor: { when: 'the question is about one machine on its own', tool: 'model_verify' },
32
+ },
33
+ model_automaton_verify: {
34
+ owns: 'a finite-word language, and its relation to another automaton',
35
+ notFor: { when: 'the question is reachability or deadlock inside one state machine', tool: 'model_verify' },
36
+ },
37
+ model_edit: {
38
+ owns: 'one editing round on a model file, answered with the structural diff and the change in findings',
39
+ notFor: { when: 'the edit is done and the model has to be decided again', tool: 'model_verify' },
40
+ },
41
+ model_datamodel_verify: {
42
+ owns: 'entities, keys, migrations, and whether the data survived them',
43
+ notFor: { when: 'the question is about behaviour over time rather than stored state', tool: 'model_verify' },
44
+ },
45
+ model_design_review: {
46
+ owns: "a design's own declarations: layering, responsibility, substitutability, interface width",
47
+ notFor: { when: 'the question is a fact about the graph - does this edge exist', tool: 'uml_structure_verify' },
48
+ },
49
+ model_interleaving_scan: {
50
+ owns: 'mining a document or plan for concurrency claims, and routing each to the analysis that decides it',
51
+ notFor: { when: 'the protocol itself has to be decided', tool: 'model_compose_verify' },
52
+ },
53
+ model_export: {
54
+ owns: 'a model translated into UPPAAL, TLA+, PRISM or SPIN for a property this plugin does not decide',
55
+ notFor: { when: 'the property can be decided here', tool: 'model_verify' },
56
+ },
57
+ report_diff: {
58
+ owns: 'what changed between two reports of the same family',
59
+ notFor: { when: 'the current report does not exist yet', tool: 'model_verify' },
60
+ },
61
+ uml_render: {
62
+ owns: 'drawing a model in the notation that can carry its family',
63
+ notFor: { when: 'the question is whether the modelling is sound', tool: 'uml_review' },
64
+ },
65
+ uml_parse: {
66
+ owns: 'reading a diagram back into the model its family declares',
67
+ notFor: { when: 'the question is whether the diagram says something sound', tool: 'uml_review' },
68
+ },
69
+ uml_review: {
70
+ owns: 'auditing the modelling: reachability, ambiguity, round-trip fidelity, family-specific defects',
71
+ notFor: { when: 'the claim is about behaviour rather than the drawing', tool: 'model_verify' },
72
+ },
73
+ uml_explain_labels: {
74
+ owns: 'the label spellings a notation accepts and the directives that pin the rest',
75
+ notFor: { when: 'a diagram has to be read rather than explained', tool: 'uml_parse' },
76
+ },
77
+ uml_structure_verify: {
78
+ owns: 'a dependency graph as a graph - cycles, layers, dangling edges - from a drawing or a source tree',
79
+ notFor: { when: 'the question is a rule the design declared for itself, not an edge that exists', tool: 'model_design_review' },
80
+ },
81
+ }
82
+
83
+ /** The route for a tool, or undefined when the tool has none. */
84
+ export function routeFor(tool: string): Route | undefined {
85
+ return ROUTES[tool]
86
+ }
87
+
88
+ /** Every tool a route names, for a caller that has to check the table against the registry. */
89
+ export function routedTools(): string[] {
90
+ return Object.keys(ROUTES)
91
+ }
92
+
93
+ /** The tools named as the owner of an adjacent case. */
94
+ export function siblingTools(): string[] {
95
+ return [...new Set(Object.values(ROUTES).map((route) => route.notFor.tool))].sort()
96
+ }