dsh-logicprobe 1.0.0 → 1.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-logicprobe",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Verifies claims about designs and code. Facts go against the source. Behaviour goes through an executable model: state machines, protocols, composition, data models, migrations. Structure diagrams are read as dependency graphs, at one level or several. Two reports can be compared for regression. Exports UPPAAL, TLA+, PRISM and SPIN input. Ships a native DeepSeek Harness bundle that injects the verification gate into the first model step.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -170,7 +170,7 @@ UML modelling variant (the task is to draw a flow, not to check a claim):
170
170
  | `logicprobe_compose_verify` | Two or more machines checked together (rendezvous handshake semantics): C1 composition deadlock, C2 rendezvous never fires. |
171
171
  | `logicprobe_concurrency_scan` | Mines concurrency claims (thread-safe, lock-free, race condition, mutex, ISR-safe) and routes them to dedicated verification. It does not prove concurrency safety. |
172
172
  | `logicprobe_export` | Emits external-checker input from a verified model: UPPAAL, TLA+, PRISM, SPIN. |
173
- | `logicprobe_uml` | render / parse / review for UML modelling — the diagram domain belongs to the `logicprobe-uml` skill. |
173
+ | `logicprobe_uml` | render / parse / review / explain-labels for UML modelling — the diagram domain belongs to the `logicprobe-uml` skill. |
174
174
 
175
175
  The repository engine mirrors the same checks and carries the `compose` and `export` subcommands for hosts without the native tools.
176
176
 
@@ -189,7 +189,7 @@ Every tool report carries the same outcome contract. **`ok` says a report was pr
189
189
  | `hashes` | Every hash the report carries, in one place (a model hash, a diagram hash, before/after hashes). |
190
190
  | `nextSteps[]` | What to do next, derived from the findings; never empty, and identical for two runs over the same input. |
191
191
  | `metadataKeys[]` | Paths of the `_`-prefixed annotation keys the schema ignored, e.g. `["_source", "_verified", "states[0]._note"]` (present only when the input carried any). |
192
- | `narrativeCoverage` | `{states: "5/5", events: "3/9", scenarios: "0/12"}` when the model carries a narrative — a partial narrative is valid, and this is how much is still undocumented. |
192
+ | `narrativeCoverage` | `{states: "5/5", events: "3/9", scenarios: "0/12"}` when the model carries a narrative — a partial narrative is valid, and this is how much is still undocumented. The gap is also a `NARRATIVE_PARTIAL` (info) finding, so a gate can read it without parsing `nextSteps` text. |
193
193
 
194
194
  Turn the verdict into an answer, never into a summary of `ok`:
195
195
 
@@ -395,7 +395,7 @@ This skill owns behavioural claims. Three neighbouring domains have their own sk
395
395
 
396
396
  | Domain | Skill |
397
397
  |---|---|
398
- | Drawing a code flow as UML, or auditing a diagram somebody drew (render / parse / review, round-trip fidelity, component and class diagrams refused) | `logicprobe-uml` |
398
+ | Drawing a code flow as UML, or auditing a diagram somebody drew (render / parse / review / explain-labels, round-trip fidelity, component and class diagrams refused) | `logicprobe-uml` |
399
399
  | Reviewing architecture, module structure or a dependency diagram (isolated nodes, dangling endpoints, cycles, allowed-dependency and layer violations, missing required edges) | `logicprobe-structure` |
400
400
  | Concurrency claims — "thread-safe", "lock-free", "no data race", "ISR-safe" — mined and routed to TSan/Helgrind/CBMC/TLA+, never proven here | `logicprobe-concurrency` |
401
401
  | Entities, fields, relationships, data invariants, schema migrations (DS1-DS4, DA1-DA12, DD1-DD4) | `logicprobe-datamodel` |
@@ -77,6 +77,13 @@ it, and the report lists it under `metadataKeys`:
77
77
  "metadataKeys": ["_extraction_caveats", "_source", "_verified", "states[0]._note"]
