dsh-model-everything 0.0.0-stage → 0.2.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 (133) hide show
  1. package/LICENSE +21 -0
  2. package/README.en-US.md +332 -0
  3. package/README.md +332 -3
  4. package/cordis.patch.yml +20 -0
  5. package/examples/README.md +48 -0
  6. package/examples/control-dispatcher.json +113 -0
  7. package/examples/control-dispatcher.overbudget.json +113 -0
  8. package/examples/ecommerce-data-model.json +185 -0
  9. package/examples/order-state-machine.after.json +116 -0
  10. package/examples/order-state-machine.before.json +101 -0
  11. package/examples/user-migration.after.json +29 -0
  12. package/examples/user-migration.before.json +29 -0
  13. package/gate/locale/en.json +6 -0
  14. package/gate/locale/zh.json +6 -0
  15. package/gate/package.json +5 -0
  16. package/lib/baseline.js +249 -0
  17. package/lib/compose-tool.js +80 -0
  18. package/lib/concurrency-tool.js +40 -0
  19. package/lib/concurrency.js +206 -0
  20. package/lib/data-engine.js +1141 -0
  21. package/lib/data-tool.js +87 -0
  22. package/lib/design-engine.js +1149 -0
  23. package/lib/design-tool.js +63 -0
  24. package/lib/engine.js +4343 -0
  25. package/lib/export-tool.js +78 -0
  26. package/lib/exporters.js +434 -0
  27. package/lib/gate.js +274 -0
  28. package/lib/granularity.js +237 -0
  29. package/lib/index.js +237 -0
  30. package/lib/json-value.js +18 -0
  31. package/lib/model-file.js +48 -0
  32. package/lib/option-shape.js +62 -0
  33. package/lib/report-diff-tool.js +63 -0
  34. package/lib/report-view.js +433 -0
  35. package/lib/structure-tool.js +91 -0
  36. package/lib/structure.js +711 -0
  37. package/lib/tool.js +94 -0
  38. package/lib/types/baseline.d.ts +65 -0
  39. package/lib/types/compose-tool.d.ts +7 -0
  40. package/lib/types/concurrency-tool.d.ts +8 -0
  41. package/lib/types/concurrency.d.ts +37 -0
  42. package/lib/types/data-engine.d.ts +211 -0
  43. package/lib/types/data-tool.d.ts +10 -0
  44. package/lib/types/design-engine.d.ts +215 -0
  45. package/lib/types/design-tool.d.ts +11 -0
  46. package/lib/types/engine.d.ts +494 -0
  47. package/lib/types/export-tool.d.ts +7 -0
  48. package/lib/types/exporters.d.ts +37 -0
  49. package/lib/types/gate.d.ts +91 -0
  50. package/lib/types/granularity.d.ts +106 -0
  51. package/lib/types/index.d.ts +45 -0
  52. package/lib/types/json-value.d.ts +21 -0
  53. package/lib/types/model-file.d.ts +7 -0
  54. package/lib/types/option-shape.d.ts +22 -0
  55. package/lib/types/report-diff-tool.d.ts +8 -0
  56. package/lib/types/report-view.d.ts +50 -0
  57. package/lib/types/structure-tool.d.ts +10 -0
  58. package/lib/types/structure.d.ts +152 -0
  59. package/lib/types/tool.d.ts +11 -0
  60. package/lib/types/uml-communication.d.ts +109 -0
  61. package/lib/types/uml-composite.d.ts +107 -0
  62. package/lib/types/uml-interaction-overview.d.ts +91 -0
  63. package/lib/types/uml-profile.d.ts +112 -0
  64. package/lib/types/uml-structure.d.ts +53 -0
  65. package/lib/types/uml-syntax.d.ts +296 -0
  66. package/lib/types/uml-timing.d.ts +152 -0
  67. package/lib/types/uml-tool.d.ts +12 -0
  68. package/lib/types/uml-usecase.d.ts +58 -0
  69. package/lib/types/uml.d.ts +303 -0
  70. package/lib/uml-communication.js +353 -0
  71. package/lib/uml-composite.js +442 -0
  72. package/lib/uml-interaction-overview.js +277 -0
  73. package/lib/uml-profile.js +373 -0
  74. package/lib/uml-structure.js +306 -0
  75. package/lib/uml-syntax.js +545 -0
  76. package/lib/uml-timing.js +974 -0
  77. package/lib/uml-tool.js +213 -0
  78. package/lib/uml-usecase.js +532 -0
  79. package/lib/uml.js +2110 -0
  80. package/locale/en.json +6 -0
  81. package/locale/zh.json +6 -0
  82. package/package.json +128 -6
  83. package/skills/model-everything/SKILL.md +450 -0
  84. package/skills/model-everything/references/dsh-model-schema.md +437 -0
  85. package/skills/model-everything/references/gap-routing-guide.md +42 -0
  86. package/skills/model-everything/references/hash-spec.md +152 -0
  87. package/skills/model-everything/references/logic-verification-guide.md +422 -0
  88. package/skills/model-everything-datamodel/SKILL.md +130 -0
  89. package/skills/model-everything-datamodel/references/data-model-guide.md +84 -0
  90. package/skills/model-everything-datamodel/references/data-model-schema.md +128 -0
  91. package/skills/model-everything-design/SKILL.md +115 -0
  92. package/skills/model-everything-interleaving/SKILL.md +63 -0
  93. package/skills/model-everything-interleaving/SKILL.zh-CN.md +60 -0
  94. package/skills/model-everything-interleaving/references/concurrency-risk-guide.md +63 -0
  95. package/skills/model-everything-report-reading/SKILL.md +137 -0
  96. package/skills/model-everything-report-reading/references/reading-a-report.md +166 -0
  97. package/skills/model-everything-report-reading/references/reading-a-report.mmd +59 -0
  98. package/skills/model-everything-structure/SKILL.md +96 -0
  99. package/skills/model-everything-structure/references/structure-review-guide.md +201 -0
  100. package/skills/model-everything-uml/SKILL.md +60 -0
  101. package/skills/model-everything-uml/references/uml-modeling-guide.md +551 -0
  102. package/src/baseline.ts +288 -0
  103. package/src/compose-tool.ts +82 -0
  104. package/src/concurrency-tool.ts +44 -0
  105. package/src/concurrency.ts +254 -0
  106. package/src/data-engine.ts +1208 -0
  107. package/src/data-tool.ts +91 -0
  108. package/src/design-engine.ts +1263 -0
  109. package/src/design-tool.ts +68 -0
  110. package/src/engine.ts +4564 -0
  111. package/src/export-tool.ts +81 -0
  112. package/src/exporters.ts +399 -0
  113. package/src/gate.ts +330 -0
  114. package/src/granularity.ts +312 -0
  115. package/src/index.ts +289 -0
  116. package/src/json-value.ts +20 -0
  117. package/src/model-file.ts +49 -0
  118. package/src/option-shape.ts +64 -0
  119. package/src/report-diff-tool.ts +67 -0
  120. package/src/report-view.ts +483 -0
  121. package/src/structure-tool.ts +93 -0
  122. package/src/structure.ts +786 -0
  123. package/src/tool.ts +103 -0
  124. package/src/uml-communication.ts +428 -0
  125. package/src/uml-composite.ts +520 -0
  126. package/src/uml-interaction-overview.ts +321 -0
  127. package/src/uml-profile.ts +439 -0
  128. package/src/uml-structure.ts +317 -0
  129. package/src/uml-syntax.ts +764 -0
  130. package/src/uml-timing.ts +1106 -0
  131. package/src/uml-tool.ts +230 -0
  132. package/src/uml-usecase.ts +566 -0
  133. package/src/uml.ts +2139 -0
