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
package/lib/editor.js ADDED
@@ -0,0 +1,293 @@
1
+ /**
2
+ * Applying edits to a model file: the delta side of the verification loop.
3
+ *
4
+ * A model is the largest thing any of these tools takes. Handing it back whole on every
5
+ * iteration is what makes a four-round fix cost four models. Here an edit names one change,
6
+ * the file carries the rest, and the caller gets the structural diff plus - when asked - the
7
+ * difference in what the engine decided, in the same call.
8
+ *
9
+ * Every operation works on a copy and the result is validated before anything is written. A
10
+ * refused edit leaves the file exactly as it was: an invalid model on disk would make the next
11
+ * verification spend a round reporting that, which is the cost this tool exists to remove.
12
+ */
13
+ import { readFileSync, renameSync, writeFileSync } from 'node:fs';
14
+ import { resolve } from 'node:path';
15
+ import { validateModel } from './engine.js';
16
+ import { OptionShapeError } from './option-shape.js';
17
+ const CLONE = (value) => JSON.parse(JSON.stringify(value));
18
+ /** Read the model at `path`; a missing file is a refusal, not an empty model. */
19
+ export function readModelFile(path) {
20
+ if (typeof path !== 'string' || path.trim() === '') {
21
+ throw new OptionShapeError('EDIT_OPTION_INVALID', 'modelPath must be a non-empty path to a JSON file.');
22
+ }
23
+ const full = resolve(path);
24
+ let text;
25
+ try {
26
+ text = readFileSync(full, 'utf8');
27
+ }
28
+ catch (error) {
29
+ const reason = error instanceof Error ? error.message : String(error);
30
+ throw new OptionShapeError('EDIT_OPTION_INVALID', `modelPath could not be read: ${reason}. Edits apply to a file that already exists; model_verify can create the model inline first, or write it yourself.`);
31
+ }
32
+ let parsed;
33
+ try {
34
+ parsed = JSON.parse(text.replace(/^\uFEFF/, ''));
35
+ }
36
+ catch (error) {
37
+ const reason = error instanceof Error ? error.message : String(error);
38
+ throw new OptionShapeError('EDIT_OPTION_INVALID', `modelPath is not valid JSON: ${reason}.`);
39
+ }
40
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
41
+ throw new OptionShapeError('EDIT_OPTION_INVALID', 'modelPath must hold a model object, not a list or a scalar.');
42
+ }
43
+ return { model: parsed, full };
44
+ }
45
+ function statesOf(model) {
46
+ return Array.isArray(model.states) ? model.states : [];
47
+ }
48
+ function transitionsOf(model) {
49
+ return Array.isArray(model.transitions) ? model.transitions : [];
50
+ }
51
+ function requireString(value, name) {
52
+ if (typeof value !== 'string' || value === '')
53
+ throw new OptionShapeError('EDIT_OPTION_INVALID', `${name} must be a non-empty string.`);
54
+ return value;
55
+ }
56
+ /** JSON pointer, so anything without a named operation still has a way to be edited. */
57
+ function pointerParts(path) {
58
+ const text = requireString(path, 'path');
59
+ if (!text.startsWith('/'))
60
+ throw new OptionShapeError('EDIT_OPTION_INVALID', `path must be a JSON pointer starting with "/", got ${JSON.stringify(text)}.`);
61
+ return text.split('/').slice(1).map((part) => part.replace(/~1/g, '/').replace(/~0/g, '~'));
62
+ }
63
+ function atPointer(model, parts, create) {
64
+ let node = model;
65
+ for (const part of parts.slice(0, -1)) {
66
+ const next = node[part];
67
+ if (next === undefined) {
68
+ if (!create)
69
+ throw new OptionShapeError('EDIT_OPTION_INVALID', `path does not exist at "${part}".`);
70
+ const created = {};
71
+ node[part] = created;
72
+ node = created;
73
+ }
74
+ else if (next !== null && typeof next === 'object') {
75
+ node = next;
76
+ }
77
+ else {
78
+ throw new OptionShapeError('EDIT_OPTION_INVALID', `path runs through "${part}", which is not an object.`);
79
+ }
80
+ }
81
+ return { parent: node, key: parts[parts.length - 1] ?? '' };
82
+ }
83
+ /**
84
+ * Accept the compact spelling of an operation as well as the long one.
85
+ *
86
+ * An operation is a small thing - add a state, wire a transition - and the long form spends more
87
+ * tokens on key names than on the change. Five operations cost 76 tokens spelled out and 39 in the
88
+ * compact form, and a batch of twenty is where that difference starts to matter.
89
+ *
90
+ * {addState: "c"} {addState: {id: "c", terminal: true}}
91
+ * {removeState: "c"}
92
+ * {addTransition: ["a", "reset", "c"]} {addTransition: {from, event, to, guard?, ...}}
93
+ * {removeTransition: ["a", "go"]} {updateTransition: ["a", "go", {guard: ...}]}
94
+ * {set: ["/init", "a"]} {remove: "/init"}
95
+ *
96
+ * The long form is unchanged and is what the errors talk about.
97
+ */
98
+ export function normalizeOperation(raw) {
99
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
100
+ throw new OptionShapeError('EDIT_OPTION_INVALID', 'every op must be an object.');
101
+ }
102
+ const value = raw;
103
+ if (typeof value.op === 'string')
104
+ return value;
105
+ const keys = Object.keys(value);
106
+ if (keys.length !== 1) {
107
+ throw new OptionShapeError('EDIT_OPTION_INVALID', `an operation names exactly one op, got ${JSON.stringify(keys)}.`);
108
+ }
109
+ const name = keys[0];
110
+ const argument = value[name];
111
+ if (name === 'addState' || name === 'removeState') {
112
+ if (typeof argument === 'string') {
113
+ return name === 'addState' ? { op: name, state: { id: argument } } : { op: name, id: argument };
114
+ }
115
+ if (name === 'addState' && argument !== null && typeof argument === 'object')
116
+ return { op: name, state: argument };
117
+ throw new OptionShapeError('EDIT_OPTION_INVALID', `${name} takes a state id, or a state object for addState.`);
118
+ }
119
+ if (name === 'addTransition' || name === 'removeTransition' || name === 'updateTransition') {
120
+ if (Array.isArray(argument)) {
121
+ if (name === 'addTransition' && argument.length === 3) {
122
+ const [from, event, to] = argument;
123
+ return { op: name, transition: { from, event, to } };
124
+ }
125
+ if (name === 'removeTransition' && argument.length === 2) {
126
+ const [from, event] = argument;
127
+ return { op: name, from, event };
128
+ }
129
+ if (name === 'updateTransition' && argument.length === 3) {
130
+ const [from, event, patch] = argument;
131
+ return { op: name, from, event, patch };
132
+ }
133
+ throw new OptionShapeError('EDIT_OPTION_INVALID', `${name} takes ${name === 'updateTransition' ? '["from", "event", {patch}]' : name === 'addTransition' ? '["from", "event", "to"]' : '["from", "event"]'}.`);
134
+ }
135
+ if (name === 'addTransition' && argument !== null && typeof argument === 'object')
136
+ return { op: name, transition: argument };
137
+ throw new OptionShapeError('EDIT_OPTION_INVALID', `${name} takes a list, or a transition object for addTransition.`);
138
+ }
139
+ if (name === 'set') {
140
+ if (Array.isArray(argument) && argument.length === 2)
141
+ return { op: 'set', path: argument[0], value: argument[1] };
142
+ if (argument !== null && typeof argument === 'object' && 'path' in argument) {
143
+ const { path, value } = argument;
144
+ return { op: 'set', path, value };
145
+ }
146
+ throw new OptionShapeError('EDIT_OPTION_INVALID', 'set takes ["/path", value] or {path, value}.');
147
+ }
148
+ if (name === 'remove') {
149
+ if (typeof argument === 'string')
150
+ return { op: 'remove', path: argument };
151
+ throw new OptionShapeError('EDIT_OPTION_INVALID', 'remove takes a JSON pointer string.');
152
+ }
153
+ throw new OptionShapeError('EDIT_OPTION_INVALID', `unknown op ${JSON.stringify(name)}. Known ops: addState, removeState, addTransition, removeTransition, updateTransition, set, remove.`);
154
+ }
155
+ /** Apply one operation to a model copy, returning the changes it made. */
156
+ export function applyOperation(model, operation) {
157
+ const op = requireString(operation.op, 'op');
158
+ const changes = [];
159
+ if (op === 'addState') {
160
+ const state = operation.state;
161
+ const id = requireString(state?.id, 'state.id');
162
+ if (statesOf(model).some((entry) => entry.id === id)) {
163
+ throw new OptionShapeError('EDIT_OPTION_INVALID', `state "${id}" already exists; use update on it instead of adding it twice.`);
164
+ }
165
+ model.states = [...statesOf(model), state];
166
+ changes.push({ kind: 'added', what: 'state ' + id, detail: state?.terminal === true ? 'terminal' : '' });
167
+ return changes;
168
+ }
169
+ if (op === 'removeState') {
170
+ const id = requireString(operation.id, 'id');
171
+ const before = statesOf(model);
172
+ if (!before.some((entry) => entry.id === id))
173
+ throw new OptionShapeError('EDIT_OPTION_INVALID', `no state "${id}" to remove.`);
174
+ model.states = before.filter((entry) => entry.id !== id);
175
+ const dropped = transitionsOf(model).filter((entry) => entry.from === id || entry.to === id);
176
+ model.transitions = transitionsOf(model).filter((entry) => entry.from !== id && entry.to !== id);
177
+ changes.push({ kind: 'removed', what: 'state ' + id, detail: dropped.length > 0 ? `and ${String(dropped.length)} transition(s) that used it` : '' });
178
+ return changes;
179
+ }
180
+ if (op === 'addTransition') {
181
+ const transition = operation.transition;
182
+ const from = requireString(transition?.from, 'transition.from');
183
+ const event = requireString(transition?.event, 'transition.event');
184
+ const to = requireString(transition?.to, 'transition.to');
185
+ if (transitionsOf(model).some((entry) => entry.from === from && entry.event === event)) {
186
+ throw new OptionShapeError('EDIT_OPTION_INVALID', `a transition for ${from} + ${event} already exists; update it instead of adding another.`);
187
+ }
188
+ model.transitions = [...transitionsOf(model), transition];
189
+ changes.push({ kind: 'added', what: `transition ${from} + ${event}`, detail: 'to ' + to });
190
+ return changes;
191
+ }
192
+ if (op === 'removeTransition') {
193
+ const from = requireString(operation.from, 'from');
194
+ const event = requireString(operation.event, 'event');
195
+ const before = transitionsOf(model);
196
+ const kept = before.filter((entry) => !(entry.from === from && entry.event === event));
197
+ if (kept.length === before.length)
198
+ throw new OptionShapeError('EDIT_OPTION_INVALID', `no transition for ${from} + ${event} to remove.`);
199
+ model.transitions = kept;
200
+ changes.push({ kind: 'removed', what: `transition ${from} + ${event}`, detail: '' });
201
+ return changes;
202
+ }
203
+ if (op === 'updateTransition') {
204
+ const from = requireString(operation.from, 'from');
205
+ const event = requireString(operation.event, 'event');
206
+ const patch = operation.patch;
207
+ if (patch === undefined || typeof patch !== 'object' || Array.isArray(patch)) {
208
+ throw new OptionShapeError('EDIT_OPTION_INVALID', 'patch must be an object of the fields to replace.');
209
+ }
210
+ const list = transitionsOf(model);
211
+ const index = list.findIndex((entry) => entry.from === from && entry.event === event);
212
+ if (index < 0)
213
+ throw new OptionShapeError('EDIT_OPTION_INVALID', `no transition for ${from} + ${event} to update.`);
214
+ list[index] = { ...list[index], ...patch };
215
+ model.transitions = list;
216
+ changes.push({ kind: 'updated', what: `transition ${from} + ${event}`, detail: Object.keys(patch).join(', ') });
217
+ return changes;
218
+ }
219
+ if (op === 'set') {
220
+ const parts = pointerParts(operation.path);
221
+ if (parts.length === 0)
222
+ throw new OptionShapeError('EDIT_OPTION_INVALID', 'path must name something inside the model.');
223
+ const { parent, key } = atPointer(model, parts, true);
224
+ parent[key] = CLONE(operation.value);
225
+ changes.push({ kind: 'updated', what: 'set ' + String(operation.path), detail: '' });
226
+ return changes;
227
+ }
228
+ if (op === 'remove') {
229
+ const parts = pointerParts(operation.path);
230
+ if (parts.length === 0)
231
+ throw new OptionShapeError('EDIT_OPTION_INVALID', 'path must name something inside the model.');
232
+ const { parent, key } = atPointer(model, parts, false);
233
+ if (!(key in parent))
234
+ throw new OptionShapeError('EDIT_OPTION_INVALID', `nothing at ${String(operation.path)} to remove.`);
235
+ delete parent[key];
236
+ changes.push({ kind: 'removed', what: 'remove ' + String(operation.path), detail: '' });
237
+ return changes;
238
+ }
239
+ throw new OptionShapeError('EDIT_OPTION_INVALID', `unknown op ${JSON.stringify(op)}. Known ops: addState, removeState, addTransition, removeTransition, updateTransition, set, remove.`);
240
+ }
241
+ export function applyEdits(path, operations, options = {}) {
242
+ if (!Array.isArray(operations) || operations.length === 0) {
243
+ throw new OptionShapeError('EDIT_OPTION_INVALID', 'ops must be a non-empty array of operations.');
244
+ }
245
+ const { model: original, full } = readModelFile(path);
246
+ const next = CLONE(original);
247
+ const changes = [];
248
+ const normalized = operations.map((raw, index) => {
249
+ try {
250
+ return normalizeOperation(raw);
251
+ }
252
+ catch (error) {
253
+ const reason = error instanceof Error ? error.message : String(error);
254
+ throw new OptionShapeError('EDIT_OPTION_INVALID', `operation ${String(index + 1)} was refused: ${reason}`);
255
+ }
256
+ });
257
+ for (const [index, raw] of normalized.entries()) {
258
+ try {
259
+ changes.push(...applyOperation(next, raw));
260
+ }
261
+ catch (error) {
262
+ const reason = error instanceof Error ? error.message : String(error);
263
+ throw new OptionShapeError('EDIT_OPTION_INVALID', `operation ${String(index + 1)} (${String(raw.op)}) was refused: ${reason}`);
264
+ }
265
+ }
266
+ const validation = validateModel(next);
267
+ if (!validation.ok && options.allowInvalid !== true) {
268
+ // Name the operation the batch first stops validating at, not just the symptom: a caller that
269
+ // reads "a transition to an unknown state" still has to find which entry introduced it.
270
+ let firstBad = -1;
271
+ const partial = CLONE(original);
272
+ for (const [index, raw] of operations.entries()) {
273
+ try {
274
+ applyOperation(partial, raw);
275
+ }
276
+ catch {
277
+ firstBad = index;
278
+ break;
279
+ }
280
+ if (!validateModel(partial).ok) {
281
+ firstBad = index;
282
+ break;
283
+ }
284
+ }
285
+ const where = firstBad < 0 ? '' : ` Operation ${String(firstBad + 1)} (${String(operations[firstBad]?.op)}) is where it first stops validating.`;
286
+ throw new OptionShapeError('EDIT_RESULT_INVALID', 'the batch would leave an invalid model, so nothing was written: ' + validation.errors.join('; ') + '.' + where + ' Fix the list, or pass allowInvalid to write it anyway and continue in a second call.');
287
+ }
288
+ const text = JSON.stringify(validation.ok ? validation.model : next, null, 2) + '\n';
289
+ const temporary = full + '.tmp';
290
+ writeFileSync(temporary, text, 'utf8');
291
+ renameSync(temporary, full);
292
+ return { model: next, changes, full, valid: validation.ok, errors: validation.ok ? [] : validation.errors };
293
+ }
package/lib/engine.js CHANGED
@@ -62,6 +62,12 @@ function verificationNextSteps(checks, coverage) {
62
62
  if (coverage !== undefined && !narrativeComplete(coverage)) {
63
63
  steps.push('Complete the narrative (states ' + coverage.states + ', events ' + coverage.events + ', scenarios ' + coverage.scenarios + ') — a partial narrative is valid, but a reader still has to re-derive the missing symbols.');
64
64
  }
65
+ // A truncated search is the one outcome where "look harder" is the wrong advice: the wall is
66
+ // combinatorial, and raising maxStates buys a constant factor. Name the ladder instead, so the reader
67
+ // knows this is not a bug to fix but a model to reduce, or a different tool to use.
68
+ if (hasUndecidedFinding(checks)) {
69
+ steps.push('The search stopped before deciding, so the undecided checks are neither proven nor refuted. Do not just raise maxStates: first reduce the model (drop variables the property does not read, collapse symmetric states, order independent events), and if it still explodes, move to a tool that does not enumerate every state - SPIN for partial-order reduction, NuSMV/nuXMV for symbolic search, CBMC for bounded SAT. This plugin establishes no abstraction and reads no program source, so a property that needs one is outside it.');
70
+ }
65
71
  // The range is dropped: the code that would be reported back is already named in `findings`, and a
66
72
  // reader who has not seen a report yet learns nothing from four digits.
67
73
  steps.push('Re-run with beforeModel/stateMapping after the change to prove the behaviour did not regress: behaviour preserved, invariants continuous, the state mapping consistent. Then compare the two reports with `report_diff` rather than reading them side by side.');
@@ -181,6 +187,7 @@ export const REPORT_SCHEMAS = {
181
187
  granularity: 'model-everything/granularity/v1',
182
188
  design: 'model-everything/design/v1',
183
189
  baseline: 'model-everything/baseline/v1',
190
+ edit: 'model-everything/edit/v1',
184
191
  };
185
192
  export function narrativeCoverageOf(model) {
186
193
  const narrative = model.narrative;
@@ -1,5 +1,6 @@
1
1
  import { defineTool } from '@deepseek-ai/dsh-tools';
2
2
  import { REPORT_SCHEMAS, DEFAULT_HASH_SPEC, modelHash, refusalVerdict, validateModel, verdictOf } from './engine.js';
3
+ import { reportCard, reportResultView } from './presentation.js';
3
4
  import { ExportError, exportModel } from './exporters.js';
4
5
  import { renderReportText } from './report-view.js';
5
6
  /** The model hash of an export's input, when the input validates (the success path always does). */
@@ -44,7 +45,11 @@ export const modelExportTool = defineTool({
44
45
  render(args, value) {
45
46
  return [{ type: 'text', text: renderReportText(value, { full: args.reportView === 'full' }) }];
46
47
  },
47
- },
48
+ // The card a UI shows, separated from the text the model reads.
49
+ presentationMeta(_args, value) {
50
+ return reportCard(value, EXPORT_TOOL_NAME);
51
+ },
52
+ }, presentResult: reportResultView(EXPORT_TOOL_NAME),
48
53
  timeoutMs: 10_000,
49
54
  isConcurrencySafe: () => true,
50
55
  async execute(args) {
package/lib/index.js CHANGED
@@ -33,6 +33,8 @@ import { umlRenderTool, umlParseTool, umlReviewTool, umlExplainLabelsTool } from
33
33
  import { umlStructureTool } from './structure-tool.js';
34
34
  import { designTool } from './design-tool.js';
35
35
  import { reportDiffTool } from './report-diff-tool.js';
36
+ import { modelEditTool } from './edit-tool.js';
37
+ import { modelAutomatonTool } from './automaton-tool.js';
36
38
  import { ENGINE_SCHEMA_VERSION } from './engine.js';
37
39
  // DSH 0.1.7-alpha.1 (session format v4) retires the shared
38
40
  // `{ kind: 'plugin', plugin }` wrapper: native admission rejects it in every
@@ -192,6 +194,8 @@ export function apply(ctx, config) {
192
194
  ctx.effect(() => tools.register(umlStructureTool), 'dsh-model-everything: structure tool');
193
195
  ctx.effect(() => tools.register(designTool), 'dsh-model-everything: design review tool');
194
196
  ctx.effect(() => tools.register(reportDiffTool), 'dsh-model-everything: report diff tool');
197
+ ctx.effect(() => tools.register(modelEditTool), 'dsh-model-everything: model edit tool');
198
+ ctx.effect(() => tools.register(modelAutomatonTool), 'dsh-model-everything: automaton tool');
195
199
  toolRegistered = true;
196
200
  dataToolRegistered = true;
197
201
  concurrencyToolRegistered = true;
@@ -0,0 +1,81 @@
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
+ const severity = (entry) => String(entry.severity ?? 'info');
25
+ const errors = findings.filter((entry) => severity(entry) === 'error');
26
+ const warnings = findings.filter((entry) => severity(entry) === 'warning');
27
+ const summary = record(report.summary);
28
+ const checksRun = typeof summary?.checksRun === 'number'
29
+ ? summary.checksRun
30
+ : array(report.checks).length > 0
31
+ ? array(report.checks).length
32
+ : undefined;
33
+ const first = errors[0] ?? warnings[0];
34
+ const next = array(report.nextSteps).map((step) => String(step))[0];
35
+ return {
36
+ ...(typeof report.verdict === 'string' ? { verdict: report.verdict } : {}),
37
+ ...(typeof report.verdictReason === 'string' ? { verdictReason: shorten(report.verdictReason) } : {}),
38
+ errors: errors.length,
39
+ warnings: warnings.length,
40
+ ...(checksRun === undefined ? {} : { checksRun }),
41
+ first: first === undefined
42
+ ? null
43
+ : { code: String(first.code ?? 'UNKNOWN'), severity: severity(first), message: shorten(String(first.message ?? '')) },
44
+ next: next === undefined ? null : shorten(next),
45
+ family,
46
+ ...(routeFor(family) === undefined
47
+ ? {}
48
+ : { route: { owns: routeFor(family)?.owns ?? '', instead: routeFor(family)?.notFor.tool ?? '' } }),
49
+ };
50
+ }
51
+ /** The card body: four lines, in the order a reader asks the questions. */
52
+ export function cardBlocks(card) {
53
+ const lines = [];
54
+ const head = card.verdict === undefined ? card.family : card.verdict.toUpperCase();
55
+ const counts = card.checksRun === undefined
56
+ ? `${String(card.errors)} error(s), ${String(card.warnings)} warning(s)`
57
+ : `${String(card.checksRun)} checks · ${String(card.errors)} error(s) · ${String(card.warnings)} warning(s)`;
58
+ lines.push(head + ' — ' + counts);
59
+ if (card.verdictReason !== undefined)
60
+ lines.push(card.verdictReason);
61
+ if (card.first !== null) {
62
+ lines.push((card.first.severity === 'error' ? '✗ ' : '⚠ ') + card.first.code + ': ' + card.first.message);
63
+ }
64
+ if (card.next !== null)
65
+ lines.push('next: ' + card.next);
66
+ return [{ type: 'text', text: lines.join('\n') }];
67
+ }
68
+ /**
69
+ * `presentResult` for a report tool: render the card from the persisted payload, and fall back to the
70
+ * raw content by returning undefined when there is none.
71
+ */
72
+ export function reportResultView(family) {
73
+ return (_args, result) => {
74
+ const card = record(result.meta);
75
+ if (card === undefined || card === null)
76
+ return undefined;
77
+ if (card.verdict === undefined && card.next === null && card.first === null)
78
+ return undefined;
79
+ return { card: 'generic', title: family + ' ' + (card.verdict ?? ''), content: cardBlocks(card) };
80
+ };
81
+ }
@@ -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 { reportCard, reportResultView } 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,11 @@ export const reportDiffTool = defineTool({
49
50
  render(args, value) {
50
51
  return [{ type: 'text', text: renderReportText(value, { full: args.reportView === 'full' }) }];
51
52
  },
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
+ },
57
+ }, presentResult: reportResultView(REPORT_DIFF_TOOL_NAME),
53
58
  timeoutMs: 10_000,
54
59
  isConcurrencySafe: () => true,
55
60
  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
+ }