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.
Files changed (78) hide show
  1. package/README.en-US.md +63 -19
  2. package/README.md +373 -345
  3. package/cordis.patch.yml +9 -0
  4. package/diagram/package.json +5 -0
  5. package/lib/client.js +1273 -0
  6. package/lib/diagram-diff-tool.js +90 -0
  7. package/lib/diagram-render-tool.js +102 -0
  8. package/lib/diagram-review-tool.js +99 -0
  9. package/lib/diagram.js +43 -0
  10. package/lib/gate.js +14 -12
  11. package/lib/presentation.js +10 -0
  12. package/lib/render/budget.js +99 -0
  13. package/lib/render/collapse.js +163 -0
  14. package/lib/render/diff.js +138 -0
  15. package/lib/render/from-mermaid.js +211 -0
  16. package/lib/render/from-plantuml.js +158 -0
  17. package/lib/render/index.js +42 -0
  18. package/lib/render/layout.js +52 -0
  19. package/lib/render/model.js +17 -0
  20. package/lib/render/paint.js +194 -0
  21. package/lib/render/palette.js +127 -0
  22. package/lib/render/place.js +210 -0
  23. package/lib/render/svg.js +107 -0
  24. package/lib/render/viewmodel.js +126 -0
  25. package/lib/render/write.js +14 -0
  26. package/lib/scan.js +248 -8
  27. package/lib/structure-tool.js +2 -2
  28. package/lib/types/client.d.ts +83 -0
  29. package/lib/types/diagram-diff-tool.d.ts +8 -0
  30. package/lib/types/diagram-render-tool.d.ts +8 -0
  31. package/lib/types/diagram-review-tool.d.ts +8 -0
  32. package/lib/types/diagram.d.ts +34 -0
  33. package/lib/types/presentation.d.ts +7 -0
  34. package/lib/types/render/budget.d.ts +55 -0
  35. package/lib/types/render/collapse.d.ts +23 -0
  36. package/lib/types/render/diff.d.ts +44 -0
  37. package/lib/types/render/from-mermaid.d.ts +27 -0
  38. package/lib/types/render/from-plantuml.d.ts +27 -0
  39. package/lib/types/render/index.d.ts +38 -0
  40. package/lib/types/render/layout.d.ts +22 -0
  41. package/lib/types/render/model.d.ts +56 -0
  42. package/lib/types/render/paint.d.ts +94 -0
  43. package/lib/types/render/palette.d.ts +41 -0
  44. package/lib/types/render/place.d.ts +39 -0
  45. package/lib/types/render/svg.d.ts +19 -0
  46. package/lib/types/render/viewmodel.d.ts +20 -0
  47. package/lib/types/render/write.d.ts +14 -0
  48. package/lib/types/scan.d.ts +10 -2
  49. package/lib/types/structure.d.ts +1 -0
  50. package/package.json +148 -131
  51. package/skills/model-everything/SKILL.md +454 -454
  52. package/skills/model-everything/references/architecture-domains.md +137 -0
  53. package/skills/model-everything/references/architecture-views.md +119 -0
  54. package/skills/model-everything-diagram/SKILL.md +82 -0
  55. package/src/client.ts +307 -0
  56. package/src/diagram-diff-tool.ts +94 -0
  57. package/src/diagram-render-tool.ts +108 -0
  58. package/src/diagram-review-tool.ts +109 -0
  59. package/src/diagram.ts +61 -0
  60. package/src/gate.ts +14 -12
  61. package/src/presentation.ts +12 -0
  62. package/src/render/budget.ts +135 -0
  63. package/src/render/collapse.ts +155 -0
  64. package/src/render/diff.ts +162 -0
  65. package/src/render/from-mermaid.ts +236 -0
  66. package/src/render/from-plantuml.ts +178 -0
  67. package/src/render/index.ts +73 -0
  68. package/src/render/layout.ts +62 -0
  69. package/src/render/model.ts +67 -0
  70. package/src/render/paint.ts +290 -0
  71. package/src/render/palette.ts +164 -0
  72. package/src/render/place.ts +234 -0
  73. package/src/render/svg.ts +127 -0
  74. package/src/render/viewmodel.ts +136 -0
  75. package/src/render/write.ts +17 -0
  76. package/src/scan.ts +807 -564
  77. package/src/structure-tool.ts +2 -2
  78. package/src/structure.ts +1 -1