78
78
  ```
79
79
 
80
+ The list echoes *this input*, it is not a memory: a `_` key the current object does not
81
+ carry is absent, even if an earlier version of the file had it. Nested keys are spelled
82
+ the way the walker sees them (`states[0]._note`, `invariants[0]._probe`,
83
+ `transitions[0]._why`), and the field is absent entirely when the input carries no `_`
84
+ key at all. Read it as "what this run ignored", never as "what this model has ever
85
+ recorded".
86
+
80
87
  Why it exists: provenance, verification snapshots and extraction caveats belong *with*
81
88
  the model. Pushed into a sidecar they drift away from it — nobody can say which hash
82
89
  belongs to which note. With this exemption the archive record travels inside the file
@@ -166,7 +173,7 @@ block is valid. What must hold is that whatever is written is *right*:
166
173
  | Case | Result |
167
174
  |---|---|
168
175
  | `narrative` absent | Valid. The review reports `UML013_NO_NARRATIVE` (info); `logicprobe_verify` reports no coverage at all. |
169
- | One or two of `states` / `events` / `scenarios` given | Valid. The report adds `narrativeCoverage: {states: "5/5", events: "3/9", scenarios: "0/12"}` (covered/total per dimension), and `nextSteps` names the gap. A `UML027_NARRATIVE_PARTIAL` (info) finding appears in a UML review. |
176
+ | One or two of `states` / `events` / `scenarios` given | Valid. The report adds `narrativeCoverage: {states: "5/5", events: "3/9", scenarios: "0/12"}` (covered/total per dimension), and `nextSteps` names the gap. Both sides also report it as a finding: `logicprobe_verify` carries `NARRATIVE_PARTIAL` (info) and a UML review carries `UML027_NARRATIVE_PARTIAL` (info). An `info` finding never changes `verdict`, `summary.errors`, `summary.warnings` or the check count. |
170
177
  | `narrative` present but `{}` | **Error**: an empty block claims documentation that does not exist. Give at least one dimension. |
171
178
  | Unknown state/event id, empty description, duplicate `(from, event)` scenario | **Error**: the narrative is wrong, not merely incomplete. |
172
179
 
@@ -220,6 +227,13 @@ A guard is exactly one of:
220
227
 
221
228
  ## Invariants
222
229
 
230
+ Every invariant carries `id` and **`description`** — both required strings — plus a
231
+ `kind` and that kind's own fields. `description` is not decoration: it is echoed into
232
+ the finding a violation produces, and it takes part in `modelHash`, so a model that
233
+ gains one hashes differently. An invariant without it is rejected with `MODEL_INVALID`,
234
+ and the report's `nextSteps` names the field to add; a `_`-prefixed key is not a
235
+ substitute for it.
236
+
223
237
  | Kind | Shape | Checks |
224
238
  |---|---|---|
225
239
  | `never-states` | `{ states: ["ERROR"] }` | No reachable runtime state may be in the forbidden set |
@@ -314,6 +328,13 @@ Two or more machines can be checked together with `runCompositionVerification` (
314
328
  - non-rendezvous events advance exactly one firing machine;
315
329
  - a rendezvous (handshake) event fires only when at least two machines declare it **and** every such non-terminal machine has it jointly enabled (guards held); participants advance simultaneously. A machine whose alphabet does not contain the event does not participate, and a terminal machine is stopped;
316
330
  - therefore a machine that is *waiting* for a rendezvous is not blocked from everything else: it may take any number of its own non-rendezvous steps first. That is how different rates are modelled — there is **no rate ratio** and no fairness bound, so "the 1 ms pump runs N times while the ISR waits" is expressed by the pump's own tick event, not by a tempo setting. A bound would only prune interleavings, never add behaviour; if you need to *forbid* a fast machine from overtaking, encode that in the model (a guard on a counter the slow machine resets), not in the composition options.
331
+ - **there is no cross-machine shared state.** Each machine's `variables` are private to
332
+ it: machine A's `ready` and machine B's `ready` are two different
333
+ quantities, and no guard can read another machine's variable. Rendezvous events are
334
+ the *only* coupling between machines. So "two machines must agree on one predicate"
335
+ cannot be modelled by giving each a copy of it — model the shared condition inside a
336
+ single machine, or make the agreement an explicit handshake. The report names the
337
+ variables it saw when two or more machines each declare some, in `nextSteps`.
317
338
 
318
339
  Inputs: the DSH tool takes an array of model objects (an agent reads the files and passes them); the CLI takes any number of files directly — `compose models/*.json --rendezvous req,ack` — so nothing has to be concatenated by hand.
319
340
 
@@ -321,8 +342,8 @@ Inputs: the DSH tool takes an array of model objects (an agent reads the files a
321
342
 
322
343
  Both findings carry the *why*, not just the *what*:
323
344
 
324
- - **C1_COMPOSITION_DEADLOCK** — the composite state, the number of steps to reach it (**shortest**, because the search is breadth-first), and `evidence.perMachine`: for every machine, its state, whether it is terminal, and every event in its alphabet with a reason — `rendezvous-needs-partner` (fewer than two machines declare it), `rendezvous-partner-not-ready` (a declaring partner is terminal or has no enabled transition), `no-enabled-transition` (guard false or no such transition), or `enabled`. An event this machine can fire on its own is never labelled `enabled` when the handshake itself is missing.
325
- - **C2_RENDEZVOUS_NEVER_FIRES** — `evidence.machines` gives, per machine, whether it declares the event, whether it ever had it enabled, and the states where it did. The reason distinguishes "fewer than two machines declare it" from "machine *i* declares it but never enables it".
345
+ - **C1_COMPOSITION_DEADLOCK** — the composite state, the number of steps to reach it (**shortest**, because the search is breadth-first), and `evidence.perMachine`: for every machine, its state, whether it is terminal, and every event in its alphabet with a reason — `rendezvous-needs-partner` (fewer than two machines declare it), `rendezvous-partner-not-ready` (a declaring partner is terminal or has no enabled transition), `no-enabled-transition` (guard false or no such transition), or `enabled`. An event this machine can fire on its own is never labelled `enabled` when the handshake itself is missing. In practice a census only ever shows the three blocked reasons: it is emitted only when no machine can move, and "can move" is decided by the same predicate that would label an event `enabled`, so that fourth label is unreachable there. Treat the three observable values as the contract, and keep tolerating the literal.
346
+ - **C2_RENDEZVOUS_NEVER_FIRES** — an **error**, the same severity as C1: a handshake that can never fire leaves the composition unable to advance through it, so a gate that reads only `verdict` stops on it. `evidence.machines` gives, per machine, whether it declares the event, whether it ever had it enabled, and the states where it did. The reason distinguishes "fewer than two machines declare it" from "machine *i* declares it but never enables it".
326
347
 
327
348
  `maxStates` caps the product space, and the cap is **reported, never hidden**: a truncated run cannot claim "no deadlock reachable" (the C1 check detail says so), and a C2 finding gained under truncation carries "may be an artefact of the cap" in its message. Bump `maxStates` before believing a negative result on a large product. Note that a machine with an unbounded counter produces an infinite composite space and will always truncate.
328
349
 
@@ -69,6 +69,13 @@ Round-trip reports (`logicprobe_uml action=review`, `roundTrip`) carry `hashSpec
69
69
  alongside `modelHash` and `parsedHash`, so a fidelity comparison is always
70
70
  spec-qualified.
71
71
 
72
+ **Read `diffs`, not `ok`.** `roundTrip.ok` says only that the model was rendered and the
73
+ result read back; it is `true` while `modelHash` and `parsedHash` legitimately differ,
74
+ because the model hash covers what a diagram cannot carry (the `narrative` block, in
75
+ particular) and the parsed hash covers what the diagram actually says. Fidelity is
76
+ `roundTrip.diffs.length === 0`. Equal hashes are neither necessary nor sufficient for
77
+ fidelity, and the two must not be forced to agree.
78
+
72
79
  Every report also aggregates its hashes under `hashes`, so a baseline diff or an archive
73
80
  record reads one field instead of walking the report:
74
81
 
@@ -83,6 +90,24 @@ dependency matrix was supplied. A data-model report carries `hashes.modelHash`;
83
90
  data-model hash uses the same normalization but its schema is not covered by the
84
91
  published specification.
85
92
 
93
+ A `diagram` hash is a **text** hash, not a semantic one: `sha256` over the exact
94
+ characters, so comments and `note` blocks change it, and so does a trailing newline.
95
+ Two renderings that mean the same thing hash differently when their text differs, and
96
+ that is intended — it is what makes "the diagram I reviewed" checkable. For one model
97
+ (`modelHash` `77a43a39822147707229638515cf8f0c6bef4b08635ea6197d2181fc6c26f0b4`) four
98
+ probe variants hash as:
99
+
100
+ | Diagram text | `hashes.diagram` |
101
+ |--------------|------------------|
102
+ | clean | `25ee1d0b803c9477d526d92c1dbb9059e408e282e515499dba06d14630286c8d` |
103
+ | one `%%` comment line | `8c41f046c954e1dc086eee8bd24c3d1d033b489840895520002c1e8120dda052` |
104
+ | one `'` comment line (PlantUML style) | `5552a91a73972edb71ea98f460fa6a393c6556bd1241461f0f29cd51531e9e56` |
105
+ | one multi-line `note … end note` block | `359182313f451713a937f5148328d238e8ea1941fbcef663c4c34e0a7f1bbcda` |
106
+
107
+ Compare diagram hashes only between identical byte strings: the same diagram handed
108
+ over without its trailing newline hashes differently (`8c41f046…` becomes
109
+ `c8f3a41b…`). That is the specification, not drift.
110
+
86
111
  ## Checking an archived hash
87
112
 
88
113
  `--hash-spec` recomputes under a named specification; `--hash-check` answers whether a
@@ -16,6 +16,7 @@ This skill covers the diagram half of the Logic Probe toolbox. The claim-verific
16
16
  | Modelling a code flow as a state, activity or sequence diagram | `logicprobe_uml action=render`: model to diagram source, with a citation per state, event, guard and action |
17
17
  | Reading a hand-drawn diagram back into a model | `action=parse`: Mermaid or PlantUML state/activity text into a LogicModelV1 that `logicprobe_verify` can check |
18
18
  | Auditing a diagram somebody drew | `action=review`: structural defects, documentation gaps, and render-versus-model round-trip fidelity |
19
+ | Printing what the parser accepts for labels and comments | `action=explain-labels`: the accepted label spellings, the form the renderer writes, and which lines are ignored (the CLI mirror is `uml-review --explain-labels`) |
19
20
  | A diagram whose claims must be tied to the code | Every element needs a source citation; the review says what the diagram says, not whether the code is correct |
20
21
 
21
22
  Pipeline (DSH):
@@ -29,8 +30,9 @@ code flow → model (citation per element) → logicprobe_uml action=render →
29
30
  - **render** — model to diagram. Mermaid covers `state`, `activity` and `sequence`. PlantUML covers `state` and `sequence`. Any construct the notation cannot carry becomes a warning, never a silent drop. PlantUML activity is refused instead of approximated.
30
31
  - **parse** — diagram to model. It reads Mermaid and PlantUML state or activity text, so a hand-drawn diagram can be verified like any other model. Two inputs are refused because they cannot become a machine: a sequence diagram (a trace cannot reconstruct a machine) and a different Mermaid family (`classDiagram`, `erDiagram`, `gantt`, `mindmap`, …) — both come back as `errorCode: "UML_NOT_A_STATE_DIAGRAM"` with the discarded construct named.
31
32
  - **review** — it answers one of three questions. Give it a model: is the machine well-modelled? Give it a diagram: what does the diagram say? Give it both: does the diagram match the model?
33
+ - **explain-labels** — the parser's own account of labels and comments: the accepted spellings, the form the renderer writes, and which lines it ignores. No diagram needed. It answers "why does my hand-drawn diagram report drift or ignored lines?" without reading the source.
32
34
 
33
- A host without the tools runs the same three actions through the repository engine. The engine is deliberately single-source — do not copy it into this skill.
35
+ A host without the tools runs the same four actions through the repository engine. The engine is deliberately single-source — do not copy it into this skill.
34
36
 
35
37
  ## A Structure Diagram Is the Dangerous Case, Because It Parses
36
38
 
@@ -227,10 +227,10 @@ Now delete the `ERROR --cooldown--> STARTING` arrow and review again. `ACTIVE` a
227
227
  ## Labels and comments: what the parser expects
228
228
 
229
229
  Two things cause most of the friction with hand-written diagrams. Both are printed by the
230
- tool itself: `logicprobe_uml` does not have a flag for it (it documents the convention in
231
- its description), and the CLI has `uml-review --explain-labels` (add `--notation
232
- plantuml` for that flavour), which prints exactly the table below from the same rules the
233
- parser applies.
230
+ tools themselves: `logicprobe_uml action="explain-labels"` returns the convention (pass
231
+ `notation: "plantuml"` for that flavour), and the CLI mirror is `uml-review
232
+ --explain-labels` (with `--notation plantuml`). Either one prints exactly the table below,
233
+ from the same rules the parser applies.
234
234
 
235
235
  **1. State meanings, in the spellings the parser accepts.** A label carries a *meaning*
236
236
  when it differs from the bare id. Accepted, in this order:
@@ -254,12 +254,13 @@ directives the renderer writes (`%%logicprobe:` in Mermaid, `'logicprobe:` in Pl
254
254
  `uml v1 …`, `init ID`, `terminal ID`, `alias X id`, `variable NAME kind`. They are
255
255
  ordinary comments to every renderer, which is why the diagram stays valid.
256
256
 
257
- Everything else is either notation chrome (skipped silently) or an ignored line:
257
+ Everything else is notation chrome (skipped silently), an ignored line, or a reported
258
+ construct:
258
259
 
259
260
  | Line | Behaviour |
260
261
  |---|---|
261
262
  | a `%%` / `'` comment that is not a directive | `UML_PARSE_IGNORED_LINE` warning — the line carried no statement |
262
- | a multi-line `note … end note` block | skipped silently (prose is not a state meaning) |
263
+ | a multi-line `note … end note` block | its body is skipped (prose is not a state meaning) and the block is reported once in `discardedConstructs` |
263
264
  | `@startuml`/`@enduml`, `stateDiagram-v2`, `direction`, `classDef`/`style`/`linkStyle`/`click`, `scale`, `skinparam`, `title`, `hide`, `autonumber` | skipped silently |
264
265
  | anything else the parser cannot read | `UML_PARSE_IGNORED_LINE` warning, and the model is built from what it did read |
265
266
 
package/src/engine.ts CHANGED
@@ -206,7 +206,11 @@ export interface PathStep {
206
206
 
207
207
  export interface Finding {
208
208
  code: string
209
- severity: 'error' | 'warning'
209
+ /**
210
+ * `info` is a note, not a defect: it is reported so a machine-readable reader can see it,
211
+ * and it never contributes to `verdict`, `summary.errors` or `summary.warnings`.
212
+ */
213
+ severity: 'error' | 'warning' | 'info'
210
214
  message: string