package/src/gate.ts ADDED
@@ -0,0 +1,330 @@
1
+ /**
2
+ * The gate: one optional routing message, injected once per session.
3
+ *
4
+ * Why it exists. Routing works through text: the measured result is that a session asked
5
+ * to check something loads the skill whose catalog line matches. What that measurement
6
+ * also showed is the limit - a session that never decides to load anything is never routed,
7
+ * and the catalog line is cut at 500 characters, so the tail of a long line is invisible at
8
+ * the moment of choosing. This component carries the routing in a message instead of in the
9
+ * line, for a deployment that wants the decision made for it.
10
+ *
11
+ * Why it is a separate row. The plugin's doctrine is that a capability is discoverable
12
+ * through its own tool description or skill text. Injection departs from that, so it is
13
+ * opt-in and mounted as its own component: install it, disable it, or never enable it,
14
+ * without touching the tools or the skills. `enabled` is a live switch, so the Web Plugins
15
+ * page flips it inside a running host.
16
+ *
17
+ * Why it is off by default. It costs tokens in every session that turns it on, to fix a
18
+ * problem text-only routing was measured not to have once the session engages. A deployment
19
+ * that wants it says so; the default pays nothing.
20
+ *
21
+ * What it does not do. It routes; it checks nothing. It cannot make a skill load, and it
22
+ * does not restate the doctrine - the skill bodies carry that, and duplicating them here
23
+ * would spend the session's context on a second copy.
24
+ *
25
+ * @module dsh-model-everything-gate
26
+ */
27
+
28
+ import { readdirSync } from 'node:fs'
29
+ import { fileURLToPath } from 'node:url'
30
+ import type { Context, Volatile } from '@deepseek-ai/cordis'
31
+ import z from '@deepseek-ai/schemastery'
32
+ import { createUserMessage } from '@deepseek-ai/dsh-llm'
33
+ import type { ContextFormed } from '@deepseek-ai/dsh-llm'
34
+ import type { HostCordisInspectProviderRegistration } from '@deepseek-ai/dsh-cordis-host-runner'
35
+ import type { Session, UserMessage } from '@deepseek-ai/dsh-session'
36
+
37
+ // DSH 0.1.7-alpha.1 (session format v4) retires the shared `{ kind: 'plugin', plugin }`
38
+ // wrapper, so the producer-owned kind is declared here. The history guard below matches on
39
+ // it, which is what keeps the gate to one copy per session.
40
+ declare module '@deepseek-ai/dsh-llm' {
41
+ interface MessageSourceMap {
42
+ 'plugin:dsh-model-everything-gate': { kind: 'plugin:dsh-model-everything-gate' } & ContextFormed
43
+ }
44
+ }
45
+
46
+ export const name = 'dsh-model-everything-gate'
47
+
48
+ /** Producer-owned message source kind declared in `MessageSourceMap` above. */
49
+ const GATE_SOURCE_KIND: 'plugin:dsh-model-everything-gate' = 'plugin:dsh-model-everything-gate'
50
+
51
+ /**
52
+ * The default text. It names every skill the package ships, because its job is the routing
53
+ * the catalog line cannot carry once the line is long; `tests/gate` fails when a skill is
54
+ * added and this list is not, which is the only thing keeping the two in step.
55
+ *
56
+ * The rule is stated at both ends, in different words. That is a measured change, not a
57
+ * stylistic one: the single-statement version left the one case that needed the plugin - a
58
+ * migration question the session believed it could answer from memory - with no skill loaded
59
+ * and no tool run. Stating it at the start and restating it at the end fixed that case and
60
+ * added no load on a control that must load nothing. Two sentences cost 403 characters; the
61
+ * comparison is in the routing measurement under "第三臂".
62
+ */
63
+ /** One row per skill: the request it answers, in the words a user would use. */
64
+ const SKILL_ROWS: Record<string, string> = {
65
+ 'model-everything': 'claims against code, state machines, protocols',
66
+ 'model-everything-uml': 'drawing a diagram, or reading one someone drew',
67
+ 'model-everything-structure': 'an architecture or dependency drawing',
68
+ 'model-everything-design': 'layering, responsibilities, substitutability, interface width',
69
+ 'model-everything-datamodel': 'a schema, a migration, a data guarantee',
70
+ 'model-everything-interleaving': 'thread-safe, lock-free, ISR, async interleaving; a shared location is decided, the rest is routed',
71
+ 'model-everything-report-reading': 'reading a report this plugin returned',
72
+ }
73
+
74
+ /**
75
+ * The skills this package actually ships, read from disk when the module loads.
76
+ *
77
+ * The table below advertises only these. A row for a skill that is not in the package sends the
78
+ * model to a name that resolves to nothing, which is the one failure a routing message must not
79
+ * have; a skill added without a row still gets an entry, reading "see its catalog line", so the
80
+ * table can be incomplete but never wrong.
81
+ */
82
+ export function shippedSkills(): string[] {
83
+ try {
84
+ return readdirSync(fileURLToPath(new URL('../skills/', import.meta.url)), { withFileTypes: true })
85
+ .filter((entry) => entry.isDirectory())
86
+ .map((entry) => entry.name)
87
+ .sort()
88
+ } catch {
89
+ return []
90
+ }
91
+ }
92
+
93
+ /**
94
+ * The text, rendered from the skills that ship.
95
+ *
96
+ * The rule is stated at both ends, in different words. That is a measured change, not a stylistic
97
+ * one: with a single statement, the one case that needed the plugin - a migration question the
98
+ * session believed it could answer from memory - loaded no skill and ran no tool. Stated at the
99
+ * start and restated at the end, it did both, and a control task was not over-triggered.
100
+ *
101
+ * The shape follows a neighbouring plugin's injected section: a table that chooses between near-equals,
102
+ * rows only for what is installed, and a rule against degrading silently when a tool refuses.
103
+ *
104
+ * @param skills - the skills to advertise; defaults to the ones on disk.
105
+ * @param extraGuidance - a deployment's own sentence, appended last.
106
+ */
107
+ export function renderGateText(skills: string[] = shippedSkills(), extraGuidance = ''): string {
108
+ const rows = skills.map((skill) => '| ' + (SKILL_ROWS[skill] ?? 'see its catalog line') + ' | `' + skill + '` |')
109
+ const extra = extraGuidance.trim()
110
+ return `<EXTREMELY_IMPORTANT>
111
+ 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.
112
+
113
+ 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.
114
+
115
+ **Unsure which row applies?** Load \`model-everything\` first: it owns the doctrine, the model schema and the routing to its siblings.
116
+
117
+ | The request | The skill |
118
+ | --- | --- |
119
+ ${rows.join('\n')}
120
+
121
+ **Tools** (dsh):
122
+
123
+ - \`model_verify\` — one state machine: structural checks, adversarial probes, budgets, deadlines, before/after regression.
124
+ - \`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, migration coverage, copy consistency, rollback symmetry.
126
+ - \`model_design_review\` — a design against its own declarations: layering, boundaries, substitutability, interface width. The graph's shape is reported, not judged.
127
+ - \`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.
128
+ - \`model_export\` — UPPAAL, TLA+, PRISM or SPIN input from a verified model. It exports; it does not check the target tool.
129
+ - \`uml_render\`, \`uml_parse\`, \`uml_review\` — draw a model, read a diagram into one, audit one. Round-trip fidelity is measured, never assumed.
130
+ - \`uml_explain_labels\` — the accepted label and comment forms for one notation.
131
+ - \`uml_structure_verify\` — dependency review of a structure drawing, one level or several. It audits the drawing, not the code.
132
+ - \`report_diff\` — baseline against current: what a change added, removed or changed.
133
+
134
+ **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.
135
+
136
+ **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}
137
+
138
+ 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.
139
+ </EXTREMELY_IMPORTANT>`
140
+ }
141
+
142
+ /** The shipped default: every row the package can advertise, no deployment guidance. */
143
+ export const DEFAULT_GATE_TEXT = renderGateText()
144
+
145
+ /**
146
+ * A live switch, when this host's schemastery supports one.
147
+ *
148
+ * The settings service projects ONLY fields under a `.volatile()` node, so a plain boolean
149
+ * cannot be flipped from the Web Plugins page without remounting the row. The returned
150
+ * schema keeps the field's static type; {@link Config} carries the union the host actually
151
+ * hands over, and {@link injectionEnabled} accepts both shapes - a host that predates
152
+ * `.volatile()` simply gets a switch that needs a restart.
153
+ */
154
+ interface LiveField {
155
+ volatile?: () => unknown
156
+ }
157
+ function live<T>(field: T): T {
158
+ const probe = field as T & LiveField
159
+ return typeof probe.volatile === 'function' ? (probe.volatile() as T) : field
160
+ }
161
+
162
+ export interface Config {
163
+ /** Whether the gate is injected. Read through {@link injectionEnabled}. On unless a deployment says otherwise. */
164
+ enabled: Volatile<boolean> | boolean
165
+ /** The text to inject. Override in the profile's `cordis.patch.yml` row, not in a GUI form. */
166
+ gateContent: string
167
+ /** A deployment's own sentence, appended to whatever text is injected. */
168
+ extraGuidance: string
169
+ }
170
+
171
+ export const Config = z.object({
172
+ // On by default, and live so a deployment can turn it off without a restart. The comparison that
173
+ // decided this is in the routing measurement: at 2933 characters it fixed the one request class
174
+ // the plugin otherwise never reached, and a control task was not over-triggered.
175
+ enabled: live(z.boolean().default(true)),
176
+ // Not volatile on purpose: the settings projection feeds a GUI form, and a kilobyte of
177
+ // prose does not belong in one.
178
+ gateContent: z.string().default(DEFAULT_GATE_TEXT),
179
+ // A neighbour's technique, adopted: a short free-text slot a deployment can use for its own
180
+ // rule without forking the package. Appended last, so it is read after the routing.
181
+ extraGuidance: z.string().default(''),
182
+ })
183
+
184
+ /**
185
+ * Read the switch as a boolean, whichever shape this host produced.
186
+ * @param config - the resolved plugin configuration.
187
+ * @returns whether the gate may be injected.
188
+ */
189
+ function injectionEnabled(config: Config): boolean {
190
+ const value = config.enabled
191
+ return typeof value === 'boolean' ? value : value.get()
192
+ }
193
+
194
+ function gateText(config: Config): string {
195
+ const extra = typeof config.extraGuidance === 'string' ? config.extraGuidance.trim() : ''
196
+ return extra === '' ? config.gateContent : config.gateContent + '\n\n' + extra
197
+ }
198
+
199
+ function gateMessage(text: string): UserMessage {
200
+ return createUserMessage({
201
+ content: [{ type: 'text', text }],
202
+ source: { kind: GATE_SOURCE_KIND },
203
+ })
204
+ }
205
+
206
+ interface SessionEventLike {
207
+ type: string
208
+ data?: Record<string, unknown>
209
+ }
210
+
211
+ /**
212
+ * Minimum session-store surface this plugin reads. DSH 0.1.2-alpha.4 replaced the
213
+ * `Session.events` getter with on-demand reads; releases up to 0.1.2-alpha.3 expose the log
214
+ * as a snapshot. Read through a structural union so one build serves every declared release.
215
+ */
216
+ interface SessionEventSource {
217
+ events?: readonly SessionEventLike[]
218
+ snapshotEvents?: () => readonly SessionEventLike[]
219
+ }
220
+
221
+ function readSessionEvents(session: Session): readonly SessionEventLike[] {
222
+ const source = session as unknown as SessionEventSource
223
+ if (typeof source.snapshotEvents === 'function') {
224
+ const snapshot = source.snapshotEvents()
225
+ if (Array.isArray(snapshot)) return snapshot
226
+ }
227
+ if (Array.isArray(source.events)) return source.events
228
+ return []
229
+ }
230
+
231
+ /**
232
+ * Whether the gate already entered this session's durable history. The pre-step listener
233
+ * re-appends the gate until it does; once a step committed it, every later step - and a
234
+ * resume of a session that kept it - skips the injection. A gate dropped before any step
235
+ * ran simply re-injects on the first step that does.
236
+ * @param session - the agent's session.
237
+ * @returns whether a gate message is already in the log.
238
+ */
239
+ function gateInHistory(session: Session): boolean {
240
+ return readSessionEvents(session).some((event) => {
241
+ if (event.type !== 'user/message') return false
242
+ const source = event.data?.source as { kind?: string } | undefined
243
+ return source?.kind === GATE_SOURCE_KIND
244
+ })
245
+ }
246
+
247
+ /**
248
+ * Model-visible status, so "is the gate on" is answered by inspection rather than by reading
249
+ * a session transcript. A separate provider id from the main row: these are two components,
250
+ * and one of them being mounted says nothing about the other.
251
+ * @param config - the resolved plugin configuration.
252
+ * @returns the inspect provider registration.
253
+ */
254
+ function inspectProvider(config: Config): HostCordisInspectProviderRegistration {
255
+ return {
256
+ manifest: {
257
+ id: 'dsh-model-everything-gate',
258
+ description: 'The optional session-start routing message of dsh-model-everything: whether it is injected, and how much text it costs. It routes a request to a skill; it checks nothing.',
259
+ methods: [
260
+ {
261
+ name: 'status',
262
+ description: 'Read the gate status: whether injection is enabled and how long the injected text is.',
263
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
264
+ outputSchema: {
265
+ type: 'object',
266
+ description: 'Gate status.',
267
+ properties: {
268
+ enabled: { type: 'boolean', description: 'Whether the gate is injected into new sessions.' },
269
+ gateContentLength: { type: 'integer', description: 'Length in characters of the injected text.' },
270
+ },
271
+ additionalProperties: false,
272
+ },
273
+ },
274
+ ],
275
+ },
276
+ query: async (method: string) => {
277
+ if (method === 'status') {
278
+ return {
279
+ enabled: injectionEnabled(config),
280
+ gateContentLength: config.gateContent.length,
281
+ }
282
+ }
283
+ return null
284
+ },
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Mount the gate.
290
+ *
291
+ * The listener is registered whether or not the switch is on: the switch is volatile, so
292
+ * `apply` runs once and the value behind it can turn on later from the Plugins page.
293
+ * Returning early here would freeze the decision for the lifetime of the mount.
294
+ *
295
+ * @param ctx - the plugin context.
296
+ * @param config - the resolved plugin configuration.
297
+ */
298
+ export function apply(ctx: Context, config: Config): void {
299
+ // Catalog visibility is optional and the registry may be mounted after this row applies,
300
+ // so registration is retried on each step until it lands.
301
+ let providerRegistered = false
302
+ const registerProvider = (): void => {
303
+ if (providerRegistered) return
304
+ const inspect = ctx.get('cordisInspect')
305
+ if (inspect === undefined) return
306
+ try {
307
+ ctx.effect(() => inspect.register(inspectProvider(config)), 'dsh-model-everything-gate: inspect provider')
308
+ providerRegistered = true
309
+ } catch (error) {
310
+ console.warn('[dsh-model-everything-gate] inspect provider registration failed', error)
311
+ }
312
+ }
313
+ registerProvider()
314
+
315
+ // Inject on the FIRST model step that runs, instead of at session start: a session-start
316
+ // message lands in the inbox, which a blank-session preset switch can clear before the
317
+ // first step. The pre-step decision is durable - the message enters history with the step,
318
+ // and the guard below stops it from being added a second time.
319
+ ctx.on('agent/pre-step', async ({ agent }, next) => {
320
+ const decision = await next()
321
+ if (decision.kind === 'reject') return decision
322
+ registerProvider()
323
+ if (!injectionEnabled(config)) return decision
324
+ if (gateInHistory(agent.session)) return decision
325
+ return {
326
+ kind: 'enter',
327
+ messages: [...decision.messages, gateMessage(gateText(config))],
328
+ }
329
+ })
330
+ }
@@ -0,0 +1,312 @@
1
+ /**
2
+ * Multi-granularity structure review: one architecture, several levels of detail.
3
+ *
4
+ * A module diagram and the repository-wide diagram it belongs to are two views of one
5
+ * fact, and nothing keeps them in step — the child grows a dependency the parent never
6
+ * had, or the parent keeps an arrow the child quietly stopped drawing. This module
7
+ * checks that a child diagram is a **refinement** of its declared parent:
8
+ *
9
+ * - every dependency the child draws between two nodes the parent also has must exist in
10
+ * the parent (nothing appears out of nowhere);
11
+ * - an edge the parent draws and the child does not must be *expanded* in the child, i.e.
12
+ * a child path must connect the same two ends;
13
+ * - the declared parent relation must be a forest, not a cycle.
14
+ *
15
+ * Every per-diagram structural check still runs, and its findings are tagged with the
16
+ * diagram they came from (`file`), so one report can carry several diagrams without the
17
+ * findings becoming anonymous.
18
+ *
19
+ * @module this plugin-granularity
20
+ */
21
+
22
+ import { createHash } from 'node:crypto'
23
+ import { REPORT_SCHEMAS, canonicalJson, verdictOfFindings, type Finding, type ReportSchema, type Verdict, type VerdictSummary } from './engine.js'
24
+ import { parseStructure, reviewStructure, type StructureGraph, type StructureNotation } from './structure.js'
25
+
26
+ export interface GranularityDiagram {
27
+ /** Name of this level, e.g. `L2/motion` or `repo`. Referenced by a child's `parent`. */
28
+ name: string
29
+ /** Diagram source text. */
30
+ diagram: string
31
+ /** Name of the diagram this one refines; absent for a root. */
32
+ parent?: string
33
+ }
34
+
35
+ export interface GranularityOptions {
36
+ diagrams: GranularityDiagram[]
37
+ notation?: 'auto' | StructureNotation
38
+ /** Optional dependency matrix, applied to every diagram. */
39
+ matrix?: unknown
40
+ }
41
+
42
+ export interface GranularityEdgeRef {
43
+ from: string
44
+ to: string
45
+ line?: number
46
+ }
47
+
48
+ export interface GranularityPairSummary {
49
+ parent: string
50
+ child: string
51
+ parentNodes: number
52
+ childNodes: number
53
+ parentEdges: number
54
+ childEdges: number
55
+ /** Child edges whose endpoints both exist in the parent and which the parent also draws. */
56
+ inheritedEdges: number
57
+ /** Child edges with at least one endpoint the parent does not have (the refinement's own detail). */
58
+ newEdges: number
59
+ /** Parent edges with no direct child counterpart, but a child path connects the same ends. */
60
+ expandedEdges: GranularityEdgeRef[]
61
+ /** Parent edges with no direct counterpart and no connecting path — the refinement lost them. */
62
+ missingEdges: GranularityEdgeRef[]
63
+ /** Child edges between two parent nodes that the parent does not draw — invented dependencies. */
64
+ inventedEdges: GranularityEdgeRef[]
65
+ }
66
+
67
+ export interface GranularityDiagramSummary {
68
+ name: string
69
+ parent?: string
70
+ notation: StructureNotation
71
+ nodes: number
72
+ edges: number
73
+ verdict: Verdict
74
+ findings: number
75
+ }
76
+
77
+ export interface GranularityReport {
78
+ ok: boolean
79
+ ran: boolean
80
+ verdict: Verdict
81
+ verdictReason: string
82
+ schema: ReportSchema
83
+ diagrams: GranularityDiagramSummary[]
84
+ pairs: GranularityPairSummary[]
85
+ findings: Finding[]
86
+ summary: {
87
+ diagrams: number
88
+ roots: number
89
+ pairs: number
90
+ nodes: number
91
+ edges: number
92
+ inventedEdges: number
93
+ missingEdges: number
94
+ expandedEdges: number
95
+ errors: number
96
+ warnings: number
97
+ }
98
+ hashes: { diagrams: Array<{ name: string; hash: string }> }
99
+ warnings: string[]
100
+ nextSteps: string[]
101
+ }
102
+
103
+ /** Reachability in one diagram, ignoring edge direction? No: a dependency path is directed. */
104
+ function reaches(graph: StructureGraph, from: string, to: string): boolean {
105
+ const adjacency = new Map<string, string[]>()
106
+ for (const node of graph.nodes) adjacency.set(node.id, [])
107
+ for (const edge of graph.edges) adjacency.get(edge.from)?.push(edge.to)
108
+ const seen = new Set<string>([from])
109
+ const queue: string[] = [from]
110
+ while (queue.length > 0) {
111
+ const current = queue.shift() as string
112
+ for (const next of adjacency.get(current) ?? []) {
113
+ if (next === to) return true
114
+ if (seen.has(next)) continue
115
+ seen.add(next)
116
+ queue.push(next)
117
+ }
118
+ }
119
+ return false
120
+ }
121
+
122
+ /** The parent relation must be a forest: report the first cycle it contains, if any. */
123
+ function parentCycle(diagrams: GranularityDiagram[]): string[] | undefined {
124
+ const byName = new Map(diagrams.map((entry) => [entry.name, entry]))
125
+ for (const start of diagrams) {
126
+ const path: string[] = [start.name]
127
+ const seen = new Set<string>([start.name])
128
+ let cursor = start.parent
129
+ while (cursor !== undefined) {
130
+ if (seen.has(cursor)) {
131
+ path.push(cursor)
132
+ return path
133
+ }
134
+ seen.add(cursor)
135
+ path.push(cursor)
136
+ cursor = byName.get(cursor)?.parent
137
+ }
138
+ }
139
+ return undefined
140
+ }
141
+
142
+ /**
143
+ * Review a set of structure diagrams with declared parent relations. Per-diagram
144
+ * structural checks run first; the cross-diagram refinement checks follow.
145
+ */
146
+ export function reviewGranularity(options: GranularityOptions): GranularityReport {
147
+ const diagrams = options.diagrams
148
+ const findings: Finding[] = []
149
+ const warnings: string[] = []
150
+ const diagramSummaries: GranularityDiagramSummary[] = []
151
+ const graphs = new Map<string, StructureGraph>()
152
+
153
+ if (diagrams.length === 0) {
154
+ findings.push({ code: 'UML031_NO_DIAGRAMS', severity: 'error', message: 'no diagram was supplied, so there are no granularity levels to compare.' })
155
+ }
156
+
157
+ const cycle = parentCycle(diagrams)
158
+ if (cycle !== undefined) {
159
+ findings.push({
160
+ code: 'UML030_PARENT_CYCLE',
161
+ severity: 'error',
162
+ message: 'the declared parent relation has a cycle: ' + cycle.join(' → ') + '. A refinement hierarchy must be a forest, or "which level owns this dependency" has no answer.',
163
+ evidence: { cycle },
164
+ })
165
+ }
166
+
167
+ const knownNames = new Set(diagrams.map((entry) => entry.name))
168
+ for (const entry of diagrams) {
169
+ if (entry.parent !== undefined && !knownNames.has(entry.parent)) {
170
+ findings.push({
171
+ code: 'UML028_UNKNOWN_PARENT',
172
+ severity: 'error',
173
+ message: 'diagram "' + entry.name + '" declares parent "' + entry.parent + '", which is not part of this set.',
174
+ file: entry.name,
175
+ evidence: { diagram: entry.name, parent: entry.parent, known: [...knownNames].sort() },
176
+ })
177
+ }
178
+ const report = reviewStructure({
179
+ diagram: entry.diagram,
180
+ notation: options.notation ?? 'auto',
181
+ ...(options.matrix === undefined ? {} : { matrix: options.matrix }),
182
+ })
183
+ graphs.set(entry.name, report.graph)
184
+ // Tag every finding with the diagram it came from: with several diagrams in one
185
+ // report, an untagged finding is unactionable.
186
+ for (const finding of report.findings) {
187
+ findings.push({ ...finding, file: entry.name, ...(finding.evidence === undefined ? {} : {}) })
188
+ }
189
+ warnings.push(...report.warnings.map((warning) => entry.name + ': ' + warning))
190
+ diagramSummaries.push({
191
+ name: entry.name,
192
+ ...(entry.parent === undefined ? {} : { parent: entry.parent }),
193
+ notation: report.notation,
194
+ nodes: report.summary.nodes,
195
+ edges: report.summary.edges,
196
+ verdict: report.verdict,
197
+ findings: report.findings.length,
198
+ })
199
+ }
200
+
201
+ const pairs: GranularityPairSummary[] = []
202
+ for (const child of diagrams) {
203
+ if (child.parent === undefined) continue
204
+ const parent = diagrams.find((entry) => entry.name === child.parent)
205
+ const parentGraph = graphs.get(child.parent)
206
+ const childGraph = graphs.get(child.name)
207
+ if (parent === undefined || parentGraph === undefined || childGraph === undefined) continue
208
+ const parentNodes = new Set(parentGraph.nodes.map((node) => node.id))
209
+ const childNodes = new Set(childGraph.nodes.map((node) => node.id))
210
+ const parentEdges = new Set(parentGraph.edges.map((edge) => edge.from + '\u0000' + edge.to))
211
+ const childEdges = new Set(childGraph.edges.map((edge) => edge.from + '\u0000' + edge.to))
212
+
213
+ let inheritedEdges = 0
214
+ let newEdges = 0
215
+ const inventedEdges: GranularityEdgeRef[] = []
216
+ for (const edge of childGraph.edges) {
217
+ const hasFrom = parentNodes.has(edge.from)
218
+ const hasTo = parentNodes.has(edge.to)
219
+ if (!hasFrom || !hasTo) { newEdges += 1; continue }
220
+ if (parentEdges.has(edge.from + '\u0000' + edge.to)) { inheritedEdges += 1; continue }
221
+ inventedEdges.push({ from: edge.from, to: edge.to, line: edge.line })
222
+ }
223
+
224
+ const expandedEdges: GranularityEdgeRef[] = []
225
+ const missingEdges: GranularityEdgeRef[] = []
226
+ for (const edge of parentGraph.edges) {
227
+ const key = edge.from + '\u0000' + edge.to
228
+ if (childEdges.has(key)) continue
229
+ // The child only has to answer for a parent edge whose ends it also carries; a child
230
+ // may legitimately cover a subtree of the parent.
231
+ if (!childNodes.has(edge.from) || !childNodes.has(edge.to)) continue
232
+ if (reaches(childGraph, edge.from, edge.to)) expandedEdges.push({ from: edge.from, to: edge.to, line: edge.line })
233
+ else missingEdges.push({ from: edge.from, to: edge.to, line: edge.line })
234
+ }
235
+
236
+ if (inventedEdges.length > 0) {
237
+ findings.push({
238
+ code: 'UML029_REFINEMENT_VIOLATION',
239
+ severity: 'error',
240
+ message: 'diagram "' + child.name + '" draws ' + String(inventedEdges.length) + ' dependency(ies) between nodes its parent "' + parent.name + '" also has, but the parent does not: ' + inventedEdges.map((edge) => edge.from + ' → ' + edge.to).join(', ') + '. A refinement may add detail below the parent level, never a dependency at the parent level.',
241
+ file: child.name,
242
+ evidence: { kind: 'invented-edge', parent: parent.name, edges: inventedEdges },
243
+ })
244
+ }
245
+ if (missingEdges.length > 0) {
246
+ findings.push({
247
+ code: 'UML029_REFINEMENT_VIOLATION',
248
+ severity: 'error',
249
+ message: 'diagram "' + child.name + '" drops ' + String(missingEdges.length) + ' dependency(ies) its parent "' + parent.name + '" draws, without expanding them into a path: ' + missingEdges.map((edge) => edge.from + ' → ' + edge.to).join(', ') + '.',
250
+ file: child.name,
251
+ evidence: { kind: 'unexpanded-edge', parent: parent.name, edges: missingEdges },
252
+ })
253
+ }
254
+ pairs.push({
255
+ parent: parent.name,
256
+ child: child.name,
257
+ parentNodes: parentNodes.size,
258
+ childNodes: childNodes.size,
259
+ parentEdges: parentGraph.edges.length,
260
+ childEdges: childGraph.edges.length,
261
+ inheritedEdges,
262
+ newEdges,
263
+ expandedEdges,
264
+ missingEdges,
265
+ inventedEdges,
266
+ })
267
+ }
268
+
269
+ const errors = findings.filter((finding) => finding.severity === 'error').length
270
+ const warningCount = findings.filter((finding) => finding.severity === 'warning').length
271
+ const invented = pairs.reduce((sum, pair) => sum + pair.inventedEdges.length, 0)
272
+ const missing = pairs.reduce((sum, pair) => sum + pair.missingEdges.length, 0)
273
+ const expanded = pairs.reduce((sum, pair) => sum + pair.expandedEdges.length, 0)
274
+
275
+ const nextSteps: string[] = []
276
+ if (errors > 0) nextSteps.push('Fix the refinement violations first: a level that invents or drops a parent-level dependency makes the hierarchy unusable as a rule source.')
277
+ if (pairs.length === 0 && diagrams.length > 0) nextSteps.push('Give the levels a `parent` so the refinement can be checked; a set of unrelated diagrams has no consistency property to verify.')
278
+ if (expanded > 0) nextSteps.push(String(expanded) + ' parent edge(s) are drawn as a path in the child — that is a legitimate expansion, and it is listed per pair so a reviewer can confirm each one.')
279
+ nextSteps.push('Reconcile the levels with the source-side scan: the diagram pair is consistent or not, and neither says anything about the code until it is scanned.')
280
+
281
+ return {
282
+ ok: true,
283
+ ran: true,
284
+ ...verdictOfFindings(findings),
285
+ schema: REPORT_SCHEMAS.granularity,
286
+ diagrams: diagramSummaries,
287
+ pairs,
288
+ findings,
289
+ summary: {
290
+ diagrams: diagrams.length,
291
+ roots: diagrams.filter((entry) => entry.parent === undefined).length,
292
+ pairs: pairs.length,
293
+ nodes: diagrams.reduce((sum, entry) => sum + (graphs.get(entry.name)?.nodes.length ?? 0), 0),
294
+ edges: diagrams.reduce((sum, entry) => sum + (graphs.get(entry.name)?.edges.length ?? 0), 0),
295
+ inventedEdges: invented,
296
+ missingEdges: missing,
297
+ expandedEdges: expanded,
298
+ errors,
299
+ warnings: warningCount,
300
+ },
301
+ hashes: { diagrams: diagrams.map((entry) => ({ name: entry.name, hash: createHash('sha256').update(entry.diagram).digest('hex') })) },
302
+ warnings,
303
+ nextSteps,
304
+ }
305
+ }
306
+
307
+ /** Deterministic JSON of a granularity report's pair table, for a stable summary line. */
308
+ export function granularityDigest(pairs: GranularityPairSummary[]): string {
309
+ return canonicalJson(pairs)
310
+ }
311
+
312
+ export type { VerdictSummary }