@@ -0,0 +1,90 @@
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
+ import { defineTool } from '@deepseek-ai/dsh-tools';
8
+ import { render } from './render/index.js';
9
+ import { diffStructure } from './render/diff.js';
10
+ export const DIAGRAM_DIFF_TOOL_NAME = 'diagram_diff';
11
+ /** Parse one side of a comparison, reporting which side failed to produce a drawing. */
12
+ function sideOf(source, notation, which) {
13
+ if (source.trim() === '')
14
+ return { refused: [], problem: which + ' is empty: pass the diagram text for that side' };
15
+ const rendered = render({ source, notation });
16
+ if (rendered.diagram.nodes.length === 0) {
17
+ return { refused: rendered.refused, problem: which + ' produced no nodes (' + String(rendered.refused.length) + ' line(s) refused)' };
18
+ }
19
+ return { diagram: rendered.diagram, refused: rendered.refused };
20
+ }
21
+ export const diagramDiffTool = defineTool({
22
+ name: DIAGRAM_DIFF_TOOL_NAME,
23
+ description: '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).',
24
+ parameters: {
25
+ before: {
26
+ type: 'string',
27
+ description: 'The earlier diagram source.',
28
+ },
29
+ after: {
30
+ type: 'string',
31
+ description: 'The later diagram source.',
32
+ },
33
+ notation: {
34
+ type: 'string',
35
+ enum: ['auto', 'mermaid', 'plantuml'],
36
+ description: 'Which notation both sides are written in. Default auto: decided per side from its first meaningful line.',
37
+ },
38
+ },
39
+ output: {
40
+ schema: { type: 'json', description: 'Structural diff: the verdict, every change with the id it is about, and the sizes of both sides.' },
41
+ render(_args, value) {
42
+ const report = value;
43
+ const changes = Array.isArray(report.changes) ? report.changes : [];
44
+ const measured = (report.measured ?? {});
45
+ const lines = [];
46
+ lines.push('dsh-diagram/diff/v1 verdict=' + String(report.verdict) + ' ' + String(changes.length) + ' change(s)');
47
+ lines.push('MEASURED before=' + String(measured.before?.nodes) + ' node(s)/' + String(measured.before?.groups) + ' group(s)' +
48
+ ' after=' + String(measured.after?.nodes) + ' node(s)/' + String(measured.after?.groups) + ' group(s)');
49
+ for (const change of changes)
50
+ lines.push(' [' + String(change.severity) + '/' + String(change.code) + '] ' + String(change.message));
51
+ if (changes.length === 0)
52
+ lines.push('NEXT The structure is unchanged. That is all this compares: node text, edges and guards are not part of it.');
53
+ else
54
+ lines.push('NEXT Read each change against the design intent: a move is a decision, and this tool only reports that it happened.');
55
+ return [{ type: 'text', text: lines.join('\n') }];
56
+ },
57
+ },
58
+ timeoutMs: 10_000,
59
+ isConcurrencySafe: () => true,
60
+ async execute(args) {
61
+ const notation = args.notation === 'mermaid' || args.notation === 'plantuml' ? args.notation : 'auto';
62
+ const before = sideOf(typeof args.before === 'string' ? args.before : '', notation, 'before');
63
+ const after = sideOf(typeof args.after === 'string' ? args.after : '', notation, 'after');
64
+ const problems = [before.problem, after.problem].filter((problem) => problem !== undefined);
65
+ if (before.diagram === undefined || after.diagram === undefined) {
66
+ return {
67
+ ok: false,
68
+ verdict: 'fail',
69
+ changes: problems.map((problem) => ({ code: 'D1_SIDE_UNREADABLE', severity: 'error', kind: 'removed', subject: '-', message: problem })),
70
+ measured: {
71
+ before: { nodes: before.diagram?.nodes.length ?? 0, groups: before.diagram?.groups?.length ?? 0 },
72
+ after: { nodes: after.diagram?.nodes.length ?? 0, groups: after.diagram?.groups?.length ?? 0 },
73
+ },
74
+ };
75
+ }
76
+ const report = diffStructure(before.diagram, after.diagram);
77
+ return {
78
+ ok: report.verdict !== 'fail',
79
+ verdict: report.verdict,
80
+ changes: report.changes.map((change) => ({
81
+ code: change.code,
82
+ severity: change.severity,
83
+ kind: change.kind,
84
+ subject: change.subject,
85
+ message: change.message,
86
+ })),
87
+ measured: report.measured,
88
+ };
89
+ },
90
+ });
@@ -0,0 +1,102 @@
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
+ import { defineTool } from '@deepseek-ai/dsh-tools';
8
+ import { render } from './render/index.js';
9
+ import { schemeNames } from './render/palette.js';
10
+ import { writeSvg } from './render/write.js';
11
+ export const DIAGRAM_TOOL_NAME = 'diagram_render';
12
+ export const diagramRenderTool = defineTool({
13
+ name: DIAGRAM_TOOL_NAME,
14
+ description: '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.',
15
+ parameters: {
16
+ source: {
17
+ type: 'string',
18
+ description: 'The diagram source text, in Mermaid or PlantUML syntax.',
19
+ },
20
+ notation: {
21
+ type: 'string',
22
+ enum: ['auto', 'mermaid', 'plantuml'],
23
+ description: 'Which notation `source` is written in. Default auto: read from the first meaningful line.',
24
+ },
25
+ title: {
26
+ type: 'string',
27
+ description: 'The heading drawn above the diagram. Defaults to the component configuration.',
28
+ },
29
+ scheme: {
30
+ type: 'string',
31
+ enum: schemeNames(),
32
+ description: 'Colour scheme. Default: the standard light palette. print is greyscale, for a drawing that will be printed or embedded in a plain document.',
33
+ },
34
+ out: {
35
+ type: 'string',
36
+ description: 'Write the SVG here and report the path. Relative paths resolve against the session workspace.',
37
+ },
38
+ },
39
+ output: {
40
+ schema: { type: 'json', description: 'Drawing result: the notation used, the counts, every refused line, and the SVG markup.' },
41
+ render(_args, value) {
42
+ const result = value;
43
+ const lines = [];
44
+ const refused = Array.isArray(result.refused) ? result.refused : [];
45
+ lines.push('dsh-diagram/v1 notation=' + String(result.notation) + ' nodes=' + String(result.nodes) + ' edges=' + String(result.edges) +
46
+ (Number(result.groups) > 0 ? ' groups=' + String(result.groups) : '') +
47
+ (result.path === undefined ? '' : ' written=' + String(result.path)));
48
+ lines.push('CAPTION ' + String(result.caption));
49
+ if (refused.length > 0) {
50
+ lines.push('');
51
+ lines.push('REFUSED ' + String(refused.length) + ' line(s) - the drawing covers the rest');
52
+ for (const line of refused)
53
+ lines.push(' ' + line);
54
+ 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.');
55
+ }
56
+ else {
57
+ lines.push('NEXT Every line was drawn. Paste the SVG where the diagram belongs, or pass `out` to write it next to the document.');
58
+ }
59
+ return [{ type: 'text', text: lines.join('\n') }];
60
+ },
61
+ },
62
+ timeoutMs: 10_000,
63
+ isConcurrencySafe: () => true,
64
+ async execute(args) {
65
+ const source = typeof args.source === 'string' ? args.source : '';
66
+ if (source.trim() === '') {
67
+ // One shape for every answer, so the output contract has nothing to special-case.
68
+ return {
69
+ ok: false,
70
+ notation: 'mermaid',
71
+ nodes: 0,
72
+ edges: 0,
73
+ groups: 0,
74
+ caption: 'no source',
75
+ refused: ['source is empty: pass the diagram text to draw'],
76
+ dropped: 0,
77
+ svg: '',
78
+ };
79
+ }
80
+ const notation = args.notation === 'mermaid' || args.notation === 'plantuml' ? args.notation : 'auto';
81
+ const title = typeof args.title === 'string' && args.title.trim() !== '' ? args.title.trim() : undefined;
82
+ // The argument validator has already held this to the shipped schemes; an unknown value falls back rather
83
+ // than throwing, because a stale deployment may still send one.
84
+ const scheme = schemeNames().includes(args.scheme) ? args.scheme : 'default';
85
+ const rendered = render({ source, notation, ...(title === undefined ? {} : { title }) }, {}, { scheme });
86
+ const written = typeof args.out === 'string' && args.out.trim() !== ''
87
+ ? writeSvg({ source, notation, ...(title === undefined ? {} : { title }) }, args.out.trim(), {}, { scheme })
88
+ : undefined;
89
+ return {
90
+ ok: rendered.refused.length === 0,
91
+ notation: rendered.notation,
92
+ nodes: rendered.diagram.nodes.length,
93
+ edges: rendered.diagram.edges.length,
94
+ groups: rendered.diagram.groups?.length ?? 0,
95
+ caption: rendered.diagram.caption,
96
+ refused: rendered.refused,
97
+ dropped: rendered.dropped,
98
+ ...(written === undefined ? {} : { path: written.path, bytes: written.bytes }),
99
+ svg: rendered.markup,
100
+ };
101
+ },
102
+ });
@@ -0,0 +1,99 @@
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
+ import { defineTool } from '@deepseek-ai/dsh-tools';
8
+ import { render } from './render/index.js';
9
+ import { checkBudget, DEFAULT_LIMITS } from './render/budget.js';
10
+ export const DIAGRAM_REVIEW_TOOL_NAME = 'diagram_review';
11
+ /** Read the three caps a caller may override, ignoring anything that is not a positive number. */
12
+ function limitsOf(args) {
13
+ const pick = (value, fallback) => typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
14
+ return {
15
+ maxMembers: pick(args.maxMembers, DEFAULT_LIMITS.maxMembers),
16
+ maxDepth: pick(args.maxDepth, DEFAULT_LIMITS.maxDepth),
17
+ maxNodes: pick(args.maxNodes, DEFAULT_LIMITS.maxNodes),
18
+ };
19
+ }
20
+ export const diagramReviewTool = defineTool({
21
+ name: DIAGRAM_REVIEW_TOOL_NAME,
22
+ description: '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).',
23
+ parameters: {
24
+ source: {
25
+ type: 'string',
26
+ description: 'The diagram source text, in Mermaid or PlantUML syntax.',
27
+ },
28
+ notation: {
29
+ type: 'string',
30
+ enum: ['auto', 'mermaid', 'plantuml'],
31
+ description: 'Which notation `source` is written in. Default auto: read from the first meaningful line.',
32
+ },
33
+ maxMembers: {
34
+ type: 'integer',
35
+ description: 'How many direct children a boundary may hold. Default 7: more than a handful of boxes stops reading as a package.',
36
+ },
37
+ maxDepth: {
38
+ type: 'integer',
39
+ description: 'How deep boundaries may nest. Default 3.',
40
+ },
41
+ maxNodes: {
42
+ type: 'integer',
43
+ description: 'How many nodes the whole drawing may hold. Default 40.',
44
+ },
45
+ },
46
+ output: {
47
+ schema: { type: 'json', description: 'Structural review: the verdict, one finding per breached budget, and what was measured.' },
48
+ render(_args, value) {
49
+ const report = value;
50
+ const findings = Array.isArray(report.findings) ? report.findings : [];
51
+ const measured = (report.measured ?? {});
52
+ const lines = [];
53
+ lines.push('dsh-diagram/review/v1 verdict=' + String(report.verdict) + ' ' + String(findings.length) + ' finding(s)');
54
+ lines.push('MEASURED nodes=' + String(measured.nodes) + ' groups=' + String(measured.groups) + ' deepest=' + String(measured.deepest) +
55
+ (measured.widest === null || measured.widest === undefined ? '' : ' widest=' + JSON.stringify(measured.widest)));
56
+ for (const finding of findings) {
57
+ lines.push(' [' + String(finding.severity) + '/' + String(finding.code) + '] ' + String(finding.message));
58
+ }
59
+ for (const line of Array.isArray(report.refused) ? report.refused : []) {
60
+ lines.push(' refused ' + line);
61
+ }
62
+ if (findings.length === 0)
63
+ lines.push('NEXT The drawing is inside every budget. That says nothing about whether the system it describes is right.');
64
+ else
65
+ lines.push('NEXT Split or fold the groups named above, or raise the budget if the medium is larger than a conversation pane.');
66
+ return [{ type: 'text', text: lines.join('\n') }];
67
+ },
68
+ },
69
+ timeoutMs: 10_000,
70
+ isConcurrencySafe: () => true,
71
+ async execute(args) {
72
+ const source = typeof args.source === 'string' ? args.source : '';
73
+ const notation = args.notation === 'mermaid' || args.notation === 'plantuml' ? args.notation : 'auto';
74
+ if (source.trim() === '') {
75
+ return {
76
+ ok: false,
77
+ verdict: 'fail',
78
+ findings: [{ code: 'D0_NO_SOURCE', severity: 'error', message: 'source is empty: pass the diagram text to review' }],
79
+ measured: { nodes: 0, groups: 0, deepest: 0, widest: null },
80
+ refused: [],
81
+ };
82
+ }
83
+ const rendered = render({ source, notation });
84
+ const report = checkBudget(rendered.diagram, limitsOf(args));
85
+ return {
86
+ ok: report.verdict !== 'fail',
87
+ verdict: report.verdict,
88
+ // Plain JSON fields: a finding whose `group` is absent must not carry an undefined key.
89
+ findings: report.findings.map((finding) => ({
90
+ code: finding.code,
91
+ severity: finding.severity,
92
+ message: finding.message,
93
+ ...(finding.group === undefined ? {} : { group: finding.group }),
94
+ })),
95
+ measured: report.measured,
96
+ refused: rendered.refused,
97
+ };
98
+ },
99
+ });
package/lib/diagram.js ADDED
@@ -0,0 +1,43 @@
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
+ import z from '@deepseek-ai/schemastery';
13
+ import { diagramDiffTool } from './diagram-diff-tool.js';
14
+ import { diagramRenderTool } from './diagram-render-tool.js';
15
+ import { diagramReviewTool } from './diagram-review-tool.js';
16
+ /** The default a deployment inherits: on, because a component nobody knows about is a component nobody uses. */
17
+ const DEFAULT_ENABLED = true;
18
+ /**
19
+ * A live boolean, or a plain one on a host that predates the live wrapper.
20
+ *
21
+ * `live` is what makes the Plugins page switch take effect without a restart; the value it returns is read
22
+ * through {@link enabledOf} rather than compared, because both shapes have been seen in the wild.
23
+ */
24
+ const liveBoolean = z.live;
25
+ export const Config = z.object({
26
+ enabled: liveBoolean === undefined
27
+ ? z.boolean().default(DEFAULT_ENABLED)
28
+ : liveBoolean(z.boolean().default(DEFAULT_ENABLED)),
29
+ defaultTitle: z.string().default('diagram'),
30
+ });
31
+ /** Read the switch as a boolean, whichever shape this host produced. */
32
+ export function enabledOf(config) {
33
+ const value = config.enabled;
34
+ return typeof value === 'boolean' ? value : value.get();
35
+ }
36
+ export function apply(ctx, config) {
37
+ if (!enabledOf(config))
38
+ return;
39
+ ctx.tools.register(diagramRenderTool);
40
+ ctx.tools.register(diagramReviewTool);
41
+ ctx.tools.register(diagramDiffTool);
42
+ }
43
+ export const inject = ['tools'];
package/lib/gate.js CHANGED
@@ -51,6 +51,7 @@ const SKILL_ROWS = {
51
51
  'model-everything-design': 'layering, responsibilities, substitutability, interface width',
52
52
  'model-everything-datamodel': 'a schema, a migration, a data guarantee',
53
53
  'model-everything-interleaving': 'thread-safe, lock-free, ISR, async interleaving; a shared location is decided, the rest is routed',
54
+ 'model-everything-diagram': 'drawing, sizing or diffing a diagram from its source text',
54
55
  'model-everything-report-reading': 'reading a report this plugin returned',
55
56
  };