211
215
  /** Longer explanation: the shortest-path note, the per-machine reasons, the quoted source lines. */
212
216
  detail?: string
@@ -286,6 +290,20 @@ function verificationNextSteps(checks: CheckResult[], coverage: NarrativeCoverag
286
290
  return steps
287
291
  }
288
292
 
293
+ /**
294
+ * Repair guidance for a rejected model. A refusal reports the offending path and the rule
295
+ * it broke, but not what to write instead; for fields whose absence is a documented trap,
296
+ * that is the difference between "fix it" and "guess again". Derived only from the
297
+ * validation messages, so the list stays deterministic.
298
+ */
299
+ function refusalGuidance(errors: string[]): string[] {
300
+ const steps: string[] = []
301
+ if (errors.some((message) => message.includes('.description'))) {
302
+ steps.push('Add the required "description" string to every invariant (for example "description": "what this invariant guarantees"); a `_`-prefixed key is not a substitute, and the value takes part in `modelHash`.')
303
+ }
304
+ return steps
305
+ }
306
+
289
307
  export interface ComparisonSummary {
290
308
  beforeModelHash: string
291
309
  afterModelHash: string
@@ -366,6 +384,7 @@ export const REPORT_SCHEMAS = {
366
384
  umlRender: 'logicprobe/uml/render/v1',
367
385
  umlParse: 'logicprobe/uml/parse/v1',
368
386
  umlReview: 'logicprobe/uml/review/v1',
387
+ umlExplainLabels: 'logicprobe/uml/explain-labels/v1',
369
388
  structure: 'logicprobe/structure/v1',
370
389
  granularity: 'logicprobe/granularity/v1',
371
390
  baseline: 'logicprobe/baseline/v1',
@@ -1060,7 +1079,9 @@ function checkResult(id: string, name: string, findings: Finding[], detail: stri
1060
1079
  return {
1061
1080
  id,
1062
1081
  name,
1063
- status: findings.length === 0 ? 'pass' : 'fail',
1082
+ // An info finding is a note, not a defect: it must not flip a check to `fail`, or the
1083
+ // check would be dragged into the "resolve the failing checks" list for a remark.
1084
+ status: errors + warnings === 0 ? 'pass' : 'fail',
1064
1085
  detail: detail + (errors > 0 ? ' (' + errors + ' errors' + (warnings > 0 ? ', ' + warnings + ' warnings' : '') + ')' : warnings > 0 ? ' (' + warnings + ' warnings)' : ''),
1065
1086
  findings,
1066
1087
  }
@@ -2893,7 +2914,7 @@ export function runVerification(input: unknown, options: VerificationOptions = {
2893
2914
  ...metadata,
2894
2915
  summary: { states: 0, transitions: 0, errors: validation.errors.length, warnings: 0, checksRun: 0 },
2895
2916
  checks: [modelCheck],
2896
- nextSteps: verificationNextSteps([modelCheck], undefined),
2917
+ nextSteps: [...refusalGuidance(validation.errors), ...verificationNextSteps([modelCheck], undefined)],
2897
2918
  }
2898
2919
  }
2899
2920
  const model = validation.model
@@ -2973,6 +2994,16 @@ export function runVerification(input: unknown, options: VerificationOptions = {
2973
2994
  const warnings = checks.reduce((sum, check) => sum + check.findings.filter((finding) => finding.severity === 'warning').length, 0)
2974
2995
  const coverageNotes = computeCoverageNotes(model)
2975
2996
  const narrativeCoverage = narrativeCoverageOf(model)
2997
+ // R3: a partial narrative is valid, but the gap must be machine-readable instead of only a
2998
+ // sentence in `nextSteps`. It rides on S5 — the check that already reports undocumented
2999
+ // (state, event) combinations — so the documented check count stays 22.
3000
+ if (narrativeCoverage !== undefined && !narrativeComplete(narrativeCoverage)) {
3001
+ checks.find((check) => check.id === 'S5')?.findings.push({
3002
+ code: 'NARRATIVE_PARTIAL',
3003
+ severity: 'info',
3004
+ message: 'Narrative coverage is partial (states ' + narrativeCoverage.states + ', events ' + narrativeCoverage.events + ', scenarios ' + narrativeCoverage.scenarios + '): the checks still run over the whole model, but the undocumented symbols have to be re-derived by the reader.',
3005
+ })
3006
+ }
2976
3007
  return {
2977
3008
  ok: true,
2978
3009
  ran: true,
@@ -3223,11 +3254,21 @@ export function runCompositionVerification(machinesInput: unknown[], options: Co
3223
3254
  .map((machine) => 'machine ' + String(machine.index) + ' at ' + machine.state + ': ' + (machine.blocked.length === 0
3224
3255
  ? 'empty event alphabet'
3225
3256
  : machine.blocked.map((entry) => entry.event + ' (' + entry.reason + ')').join(', ')))
3257
+ // A non-terminal state with no outgoing transition anywhere in its own model is a
3258
+ // modelling gap, not a design defect: without this note the census reads as
3259
+ // "the architecture deadlocks" when the model was simply left unfinished.
3260
+ const gaps = node.runtimes
3261
+ .map((runtime, index) => ({ index, state: runtime.state }))
3262
+ .filter((entry) => !isTerminal(models[entry.index], entry.state)
3263
+ && !models[entry.index].transitions.some((transition) => transition.from === entry.state))
3264
+ const gapNote = gaps.length === 0
3265
+ ? ''
3266
+ : ' Modelling note: ' + gaps.map((gap) => 'machine ' + String(gap.index) + ' state ' + gap.state).join(', ') + ' has no outgoing transition in its own model, so this deadlock may be a modelling gap rather than a design defect.'
3226
3267
  c1Findings.push({
3227
3268
  code: 'C1_COMPOSITION_DEADLOCK',
3228
3269
  severity: 'error',
3229
3270
  message: 'Composition deadlock: no machine can advance from (' + node.runtimes.map((runtime) => runtime.state).join(', ') + ') while at least one is not terminal.',
3230
- detail: 'Shortest counterexample (breadth-first): ' + String(node.path.length) + ' step(s) to reach it. ' + reasons.join(' | '),
3271
+ detail: 'Shortest counterexample (breadth-first): ' + String(node.path.length) + ' step(s) to reach it. ' + reasons.join(' | ') + gapNote,
3231
3272
  evidence: { steps: node.path, states: node.runtimes.map((runtime) => runtime.state), depth: node.path.length, perMachine, reasons },
3232
3273
  })
3233
3274
  continue
@@ -3269,7 +3310,7 @@ export function runCompositionVerification(machinesInput: unknown[], options: Co
3269
3310
  const caveat = truncated ? ' The search was truncated at ' + String(maxStates) + ' composite states, so this may be an artefact of the cap rather than a property of the model.' : ''
3270
3311
  c2Findings.push({
3271
3312
  code: 'C2_RENDEZVOUS_NEVER_FIRES',
3272
- severity: 'warning',
3313
+ severity: 'error',
3273
3314
  message: 'Rendezvous event ' + event + ' can never fire: ' + reason + '.' + caveat,
3274
3315
  detail: machines.map((machine) => 'machine ' + String(machine.index) + (machine.declares ? ' declares' : ' does not declare')
3275
3316
  + (machine.everEnabled ? ', enabled at ' + (machine.enabledAt.length === 0 ? 'no visited state' : machine.enabledAt.join('/')) : ', never enabled')).join(' | '),
@@ -3277,15 +3318,18 @@ export function runCompositionVerification(machinesInput: unknown[], options: Co
3277
3318
  })
3278
3319
  }
3279
3320
  }
3280
- const errors = c1Findings.length
3281
- const warnings = c2Findings.length
3321
+ // C2 is an error, not a warning: a rendezvous that can never fire means the composition
3322
+ // cannot advance through that handshake — the same "stuck forever" outcome C1 reports,
3323
+ // so a gate that only reads `verdict` must be able to stop on it.
3324
+ const errors = c1Findings.length + c2Findings.length
3325
+ const warnings = 0
3282
3326
  const c1Detail = c1Findings.length > 0
3283
3327
  ? 'Composition deadlocks: ' + String(c1Findings.length)
3284
3328
  : truncated
3285
3329
  ? 'No composition deadlock found, but the search was truncated at ' + String(maxStates) + ' composite states: absence is not proven'
3286
3330
  : 'No composition deadlock reachable'
3287
3331
  const c2Detail = c2Findings.length > 0
3288
- ? 'Rendezvous warnings: ' + String(c2Findings.length)
3332
+ ? 'Rendezvous errors: ' + String(c2Findings.length)
3289
3333
  : truncated
3290
3334
  ? 'All rendezvous events fired, but the search was truncated at ' + String(maxStates) + ' composite states'
3291
3335
  : 'All rendezvous events can fire'
@@ -3294,8 +3338,21 @@ export function runCompositionVerification(machinesInput: unknown[], options: Co
3294
3338
  checkResult('C2', 'Rendezvous Sync', c2Findings, c2Detail),
3295
3339
  ]
3296
3340
  const nextSteps: string[] = []
3297
- if (errors > 0) nextSteps.push('Resolve the error findings first: the composition deadlocks at a reachable state, so at least one machine is missing an exit or a handshake partner.')
3298
- if (warnings > 0) nextSteps.push('Review the warning findings: a rendezvous event that can never fire means the handshake is declared but unreachable.')
3341
+ if (errors > 0) {
3342
+ const why = c1Findings.length > 0
3343
+ ? 'the composition deadlocks at a reachable state, so at least one machine is missing an exit or a handshake partner'
3344
+ : 'a rendezvous event can never fire, so the composition cannot advance through that handshake'
3345
+ nextSteps.push('Resolve the error findings first: ' + why + '.')
3346
+ }
3347
+ // Composition couples machines only through rendezvous: each machine's `variables` are
3348
+ // private to it, so two machines cannot share one predicate. Name them when the input
3349
+ // looks like it is trying to (two or more machines each declare variables).
3350
+ const owners = models
3351
+ .map((entry, index) => ({ index, names: (entry.variables ?? []).map((variable) => variable.name) }))
3352
+ .filter((entry) => entry.names.length > 0)
3353
+ if (owners.length >= 2) {
3354
+ nextSteps.push('Each machine\'s variables are private to it (' + owners.map((entry) => 'machine ' + String(entry.index) + ': ' + entry.names.join(', ')).join('; ') + '): composition couples machines only through rendezvous, so a value two machines must agree on has to be modelled as a handshake, or inside a single machine.')
3355
+ }
3299
3356
  nextSteps.push('Re-run the composition after the change; every machine must be able to advance, or explain why it is terminal.')
3300
3357
  return {
3301
3358
  ok: errors === 0,
package/src/uml-tool.ts CHANGED
@@ -2,7 +2,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
2
2
  import { createHash } from 'node:crypto'
3
3
  import type { JsonValue } from './json-value.js'
4
4
  import { REPORT_SCHEMAS, DEFAULT_HASH_SPEC, modelHash, refusalVerdict, verdictOf, verdictOfFindings, type VerdictSummary } from './engine.js'
5
- import { renderUml, parseUml, parseFindings, reviewUml, UmlError, type UmlDiagram, type UmlFinding, type UmlNotation, type UmlParseResult } from './uml.js'
5
+ import { renderUml, parseUml, parseFindings, reviewUml, explainLabels, UmlError, type UmlDiagram, type UmlFinding, type UmlNotation, type UmlParseResult } from './uml.js'
6
6
 
7
7
  export const LOGICPROBE_UML_TOOL_NAME = 'logicprobe_uml'
8
8
 
@@ -21,13 +21,13 @@ export const LOGICPROBE_UML_TOOL_NAME = 'logicprobe_uml'
21
21
  export const logicProbeUmlTool = defineTool({
22
22
  name: LOGICPROBE_UML_TOOL_NAME,
23
23
  description:
24
- 'Model a code flow as UML, and audit the modelling. action="render" turns a LogicModelV1 into diagram source: mermaid (state, activity, sequence) or plantuml (state, sequence). action="parse" reads Mermaid or PlantUML state or activity text back into a LogicModelV1, so a hand-drawn diagram can be verified. A sequence diagram is refused: a trace cannot reconstruct a machine. So is any other diagram family (class, ER, gantt, mindmap). That refusal carries errorCode "UML_NOT_A_STATE_DIAGRAM". A PlantUML component, package or deployment diagram declares no kind, so the parser detects those constructs itself. It returns a by-product model and reports the error UML_NOT_A_STATE_DIAGRAM, with `discardedConstructs` and `discardedEdges`. Never treat that model as a model of the file. action="review" audits the modelling: structural defects (UML002-UML009), documentation gaps (UML010-UML015), and a fidelity check that re-parses the rendered diagram (UML017). Give it a model, a diagram, or both. Rendering never invents structure. A construct the notation cannot express becomes a warning. For dependency questions on a component or class diagram, use `logicprobe_structure_verify` instead. Read `verdict`, not `ok`: error findings mean a failed review.',
24
+ 'Model a code flow as UML, and audit the modelling. action="render" turns a LogicModelV1 into diagram source: mermaid (state, activity, sequence) or plantuml (state, sequence). action="parse" reads Mermaid or PlantUML state or activity text back into a LogicModelV1, so a hand-drawn diagram can be verified. A sequence diagram is refused: a trace cannot reconstruct a machine. So is any other diagram family (class, ER, gantt, mindmap). That refusal carries errorCode "UML_NOT_A_STATE_DIAGRAM". A PlantUML component, package or deployment diagram declares no kind, so the parser detects those constructs itself. It returns a by-product model and reports the error UML_NOT_A_STATE_DIAGRAM, with `discardedConstructs` and `discardedEdges`. Never treat that model as a model of the file. action="review" audits the modelling: structural defects (UML002-UML009), documentation gaps (UML010-UML015), and a fidelity check that re-parses the rendered diagram (UML017). Give it a model, a diagram, or both. action="explain-labels" prints the label and comment conventions the parser accepts, without needing a diagram. Rendering never invents structure. A construct the notation cannot express becomes a warning. For dependency questions on a component or class diagram, use `logicprobe_structure_verify` instead. Read `verdict`, not `ok`: error findings mean a failed review.',
25
25
  parameters: {
26
26
  action: {
27
27
  type: 'string',
28
28
  required: true,
29
- enum: ['render', 'parse', 'review'],
30
- description: 'render = model → UML source; parse = UML source → model; review = audit the modelling (and its fidelity to the model).',
29
+ enum: ['render', 'parse', 'review', 'explain-labels'],
30
+ description: 'render = model → UML source; parse = UML source → model; review = audit the modelling (and its fidelity to the model); explain-labels = print the label and comment conventions this parser accepts.',
31
31
  },
32
32
  model: {
33
33
  type: 'json',
@@ -107,6 +107,19 @@ export const logicProbeUmlTool = defineTool({
107
107
  warnings: result.warnings,
108
108
  } as unknown as JsonValue
109
109
  }
110
+ // R9: the label contract was documented in the library and in `explainLabels()`, but no
111
+ // tool exposed it, so it was reachable only by reading the source or the docs.
112
+ if (args.action === 'explain-labels') {
113
+ const notation = (args.notation === undefined || args.notation === 'auto' ? 'mermaid' : args.notation) as UmlNotation
114
+ return {
115
+ ok: true,
116
+ ran: true,
117
+ ...verdictOf(0, 0),
118
+ schema: REPORT_SCHEMAS.umlExplainLabels,
119
+ action: 'explain-labels',
120
+ ...explainLabels(notation),
121
+ } as unknown as JsonValue
122
+ }
110
123
  const report = reviewUml({
111
124
  model: args.model,
112
125
  diagram: args.diagram,
package/src/uml.ts CHANGED
@@ -870,19 +870,23 @@ function declaredIdentifier(text: string): string | undefined {
870
870
  * file, which is a false guarantee of exactly the kind this plugin exists to prevent.
871
871
  */
872
872
  export function parseFindings(parsed: UmlParseResult): UmlFinding[] {
873
- if (parsed.discardedConstructs.length === 0) return []
873
+ // A note block is reported in `discardedConstructs` for transparency, but it does not make
874
+ // the text "not a state diagram": the rest of the file still is one, so only the constructs
875
+ // that actually replace a state declaration count towards the refusal.
876
+ const unsupported = parsed.discardedConstructs.filter((entry) => entry.construct !== 'note')
877
+ if (unsupported.length === 0) return []
874
878
  const counts = new Map<string, number>()
875
- for (const entry of parsed.discardedConstructs) counts.set(entry.construct, (counts.get(entry.construct) ?? 0) + 1)
879
+ for (const entry of unsupported) counts.set(entry.construct, (counts.get(entry.construct) ?? 0) + 1)
876
880
  const summary = [...counts.entries()]
877
881
  .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
878
882
  .map(([construct, count]) => construct + ' ×' + String(count))
879
883
  .join(', ')
880
- const shown = parsed.discardedConstructs.slice(0, 12).map((entry) => 'line ' + String(entry.line) + ': ' + entry.text).join(' | ')
884
+ const shown = unsupported.slice(0, 12).map((entry) => 'line ' + String(entry.line) + ': ' + entry.text).join(' | ')
881
885
  return [{
882
886
  code: 'UML_NOT_A_STATE_DIAGRAM',
883
887
  severity: 'error',
884
- message: 'the text is not a state or activity diagram: ' + String(parsed.discardedConstructs.length) + ' declaration(s) of unsupported construct(s) (' + summary + ') and ' + String(parsed.discardedEdges) + ' arrow(s) between them were read as states and transitions, so the parsed model is not this diagram.',
885
- detail: shown + (parsed.discardedConstructs.length > 12 ? ' | … ' + String(parsed.discardedConstructs.length - 12) + ' more' : ''),
888
+ message: 'the text is not a state or activity diagram: ' + String(unsupported.length) + ' declaration(s) of unsupported construct(s) (' + summary + ') and ' + String(parsed.discardedEdges) + ' arrow(s) between them were read as states and transitions, so the parsed model is not this diagram.',
889
+ detail: shown + (unsupported.length > 12 ? ' | … ' + String(unsupported.length - 12) + ' more' : ''),
886
890
  }]
887
891
  }
888
892
 
@@ -916,7 +920,16 @@ function parseStateDiagram(text: string, notation: UmlNotation): UmlParseResult
916
920
  // A multi-line note body is prose, not a statement: skip it silently rather than
917
921
  // reporting one ignored line per line of text.
918
922
  if (inNote) return
919
- if (noteStart && !/:\s*.+$/.test(line)) { inNote = true; return }
923
+ if (noteStart && !/:\s*.+$/.test(line)) {
924
+ // R7: a multi-line note block is prose the notation carries and LogicModelV1 does not.
925
+ // Its body is still skipped line by line, but the block itself is NOT silent: it is
926
+ // reported once, like any other unsupported construct, so "the diagram hash changed and
927
+ // the report said nothing" cannot happen. It stays out of `warnings` (no per-line noise)
928
+ // and `parseFindings` keeps it out of the "not a state diagram" verdict.
929
+ raw.discarded.push({ construct: 'note', line: index + 1, text: line })
930
+ inNote = true
931
+ return
932
+ }
920
933
  const note = /^note\s+(?:over|right of|left of)\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.+)$/i.exec(line)
921
934
  if (note !== null) {
922
935
  declare(note[1])
@@ -1084,7 +1097,7 @@ export function explainLabels(notation: UmlNotation = 'mermaid'): LabelExplanati
1084
1097
  renderedForm: 'state "ID(meaning)" as ID',
1085
1098
  ignoredLines: [
1086
1099
  { pattern: 'a `' + prefix + '` comment line that is not a `logicprobe:` directive', behaviour: 'UML_PARSE_IGNORED_LINE warning: it carries no state-machine statement' },
1087
- { pattern: 'a `note …` block written over several lines', behaviour: 'skipped silently (a block is not a state meaning)' },
1100
+ { pattern: 'a `note …` block written over several lines', behaviour: 'its body is skipped line by line (prose, not a statement) and the block itself is reported once in discardedConstructs' },
1088
1101
  { pattern: 'notation chrome: @startuml/@enduml, stateDiagram-v2, direction, classDef/style/linkStyle/click, scale, skinparam, title, hide, autonumber', behaviour: 'skipped silently' },
1089
1102
  { pattern: 'anything else the parser cannot read', behaviour: 'UML_PARSE_IGNORED_LINE warning, and the model is built from what it did read' },
1090
1103
  ],