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
@@ -1,68 +1,76 @@
1
- import { defineTool } from '@deepseek-ai/dsh-tools'
2
- import type { JsonValue } from './json-value.js'
3
- import { REPORT_SCHEMAS } from './engine.js'
4
- import { oneOf } from './model-file.js'
5
- import { runDesignReview } from './design-engine.js'
6
- import { renderReportText } from './report-view.js'
7
-
8
- export const DESIGN_TOOL_NAME = 'model_design_review'
9
-
10
- /**
11
- * Model-visible DSH tool that reviews a *design*: the shape of its dependency graph, and the
12
- * contracts the design states about itself.
13
- *
14
- * This is not `model_verify`. That one asks whether a machine can misbehave; this one asks
15
- * whether a design keeps its own declarations - which layer may depend on which, which
16
- * responsibility owns which dependency, which subtype may refuse which event. Two engines,
17
- * two questions, two verdicts that mean different things.
18
- */
19
- export const designTool = defineTool({
20
- name: DESIGN_TOOL_NAME,
21
- description:
22
- 'Hold a design to the contracts it declares about itself: layering, responsibility ownership, substitutability, interface width. For auditing a dependency diagram against a matrix use `uml_structure_verify`; this one decides the declarations the design makes about itself and reports the shape of its graph. Violations of a declaration decide `verdict`: DES1 cycle, DES2 layer order, DES3 boundary leak, DES5 unplaced node, DES6 missing required dependency, DES7 substitutability, DES9 split interface. Signals are facts, not defects: DES4 load-bearing element, DES8 uncalled operation, DES10 responsibility gap, DES11 abstraction on a concrete type. A check with nothing to hold the design to is `skip`, not `pass`. `facts` has ranks, fan-in/out, components, density, cyclomatic complexity, and the fewest edges whose removal makes the graph acyclic (exact for a small cyclic part, an upper bound past that: `feedbackArcsExact` says which). Read `verdict`, never `ok`.',
23
- parameters: {
24
- model: {
25
- type: 'json',
26
- description:
27
- 'DesignModelV1: {schemaVersion: 1, architecture: {nodes: [ids], edges: [[from, to]]}, types?: [{name, stereotype, extends?, implements?, operations?, transitions?, forbids?, responsibilities?, dependsOn?, uses?, layer?}], declare?: {layers?, boundaries?, allowedEdges?, requiredEdges?}}. The schema is closed: an unknown key is refused rather than ignored. `types` is what makes the principle checks possible - with no `extends` there is no substitutability to decide, with no `uses` no interface can be called fat, and no `responsibilities` means nothing to compare the dependencies against.',
28
- },
29
- modelPath: {
30
- type: 'string',
31
- description: 'Path to a JSON file holding the model, instead of inlining it. Formatting does not matter: whitespace, a BOM and pretty printing all parse. Relative paths resolve against the process working directory, which is the session workspace.',
32
- },
33
- check: {
34
- type: 'string',
35
- description: 'Print one check in full when the digest truncated it. Pass the check id the CHECKS line lists (for example A9); the per-check budget no longer applies, and an id this report does not carry is answered with the ids that it does.',
36
- },
37
- reportView: {
38
- type: 'string',
39
- enum: ['digest', 'full'],
40
- description: 'digest (default) prints the readable body; full prints the structured report, which report_diff needs.',
41
- },
42
- },
43
- output: {
44
- schema: {
45
- type: 'json',
46
- description: 'Design review report: every check with its status, `violations` (they decide the verdict), `signals`, `facts` (graph features), summary and next steps.',
47
- },
48
- render(args, value) {
49
- return [{ type: 'text' as const, text: renderReportText(value, { full: args.reportView === 'full', check: args.check }) }]
50
- },
51
- },
52
- timeoutMs: 10_000,
53
- isConcurrencySafe: () => true,
54
- async execute(args) {
55
- if (args.model === undefined && args.modelPath === undefined) {
56
- return {
57
- ok: false,
58
- ran: false,
59
- verdict: 'fail',
60
- verdictReason: 'the tool did not run: pass `model` (a DesignModelV1 with schemaVersion, architecture and the declarations) or `modelPath` (a JSON file holding one)',
61
- schema: REPORT_SCHEMAS.design,
62
- errorCode: 'DESIGN_INPUT',
63
- error: 'design review needs `model` or `modelPath`',
64
- } as unknown as JsonValue
65
- }
66
- return runDesignReview(oneOf(args.model, args.modelPath, 'model', 'modelPath', 'DESIGN_OPTION_INVALID')) as unknown as JsonValue
67
- },
68
- })
1
+ import { defineTool } from '@deepseek-ai/dsh-tools'
2
+ import type { JsonValue } from './json-value.js'
3
+ import { REPORT_SCHEMAS } from './engine.js'
4
+ import { designResultView, reportCallView, reportCard } from './presentation.js'
5
+ import { oneOf } from './model-file.js'
6
+ import { runDesignReview } from './design-engine.js'
7
+ import { renderReportText } from './report-view.js'
8
+
9
+ export const DESIGN_TOOL_NAME = 'model_design_review'
10
+
11
+ /**
12
+ * Model-visible DSH tool that reviews a *design*: the shape of its dependency graph, and the
13
+ * contracts the design states about itself.
14
+ *
15
+ * This is not `model_verify`. That one asks whether a machine can misbehave; this one asks
16
+ * whether a design keeps its own declarations - which layer may depend on which, which
17
+ * responsibility owns which dependency, which subtype may refuse which event. Two engines,
18
+ * two questions, two verdicts that mean different things.
19
+ */
20
+ export const designTool = defineTool({
21
+ name: DESIGN_TOOL_NAME,
22
+ description:
23
+ 'Hold a design to the contracts it declares about itself: layering, responsibility ownership, substitutability, interface width. For auditing a dependency diagram against a matrix use `uml_structure_verify`; this one decides the declarations the design makes about itself and reports the shape of its graph. The split matters when a reader has a fact and a rule in one hand: which edges actually exist is the graph tool (it can scan a source tree with `repoPath`), and whether those edges were allowed is this one. Violations of a declaration decide `verdict`: DES1 cycle, DES2 layer order, DES3 boundary leak, DES5 unplaced node, DES6 missing required dependency, DES7 substitutability, DES9 split interface. Signals are facts, not defects: DES4 load-bearing element, DES8 uncalled operation, DES10 responsibility gap, DES11 abstraction on a concrete type. A check with nothing to hold the design to is `skip`, not `pass`. `facts` has ranks, fan-in/out, components, density, cyclomatic complexity, and the fewest edges whose removal makes the graph acyclic (exact for a small cyclic part, an upper bound past that: `feedbackArcsExact` says which). Read `verdict`, never `ok`.',
24
+ parameters: {
25
+ model: {
26
+ type: 'json',
27
+ description:
28
+ 'DesignModelV1: {schemaVersion: 1, architecture: {nodes: [ids], edges: [[from, to]]}, types?: [{name, stereotype, extends?, implements?, operations?, transitions?, forbids?, responsibilities?, dependsOn?, uses?, layer?}], declare?: {layers?, boundaries?, allowedEdges?, requiredEdges?}}. The schema is closed: an unknown key is refused rather than ignored. `types` is what makes the principle checks possible - with no `extends` there is no substitutability to decide, with no `uses` no interface can be called fat, and no `responsibilities` means nothing to compare the dependencies against.',
29
+ },
30
+ modelPath: {
31
+ type: 'string',
32
+ description: 'Path to a JSON file holding the model, instead of inlining it. Formatting does not matter: whitespace, a BOM and pretty printing all parse. Relative paths resolve against the process working directory, which is the session workspace.',
33
+ },
34
+ check: {
35
+ type: 'string',
36
+ description: 'Print one check in full when the digest truncated it. Pass the check id the CHECKS line lists (for example A9); the per-check budget no longer applies, and an id this report does not carry is answered with the ids that it does.',
37
+ },
38
+ reportView: {
39
+ type: 'string',
40
+ enum: ['digest', 'full'],
41
+ description: 'digest (default) prints the readable body; full prints the structured report, which report_diff needs.',
42
+ },
43
+ },
44
+ output: {
45
+ schema: {
46
+ type: 'json',
47
+ description: 'Design review report: every check with its status, `violations` (they decide the verdict), `signals`, `facts` (graph features), summary and next steps.',
48
+ },
49
+ render(args, value) {
50
+ return [{ type: 'text' as const, text: renderReportText(value, { full: args.reportView === 'full', check: args.check }) }]
51
+ },
52
+ // The card a UI shows, separated from the text the model reads.
53
+ presentationMeta(_args, value) {
54
+ return reportCard(value, DESIGN_TOOL_NAME) as unknown as JsonValue
55
+ },
56
+ },
57
+ presentCall: reportCallView(DESIGN_TOOL_NAME),
58
+ presentResult: designResultView,
59
+
60
+ timeoutMs: 10_000,
61
+ isConcurrencySafe: () => true,
62
+ async execute(args) {
63
+ if (args.model === undefined && args.modelPath === undefined) {
64
+ return {
65
+ ok: false,
66
+ ran: false,
67
+ verdict: 'fail',
68
+ verdictReason: 'the tool did not run: pass `model` (a DesignModelV1 with schemaVersion, architecture and the declarations) or `modelPath` (a JSON file holding one)',
69
+ schema: REPORT_SCHEMAS.design,
70
+ errorCode: 'DESIGN_INPUT',
71
+ error: 'design review needs `model` or `modelPath`',
72
+ } as unknown as JsonValue
73
+ }
74
+ return runDesignReview(oneOf(args.model, args.modelPath, 'model', 'modelPath', 'DESIGN_OPTION_INVALID')) as unknown as JsonValue
75
+ },
76
+ })
@@ -0,0 +1,121 @@
1
+ /**
2
+ * `model_edit`: change a model file by naming the change, then see what the engine now says.
3
+ *
4
+ * The loop this replaces is edit-and-re-verify: hand back the whole model, get the whole report,
5
+ * repeat. Here the file holds the model, an operation holds the change, and the report carries the
6
+ * structural diff plus the difference in findings - so one call answers "what did I change, and did
7
+ * it fix anything".
8
+ *
9
+ * The description is written for the decision, not the schema: when this tool pays, when another
10
+ * one does, and what it refuses.
11
+ */
12
+ import { defineTool } from '@deepseek-ai/dsh-tools'
13
+
14
+ import { diffReports } from './baseline.js'
15
+ import { applyEdits, readModelFile } from './editor.js'
16
+ import { REPORT_SCHEMAS, runVerification } from './engine.js'
17
+ import { editResultView, reportCallView, reportCard } from './presentation.js'
18
+ import type { JsonValue } from './json-value.js'
19
+ import { optionRefusal } from './option-shape.js'
20
+ import { renderReportText } from './report-view.js'
21
+
22
+ export const MODEL_EDIT_TOOL_NAME = 'model_edit'
23
+
24
+ export const modelEditTool = defineTool({
25
+ name: MODEL_EDIT_TOOL_NAME,
26
+ description:
27
+ 'Change a model file by naming one change, then see what the engine now decides. Use it to fix a model over several rounds: write the model to a file once, then add a state, tighten a guard, drop a transition and let the report show whether the finding went away. ' +
28
+ 'It answers with the structural diff and, unless you set verify: false, the difference in findings - new errors, gone findings, before and after verdict - so one call replaces edit-then-verify-then-compare. ' +
29
+ 'It refuses an edit that would leave an invalid model and writes nothing, so the file is always verifiable. ' +
30
+ 'Do not use it to build a model from nothing (write the file or verify inline first), to check work that is not a state machine (`model_datamodel_verify`, `model_design_review`), or to judge the design as a whole (`model_verify` reports; this tool only compares its own edit).',
31
+ parameters: {
32
+ modelPath: {
33
+ type: 'string',
34
+ required: true,
35
+ description: 'Path to the model file to edit. It must already exist; the edit is applied to a copy and written back atomically.',
36
+ },
37
+ ops: {
38
+ type: 'json',
39
+ required: true,
40
+ description:
41
+ 'Ordered list of operations, applied as one batch. Compact form: {addState: "c"} · {removeState: "c"} · {addTransition: ["from", "event", "to"]} · {removeTransition: ["from", "event"]} · {updateTransition: ["from", "event", {guard: ...}]} · {set: ["/path", value]} · {remove: "/path"}. Long form ({op: ..., state/transition/path/value}) works too, and addState/addTransition take the full object when you need terminal, guard, updates or cost.',
42
+ },
43
+ reportView: {
44
+ type: 'string',
45
+ enum: ['digest', 'full'],
46
+ description: 'digest (default) prints the changes and the report delta; full prints the structured result, which report_diff needs.',
47
+ },
48
+ allowInvalid: {
49
+ type: 'boolean',
50
+ description: 'Write the batch even when the result does not validate. Use it only for an edit that needs two calls with a broken model in between; the reply then carries validationErrors, and the next verification will refuse the file.',
51
+ },
52
+ verify: {
53
+ type: 'boolean',
54
+ description: 'Run the engine on the model before and after the edit and return the difference in findings. Default true; pass false for a batch of edits where only the final state matters.',
55
+ },
56
+ },
57
+ output: {
58
+ schema: {
59
+ type: 'json',
60
+ description: 'the edit result: structural changes, the new verdict, and the report delta when verify ran.',
61
+ },
62
+ render(args, value) {
63
+ return [{ type: 'text' as const, text: renderReportText(value, { full: args.reportView === 'full' }) }]
64
+ },
65
+ // The card a UI shows, separated from the text the model reads.
66
+ presentationMeta(_args, value) {
67
+ return reportCard(value, MODEL_EDIT_TOOL_NAME) as unknown as JsonValue
68
+ },
69
+ },
70
+ presentCall: reportCallView(MODEL_EDIT_TOOL_NAME),
71
+ presentResult: editResultView(MODEL_EDIT_TOOL_NAME),
72
+
73
+ timeoutMs: 20_000,
74
+ isConcurrencySafe: () => false,
75
+ async execute(args) {
76
+ try {
77
+ const before = readModelFile(args.modelPath).model
78
+ const verify = args.verify !== false
79
+ const beforeReport = verify ? runVerification(before) : undefined
80
+ const { model, changes, valid, errors } = applyEdits(args.modelPath, args.ops, { allowInvalid: args.allowInvalid === true })
81
+ const afterReport = verify ? runVerification(model) : undefined
82
+ // A model that did not decide anything (checksRun 0 - the engine refused its shape) has no
83
+ // findings to compare. Diffing it would raise, and it would raise after the file was written,
84
+ // so the reply would claim nothing ran while the edit had landed. Say there is no delta instead.
85
+ const decided = (report: typeof beforeReport): boolean => (report?.summary?.checksRun ?? 0) > 0
86
+ const compare = beforeReport !== undefined && afterReport !== undefined && decided(beforeReport) && decided(afterReport)
87
+ const delta = compare ? diffReports(beforeReport, afterReport) : undefined
88
+ const noDelta = verify && !compare ? (decided(beforeReport) ? 'the edited model did not decide anything, so there is no after-report to compare' : 'the model before the edit did not decide anything, so there is no before-report to compare') : undefined
89
+ const verdict = afterReport?.verdict ?? 'pass'
90
+ const reason =
91
+ (afterReport === undefined ? 'edit applied, not verified: ' : '') +
92
+ changes.map((change) => `${change.kind} ${change.what}${change.detail === '' ? '' : ' (' + change.detail + ')'}`).join('; ') +
93
+ (delta === undefined ? (noDelta === undefined ? '' : ` — ${noDelta}`) : ` — verdict ${String(beforeReport?.verdict ?? '?')} -> ${String(delta.currentVerdict ?? '?')}`)
94
+ return {
95
+ ok: true,
96
+ ran: true,
97
+ schema: REPORT_SCHEMAS.edit,
98
+ verdict,
99
+ verdictReason: reason,
100
+ changes,
101
+ ...(valid ? {} : { modelValid: false, validationErrors: errors }),
102
+ // `undefined` is not lossless JSON: with verify off there is no hash to report, so the field
103
+ // is omitted rather than filled with nothing.
104
+ ...(afterReport === undefined ? {} : { hashes: { hashSpec: afterReport.hashSpec, modelHash: afterReport.modelHash } }),
105
+ ...(beforeReport === undefined ? {} : { before: { verdict: beforeReport.verdict, modelHash: beforeReport.modelHash } }),
106
+ ...(afterReport === undefined ? {} : { after: { verdict: afterReport.verdict, errors: afterReport.summary?.errors, warnings: afterReport.summary?.warnings } }),
107
+ ...(delta === undefined ? {} : { reportDelta: delta }),
108
+ nextSteps:
109
+ delta === undefined
110
+ ? [noDelta === undefined
111
+ ? 'The file now holds the edited model. Run `model_verify` with its `modelPath` when the edits are done.'
112
+ : 'The edit is on disk. Run `model_verify` on the file: it now decides whether this is a valid model.']
113
+ : delta.added.length === 0 && delta.removed.length === 0
114
+ ? ['The edit moved nothing in the findings. Change the part of the model the finding names, or report the finding as unresolved.']
115
+ : [],
116
+ } as unknown as JsonValue
117
+ } catch (error) {
118
+ return optionRefusal(error, 'EDIT_OPTION_INVALID') as unknown as JsonValue
119
+ }
120
+ },
121
+ })
package/src/editor.ts ADDED
@@ -0,0 +1,297 @@
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
+
16
+ import { validateModel } from './engine.js'
17
+ import { OptionShapeError } from './option-shape.js'
18
+
19
+ type Json = Record<string, unknown>
20
+
21
+ export interface EditOperation {
22
+ op: string
23
+ [key: string]: unknown
24
+ }
25
+
26
+ export interface ModelChange {
27
+ kind: 'added' | 'removed' | 'updated'
28
+ what: string
29
+ detail: string
30
+ }
31
+
32
+ const CLONE = (value: unknown): unknown => JSON.parse(JSON.stringify(value)) as unknown
33
+
34
+ /** Read the model at `path`; a missing file is a refusal, not an empty model. */
35
+ export function readModelFile(path: unknown): { model: Json; full: string } {
36
+ if (typeof path !== 'string' || path.trim() === '') {
37
+ throw new OptionShapeError('EDIT_OPTION_INVALID', 'modelPath must be a non-empty path to a JSON file.')
38
+ }
39
+ const full = resolve(path)
40
+ let text: string
41
+ try {
42
+ text = readFileSync(full, 'utf8')
43
+ } catch (error) {
44
+ const reason = error instanceof Error ? error.message : String(error)
45
+ 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.`)
46
+ }
47
+ let parsed: unknown
48
+ try {
49
+ parsed = JSON.parse(text.replace(/^\uFEFF/, ''))
50
+ } catch (error) {
51
+ const reason = error instanceof Error ? error.message : String(error)
52
+ throw new OptionShapeError('EDIT_OPTION_INVALID', `modelPath is not valid JSON: ${reason}.`)
53
+ }
54
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
55
+ throw new OptionShapeError('EDIT_OPTION_INVALID', 'modelPath must hold a model object, not a list or a scalar.')
56
+ }
57
+ return { model: parsed as Json, full }
58
+ }
59
+
60
+ function statesOf(model: Json): Json[] {
61
+ return Array.isArray(model.states) ? (model.states as Json[]) : []
62
+ }
63
+ function transitionsOf(model: Json): Json[] {
64
+ return Array.isArray(model.transitions) ? (model.transitions as Json[]) : []
65
+ }
66
+
67
+ function requireString(value: unknown, name: string): string {
68
+ if (typeof value !== 'string' || value === '') throw new OptionShapeError('EDIT_OPTION_INVALID', `${name} must be a non-empty string.`)
69
+ return value
70
+ }
71
+
72
+ /** JSON pointer, so anything without a named operation still has a way to be edited. */
73
+ function pointerParts(path: unknown): string[] {
74
+ const text = requireString(path, 'path')
75
+ if (!text.startsWith('/')) throw new OptionShapeError('EDIT_OPTION_INVALID', `path must be a JSON pointer starting with "/", got ${JSON.stringify(text)}.`)
76
+ return text.split('/').slice(1).map((part) => part.replace(/~1/g, '/').replace(/~0/g, '~'))
77
+ }
78
+
79
+ function atPointer(model: Json, parts: string[], create: boolean): { parent: Json; key: string } {
80
+ let node: Json = model
81
+ for (const part of parts.slice(0, -1)) {
82
+ const next = (node as Record<string, unknown>)[part]
83
+ if (next === undefined) {
84
+ if (!create) throw new OptionShapeError('EDIT_OPTION_INVALID', `path does not exist at "${part}".`)
85
+ const created: Json = {}
86
+ ;(node as Record<string, unknown>)[part] = created
87
+ node = created
88
+ } else if (next !== null && typeof next === 'object') {
89
+ node = next as Json
90
+ } else {
91
+ throw new OptionShapeError('EDIT_OPTION_INVALID', `path runs through "${part}", which is not an object.`)
92
+ }
93
+ }
94
+ return { parent: node, key: parts[parts.length - 1] ?? '' }
95
+ }
96
+
97
+ /**
98
+ * Accept the compact spelling of an operation as well as the long one.
99
+ *
100
+ * An operation is a small thing - add a state, wire a transition - and the long form spends more
101
+ * tokens on key names than on the change. Five operations cost 76 tokens spelled out and 39 in the
102
+ * compact form, and a batch of twenty is where that difference starts to matter.
103
+ *
104
+ * {addState: "c"} {addState: {id: "c", terminal: true}}
105
+ * {removeState: "c"}
106
+ * {addTransition: ["a", "reset", "c"]} {addTransition: {from, event, to, guard?, ...}}
107
+ * {removeTransition: ["a", "go"]} {updateTransition: ["a", "go", {guard: ...}]}
108
+ * {set: ["/init", "a"]} {remove: "/init"}
109
+ *
110
+ * The long form is unchanged and is what the errors talk about.
111
+ */
112
+ export function normalizeOperation(raw: unknown): EditOperation {
113
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
114
+ throw new OptionShapeError('EDIT_OPTION_INVALID', 'every op must be an object.')
115
+ }
116
+ const value = raw as Record<string, unknown>
117
+ if (typeof value.op === 'string') return value as EditOperation
118
+ const keys = Object.keys(value)
119
+ if (keys.length !== 1) {
120
+ throw new OptionShapeError('EDIT_OPTION_INVALID', `an operation names exactly one op, got ${JSON.stringify(keys)}.`)
121
+ }
122
+ const name = keys[0]
123
+ const argument = value[name]
124
+ if (name === 'addState' || name === 'removeState') {
125
+ if (typeof argument === 'string') {
126
+ return name === 'addState' ? { op: name, state: { id: argument } } : { op: name, id: argument }
127
+ }
128
+ if (name === 'addState' && argument !== null && typeof argument === 'object') return { op: name, state: argument }
129
+ throw new OptionShapeError('EDIT_OPTION_INVALID', `${name} takes a state id, or a state object for addState.`)
130
+ }
131
+ if (name === 'addTransition' || name === 'removeTransition' || name === 'updateTransition') {
132
+ if (Array.isArray(argument)) {
133
+ if (name === 'addTransition' && argument.length === 3) {
134
+ const [from, event, to] = argument
135
+ return { op: name, transition: { from, event, to } }
136
+ }
137
+ if (name === 'removeTransition' && argument.length === 2) {
138
+ const [from, event] = argument
139
+ return { op: name, from, event }
140
+ }
141
+ if (name === 'updateTransition' && argument.length === 3) {
142
+ const [from, event, patch] = argument
143
+ return { op: name, from, event, patch }
144
+ }
145
+ throw new OptionShapeError('EDIT_OPTION_INVALID', `${name} takes ${name === 'updateTransition' ? '["from", "event", {patch}]' : name === 'addTransition' ? '["from", "event", "to"]' : '["from", "event"]'}.`)
146
+ }
147
+ if (name === 'addTransition' && argument !== null && typeof argument === 'object') return { op: name, transition: argument }
148
+ throw new OptionShapeError('EDIT_OPTION_INVALID', `${name} takes a list, or a transition object for addTransition.`)
149
+ }
150
+ if (name === 'set') {
151
+ if (Array.isArray(argument) && argument.length === 2) return { op: 'set', path: argument[0], value: argument[1] }
152
+ if (argument !== null && typeof argument === 'object' && 'path' in (argument as Record<string, unknown>)) {
153
+ const { path, value } = argument as Record<string, unknown>
154
+ return { op: 'set', path, value }
155
+ }
156
+ throw new OptionShapeError('EDIT_OPTION_INVALID', 'set takes ["/path", value] or {path, value}.')
157
+ }
158
+ if (name === 'remove') {
159
+ if (typeof argument === 'string') return { op: 'remove', path: argument }
160
+ throw new OptionShapeError('EDIT_OPTION_INVALID', 'remove takes a JSON pointer string.')
161
+ }
162
+ throw new OptionShapeError('EDIT_OPTION_INVALID', `unknown op ${JSON.stringify(name)}. Known ops: addState, removeState, addTransition, removeTransition, updateTransition, set, remove.`)
163
+ }
164
+
165
+ /** Apply one operation to a model copy, returning the changes it made. */
166
+ export function applyOperation(model: Json, operation: EditOperation): ModelChange[] {
167
+ const op = requireString(operation.op, 'op')
168
+ const changes: ModelChange[] = []
169
+ if (op === 'addState') {
170
+ const state = operation.state as Json | undefined
171
+ const id = requireString(state?.id, 'state.id')
172
+ if (statesOf(model).some((entry) => entry.id === id)) {
173
+ throw new OptionShapeError('EDIT_OPTION_INVALID', `state "${id}" already exists; use update on it instead of adding it twice.`)
174
+ }
175
+ model.states = [...statesOf(model), state]
176
+ changes.push({ kind: 'added', what: 'state ' + id, detail: state?.terminal === true ? 'terminal' : '' })
177
+ return changes
178
+ }
179
+ if (op === 'removeState') {
180
+ const id = requireString(operation.id, 'id')
181
+ const before = statesOf(model)
182
+ if (!before.some((entry) => entry.id === id)) throw new OptionShapeError('EDIT_OPTION_INVALID', `no state "${id}" to remove.`)
183
+ model.states = before.filter((entry) => entry.id !== id)
184
+ const dropped = transitionsOf(model).filter((entry) => entry.from === id || entry.to === id)
185
+ model.transitions = transitionsOf(model).filter((entry) => entry.from !== id && entry.to !== id)
186
+ changes.push({ kind: 'removed', what: 'state ' + id, detail: dropped.length > 0 ? `and ${String(dropped.length)} transition(s) that used it` : '' })
187
+ return changes
188
+ }
189
+ if (op === 'addTransition') {
190
+ const transition = operation.transition as Json | undefined
191
+ const from = requireString(transition?.from, 'transition.from')
192
+ const event = requireString(transition?.event, 'transition.event')
193
+ const to = requireString(transition?.to, 'transition.to')
194
+ if (transitionsOf(model).some((entry) => entry.from === from && entry.event === event)) {
195
+ throw new OptionShapeError('EDIT_OPTION_INVALID', `a transition for ${from} + ${event} already exists; update it instead of adding another.`)
196
+ }
197
+ model.transitions = [...transitionsOf(model), transition]
198
+ changes.push({ kind: 'added', what: `transition ${from} + ${event}`, detail: 'to ' + to })
199
+ return changes
200
+ }
201
+ if (op === 'removeTransition') {
202
+ const from = requireString(operation.from, 'from')
203
+ const event = requireString(operation.event, 'event')
204
+ const before = transitionsOf(model)
205
+ const kept = before.filter((entry) => !(entry.from === from && entry.event === event))
206
+ if (kept.length === before.length) throw new OptionShapeError('EDIT_OPTION_INVALID', `no transition for ${from} + ${event} to remove.`)
207
+ model.transitions = kept
208
+ changes.push({ kind: 'removed', what: `transition ${from} + ${event}`, detail: '' })
209
+ return changes
210
+ }
211
+ if (op === 'updateTransition') {
212
+ const from = requireString(operation.from, 'from')
213
+ const event = requireString(operation.event, 'event')
214
+ const patch = operation.patch as Json | undefined
215
+ if (patch === undefined || typeof patch !== 'object' || Array.isArray(patch)) {
216
+ throw new OptionShapeError('EDIT_OPTION_INVALID', 'patch must be an object of the fields to replace.')
217
+ }
218
+ const list = transitionsOf(model)
219
+ const index = list.findIndex((entry) => entry.from === from && entry.event === event)
220
+ if (index < 0) throw new OptionShapeError('EDIT_OPTION_INVALID', `no transition for ${from} + ${event} to update.`)
221
+ list[index] = { ...list[index], ...patch }
222
+ model.transitions = list
223
+ changes.push({ kind: 'updated', what: `transition ${from} + ${event}`, detail: Object.keys(patch).join(', ') })
224
+ return changes
225
+ }
226
+ if (op === 'set') {
227
+ const parts = pointerParts(operation.path)
228
+ if (parts.length === 0) throw new OptionShapeError('EDIT_OPTION_INVALID', 'path must name something inside the model.')
229
+ const { parent, key } = atPointer(model, parts, true)
230
+ parent[key] = CLONE(operation.value)
231
+ changes.push({ kind: 'updated', what: 'set ' + String(operation.path), detail: '' })
232
+ return changes
233
+ }
234
+ if (op === 'remove') {
235
+ const parts = pointerParts(operation.path)
236
+ if (parts.length === 0) throw new OptionShapeError('EDIT_OPTION_INVALID', 'path must name something inside the model.')
237
+ const { parent, key } = atPointer(model, parts, false)
238
+ if (!(key in parent)) throw new OptionShapeError('EDIT_OPTION_INVALID', `nothing at ${String(operation.path)} to remove.`)
239
+ delete parent[key]
240
+ changes.push({ kind: 'removed', what: 'remove ' + String(operation.path), detail: '' })
241
+ return changes
242
+ }
243
+ throw new OptionShapeError('EDIT_OPTION_INVALID', `unknown op ${JSON.stringify(op)}. Known ops: addState, removeState, addTransition, removeTransition, updateTransition, set, remove.`)
244
+ }
245
+
246
+ /** Validate the edited model and write it atomically. A refused edit leaves the file untouched. */
247
+ export interface EditOutcome {
248
+ model: Json
249
+ changes: ModelChange[]
250
+ full: string
251
+ /** Whether the model as written validates. False only when the caller allowed it. */
252
+ valid: boolean
253
+ errors: string[]
254
+ }
255
+
256
+ export function applyEdits(path: unknown, operations: unknown, options: { allowInvalid?: boolean } = {}): EditOutcome {
257
+ if (!Array.isArray(operations) || operations.length === 0) {
258
+ throw new OptionShapeError('EDIT_OPTION_INVALID', 'ops must be a non-empty array of operations.')
259
+ }
260
+ const { model: original, full } = readModelFile(path)
261
+ const next = CLONE(original) as Json
262
+ const changes: ModelChange[] = []
263
+ const normalized = (operations as unknown[]).map((raw, index) => {
264
+ try {
265
+ return normalizeOperation(raw)
266
+ } catch (error) {
267
+ const reason = error instanceof Error ? error.message : String(error)
268
+ throw new OptionShapeError('EDIT_OPTION_INVALID', `operation ${String(index + 1)} was refused: ${reason}`)
269
+ }
270
+ })
271
+ for (const [index, raw] of normalized.entries()) {
272
+ try {
273
+ changes.push(...applyOperation(next, raw))
274
+ } catch (error) {
275
+ const reason = error instanceof Error ? error.message : String(error)
276
+ throw new OptionShapeError('EDIT_OPTION_INVALID', `operation ${String(index + 1)} (${String((raw as EditOperation).op)}) was refused: ${reason}`)
277
+ }
278
+ }
279
+ const validation = validateModel(next)
280
+ if (!validation.ok && options.allowInvalid !== true) {
281
+ // Name the operation the batch first stops validating at, not just the symptom: a caller that
282
+ // reads "a transition to an unknown state" still has to find which entry introduced it.
283
+ let firstBad = -1
284
+ const partial = CLONE(original) as Json
285
+ for (const [index, raw] of (operations as EditOperation[]).entries()) {
286
+ try { applyOperation(partial, raw) } catch { firstBad = index; break }
287
+ if (!validateModel(partial).ok) { firstBad = index; break }
288
+ }
289
+ const where = firstBad < 0 ? '' : ` Operation ${String(firstBad + 1)} (${String((operations as EditOperation[])[firstBad]?.op)}) is where it first stops validating.`
290
+ 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.')
291
+ }
292
+ const text = JSON.stringify(validation.ok ? validation.model : next, null, 2) + '\n'
293
+ const temporary = full + '.tmp'
294
+ writeFileSync(temporary, text, 'utf8')
295
+ renameSync(temporary, full)
296
+ return { model: next, changes, full, valid: validation.ok, errors: validation.ok ? [] : validation.errors }
297
+ }
package/src/engine.ts CHANGED
@@ -300,6 +300,12 @@ function verificationNextSteps(checks: CheckResult[], coverage: NarrativeCoverag
300
300
  if (coverage !== undefined && !narrativeComplete(coverage)) {
301
301
  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.')
302
302
  }
303
+ // A truncated search is the one outcome where "look harder" is the wrong advice: the wall is
304
+ // combinatorial, and raising maxStates buys a constant factor. Name the ladder instead, so the reader
305
+ // knows this is not a bug to fix but a model to reduce, or a different tool to use.
306
+ if (hasUndecidedFinding(checks)) {
307
+ 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.')
308
+ }
303
309
  // The range is dropped: the code that would be reported back is already named in `findings`, and a
304
310
  // reader who has not seen a report yet learns nothing from four digits.
305
311
  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.')
@@ -479,6 +485,7 @@ export const REPORT_SCHEMAS = {
479
485
  granularity: 'model-everything/granularity/v1',
480
486
  design: 'model-everything/design/v1',
481
487
  baseline: 'model-everything/baseline/v1',
488
+ edit: 'model-everything/edit/v1',
482
489
  } as const
483
490
 
484
491
  export type ReportSchema = typeof REPORT_SCHEMAS[keyof typeof REPORT_SCHEMAS]