56
57
  /**
@@ -90,11 +91,11 @@ export function renderGateText(skills = shippedSkills(), extraGuidance = '') {
90
91
  const rows = skills.map((skill) => '| ' + (SKILL_ROWS[skill] ?? 'see its catalog line') + ' | `' + skill + '` |');
91
92
  const extra = extraGuidance.trim();
92
93
  return `<EXTREMELY_IMPORTANT>
93
- 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 first.
94
+ 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.
94
95
 
95
- Plugin dsh-model-everything is active. It checks claims about designs and code: facts against the source, behaviour as an executable model, structure as a graph.
96
+ dsh-model-everything is active: it checks claims about designs and code - facts against the source, behaviour as a model, structure as a graph.
96
97
 
97
- **Unsure which row applies?** Load \`model-everything\` first: it owns the doctrine, the model schema and the routing to its siblings.
98
+ **Unsure which row applies?** Load \`model-everything\` first: it owns the doctrine, the schema and the routing to its siblings.
98
99
 
99
100
  | The request | The skill |
100
101
  | --- | --- |
@@ -102,22 +103,23 @@ ${rows.join('\n')}
102
103
 
103
104
  **Tools** (dsh):
104
105
 
105
- - \`model_verify\` — one state machine: structural checks, adversarial probes, budgets, deadlines, before/after regression.
106
+ - \`model_verify\` — one state machine: structural checks, adversarial probes, budgets, deadlines, regression.
106
107
  - \`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).
107
- - \`model_datamodel_verify\` — a data model and its migration: keys, domains, migration coverage, copy consistency, rollback symmetry.
108
- - \`model_design_review\` — a design against its own declarations: layering, boundaries, substitutability, interface width. The graph's shape is reported, not judged.
109
- - \`model_interleaving_scan\` — finds claims about threads, synchronization, async and interrupts, names the family, and routes each to the tool that decides it. It proves nothing about any of them.
110
- - \`model_export\` — UPPAAL, TLA+, PRISM or SPIN input from a verified model. It exports; it does not check the target tool.
111
- - \`uml_render\`, \`uml_parse\`, \`uml_review\` — draw a model, read a diagram into one, audit one. Round-trip fidelity is measured, never assumed.
108
+ - \`model_datamodel_verify\` — a data model and its migration: keys, domains, coverage, copy consistency, rollback symmetry.
109
+ - \`model_design_review\` — a design against its own declarations: layering, boundaries, substitutability, interface width. Graph shape is reported, not judged.
110
+ - \`model_interleaving_scan\` — finds claims about threads, async and interrupts, names the family and routes each to what decides it. It proves nothing itself.
111
+ - \`model_export\` — UPPAAL, TLA+, PRISM or SPIN input from a model. It exports; it does not check the target.
112
+ - \`uml_render\`, \`uml_parse\`, \`uml_review\` — draw a model, read a diagram into one, audit one. Round-trip fidelity is measured.
112
113
  - \`uml_explain_labels\` — the accepted label and comment forms for one notation.
113
114
  - \`uml_structure_verify\` — dependency review of a structure drawing, one level or several. It audits the drawing, not the code.
115
+ - \`diagram_render\`, \`diagram_review\`, \`diagram_diff\` — draw, size and compare a Mermaid or PlantUML source. Undrawable lines are refused by number.
114
116
  - \`report_diff\` — baseline against current: what a change added, removed or changed.
115
117
 
116
- **Read a report**: gate on \`verdict\` (\`pass\`, \`pass_with_findings\`, \`fail\`), 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.
118
+ **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.
117
119
 
118
- **When a tool refuses, or is not there.** Read its \`errorCode\` and fix the input before retrying; if the tool is not registered in this deployment, say so and use the general method. A check that nothing ran is not a passing check.${extra === '' ? '' : '\n\n' + extra}
120
+ **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}
119
121
 
120
- The rule again, because it is the one that gets skipped: before answering anything about code, a design or a diagram, load the skill whose routing applies and let its tool decide - a claim you can check is not a claim you may answer from memory.
122
+ 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.
121
123
  </EXTREMELY_IMPORTANT>`;
122
124
  }
123
125
  /** The shipped default: every row the package can advertise, no deployment guidance. */
