dsh-model-everything 0.4.0 → 0.5.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.
- package/README.en-US.md +63 -19
- package/README.md +373 -345
- package/cordis.patch.yml +9 -0
- package/diagram/package.json +5 -0
- package/lib/client.js +1273 -0
- package/lib/diagram-diff-tool.js +90 -0
- package/lib/diagram-render-tool.js +102 -0
- package/lib/diagram-review-tool.js +99 -0
- package/lib/diagram.js +43 -0
- package/lib/gate.js +14 -12
- package/lib/presentation.js +10 -0
- package/lib/render/budget.js +99 -0
- package/lib/render/collapse.js +163 -0
- package/lib/render/diff.js +138 -0
- package/lib/render/from-mermaid.js +211 -0
- package/lib/render/from-plantuml.js +158 -0
- package/lib/render/index.js +42 -0
- package/lib/render/layout.js +52 -0
- package/lib/render/model.js +17 -0
- package/lib/render/paint.js +194 -0
- package/lib/render/palette.js +127 -0
- package/lib/render/place.js +210 -0
- package/lib/render/svg.js +107 -0
- package/lib/render/viewmodel.js +126 -0
- package/lib/render/write.js +14 -0
- package/lib/scan.js +248 -8
- package/lib/structure-tool.js +2 -2
- package/lib/types/client.d.ts +83 -0
- package/lib/types/diagram-diff-tool.d.ts +8 -0
- package/lib/types/diagram-render-tool.d.ts +8 -0
- package/lib/types/diagram-review-tool.d.ts +8 -0
- package/lib/types/diagram.d.ts +34 -0
- package/lib/types/presentation.d.ts +7 -0
- package/lib/types/render/budget.d.ts +55 -0
- package/lib/types/render/collapse.d.ts +23 -0
- package/lib/types/render/diff.d.ts +44 -0
- package/lib/types/render/from-mermaid.d.ts +27 -0
- package/lib/types/render/from-plantuml.d.ts +27 -0
- package/lib/types/render/index.d.ts +38 -0
- package/lib/types/render/layout.d.ts +22 -0
- package/lib/types/render/model.d.ts +56 -0
- package/lib/types/render/paint.d.ts +94 -0
- package/lib/types/render/palette.d.ts +41 -0
- package/lib/types/render/place.d.ts +39 -0
- package/lib/types/render/svg.d.ts +19 -0
- package/lib/types/render/viewmodel.d.ts +20 -0
- package/lib/types/render/write.d.ts +14 -0
- package/lib/types/scan.d.ts +10 -2
- package/lib/types/structure.d.ts +1 -0
- package/package.json +148 -131
- package/skills/model-everything/SKILL.md +454 -454
- package/skills/model-everything/references/architecture-domains.md +137 -0
- package/skills/model-everything/references/architecture-views.md +119 -0
- package/skills/model-everything-diagram/SKILL.md +82 -0
- package/src/client.ts +307 -0
- package/src/diagram-diff-tool.ts +94 -0
- package/src/diagram-render-tool.ts +108 -0
- package/src/diagram-review-tool.ts +109 -0
- package/src/diagram.ts +61 -0
- package/src/gate.ts +14 -12
- package/src/presentation.ts +12 -0
- package/src/render/budget.ts +135 -0
- package/src/render/collapse.ts +155 -0
- package/src/render/diff.ts +162 -0
- package/src/render/from-mermaid.ts +236 -0
- package/src/render/from-plantuml.ts +178 -0
- package/src/render/index.ts +73 -0
- package/src/render/layout.ts +62 -0
- package/src/render/model.ts +67 -0
- package/src/render/paint.ts +290 -0
- package/src/render/palette.ts +164 -0
- package/src/render/place.ts +234 -0
- package/src/render/svg.ts +127 -0
- package/src/render/viewmodel.ts +136 -0
- package/src/render/write.ts +17 -0
- package/src/scan.ts +807 -564
- package/src/structure-tool.ts +2 -2
- package/src/structure.ts +1 -1
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The structural diff tool: two drawings, compared by containment.
|
|
3
|
+
*
|
|
4
|
+
* One job per file: this module declares the tool and nothing else, so the documentation generator that walks
|
|
5
|
+
* `*-tool` modules finds it, and so a change to the drawing never touches the reviewing.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
9
|
+
import { render, type Notation } from './render/index.js'
|
|
10
|
+
import { diffStructure } from './render/diff.js'
|
|
11
|
+
|
|
12
|
+
export const DIAGRAM_DIFF_TOOL_NAME = 'diagram_diff'
|
|
13
|
+
|
|
14
|
+
/** Parse one side of a comparison, reporting which side failed to produce a drawing. */
|
|
15
|
+
function sideOf(source: string, notation: Notation | 'auto', which: 'before' | 'after'): { diagram?: ReturnType<typeof render>['diagram']; refused: string[]; problem?: string } {
|
|
16
|
+
if (source.trim() === '') return { refused: [], problem: which + ' is empty: pass the diagram text for that side' }
|
|
17
|
+
const rendered = render({ source, notation })
|
|
18
|
+
if (rendered.diagram.nodes.length === 0) {
|
|
19
|
+
return { refused: rendered.refused, problem: which + ' produced no nodes (' + String(rendered.refused.length) + ' line(s) refused)' }
|
|
20
|
+
}
|
|
21
|
+
return { diagram: rendered.diagram, refused: rendered.refused }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const diagramDiffTool = defineTool({
|
|
25
|
+
name: DIAGRAM_DIFF_TOOL_NAME,
|
|
26
|
+
description:
|
|
27
|
+
'Compare the containment structure of two diagrams: which boundaries are new or gone, which were renamed, which moved inside another, and which nodes changed owner. Accepts the same Mermaid and PlantUML subset as diagram_render. This decides what moved, never whether the move is better - read `verdict`, never `ok`. It does not diff report files (report_diff does) and it does not draw (diagram_render does).',
|
|
28
|
+
parameters: {
|
|
29
|
+
before: {
|
|
30
|
+
type: 'string',
|
|
31
|
+
description: 'The earlier diagram source.',
|
|
32
|
+
},
|
|
33
|
+
after: {
|
|
34
|
+
type: 'string',
|
|
35
|
+
description: 'The later diagram source.',
|
|
36
|
+
},
|
|
37
|
+
notation: {
|
|
38
|
+
type: 'string',
|
|
39
|
+
enum: ['auto', 'mermaid', 'plantuml'],
|
|
40
|
+
description: 'Which notation both sides are written in. Default auto: decided per side from its first meaningful line.',
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
output: {
|
|
44
|
+
schema: { type: 'json', description: 'Structural diff: the verdict, every change with the id it is about, and the sizes of both sides.' },
|
|
45
|
+
render(_args, value) {
|
|
46
|
+
const report = value as Record<string, unknown>
|
|
47
|
+
const changes = Array.isArray(report.changes) ? (report.changes as Array<Record<string, unknown>>) : []
|
|
48
|
+
const measured = (report.measured ?? {}) as Record<string, Record<string, number>>
|
|
49
|
+
const lines: string[] = []
|
|
50
|
+
lines.push('dsh-diagram/diff/v1 verdict=' + String(report.verdict) + ' ' + String(changes.length) + ' change(s)')
|
|
51
|
+
lines.push(
|
|
52
|
+
'MEASURED before=' + String(measured.before?.nodes) + ' node(s)/' + String(measured.before?.groups) + ' group(s)' +
|
|
53
|
+
' after=' + String(measured.after?.nodes) + ' node(s)/' + String(measured.after?.groups) + ' group(s)',
|
|
54
|
+
)
|
|
55
|
+
for (const change of changes) lines.push(' [' + String(change.severity) + '/' + String(change.code) + '] ' + String(change.message))
|
|
56
|
+
if (changes.length === 0) lines.push('NEXT The structure is unchanged. That is all this compares: node text, edges and guards are not part of it.')
|
|
57
|
+
else lines.push('NEXT Read each change against the design intent: a move is a decision, and this tool only reports that it happened.')
|
|
58
|
+
return [{ type: 'text' as const, text: lines.join('\n') }]
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
timeoutMs: 10_000,
|
|
62
|
+
isConcurrencySafe: () => true,
|
|
63
|
+
async execute(args: { before?: unknown; after?: unknown; notation?: unknown }) {
|
|
64
|
+
const notation = args.notation === 'mermaid' || args.notation === 'plantuml' ? (args.notation as Notation) : 'auto'
|
|
65
|
+
const before = sideOf(typeof args.before === 'string' ? args.before : '', notation, 'before')
|
|
66
|
+
const after = sideOf(typeof args.after === 'string' ? args.after : '', notation, 'after')
|
|
67
|
+
const problems = [before.problem, after.problem].filter((problem): problem is string => problem !== undefined)
|
|
68
|
+
if (before.diagram === undefined || after.diagram === undefined) {
|
|
69
|
+
return {
|
|
70
|
+
ok: false,
|
|
71
|
+
verdict: 'fail' as const,
|
|
72
|
+
changes: problems.map((problem) => ({ code: 'D1_SIDE_UNREADABLE', severity: 'error', kind: 'removed', subject: '-', message: problem })),
|
|
73
|
+
measured: {
|
|
74
|
+
before: { nodes: before.diagram?.nodes.length ?? 0, groups: before.diagram?.groups?.length ?? 0 },
|
|
75
|
+
after: { nodes: after.diagram?.nodes.length ?? 0, groups: after.diagram?.groups?.length ?? 0 },
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const report = diffStructure(before.diagram, after.diagram)
|
|
80
|
+
return {
|
|
81
|
+
ok: report.verdict !== 'fail',
|
|
82
|
+
verdict: report.verdict,
|
|
83
|
+
changes: report.changes.map((change) => ({
|
|
84
|
+
code: change.code,
|
|
85
|
+
severity: change.severity,
|
|
86
|
+
kind: change.kind,
|
|
87
|
+
subject: change.subject,
|
|
88
|
+
message: change.message,
|
|
89
|
+
})),
|
|
90
|
+
measured: report.measured,
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
})
|
|
94
|
+
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The drawing tool: source text in, SVG out.
|
|
3
|
+
*
|
|
4
|
+
* One job per file: this module declares the tool and nothing else, so the documentation generator that walks
|
|
5
|
+
* `*-tool` modules finds it, and so a change to the drawing never touches the reviewing.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
9
|
+
import { render, type Notation } from './render/index.js'
|
|
10
|
+
import { schemeNames, type PaletteScheme } from './render/palette.js'
|
|
11
|
+
import { writeSvg } from './render/write.js'
|
|
12
|
+
|
|
13
|
+
export const DIAGRAM_TOOL_NAME = 'diagram_render'
|
|
14
|
+
|
|
15
|
+
export const diagramRenderTool = defineTool({
|
|
16
|
+
name: DIAGRAM_TOOL_NAME,
|
|
17
|
+
description:
|
|
18
|
+
'Draw a diagram from its source text and get back SVG. Accepts Mermaid (flowchart, stateDiagram) and PlantUML (component, package, state) for the subset this renderer draws, lays out boxes and arrows itself, and reports every line it refused with its line number instead of drawing half a diagram. Pass `out` to write the SVG next to a document. This tool draws; it does not parse a diagram back into a model, and it does not review one - uml_parse and uml_review do that.',
|
|
19
|
+
parameters: {
|
|
20
|
+
source: {
|
|
21
|
+
type: 'string',
|
|
22
|
+
description: 'The diagram source text, in Mermaid or PlantUML syntax.',
|
|
23
|
+
},
|
|
24
|
+
notation: {
|
|
25
|
+
type: 'string',
|
|
26
|
+
enum: ['auto', 'mermaid', 'plantuml'],
|
|
27
|
+
description: 'Which notation `source` is written in. Default auto: read from the first meaningful line.',
|
|
28
|
+
},
|
|
29
|
+
title: {
|
|
30
|
+
type: 'string',
|
|
31
|
+
description: 'The heading drawn above the diagram. Defaults to the component configuration.',
|
|
32
|
+
},
|
|
33
|
+
scheme: {
|
|
34
|
+
type: 'string',
|
|
35
|
+
enum: schemeNames(),
|
|
36
|
+
description: 'Colour scheme. Default: the standard light palette. print is greyscale, for a drawing that will be printed or embedded in a plain document.',
|
|
37
|
+
},
|
|
38
|
+
out: {
|
|
39
|
+
type: 'string',
|
|
40
|
+
description: 'Write the SVG here and report the path. Relative paths resolve against the session workspace.',
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
output: {
|
|
44
|
+
schema: { type: 'json', description: 'Drawing result: the notation used, the counts, every refused line, and the SVG markup.' },
|
|
45
|
+
render(_args, value) {
|
|
46
|
+
const result = value as Record<string, unknown>
|
|
47
|
+
const lines: string[] = []
|
|
48
|
+
const refused = Array.isArray(result.refused) ? (result.refused as string[]) : []
|
|
49
|
+
lines.push(
|
|
50
|
+
'dsh-diagram/v1 notation=' + String(result.notation) + ' nodes=' + String(result.nodes) + ' edges=' + String(result.edges) +
|
|
51
|
+
(Number(result.groups) > 0 ? ' groups=' + String(result.groups) : '') +
|
|
52
|
+
(result.path === undefined ? '' : ' written=' + String(result.path)),
|
|
53
|
+
)
|
|
54
|
+
lines.push('CAPTION ' + String(result.caption))
|
|
55
|
+
if (refused.length > 0) {
|
|
56
|
+
lines.push('')
|
|
57
|
+
lines.push('REFUSED ' + String(refused.length) + ' line(s) - the drawing covers the rest')
|
|
58
|
+
for (const line of refused) lines.push(' ' + line)
|
|
59
|
+
lines.push('NEXT Fix or delete the refused lines, or render them in a tool that supports them. The drawing you got does not show them.')
|
|
60
|
+
} else {
|
|
61
|
+
lines.push('NEXT Every line was drawn. Paste the SVG where the diagram belongs, or pass `out` to write it next to the document.')
|
|
62
|
+
}
|
|
63
|
+
return [{ type: 'text' as const, text: lines.join('\n') }]
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
timeoutMs: 10_000,
|
|
67
|
+
isConcurrencySafe: () => true,
|
|
68
|
+
async execute(args: { source?: unknown; notation?: unknown; title?: unknown; out?: unknown; scheme?: unknown }) {
|
|
69
|
+
const source = typeof args.source === 'string' ? args.source : ''
|
|
70
|
+
if (source.trim() === '') {
|
|
71
|
+
// One shape for every answer, so the output contract has nothing to special-case.
|
|
72
|
+
return {
|
|
73
|
+
ok: false,
|
|
74
|
+
notation: 'mermaid',
|
|
75
|
+
nodes: 0,
|
|
76
|
+
edges: 0,
|
|
77
|
+
groups: 0,
|
|
78
|
+
caption: 'no source',
|
|
79
|
+
refused: ['source is empty: pass the diagram text to draw'],
|
|
80
|
+
dropped: 0,
|
|
81
|
+
svg: '',
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const notation = args.notation === 'mermaid' || args.notation === 'plantuml' ? (args.notation as Notation) : 'auto'
|
|
85
|
+
const title = typeof args.title === 'string' && args.title.trim() !== '' ? args.title.trim() : undefined
|
|
86
|
+
// The argument validator has already held this to the shipped schemes; an unknown value falls back rather
|
|
87
|
+
// than throwing, because a stale deployment may still send one.
|
|
88
|
+
const scheme = schemeNames().includes(args.scheme as PaletteScheme) ? (args.scheme as PaletteScheme) : 'default'
|
|
89
|
+
const rendered = render({ source, notation, ...(title === undefined ? {} : { title }) }, {}, { scheme })
|
|
90
|
+
const written =
|
|
91
|
+
typeof args.out === 'string' && args.out.trim() !== ''
|
|
92
|
+
? writeSvg({ source, notation, ...(title === undefined ? {} : { title }) }, args.out.trim(), {}, { scheme })
|
|
93
|
+
: undefined
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
ok: rendered.refused.length === 0,
|
|
97
|
+
notation: rendered.notation,
|
|
98
|
+
nodes: rendered.diagram.nodes.length,
|
|
99
|
+
edges: rendered.diagram.edges.length,
|
|
100
|
+
groups: rendered.diagram.groups?.length ?? 0,
|
|
101
|
+
caption: rendered.diagram.caption,
|
|
102
|
+
refused: rendered.refused,
|
|
103
|
+
dropped: rendered.dropped,
|
|
104
|
+
...(written === undefined ? {} : { path: written.path, bytes: written.bytes }),
|
|
105
|
+
svg: rendered.markup,
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
})
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The structural review tool: budgets against one drawing.
|
|
3
|
+
*
|
|
4
|
+
* One job per file: this module declares the tool and nothing else, so the documentation generator that walks
|
|
5
|
+
* `*-tool` modules finds it, and so a change to the drawing never touches the reviewing.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
9
|
+
import { render, type Notation } from './render/index.js'
|
|
10
|
+
import { checkBudget, DEFAULT_LIMITS, type BudgetLimits } from './render/budget.js'
|
|
11
|
+
|
|
12
|
+
export const DIAGRAM_REVIEW_TOOL_NAME = 'diagram_review'
|
|
13
|
+
|
|
14
|
+
/** Read the three caps a caller may override, ignoring anything that is not a positive number. */
|
|
15
|
+
function limitsOf(args: { maxMembers?: unknown; maxDepth?: unknown; maxNodes?: unknown }): BudgetLimits {
|
|
16
|
+
const pick = (value: unknown, fallback: number): number =>
|
|
17
|
+
typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback
|
|
18
|
+
return {
|
|
19
|
+
maxMembers: pick(args.maxMembers, DEFAULT_LIMITS.maxMembers),
|
|
20
|
+
maxDepth: pick(args.maxDepth, DEFAULT_LIMITS.maxDepth),
|
|
21
|
+
maxNodes: pick(args.maxNodes, DEFAULT_LIMITS.maxNodes),
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const diagramReviewTool = defineTool({
|
|
26
|
+
name: DIAGRAM_REVIEW_TOOL_NAME,
|
|
27
|
+
description:
|
|
28
|
+
'Review the structure of a diagram from its source text: how wide each boundary is, how deep they nest, and how many nodes the drawing holds, against budgets you can set. Accepts the same Mermaid and PlantUML subset as diagram_render and reports what it measured, so an empty report cannot be mistaken for a clean one. Read `verdict`, never `ok`. This reviews structure; it does not draw (diagram_render does) and it does not check behaviour (model_verify does).',
|
|
29
|
+
parameters: {
|
|
30
|
+
source: {
|
|
31
|
+
type: 'string',
|
|
32
|
+
description: 'The diagram source text, in Mermaid or PlantUML syntax.',
|
|
33
|
+
},
|
|
34
|
+
notation: {
|
|
35
|
+
type: 'string',
|
|
36
|
+
enum: ['auto', 'mermaid', 'plantuml'],
|
|
37
|
+
description: 'Which notation `source` is written in. Default auto: read from the first meaningful line.',
|
|
38
|
+
},
|
|
39
|
+
maxMembers: {
|
|
40
|
+
type: 'integer',
|
|
41
|
+
description: 'How many direct children a boundary may hold. Default 7: more than a handful of boxes stops reading as a package.',
|
|
42
|
+
},
|
|
43
|
+
maxDepth: {
|
|
44
|
+
type: 'integer',
|
|
45
|
+
description: 'How deep boundaries may nest. Default 3.',
|
|
46
|
+
},
|
|
47
|
+
maxNodes: {
|
|
48
|
+
type: 'integer',
|
|
49
|
+
description: 'How many nodes the whole drawing may hold. Default 40.',
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
output: {
|
|
53
|
+
schema: { type: 'json', description: 'Structural review: the verdict, one finding per breached budget, and what was measured.' },
|
|
54
|
+
render(_args, value) {
|
|
55
|
+
const report = value as Record<string, unknown>
|
|
56
|
+
const findings = Array.isArray(report.findings) ? (report.findings as Array<Record<string, unknown>>) : []
|
|
57
|
+
const measured = (report.measured ?? {}) as Record<string, unknown>
|
|
58
|
+
const lines: string[] = []
|
|
59
|
+
lines.push(
|
|
60
|
+
'dsh-diagram/review/v1 verdict=' + String(report.verdict) + ' ' + String(findings.length) + ' finding(s)',
|
|
61
|
+
)
|
|
62
|
+
lines.push(
|
|
63
|
+
'MEASURED nodes=' + String(measured.nodes) + ' groups=' + String(measured.groups) + ' deepest=' + String(measured.deepest) +
|
|
64
|
+
(measured.widest === null || measured.widest === undefined ? '' : ' widest=' + JSON.stringify(measured.widest)),
|
|
65
|
+
)
|
|
66
|
+
for (const finding of findings) {
|
|
67
|
+
lines.push(' [' + String(finding.severity) + '/' + String(finding.code) + '] ' + String(finding.message))
|
|
68
|
+
}
|
|
69
|
+
for (const line of Array.isArray(report.refused) ? (report.refused as string[]) : []) {
|
|
70
|
+
lines.push(' refused ' + line)
|
|
71
|
+
}
|
|
72
|
+
if (findings.length === 0) lines.push('NEXT The drawing is inside every budget. That says nothing about whether the system it describes is right.')
|
|
73
|
+
else lines.push('NEXT Split or fold the groups named above, or raise the budget if the medium is larger than a conversation pane.')
|
|
74
|
+
return [{ type: 'text' as const, text: lines.join('\n') }]
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
timeoutMs: 10_000,
|
|
78
|
+
isConcurrencySafe: () => true,
|
|
79
|
+
async execute(args: { source?: unknown; notation?: unknown; maxMembers?: unknown; maxDepth?: unknown; maxNodes?: unknown }) {
|
|
80
|
+
const source = typeof args.source === 'string' ? args.source : ''
|
|
81
|
+
const notation = args.notation === 'mermaid' || args.notation === 'plantuml' ? (args.notation as Notation) : 'auto'
|
|
82
|
+
if (source.trim() === '') {
|
|
83
|
+
return {
|
|
84
|
+
ok: false,
|
|
85
|
+
verdict: 'fail' as const,
|
|
86
|
+
findings: [{ code: 'D0_NO_SOURCE', severity: 'error', message: 'source is empty: pass the diagram text to review' }],
|
|
87
|
+
measured: { nodes: 0, groups: 0, deepest: 0, widest: null },
|
|
88
|
+
refused: [],
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const rendered = render({ source, notation })
|
|
92
|
+
const report = checkBudget(rendered.diagram, limitsOf(args))
|
|
93
|
+
return {
|
|
94
|
+
ok: report.verdict !== 'fail',
|
|
95
|
+
verdict: report.verdict,
|
|
96
|
+
// Plain JSON fields: a finding whose `group` is absent must not carry an undefined key.
|
|
97
|
+
findings: report.findings.map((finding) => ({
|
|
98
|
+
code: finding.code,
|
|
99
|
+
severity: finding.severity,
|
|
100
|
+
message: finding.message,
|
|
101
|
+
...(finding.group === undefined ? {} : { group: finding.group }),
|
|
102
|
+
})),
|
|
103
|
+
measured: report.measured,
|
|
104
|
+
refused: rendered.refused,
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
|
package/src/diagram.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The diagram component: one row, one switch, three tools.
|
|
3
|
+
*
|
|
4
|
+
* A separate component rather than a feature of the tools above it, because drawing has nothing to do with
|
|
5
|
+
* verifying. It can be on while the tools are off, off while they are on, and it carries no engine: a
|
|
6
|
+
* deployment that only wants pictures of its Mermaid diagrams installs the same package and leaves the rest of
|
|
7
|
+
* it alone.
|
|
8
|
+
*
|
|
9
|
+
* The switch is `config.enabled`, live, exactly like the routing gate: a deployment can turn the drawing off at
|
|
10
|
+
* boot, from the Plugins page, or by disabling this row, and none of those touches the tools or the skills.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import z from '@deepseek-ai/schemastery'
|
|
14
|
+
import { diagramDiffTool } from './diagram-diff-tool.js'
|
|
15
|
+
import { diagramRenderTool } from './diagram-render-tool.js'
|
|
16
|
+
import { diagramReviewTool } from './diagram-review-tool.js'
|
|
17
|
+
|
|
18
|
+
/** The default a deployment inherits: on, because a component nobody knows about is a component nobody uses. */
|
|
19
|
+
const DEFAULT_ENABLED = true
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A live boolean, or a plain one on a host that predates the live wrapper.
|
|
23
|
+
*
|
|
24
|
+
* `live` is what makes the Plugins page switch take effect without a restart; the value it returns is read
|
|
25
|
+
* through {@link enabledOf} rather than compared, because both shapes have been seen in the wild.
|
|
26
|
+
*/
|
|
27
|
+
const liveBoolean = (z as unknown as { live?: (schema: unknown) => unknown }).live
|
|
28
|
+
|
|
29
|
+
export interface Config {
|
|
30
|
+
/** Whether the diagram tools are registered. Read through {@link enabledOf}. */
|
|
31
|
+
enabled: unknown
|
|
32
|
+
/** The title a drawing carries when the caller did not name one. */
|
|
33
|
+
defaultTitle: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const Config = z.object({
|
|
37
|
+
enabled:
|
|
38
|
+
liveBoolean === undefined
|
|
39
|
+
? z.boolean().default(DEFAULT_ENABLED)
|
|
40
|
+
: (liveBoolean(z.boolean().default(DEFAULT_ENABLED)) as never),
|
|
41
|
+
defaultTitle: z.string().default('diagram'),
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
/** Read the switch as a boolean, whichever shape this host produced. */
|
|
45
|
+
export function enabledOf(config: Config): boolean {
|
|
46
|
+
const value = config.enabled as boolean | { get: () => boolean }
|
|
47
|
+
return typeof value === 'boolean' ? value : value.get()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ComponentContext {
|
|
51
|
+
tools: { register(definition: unknown): () => void }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function apply(ctx: ComponentContext, config: Config): void {
|
|
55
|
+
if (!enabledOf(config)) return
|
|
56
|
+
ctx.tools.register(diagramRenderTool)
|
|
57
|
+
ctx.tools.register(diagramReviewTool)
|
|
58
|
+
ctx.tools.register(diagramDiffTool)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const inject = ['tools']
|
package/src/gate.ts
CHANGED
|
@@ -68,6 +68,7 @@ const SKILL_ROWS: Record<string, string> = {
|
|
|
68
68
|
'model-everything-design': 'layering, responsibilities, substitutability, interface width',
|
|
69
69
|
'model-everything-datamodel': 'a schema, a migration, a data guarantee',
|
|
70
70
|
'model-everything-interleaving': 'thread-safe, lock-free, ISR, async interleaving; a shared location is decided, the rest is routed',
|
|
71
|
+
'model-everything-diagram': 'drawing, sizing or diffing a diagram from its source text',
|
|
71
72
|
'model-everything-report-reading': 'reading a report this plugin returned',
|
|
72
73
|
}
|
|
73
74
|
|
|
@@ -108,11 +109,11 @@ export function renderGateText(skills: string[] = shippedSkills(), extraGuidance
|
|
|
108
109
|
const rows = skills.map((skill) => '| ' + (SKILL_ROWS[skill] ?? 'see its catalog line') + ' | `' + skill + '` |')
|
|
109
110
|
const extra = extraGuidance.trim()
|
|
110
111
|
return `<EXTREMELY_IMPORTANT>
|
|
111
|
-
Before answering anything about code, a design or a diagram: load the skill whose row matches
|
|
112
|
+
Before answering anything about code, a design or a diagram: load the skill whose row matches and let its tool decide. Do not answer from memory.
|
|
112
113
|
|
|
113
|
-
|
|
114
|
+
dsh-model-everything is active: it checks claims about designs and code - facts against the source, behaviour as a model, structure as a graph.
|
|
114
115
|
|
|
115
|
-
**Unsure which row applies?** Load \`model-everything\` first: it owns the doctrine, the
|
|
116
|
+
**Unsure which row applies?** Load \`model-everything\` first: it owns the doctrine, the schema and the routing to its siblings.
|
|
116
117
|
|
|
117
118
|
| The request | The skill |
|
|
118
119
|
| --- | --- |
|
|
@@ -120,22 +121,23 @@ ${rows.join('\n')}
|
|
|
120
121
|
|
|
121
122
|
**Tools** (dsh):
|
|
122
123
|
|
|
123
|
-
- \`model_verify\` — one state machine: structural checks, adversarial probes, budgets, deadlines,
|
|
124
|
+
- \`model_verify\` — one state machine: structural checks, adversarial probes, budgets, deadlines, regression.
|
|
124
125
|
- \`model_compose_verify\` — two or more machines: deadlock, a rendezvous that never fires, and - with \`shared\` + \`accesses\` - an unordered write (C4) or a non-atomic update (C5).
|
|
125
|
-
- \`model_datamodel_verify\` — a data model and its migration: keys, domains,
|
|
126
|
-
- \`model_design_review\` — a design against its own declarations: layering, boundaries, substitutability, interface width.
|
|
127
|
-
- \`model_interleaving_scan\` — finds claims about threads,
|
|
128
|
-
- \`model_export\` — UPPAAL, TLA+, PRISM or SPIN input from a
|
|
129
|
-
- \`uml_render\`, \`uml_parse\`, \`uml_review\` — draw a model, read a diagram into one, audit one. Round-trip fidelity is measured
|
|
126
|
+
- \`model_datamodel_verify\` — a data model and its migration: keys, domains, coverage, copy consistency, rollback symmetry.
|
|
127
|
+
- \`model_design_review\` — a design against its own declarations: layering, boundaries, substitutability, interface width. Graph shape is reported, not judged.
|
|
128
|
+
- \`model_interleaving_scan\` — finds claims about threads, async and interrupts, names the family and routes each to what decides it. It proves nothing itself.
|
|
129
|
+
- \`model_export\` — UPPAAL, TLA+, PRISM or SPIN input from a model. It exports; it does not check the target.
|
|
130
|
+
- \`uml_render\`, \`uml_parse\`, \`uml_review\` — draw a model, read a diagram into one, audit one. Round-trip fidelity is measured.
|
|
130
131
|
- \`uml_explain_labels\` — the accepted label and comment forms for one notation.
|
|
131
132
|
- \`uml_structure_verify\` — dependency review of a structure drawing, one level or several. It audits the drawing, not the code.
|
|
133
|
+
- \`diagram_render\`, \`diagram_review\`, \`diagram_diff\` — draw, size and compare a Mermaid or PlantUML source. Undrawable lines are refused by number.
|
|
132
134
|
- \`report_diff\` — baseline against current: what a change added, removed or changed.
|
|
133
135
|
|
|
134
|
-
**Read a report**: gate on \`verdict
|
|
136
|
+
**Read a report**: gate on \`verdict\`, never on \`ok\`. \`verdictReason\` says why and \`nextSteps\` says what to do. \`ran\` says whether it executed, \`checksRun\` how much it examined: a \`skip\` is not a \`pass\`, and a code ending \`_UNDECIDED\` means the search hit its limit and decided nothing. Keys starting with \`_\` are notes: ignored, and left out of the model hash.
|
|
135
137
|
|
|
136
|
-
**When a tool refuses
|
|
138
|
+
**When a tool refuses or is absent.** Read its \`errorCode\` and fix the input; if it is not registered here, say so and use the general method. A check that nothing ran is not a passing check.${extra === '' ? '' : '\n\n' + extra}
|
|
137
139
|
|
|
138
|
-
The rule again, because it is the one that gets skipped:
|
|
140
|
+
The rule again, because it is the one that gets skipped: load the skill whose routing applies and let its tool decide - a claim you can check is not one you may answer from memory.
|
|
139
141
|
</EXTREMELY_IMPORTANT>`
|
|
140
142
|
}
|
|
141
143
|
|
package/src/presentation.ts
CHANGED
|
@@ -53,6 +53,8 @@ export interface ReportCard {
|
|
|
53
53
|
graph?: { nodes: number; edges: number; cycles: number; isolated: number; dangling: number }
|
|
54
54
|
/** `model_design_review`: three levels, because a signal is not a violation and a fact is neither. */
|
|
55
55
|
levels?: { violations: number; signals: number; facts: number; skipped: number }
|
|
56
|
+
/** `uml_structure_verify` with `repoPath`: what the scan itself resolved, and what it could not. */
|
|
57
|
+
scan?: { files: number; unresolved: number; packages: number; dynamic: number }
|
|
56
58
|
/** `model_interleaving_scan`: how many claims were found, and in which families. */
|
|
57
59
|
claims?: { absolute: number; keywords: number; families: Array<{ family: string; count: number }> }
|
|
58
60
|
}
|
|
@@ -109,6 +111,7 @@ export function reportCard(value: unknown, family: string): ReportCard {
|
|
|
109
111
|
const errors = findings.filter((entry) => severity(entry) === 'error')
|
|
110
112
|
const warnings = findings.filter((entry) => severity(entry) === 'warning')
|
|
111
113
|
const summary = record(report.summary)
|
|
114
|
+
const scan = record(report.scan)
|
|
112
115
|
const summaryErrors = typeof summary?.errors === 'number' ? summary.errors : undefined
|
|
113
116
|
const summaryWarnings = typeof summary?.warnings === 'number' ? summary.warnings : undefined
|
|
114
117
|
const checksRun = typeof summary?.checksRun === 'number'
|
|
@@ -156,6 +159,9 @@ export function reportCard(value: unknown, family: string): ReportCard {
|
|
|
156
159
|
...(violations.length === 0 && signals.length === 0 && facts.length === 0
|
|
157
160
|
? {}
|
|
158
161
|
: { levels: { violations: violations.length, signals: signals.length, facts: facts.length, skipped: array(report.checksSkipped).length } }),
|
|
162
|
+
...(typeof scan?.files === 'number'
|
|
163
|
+
? { scan: { files: scan.files, unresolved: Number(scan.unresolved ?? 0), packages: Number(scan.packages ?? 0), dynamic: Number(scan.dynamic ?? 0) } }
|
|
164
|
+
: {}),
|
|
159
165
|
...(typeof summary?.absoluteClaims === 'number'
|
|
160
166
|
? { claims: { absolute: summary.absoluteClaims, keywords: Number(summary.keywords ?? 0), families: claimFamilies(findings) } }
|
|
161
167
|
: {}),
|
|
@@ -309,6 +315,12 @@ export function structureCardBlocks(card: ReportCard): ContentBlock[] {
|
|
|
309
315
|
if (card.graph !== undefined) {
|
|
310
316
|
lines.push('graph: ' + String(card.graph.nodes) + ' nodes · ' + String(card.graph.edges) + ' edges · ' + String(card.graph.cycles) + ' cycle(s) · ' + String(card.graph.isolated) + ' isolated')
|
|
311
317
|
}
|
|
318
|
+
if (card.scan !== undefined) {
|
|
319
|
+
// A scan that resolved half its imports reads the same as one that resolved all of them unless the
|
|
320
|
+
// counts are on the card.
|
|
321
|
+
const dynamic = card.scan.dynamic > 0 ? ' · ' + String(card.scan.dynamic) + ' computed' : ''
|
|
322
|
+
lines.push('scan: ' + String(card.scan.files) + ' file(s) · ' + String(card.scan.unresolved) + ' unresolved · ' + String(card.scan.packages) + ' package(s)' + dynamic)
|
|
323
|
+
}
|
|
312
324
|
if (card.first != null) lines.push((card.first.severity === 'error' ? '✗ ' : '⚠ ') + card.first.code + ': ' + card.first.message)
|
|
313
325
|
if (card.next != null) lines.push('next: ' + card.next)
|
|
314
326
|
return [{ type: 'text', text: lines.join(String.fromCharCode(10)) }]
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural budgets for a diagram: the checks a drawing can fail without being wrong.
|
|
3
|
+
*
|
|
4
|
+
* A diagram is not verified the way a state machine is - there is no deadlock to find - but an architecture
|
|
5
|
+
* picture has properties a reader relies on, and they are checkable: a package that holds twenty things is not
|
|
6
|
+
* communicating anything, a boundary around one box is a layer the source did not mean, a group nothing points
|
|
7
|
+
* into and nothing leaves is a group that belongs on another page.
|
|
8
|
+
*
|
|
9
|
+
* Pure: a diagram and a set of limits in, findings out. The limits are arguments, not constants, because what
|
|
10
|
+
* counts as too wide depends on the medium - a slide holds less than a scrollable tab.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Diagram, DiagramGroup } from './model.js'
|
|
14
|
+
|
|
15
|
+
export type Severity = 'error' | 'warning'
|
|
16
|
+
|
|
17
|
+
export interface BudgetFinding {
|
|
18
|
+
code: string
|
|
19
|
+
severity: Severity
|
|
20
|
+
message: string
|
|
21
|
+
/** The group the finding is about, when it is about one. */
|
|
22
|
+
group?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface BudgetLimits {
|
|
26
|
+
/** How many direct children a boundary may hold: its own nodes plus the groups directly inside it. */
|
|
27
|
+
maxMembers: number
|
|
28
|
+
/** How deep boundaries may nest. */
|
|
29
|
+
maxDepth: number
|
|
30
|
+
/** How many nodes the whole drawing may hold. */
|
|
31
|
+
maxNodes: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Defaults, chosen for a drawing read in a conversation pane: a package with more than a handful of boxes stops
|
|
36
|
+
* being a package and becomes a diagram of its own, and three levels is what fits on one screen before a reader
|
|
37
|
+
* needs the source.
|
|
38
|
+
*/
|
|
39
|
+
export const DEFAULT_LIMITS: BudgetLimits = { maxMembers: 7, maxDepth: 3, maxNodes: 40 }
|
|
40
|
+
|
|
41
|
+
export interface BudgetReport {
|
|
42
|
+
verdict: 'pass' | 'pass_with_findings' | 'fail'
|
|
43
|
+
findings: BudgetFinding[]
|
|
44
|
+
/** What was measured, so a reader can tell "nothing to check" from "checked and clean". */
|
|
45
|
+
measured: { nodes: number; groups: number; deepest: number; widest: { group: string; size: number } | null }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The direct children of a boundary: its own nodes plus the groups that name it as parent. */
|
|
49
|
+
function childrenOf(groups: DiagramGroup[], group: DiagramGroup): number {
|
|
50
|
+
const nested = groups.filter((entry) => entry.parent === group.id).length
|
|
51
|
+
return group.members.length + nested
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** How deep the nesting goes, guarding against a parent cycle in the data. */
|
|
55
|
+
function depthOf(groups: DiagramGroup[], groupId: string): number {
|
|
56
|
+
const byId = new Map(groups.map((group) => [group.id, group] as const))
|
|
57
|
+
let depth = 1
|
|
58
|
+
let cursor = byId.get(groupId)?.parent
|
|
59
|
+
const seen = new Set<string>([groupId])
|
|
60
|
+
while (cursor !== undefined && !seen.has(cursor)) {
|
|
61
|
+
seen.add(cursor)
|
|
62
|
+
depth += 1
|
|
63
|
+
cursor = byId.get(cursor)?.parent
|
|
64
|
+
}
|
|
65
|
+
return depth
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Measure a drawing against its limits.
|
|
70
|
+
*
|
|
71
|
+
* Every finding names the group it is about, because a budget report that says "a group is too wide" without
|
|
72
|
+
* saying which one is a report nobody can act on.
|
|
73
|
+
*/
|
|
74
|
+
export function checkBudget(diagram: Diagram, limits: Partial<BudgetLimits> = {}): BudgetReport {
|
|
75
|
+
const caps: BudgetLimits = { ...DEFAULT_LIMITS, ...limits }
|
|
76
|
+
const groups = diagram.groups ?? []
|
|
77
|
+
const findings: BudgetFinding[] = []
|
|
78
|
+
|
|
79
|
+
const nodeIds = new Set(diagram.nodes.map((node) => node.id))
|
|
80
|
+
for (const group of groups) {
|
|
81
|
+
const size = childrenOf(groups, group)
|
|
82
|
+
const depth = depthOf(groups, group.id)
|
|
83
|
+
if (size > caps.maxMembers) {
|
|
84
|
+
findings.push({
|
|
85
|
+
code: 'B1_GROUP_TOO_WIDE',
|
|
86
|
+
severity: 'error',
|
|
87
|
+
group: group.id,
|
|
88
|
+
message: 'group "' + group.label + '" holds ' + String(size) + ' direct children; the budget is ' + String(caps.maxMembers),
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
if (depth > caps.maxDepth) {
|
|
92
|
+
findings.push({
|
|
93
|
+
code: 'B2_TOO_DEEP',
|
|
94
|
+
severity: 'error',
|
|
95
|
+
group: group.id,
|
|
96
|
+
message: 'group "' + group.label + '" nests ' + String(depth) + ' levels deep; the budget is ' + String(caps.maxDepth),
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
const realMembers = group.members.filter((member) => nodeIds.has(member))
|
|
100
|
+
if (realMembers.length === 0 && groups.every((entry) => entry.parent !== group.id)) {
|
|
101
|
+
findings.push({
|
|
102
|
+
code: 'B3_EMPTY_GROUP',
|
|
103
|
+
severity: 'error',
|
|
104
|
+
group: group.id,
|
|
105
|
+
message: 'group "' + group.label + '" holds nothing that is drawn, so the boundary claims a containment the drawing does not show',
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
if (size === 1) {
|
|
109
|
+
findings.push({
|
|
110
|
+
code: 'B4_SINGLE_MEMBER',
|
|
111
|
+
severity: 'warning',
|
|
112
|
+
group: group.id,
|
|
113
|
+
message: 'group "' + group.label + '" holds one child; a boundary around one thing usually means a layer that was never split',
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (diagram.nodes.length > caps.maxNodes) {
|
|
119
|
+
findings.push({
|
|
120
|
+
code: 'B5_TOO_MANY_NODES',
|
|
121
|
+
severity: 'error',
|
|
122
|
+
message: 'the drawing holds ' + String(diagram.nodes.length) + ' nodes; the budget is ' + String(caps.maxNodes),
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const deepest = groups.reduce((max, group) => Math.max(max, depthOf(groups, group.id)), 0)
|
|
127
|
+
const widest = groups.reduce<{ group: string; size: number } | null>((best, group) => {
|
|
128
|
+
const size = childrenOf(groups, group)
|
|
129
|
+
return best === null || size > best.size ? { group: group.label, size } : best
|
|
130
|
+
}, null)
|
|
131
|
+
|
|
132
|
+
const errors = findings.filter((finding) => finding.severity === 'error').length
|
|
133
|
+
const verdict: BudgetReport['verdict'] = errors > 0 ? 'fail' : findings.length > 0 ? 'pass_with_findings' : 'pass'
|
|
134
|
+
return { verdict, findings, measured: { nodes: diagram.nodes.length, groups: groups.length, deepest, widest } }
|
|
135
|
+
}
|