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/README.en-US.md +80 -83
- package/README.md +80 -83
- package/lib/engine.js +3151 -3101
- package/lib/types/engine.d.ts +419 -414
- package/lib/uml-tool.js +146 -133
- package/lib/uml.js +1748 -1738
- package/package.json +1 -1
- package/skills/logicprobe/SKILL.md +3 -3
- package/skills/logicprobe/references/dsh-model-schema.md +24 -3
- package/skills/logicprobe/references/hash-spec.md +25 -0
- package/skills/logicprobe-uml/SKILL.md +3 -1
- package/skills/logicprobe-uml/references/uml-modeling-guide.md +7 -6
- package/src/engine.ts +67 -10
- package/src/uml-tool.ts +17 -4
- package/src/uml.ts +20 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-logicprobe",
|
|
3
|
-
"version": "1.
|
|
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.
|
|
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
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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: '
|
|
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
|
-
|
|
3281
|
-
|
|
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
|
|
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)
|
|
3298
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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(
|
|
885
|
-
detail: shown + (
|
|
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)) {
|
|
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
|
|
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
|
],
|