@@ -50,6 +50,7 @@ export function reportCard(value, family) {
50
50
  const errors = findings.filter((entry) => severity(entry) === 'error');
51
51
  const warnings = findings.filter((entry) => severity(entry) === 'warning');
52
52
  const summary = record(report.summary);
53
+ const scan = record(report.scan);
53
54
  const summaryErrors = typeof summary?.errors === 'number' ? summary.errors : undefined;
54
55
  const summaryWarnings = typeof summary?.warnings === 'number' ? summary.warnings : undefined;
55
56
  const checksRun = typeof summary?.checksRun === 'number'
@@ -97,6 +98,9 @@ export function reportCard(value, family) {
97
98
  ...(violations.length === 0 && signals.length === 0 && facts.length === 0
98
99
  ? {}
99
100
  : { levels: { violations: violations.length, signals: signals.length, facts: facts.length, skipped: array(report.checksSkipped).length } }),
101
+ ...(typeof scan?.files === 'number'
102
+ ? { scan: { files: scan.files, unresolved: Number(scan.unresolved ?? 0), packages: Number(scan.packages ?? 0), dynamic: Number(scan.dynamic ?? 0) } }
103
+ : {}),
100
104
  ...(typeof summary?.absoluteClaims === 'number'
101
105
  ? { claims: { absolute: summary.absoluteClaims, keywords: Number(summary.keywords ?? 0), families: claimFamilies(findings) } }
102
106
  : {}),
@@ -250,6 +254,12 @@ export function structureCardBlocks(card) {
250
254
  if (card.graph !== undefined) {
251
255
  lines.push('graph: ' + String(card.graph.nodes) + ' nodes · ' + String(card.graph.edges) + ' edges · ' + String(card.graph.cycles) + ' cycle(s) · ' + String(card.graph.isolated) + ' isolated');
252
256
  }
257
+ if (card.scan !== undefined) {
258
+ // A scan that resolved half its imports reads the same as one that resolved all of them unless the
259
+ // counts are on the card.
260
+ const dynamic = card.scan.dynamic > 0 ? ' · ' + String(card.scan.dynamic) + ' computed' : '';
261
+ lines.push('scan: ' + String(card.scan.files) + ' file(s) · ' + String(card.scan.unresolved) + ' unresolved · ' + String(card.scan.packages) + ' package(s)' + dynamic);
262
+ }
253
263
  if (card.first != null)
254
264
  lines.push((card.first.severity === 'error' ? '✗ ' : '⚠ ') + card.first.code + ': ' + card.first.message);
255
265
  if (card.next != null)
@@ -0,0 +1,99 @@
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
+ * Defaults, chosen for a drawing read in a conversation pane: a package with more than a handful of boxes stops
14
+ * being a package and becomes a diagram of its own, and three levels is what fits on one screen before a reader
15
+ * needs the source.
16
+ */
17
+ export const DEFAULT_LIMITS = { maxMembers: 7, maxDepth: 3, maxNodes: 40 };
18
+ /** The direct children of a boundary: its own nodes plus the groups that name it as parent. */
19
+ function childrenOf(groups, group) {
20
+ const nested = groups.filter((entry) => entry.parent === group.id).length;
21
+ return group.members.length + nested;
22
+ }
23
+ /** How deep the nesting goes, guarding against a parent cycle in the data. */
24
+ function depthOf(groups, groupId) {
25
+ const byId = new Map(groups.map((group) => [group.id, group]));
26
+ let depth = 1;
27
+ let cursor = byId.get(groupId)?.parent;
28
+ const seen = new Set([groupId]);
29
+ while (cursor !== undefined && !seen.has(cursor)) {
30
+ seen.add(cursor);
31
+ depth += 1;
32
+ cursor = byId.get(cursor)?.parent;
33
+ }
34
+ return depth;
35
+ }
36
+ /**
37
+ * Measure a drawing against its limits.
38
+ *
39
+ * Every finding names the group it is about, because a budget report that says "a group is too wide" without
40
+ * saying which one is a report nobody can act on.
41
+ */
42
+ export function checkBudget(diagram, limits = {}) {
43
+ const caps = { ...DEFAULT_LIMITS, ...limits };
44
+ const groups = diagram.groups ?? [];
45
+ const findings = [];
46
+ const nodeIds = new Set(diagram.nodes.map((node) => node.id));
47
+ for (const group of groups) {
48
+ const size = childrenOf(groups, group);
49
+ const depth = depthOf(groups, group.id);
50
+ if (size > caps.maxMembers) {
51
+ findings.push({
52
+ code: 'B1_GROUP_TOO_WIDE',
53
+ severity: 'error',
54
+ group: group.id,
55
+ message: 'group "' + group.label + '" holds ' + String(size) + ' direct children; the budget is ' + String(caps.maxMembers),
56
+ });
57
+ }
58
+ if (depth > caps.maxDepth) {
59
+ findings.push({
60
+ code: 'B2_TOO_DEEP',
61
+ severity: 'error',
62
+ group: group.id,
63
+ message: 'group "' + group.label + '" nests ' + String(depth) + ' levels deep; the budget is ' + String(caps.maxDepth),
64
+ });
65
+ }
66
+ const realMembers = group.members.filter((member) => nodeIds.has(member));
67
+ if (realMembers.length === 0 && groups.every((entry) => entry.parent !== group.id)) {
68
+ findings.push({
69
+ code: 'B3_EMPTY_GROUP',
70
+ severity: 'error',
71
+ group: group.id,
72
+ message: 'group "' + group.label + '" holds nothing that is drawn, so the boundary claims a containment the drawing does not show',
73
+ });
74
+ }
75
+ if (size === 1) {
76
+ findings.push({
77
+ code: 'B4_SINGLE_MEMBER',
78
+ severity: 'warning',
79
+ group: group.id,
80
+ message: 'group "' + group.label + '" holds one child; a boundary around one thing usually means a layer that was never split',
81
+ });
82
+ }
83
+ }
84
+ if (diagram.nodes.length > caps.maxNodes) {
85
+ findings.push({
86
+ code: 'B5_TOO_MANY_NODES',
87
+ severity: 'error',
88
+ message: 'the drawing holds ' + String(diagram.nodes.length) + ' nodes; the budget is ' + String(caps.maxNodes),
89
+ });
90
+ }
91
+ const deepest = groups.reduce((max, group) => Math.max(max, depthOf(groups, group.id)), 0);
92
+ const widest = groups.reduce((best, group) => {
93
+ const size = childrenOf(groups, group);
94
+ return best === null || size > best.size ? { group: group.label, size } : best;
95
+ }, null);
96
+ const errors = findings.filter((finding) => finding.severity === 'error').length;
97
+ const verdict = errors > 0 ? 'fail' : findings.length > 0 ? 'pass_with_findings' : 'pass';
98
+ return { verdict, findings, measured: { nodes: diagram.nodes.length, groups: groups.length, deepest, widest } };
99
+ }