dsh-logicprobe 0.8.1 → 1.0.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 (65) hide show
  1. package/README.en-US.md +79 -14
  2. package/README.md +79 -14
  3. package/lib/baseline.js +142 -0
  4. package/lib/client.js +56 -1
  5. package/lib/compose-tool.js +1 -1
  6. package/lib/concurrency-tool.js +1 -1
  7. package/lib/concurrency.js +14 -0
  8. package/lib/data-engine.js +42 -8
  9. package/lib/engine.js +609 -118
  10. package/lib/export-tool.js +27 -2
  11. package/lib/granularity.js +237 -0
  12. package/lib/index.js +63 -32
  13. package/lib/report-diff-tool.js +39 -0
  14. package/lib/structure-tool.js +64 -0
  15. package/lib/structure.js +648 -0
  16. package/lib/tool.js +3 -3
  17. package/lib/types/baseline.d.ts +69 -0
  18. package/lib/types/concurrency.d.ts +10 -0
  19. package/lib/types/data-engine.d.ts +13 -1
  20. package/lib/types/engine.d.ts +139 -2
  21. package/lib/types/granularity.d.ts +106 -0
  22. package/lib/types/index.d.ts +9 -8
  23. package/lib/types/report-diff-tool.d.ts +8 -0
  24. package/lib/types/structure-tool.d.ts +10 -0
  25. package/lib/types/structure.d.ts +156 -0
  26. package/lib/types/tool.d.ts +2 -2
  27. package/lib/types/uml.d.ts +89 -0
  28. package/lib/uml-tool.js +36 -7
  29. package/lib/uml.js +217 -23
  30. package/locale/en.json +6 -0
  31. package/locale/zh.json +6 -0
  32. package/package.json +16 -14
  33. package/skills/logicprobe/SKILL.md +97 -58
  34. package/skills/logicprobe/references/dsh-model-schema.md +87 -15
  35. package/skills/logicprobe/references/gap-routing-guide.md +37 -36
  36. package/skills/logicprobe/references/hash-spec.md +128 -0
  37. package/skills/logicprobe/references/logic-verification-guide.md +29 -248
  38. package/skills/logicprobe-concurrency/SKILL.md +43 -0
  39. package/skills/{logicprobe → logicprobe-concurrency}/references/concurrency-risk-guide.md +1 -1
  40. package/skills/logicprobe-datamodel/SKILL.md +5 -4
  41. package/skills/logicprobe-datamodel/references/data-model-guide.md +1 -1
  42. package/skills/logicprobe-datamodel/references/data-model-schema.md +1 -1
  43. package/skills/logicprobe-structure/SKILL.md +95 -0
  44. package/skills/logicprobe-structure/references/structure-review-guide.md +177 -0
  45. package/skills/logicprobe-uml/SKILL.md +55 -0
  46. package/skills/{logicprobe → logicprobe-uml}/references/uml-modeling-guide.md +120 -8
  47. package/src/baseline.ts +180 -0
  48. package/src/client.js +56 -1
  49. package/src/compose-tool.ts +47 -47
  50. package/src/concurrency-tool.ts +2 -2
  51. package/src/concurrency.ts +23 -0
  52. package/src/data-engine.ts +51 -9
  53. package/src/engine.ts +656 -101
  54. package/src/export-tool.ts +73 -48
  55. package/src/granularity.ts +312 -0
  56. package/src/index.ts +63 -32
  57. package/src/report-diff-tool.ts +43 -0
  58. package/src/structure-tool.ts +68 -0
  59. package/src/structure.ts +724 -0
  60. package/src/tool.ts +4 -4
  61. package/src/uml-tool.ts +38 -8
  62. package/src/uml.ts +275 -24
  63. package/skills/logicprobe/references/logicprobe-engine.py +0 -4780
  64. package/skills/logicprobe/references/verification-harness.py +0 -959
  65. package/skills/logicprobe-datamodel/references/data-model-harness.py +0 -528
@@ -0,0 +1,177 @@
1
+ # Structure Review Guide
2
+
3
+ How to audit a component, package, class or deployment diagram as a dependency graph: the matrix schema, every finding code, a worked example, and the method for reconciling the diagram with a source-side check.
4
+
5
+ The tool is `logicprobe_structure_verify`. A host without it runs the repository engine on the same diagram text; the parity test compares the two field for field.
6
+
7
+ ## Inputs
8
+
9
+ ```text
10
+ logicprobe_structure_verify { diagram: "<PlantUML or Mermaid class text>", notation?: auto|plantuml|mermaid, matrix?: {...} }
11
+ ```
12
+
13
+ **Accepted diagram families**: PlantUML `component`, `package`, `class`, `interface`, `enum`, `object`, `actor`, `usecase`, `database`, `node`, `artifact`, `deployment`, `rectangle`, `folder`, `frame`, `cloud`, `queue`, `stack`, `storage`, `collections`, `agent`, `boundary`, `control`, `entity`, `namespace`, `module`; Mermaid `classDiagram` (`class X`, `X --> Y : label`, `X ..|> Y`).
14
+
15
+ **Edge forms** (PlantUML): `-->`, `->`, `..>`, `--`, `==>`, `<--`, `<..`. Mermaid class: `-->`, `..>`, `--|>`, `..|>`, `--`, `..`.
16
+
17
+ **What is read**: declarations (with `as` aliases and quoted labels), container nesting (`package "app" { … }`), arrows with labels, and note blocks (skipped — a note is not a dependency). Anything else is reported as `STRUCTURE_IGNORED_LINE` in `warnings`, never silently dropped.
18
+
19
+ **Node identity**: the alias/id when one exists (`component [Order Service] as SVC` → `SVC`), otherwise the declared name. An endpoint that only ever appears in an arrow still becomes a node, marked `declared: false` — that is what `UML021` reports.
20
+
21
+ ## Matrix schema
22
+
23
+ ```jsonc
24
+ {
25
+ "rules": [
26
+ {
27
+ "id": "R1", // required, unique; this is what the report cites
28
+ "source": "app.*", // node id or glob; absent = the rule judges no source
29
+ "allow": ["hal.*"], // permitted targets (glob or id)
30
+ "deny": ["hal.secret"], // forbidden targets; deny wins over allow, globally
31
+ "require": false // true = an edge matching source→allow must exist (UML025)
32
+ }
33
+ ],
34
+ "layers": [
35
+ { "name": "app", "members": ["SVC", "PAY"] }, // top first
36
+ { "name": "hal", "members": ["DRV", "DB"] }
37
+ ],
38
+ "default": "allow" // or "deny": anything unlisted is a violation
39
+ }
40
+ ```
41
+
42
+ Rules:
43
+
44
+ - Glob: `*` matches any run of characters (including `.` and `:`), `?` exactly one. `app.*` matches `app.motion`, not `hal.app`.
45
+ - **Deny is global.** Evaluation collects every rule whose `source` matches the edge, then: any deny match ⇒ violation; else any allow match ⇒ permitted; else the matrix `default` decides (`default` basis vs `unlisted` basis). The verdict cannot depend on rule order.
46
+ - `default: "allow"` is the permissive default: rules are exceptions. `default: "deny"` is the closed-world form and is what most architecture tables actually mean.
47
+ - Layers are independent of rules and are evaluated on every edge whose endpoints both belong to a layer: index order is top→bottom, so `app → hal` is allowed and `hal → app` is `UML024`.
48
+ - An invalid matrix is a hard error (`MATRIX_INVALID`) and no rules apply — a typo must never silently weaken the check. Unknown keys, duplicate rule ids, non-string patterns and `require` without `allow` are all rejected.
49
+
50
+ ## Finding codes
51
+
52
+ | Code | Severity | Evidence | How to read it |
53
+ |---|---|---|---|
54
+ | `UML020_ISOLATED_NODE` | warning | `nodes[{id,line}]` | Declared, non-container, degree 0. Either a dead entry or a forgotten dependency. |
55
+ | `UML021_DANGLING_REFERENCE` | error | `nodes[{id,line}]` | An arrow endpoint no declaration introduced. The dependency targets something the diagram never defines. |
56
+ | `UML022_CYCLE` | error | `cycle[]`, `path[]` | Shortest directed cycle, start repeated (`A → B → C → A`). A self-loop is a cycle of two elements (`A → A`). |
57
+ | `UML023_DISALLOWED_EDGE` | error | `edge{from,to,line}`, `rule?`, `matchedRules[]`, `basis` | `basis: deny` means a rule forbade it; `basis: unlisted` means no rule allowed it under `default: deny`. |
58
+ | `UML024_LAYER_VIOLATION` | error | `violations[{from,to,fromLayer,toLayer,line}]` | An upward edge between declared layers. |
59
+ | `UML025_MISSING_EXPECTED_EDGE` | warning | `missing[{rule,source,expected,unsatisfiedFrom,noSourceMatch}]` | A `require: true` rule the diagram does not satisfy. `noSourceMatch` distinguishes "wrong target" from "no such source node". |
60
+ | `UML026_NO_NODES` | error | `notation` | Nothing to review; the text is not a structure diagram. |
61
+ | `MATRIX_INVALID` | error | — | The matrix was rejected; its messages name the exact field. |
62
+
63
+ Every report also carries `summary` (nodes, edges, containers, isolated, dangling, cycles, disallowedEdges, layerViolations, missingExpectedEdges, errors, warnings, rules), `edgeVerdicts` when a matrix was supplied, and `verdict` / `verdictReason`. A structure diagram with an error finding is `verdict: "fail"` even though `ok: true` — `ok` only means the tool ran.
64
+
65
+ ## Worked example
66
+
67
+ The diagram (a real shape: two packages, an orphan, a back edge, an undeclared endpoint):
68
+
69
+ ```plantuml
70
+ @startuml
71
+ package "app" {
72
+ component [Order Service] as SVC
73
+ component [Payment Adapter] as PAY
74
+ }
75
+ package "hal" {
76
+ component [Storage Driver] as DRV
77
+ }
78
+ component [Orphan Cache] as CACHE
79
+ SVC --> PAY : plan
80
+ PAY --> DRV : read
81
+ DRV --> SVC : fault
82
+ SVC --> GHOST : unknown
83
+ @enduml
84
+ ```
85
+
86
+ Without a matrix the structural checks already speak:
87
+
88
+ ```text
89
+ UML021_DANGLING_REFERENCE (error) GHOST is never declared
90
+ UML020_ISOLATED_NODE (warning) CACHE has no edge
91
+ UML022_CYCLE (error) SVC → PAY → DRV → SVC
92
+ verdict: fail
93
+ ```
94
+
95
+ With the matrix from the skill body (`default: "deny"`, layers `app` over `hal`):
96
+
97
+ ```text
98
+ UML023_DISALLOWED_EDGE (error) DRV → SVC basis=deny (R3-no-back-edges)
99
+ UML023_DISALLOWED_EDGE (error) SVC → GHOST basis=unlisted (no rule allows it)
100
+ UML024_LAYER_VIOLATION (error) DRV (hal) → SVC (app)
101
+ verdict: fail, disallowedEdges: 2, layerViolations: 1
102
+ ```
103
+
104
+ Both `UML022` and `UML023` fire on the same back edge: the cycle is a graph property, the violation is a matrix property. Report both — they are different findings with different fixes (a cycle needs restructuring; a disallowed edge needs either the edge removed or the matrix amended deliberately).
105
+
106
+ ## Reconciliation with a source-side check
107
+
108
+ The diagram and an include/dependency scan over the source are two representations of one fact, and they drift. Keep the rule ids identical on both sides, then classify each difference:
109
+
110
+ | Difference | Reading | Action |
111
+ |---|---|---|
112
+ | Code has the include, diagram has no edge | The diagram is stale | Redraw; `require: true` makes this a `UML025` warning every run |
113
+ | Diagram has the edge, code has no include | Either the diagram is aspirational, or the scan scope is narrower (generated files, `#ifdef`, build-time includes) | State which, with both citations |
114
+ | Code violates a rule the diagram never showed | The architecture defect the diagram hid | Fix the code, or amend the rule deliberately and record why |
115
+ | Both agree an edge is forbidden | A real violation, agreed by two methods | Act on it |
116
+
117
+ Never present the diagram's clean verdict as evidence about the code: it is evidence about the drawing. The reconciliation is the part that touches the source.
118
+
119
+ ## Multi-granularity: one architecture at several levels
120
+
121
+ A module-level diagram and the repository-level diagram it belongs to are two views of one
122
+ fact, and nothing keeps them in step. Declare the relation and both are checked:
123
+
124
+ ```text
125
+ logicprobe_structure_verify { diagrams: [ { name, diagram, parent? }, … ] }
126
+ logicprobe_structure_verify { diagrams: [ { name: "L1", diagram: "…", parent: "L0" }, … ] }
127
+ ```
128
+
129
+ Per-diagram structural checks run first (UML020-UML026), and every finding is tagged with
130
+ the `file` it came from, so a report over several levels never carries an anonymous
131
+ finding. Then each declared pair is checked:
132
+
133
+ | Code | Severity | What it means |
134
+ |---|---|---|
135
+ | `UML028_UNKNOWN_PARENT` | error | The `parent` names a diagram that is not in the set. |
136
+ | `UML029_REFINEMENT_VIOLATION` | error | **invented-edge**: the child draws a dependency between two nodes the parent also has, but the parent does not. **unexpanded-edge**: the parent draws an edge between two nodes the child also has, the child does not draw it, and no child path connects the same ends. |
137
+ | `UML030_PARENT_CYCLE` | error | The parent relation contains a cycle, so "which level owns this dependency" has no answer. |
138
+ | `UML031_NO_DIAGRAMS` | error | Nothing was supplied to compare. |
139
+
140
+ The rule is *refinement*, not equality: a child may add nodes and edges **below** the
141
+ parent level (an edge with an endpoint the parent does not have is the child's own
142
+ detail), and it may cover a **subtree** of the parent — a parent edge whose ends are not
143
+ both present in the child is out of scope for that level, not dropped. What it may never
144
+ do is invent a dependency at the parent level, or silently drop one it should have
145
+ expanded.
146
+
147
+ Each pair is summarised so the counts can be read without diffing two diagrams by eye:
148
+
149
+ ```json
150
+ { "parent": "L0", "child": "L1",
151
+ "parentNodes": 3, "childNodes": 4, "parentEdges": 2, "childEdges": 4,
152
+ "inheritedEdges": 1, "newEdges": 2,
153
+ "expandedEdges": [{ "from": "SVC", "to": "PAY", "line": 5 }],
154
+ "missingEdges": [], "inventedEdges": [] }
155
+ ```
156
+
157
+ A parent edge the child replaces with a path is a **legitimate expansion**, and it is
158
+ listed in `expandedEdges` rather than silently accepted: a reviewer confirms each one,
159
+ because that is where a refactoring quietly changes who calls whom. `hashes.diagrams`
160
+ records a sha256 per level, so a baseline diff can tell which level moved.
161
+
162
+ The same discipline as the single-diagram review applies: this checks the *diagrams*
163
+ against each other, not against the code. A level that is consistent with its parent but
164
+ not with the code is a drawing bug; a level that disagrees with its parent is a hierarchy
165
+ bug. Both need the source-side scan to settle.
166
+
167
+ ## Extracting the matrix from an existing rule table
168
+
169
+ A machine-checked rule table (an include/dependency checker, a lint config, a review checklist) can seed the matrix: one rule entry per row, `id` = the row's own number so citations line up, `source`/`allow`/`deny` = the row's patterns, `layers` = the layering the table describes. Review the result before trusting it — a translation that silently widens `deny` into `allow` is worse than no check. Keep both files under version control together, and treat a matrix change as a code change.
170
+
171
+ ## Limits
172
+
173
+ - The graph is what the diagram draws. Function pointers, DI, registries, plugins and dynamic dispatch are invisible.
174
+ - No transitive closure unless you write the rule for it.
175
+ - Containers (`package`, `rectangle`, `frame`) are exempt from the isolated-node check by design.
176
+ - `STRUCTURE_IGNORED_LINE` warnings mean the parser met a statement it does not model; treat a non-empty list as a signal that the diagram family may not be the one you think it is.
177
+ - Behaviour is out of scope: state machines, protocols, timing, budget, probability and deadlines belong to `logicprobe` (verification) and `logicprobe-uml` (the diagram round trip).
@@ -0,0 +1,55 @@
1
+ ---
2
+ name: logicprobe-uml
3
+ description: "Use when drawing a code flow as UML or auditing a diagram someone drew: parse a hand-drawn state or activity diagram, render a model, and check round-trip fidelity (dead ends, unreachable states, label drift). Component, class and ER diagrams are refused, not modelled. Doctrine and behavioural verification: the logicprobe skill."
4
+ ---
5
+
6
+ # Logic Probe — UML Modelling
7
+
8
+ A diagram is a model, and a model can be wrong. A tidy flow chart is not evidence about the code, and a diagram that disagrees with its own model is worse than no diagram, because a reader will believe it.
9
+
10
+ This skill covers the diagram half of the Logic Probe toolbox. The claim-verification doctrine it inherits — enumerate verifiable claims, cite `file:line`, read `verdict` and never `ok` — lives in the `logicprobe` skill; load that one when the task is to verify behaviour rather than to draw or audit a diagram.
11
+
12
+ ## When This Skill Applies
13
+
14
+ | The task involves… | What it does |
15
+ |---|---|
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
+ | 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
+ | Auditing a diagram somebody drew | `action=review`: structural defects, documentation gaps, and render-versus-model round-trip fidelity |
19
+ | 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
+ Pipeline (DSH):
22
+
23
+ ```text
24
+ code flow → model (citation per element) → logicprobe_uml action=render → diagram source
25
+ → logicprobe_uml action=review → modelling findings + round-trip fidelity
26
+ → logicprobe_verify → behaviour (S1-S8 structural, A1-A14 adversarial)
27
+ ```
28
+
29
+ - **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
+ - **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
+ - **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?
32
+
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.
34
+
35
+ ## A Structure Diagram Is the Dangerous Case, Because It Parses
36
+
37
+ PlantUML component, package, class and deployment diagrams declare no diagram kind, so the parser meets their keywords line by line. It still returns *something* — the arrows look like transitions — so `parse` reports the by-product model **plus** an error finding `UML_NOT_A_STATE_DIAGRAM`, the declarations it could not represent in `discardedConstructs` (with line and text), the arrows that were misread in `discardedEdges`, and `verdict: "fail"`.
38
+
39
+ Read that as: **there is no model of this file; the diagram was not checked.** Do not feed that model to `logicprobe_verify` and call the result a review of the architecture, and do not present a component diagram as evidence about dependencies. A structure diagram has its own reviewer: the `logicprobe-structure` skill and the `logicprobe_structure_verify` tool parse the same text into a real dependency graph and check isolated nodes, dangling endpoints, cycles, allowed-dependency and layer violations (`UML020`-`UML026`). Use that one when the question is *where the dependencies point*.
40
+
41
+ Mermaid families with an explicit header (`classDiagram`, `erDiagram`, `gantt`, …) are refused outright, because an empty model would be worse than no model.
42
+
43
+ ## What the Review Reports
44
+
45
+ Structural defects: unreachable states, dead ends, ambiguous or non-exhaustive branches, self-loops with no exit, duplicate transitions. Documentation gaps: a missing narrative, unbounded variables, states the reader cannot map back to code, and label drift between diagram and narrative. It also runs the fidelity check — any structural difference between the diagram and its model is `UML017_ROUND_TRIP_MISMATCH`.
46
+
47
+ ## Rules for This Mode
48
+
49
+ 1. **A diagram is not evidence.** Every state, event, guard and action needs a citation. Use `file:line` for code and a section reference for a document. Present the citations with the diagram.
50
+ 2. **Review before you present.** Run `logicprobe_uml action=review` and fix the error findings first. An ambiguous or dead-ended diagram misleads every later reader.
51
+ 3. **Check the verdict before the picture.** `ok: true` with `verdict: "fail"` means the review failed; a diagram whose review failed is not something to show as if it were checked.
52
+ 4. **The review never replaces verification.** It covers the modelling. S1-S8 and A1-A14 cover the behaviour, and each finding names the check that settles it — hand those over to the `logicprobe` skill.
53
+ 5. **Keep the narrative with the model.** Write `narrative.states`, `narrative.events` and `narrative.scenarios`. Then the diagram stays readable against the code, and label drift shows up as a finding instead of as a stale picture.
54
+
55
+ The full checklist (codes `UML001` to `UML019` plus `UML_NOT_A_STATE_DIAGRAM`), the directive format generated diagrams carry, a worked example, and the limits of each view are in [`references/uml-modeling-guide.md`](references/uml-modeling-guide.md).
@@ -28,7 +28,7 @@ One machine, three projections. They are not interchangeable. The review reports
28
28
 
29
29
  ## Modelling a code flow from source
30
30
 
31
- Extraction follows the same discipline as `logic-verification-guide.md`, applied to code instead of a plan.
31
+ Extraction follows the same discipline as `logic-verification-guide.md`, applied to code instead of a plan. That guide ships with the entry-point skill: `../logicprobe/references/logic-verification-guide.md`.
32
32
 
33
33
  1. **Fix the boundary.** Decide which function, task, or module is the machine. In scope: state the code holds across calls, such as statics, fields, enums, and task state variables. Out of scope: the call stack inside one invocation, hardware behaviour, and scheduler preemption.
34
34
  2. **Name the states from the code.** A state exists if something survives a call boundary and is tested later. An enum, a `state` field, or a task-local variable that gates the next entry all qualify. A local variable inside one function does not.
@@ -55,11 +55,7 @@ In DSH:
55
55
 
56
56
  `logicprobe_uml` with `action=render` returns the diagram source plus `warnings`. Read those warnings. They list every construct the notation could not carry verbatim: a state id that needed an alias, a label containing `[` or `/`, a trace that was capped.
57
57
 
58
- Without the DSH tool, run the standalone engine:
59
-
60
- ```bash
61
- python skills/logicprobe/references/logicprobe-engine.py uml-render model.json --notation mermaid --diagram state
62
- ```
58
+ Without the DSH tool, run the repository engine on the same model JSON.
63
59
 
64
60
  You can also write the diagram by hand. The generator is a convenience, not a requirement. A hand-written diagram is parsed and reviewed exactly like a generated one.
65
61
 
@@ -99,6 +95,7 @@ Four input shapes, four different questions:
99
95
  | Code | Severity | What it means |
100
96
  |---|---|---|
101
97
  | `UML001_DIAGRAM_UNREADABLE` | error | The text is not readable as a Mermaid or PlantUML state or activity diagram. |
98
+ | `UML_NOT_A_STATE_DIAGRAM` | error | The text belongs to another diagram family. Numbers are reserved for `UML0xx` modelling findings, so this one carries a name instead; see [Structure diagrams are not models](#structure-diagrams-are-not-models). |
102
99
  | `UML002_UNREACHABLE_STATE` | error | No transition can enter this state from init. The diagram draws flow nobody can reach. The check is structural and ignores guards; S1 is the guard-aware check. |
103
100
  | `UML003_DEAD_END_STATE` | error | A non-terminal state has no outgoing transition. Either it is terminal, or the outgoing flow was never modelled. |
104
101
  | `UML004_AMBIGUOUS_BRANCH` | error | Two unconditional arrows share one state and event. No reader and no implementation can resolve that. S4 is the authoritative check. |
@@ -110,7 +107,8 @@ Four input shapes, four different questions:
110
107
  | `UML010_UNUSED_VARIABLE` | warning | No guard reads this variable and no action writes it. It is a symbol with no source. |
111
108
  | `UML011_UNBOUNDED_VARIABLE` | info | An integer variable has no min or max, so no range invariant can be checked and A5 has no declared domain. |
112
109
  | `UML012_NO_TERMINAL` | warning | No state is terminal, so completion, failure and a stuck flow all look the same. |
113
- | `UML013_NO_NARRATIVE` | info | The model carries no meanings, so a reader must re-derive every symbol from the source. |
110
+ | `UML013_NO_NARRATIVE` | info | The model carries no meanings, so a reader must re-derive every symbol from the source. Adding a partial narrative is a valid first step. |
111
+ | `UML027_NARRATIVE_PARTIAL` | info | The narrative covers part of the model. `narrativeCoverage` (also in the finding's `evidence`) reports `covered/total` per dimension — states, events, scenarios. Incomplete is not wrong. |
114
112
  | `UML014_UNDOCUMENTED_STATE` | info | These states render as their bare id, so the diagram cannot be read against the code. |
115
113
  | `UML015_LABEL_DRIFT` | warning | A diagram label disagrees with the model narrative. One of the two is stale, and the review cannot tell which. |
116
114
  | `UML016_DIAGRAM_PARSE_NOTES` | info | Notes collected while rendering or reading the diagram. They mark information the notation could not carry. |
@@ -118,7 +116,77 @@ Four input shapes, four different questions:
118
116
  | `UML018_FIDELITY_UNCHECKED` | info | Only a diagram was supplied, so nothing here proves it matches the code. |
119
117
  | `UML019_ROUND_TRIP_SKIPPED` | warning | The fidelity check could not run, either because the view is not round-trippable or because it was switched off. |
120
118
 
121
- `ok: true` means the review ran. It does not mean the model is good. Read the findings and the `summary` counts.
119
+ `ok: true` means the review ran. It does not mean the model is good. Read `verdict`, not `ok`:
120
+
121
+ | `verdict` | What it means | What to do |
122
+ |---|---|---|
123
+ | `pass` | No error and no warning finding. | Safe to present, with the scope the model covers. |
124
+ | `pass_with_findings` | No error finding, but warnings exist (e.g. `UML012_NO_TERMINAL`, `UML006`). | Present the pass **and** the warnings; do not upgrade it to "no issues". |
125
+ | `fail` | An error finding exists (`UML002`, `UML003`, `UML004`, `UML017`, `UML_NOT_A_STATE_DIAGRAM`, …) or the diagram could not be read (`ok: false`). | The diagram is **not** reviewed. Fix the error findings, or stop calling the file a model. |
126
+
127
+ `verdictReason` states why in one line (`2 error finding(s) (first: UML002_UNREACHABLE_STATE)`).
128
+ The non-DSH CLI (`uml-review`, `uml-parse`) exits `2` when the verdict is `fail` or the
129
+ input is refused, `0` otherwise.
130
+
131
+ ### Structure diagrams are not models
132
+
133
+ This is the failure mode worth a section of its own, because it does not look like a failure.
134
+
135
+ A PlantUML component, package, class or deployment diagram declares no diagram kind —
136
+ it simply starts with `@startuml`. A state parser therefore meets its keywords line by
137
+ line, ignores the words it does not know, and reads the arrows as state transitions:
138
+
139
+ ```plantuml
140
+ @startuml
141
+ component [Order Service] as SVC
142
+ component [Payment Adapter] as PAY
143
+ package "HAL" {
144
+ component [Storage Driver] as DRV
145
+ }
146
+ SVC --> PAY : request
147
+ PAY --> DRV : read
148
+ @enduml
149
+ ```
150
+
151
+ Before this contract existed, that parsed to `ok: true` with three "states" and two
152
+ "transitions", and "the structure diagram was reviewed" was pure fabrication. Now the
153
+ same input returns the by-product **plus**:
154
+
155
+ ```json
156
+ {
157
+ "verdict": "fail",
158
+ "findings": [{ "code": "UML_NOT_A_STATE_DIAGRAM", "severity": "error",
159
+ "message": "the text is not a state or activity diagram: 4 declaration(s) of unsupported construct(s) (component ×3, package ×1) and 2 arrow(s) between them were read as states and transitions, so the parsed model is not this diagram." }],
160
+ "discardedConstructs": [{ "construct": "component", "line": 2, "text": "component [Order Service] as SVC" }, "…"],
161
+ "discardedEdges": 2
162
+ }
163
+ ```
164
+
165
+ Read that as: **there is no model of this file.** Do not feed the by-product to
166
+ `logicprobe_verify` and call the result an architecture review, and do not quote its
167
+ "states" as dependencies. `discardedConstructs` lists every declaration that could not
168
+ be represented (with its line and text), and `discardedEdges` counts the arrows between
169
+ them that were misread.
170
+
171
+ Mermaid families with an explicit header — `classDiagram`, `erDiagram`, `gantt`,
172
+ `journey`, `mindmap`, `gitGraph`, `C4*`, `requirementDiagram`, `timeline`,
173
+ `quadrantChart`, `sankey-beta`, `block-beta`, `packet-beta`, `architecture-beta`,
174
+ `radar-beta`, `treemap-beta`, `xychart-beta` — are refused instead, because an empty
175
+ model would be worse than no model: the tool returns `ok: false` with
176
+ `errorCode: "UML_NOT_A_STATE_DIAGRAM"` naming the family, and `review` reports it as
177
+ `UML001_DIAGRAM_UNREADABLE`. Sequence diagrams are in the same class of refusal, for a
178
+ different reason (a trace cannot reconstruct a machine).
179
+
180
+ Both routes are non-pass and both name what was discarded. What the tool will **not**
181
+ do is hand back a plausible model with no verdict — that is the false guarantee.
182
+
183
+ **A structure diagram has its own reviewer.** Dependency-graph checks — isolated
184
+ nodes (`UML020`), dangling endpoints (`UML021`), cycles (`UML022`), disallowed edges
185
+ and layer violations against an allowed-dependency matrix (`UML023`, `UML024`) and
186
+ required-but-absent edges (`UML025`) — are implemented by the `logicprobe-structure`
187
+ skill and the `logicprobe_structure_verify` tool, which parse the same component,
188
+ package, class and deployment text into a real node/edge graph instead of discarding
189
+ it. Use that for architecture questions; this guide is about the state-machine half.
122
190
 
123
191
  ### What the round trip proves
124
192
 
@@ -156,6 +224,50 @@ Neither is a behaviour bug. Add the narrative so a reader can check the diagram
156
224
 
157
225
  Now delete the `ERROR --cooldown--> STARTING` arrow and review again. `ACTIVE` and `ERROR` become dead ends (`UML003`), and `cooldown` becomes an event that only fires from an unreachable state (`UML007`). The diagram still looks plausible. That is the whole point.
158
226
 
227
+ ## Labels and comments: what the parser expects
228
+
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.
234
+
235
+ **1. State meanings, in the spellings the parser accepts.** A label carries a *meaning*
236
+ when it differs from the bare id. Accepted, in this order:
237
+
238
+ | Spelling | Example | Notes |
239
+ |---|---|---|
240
+ | bare id | `state "IDLE" as IDLE` | counts as **no** meaning: the state is reported undocumented (`UML014`) |
241
+ | `ID(meaning)` | `state "IDLE(waiting for power)" as IDLE` | what the renderer writes; full-width parentheses |
242
+ | `ID(meaning)` | `state "IDLE(waiting for power)" as IDLE` | accepted as well |
243
+ | a description line | `IDLE : waiting for power` | the same meaning in the other PlantUML spelling |
244
+ | a single-line note | `note right of IDLE : waiting for power` | read as the meaning, not as a comment |
245
+ | any other text | `state "waiting for power" as IDLE` | taken verbatim |
246
+
247
+ `UML015_LABEL_DRIFT` compares the *meaning* with `narrative.states[id]` character for
248
+ character: the wrapper (`ID(…)`) is stripped, the rest must match. A different wording is
249
+ still drift — the review cannot tell which side is stale. Writing no label is not drift;
250
+ it is `UML014_UNDOCUMENTED_STATE` (info).
251
+
252
+ **2. Comment lines.** The only comment lines the parser *consumes* are the `logicprobe:`
253
+ directives the renderer writes (`%%logicprobe:` in Mermaid, `'logicprobe:` in PlantUML):
254
+ `uml v1 …`, `init ID`, `terminal ID`, `alias X id`, `variable NAME kind`. They are
255
+ ordinary comments to every renderer, which is why the diagram stays valid.
256
+
257
+ Everything else is either notation chrome (skipped silently) or an ignored line:
258
+
259
+ | Line | Behaviour |
260
+ |---|---|
261
+ | 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
+ | `@startuml`/`@enduml`, `stateDiagram-v2`, `direction`, `classDef`/`style`/`linkStyle`/`click`, `scale`, `skinparam`, `title`, `hide`, `autonumber` | skipped silently |
264
+ | anything else the parser cannot read | `UML_PARSE_IGNORED_LINE` warning, and the model is built from what it did read |
265
+
266
+ So the practical rule is: keep non-directive comments out of the diagram body (put render
267
+ commands and explanations around the diagram, not inside it), and give every state a
268
+ `ID(meaning)` label when the model carries a narrative — otherwise the diagram compares
269
+ as undocumented and a later drift check has nothing to compare against.
270
+
159
271
  ## Limits
160
272
 
161
273
  - The review is **structural**. It evaluates no guard over any valuation. "Probably exhaustive" is a shape test, not a proof. S6 is the check that decides.
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Report diffing: "did this slice add findings, or remove them?"
3
+ *
4
+ * The acceptance criterion of a refactoring slice is usually *not* "zero findings" —
5
+ * it is "no NEW findings, and none of the old ones got worse". Comparing two JSON
6
+ * reports by hand is how that gets fudged, so the comparison is code.
7
+ *
8
+ * A finding's identity is its machine-readable locator, never its prose:
9
+ * `check|code|<canonical evidence + path>`. Findings that carry neither evidence nor a
10
+ * path fall back to their message, because an engine that reports nothing structured
11
+ * has nothing stabler to match on. Two runs over the same input must produce zero
12
+ * added and zero removed findings.
13
+ *
14
+ * @module logicprobe-baseline
15
+ */
16
+
17
+ import { canonicalJson, REPORT_SCHEMAS, verdictOf, type Finding, type Verdict, type VerdictSummary } from './engine.js'
18
+
19
+ /** A finding as it appears in any report family, after flattening. */
20
+ export interface ReportFinding extends Finding {
21
+ /** The check that emitted it (verify/datamodel: `S2`, `C1`, …); absent for flat families. */
22
+ check?: string
23
+ }
24
+
25
+ export interface BaselineChange {
26
+ identity: string
27
+ code: string
28
+ before: { severity: string; message: string }
29
+ after: { severity: string; message: string }
30
+ }
31
+
32
+ export interface BaselineReport {
33
+ ok: boolean
34
+ ran: boolean
35
+ verdict: Verdict
36
+ verdictReason: string
37
+ /** The versioned report contract this result follows. */
38
+ schema: string
39
+ /** The current report's own verdict, echoed so "no new findings" cannot hide a failing run. */
40
+ currentVerdict: Verdict
41
+ added: ReportFinding[]
42
+ removed: ReportFinding[]
43
+ changed: BaselineChange[]
44
+ summary: {
45
+ baseline: number
46
+ current: number
47
+ added: number
48
+ removed: number
49
+ changed: number
50
+ addedErrors: number
51
+ addedWarnings: number
52
+ }
53
+ nextSteps: string[]
54
+ }
55
+
56
+ /**
57
+ * The stable identity of a finding: check + file + code + a canonical locator. Prose is
58
+ * excluded so a reworded message reads as `changed`, not as a new finding.
59
+ */
60
+ export function findingIdentity(finding: ReportFinding): string {
61
+ const locator = canonicalJson({
62
+ ...(finding.evidence === undefined ? {} : { evidence: finding.evidence }),
63
+ ...(finding.path === undefined ? {} : { path: finding.path }),
64
+ })
65
+ const where = locator === '{}' ? 'message:' + finding.message : locator
66
+ const file = finding.file === undefined ? '' : finding.file + '|'
67
+ return (finding.check === undefined ? '' : finding.check + '|') + file + finding.code + '|' + where
68
+ }
69
+
70
+ /** Flatten a report of any family into one finding list (`checks[].findings` or `findings[]`). */
71
+ export function flattenReportFindings(report: unknown): ReportFinding[] {
72
+ if (report === null || typeof report !== 'object') return []
73
+ const record = report as Record<string, unknown>
74
+ const flat: ReportFinding[] = []
75
+ if (Array.isArray(record.checks)) {
76
+ for (const check of record.checks as Array<Record<string, unknown>>) {
77
+ const id = typeof check.id === 'string' ? check.id : undefined
78
+ for (const finding of (Array.isArray(check.findings) ? check.findings : []) as Finding[]) {
79
+ flat.push(id === undefined ? finding : { ...finding, check: id })
80
+ }
81
+ }
82
+ }
83
+ if (Array.isArray(record.findings)) {
84
+ for (const finding of record.findings as Finding[]) flat.push(finding)
85
+ }
86
+ return flat
87
+ }
88
+
89
+ /** The report's own verdict, whatever family it came from. */
90
+ function verdictOfReport(report: unknown): Verdict {
91
+ if (report === null || typeof report !== 'object') return 'fail'
92
+ const value = (report as Record<string, unknown>).verdict
93
+ return value === 'pass' || value === 'pass_with_findings' || value === 'fail' ? value : 'fail'
94
+ }
95
+
96
+ function diffOf(added: number, removed: number, addedErrors: number, firstCode?: string): VerdictSummary {
97
+ if (addedErrors > 0) {
98
+ return {
99
+ verdict: 'fail',
100
+ verdictReason: String(added) + ' new finding(s) (' + String(addedErrors) + ' error)' + (firstCode === undefined ? '' : ' (first: ' + firstCode + ')'),
101
+ }
102
+ }
103
+ if (added > 0) {
104
+ return { verdict: 'pass_with_findings', verdictReason: String(added) + ' new warning finding(s), no new errors' }
105
+ }
106
+ if (removed > 0) {
107
+ return { verdict: 'pass', verdictReason: 'no new findings; ' + String(removed) + ' finding(s) resolved' }
108
+ }
109
+ return { verdict: 'pass', verdictReason: 'no new findings, nothing resolved' }
110
+ }
111
+
112
+ /**
113
+ * Compare a baseline report with the current one. The verdict describes the *delta*
114
+ * (a new error fails it), while `currentVerdict` keeps the absolute result visible.
115
+ */
116
+ export function diffReports(baseline: unknown, current: unknown): BaselineReport {
117
+ const before = flattenReportFindings(baseline)
118
+ const after = flattenReportFindings(current)
119
+ const beforeByIdentity = new Map<string, ReportFinding>()
120
+ for (const finding of before) beforeByIdentity.set(findingIdentity(finding), finding)
121
+ const seen = new Set<string>()
122
+ const added: ReportFinding[] = []
123
+ const changed: BaselineChange[] = []
124
+ for (const finding of after) {
125
+ const identity = findingIdentity(finding)
126
+ seen.add(identity)
127
+ const previous = beforeByIdentity.get(identity)
128
+ if (previous === undefined) {
129
+ added.push(finding)
130
+ continue
131
+ }
132
+ if (previous.severity !== finding.severity || previous.message !== finding.message) {
133
+ changed.push({
134
+ identity,
135
+ code: finding.code,
136
+ before: { severity: previous.severity, message: previous.message },
137
+ after: { severity: finding.severity, message: finding.message },
138
+ })
139
+ }
140
+ }
141
+ const removed = before.filter((finding) => !seen.has(findingIdentity(finding)))
142
+ const addedErrors = added.filter((finding) => finding.severity === 'error')
143
+ const addedWarnings = added.filter((finding) => finding.severity === 'warning')
144
+ const delta = diffOf(added.length, removed.length, addedErrors.length, addedErrors[0]?.code)
145
+
146
+ const nextSteps: string[] = []
147
+ if (addedErrors.length > 0) {
148
+ nextSteps.push('Fix or explicitly accept the ' + String(addedErrors.length) + ' new error finding(s) before calling this slice done: the baseline is what "not worse" is measured against.')
149
+ } else if (added.length > 0) {
150
+ nextSteps.push('The new warnings are not failures, but they are new: decide per finding whether the change introduced them or the baseline was incomplete.')
151
+ } else {
152
+ nextSteps.push('No new findings: re-record the baseline with this report if the slice is accepted, so the next comparison starts from it.')
153
+ }
154
+ if (changed.length > 0) nextSteps.push('Review the ' + String(changed.length) + ' changed finding(s): the same locator now reports something different, which usually means the fix moved the problem.')
155
+ if (removed.length > 0) nextSteps.push(String(removed.length) + ' finding(s) from the baseline are gone — confirm they were fixed rather than renamed out of the report (a renamed check or a reworded message changes the identity).')
156
+ if (verdictOfReport(current) === 'fail') nextSteps.push('The current report still fails on its own terms; the delta being clean does not make the run clean.')
157
+
158
+ return {
159
+ ok: true,
160
+ ran: true,
161
+ ...delta,
162
+ schema: REPORT_SCHEMAS.baseline,
163
+ currentVerdict: verdictOfReport(current),
164
+ added,
165
+ removed,
166
+ changed,
167
+ summary: {
168
+ baseline: before.length,
169
+ current: after.length,
170
+ added: added.length,
171
+ removed: removed.length,
172
+ changed: changed.length,
173
+ addedErrors: addedErrors.length,
174
+ addedWarnings: addedWarnings.length,
175
+ },
176
+ nextSteps,
177
+ }
178
+ }
179
+
180
+ export type { VerdictSummary }
package/src/client.js CHANGED
@@ -30,7 +30,61 @@ window.__ModuleLoader__.load({
30
30
  id: 'dsh-logicprobe',
31
31
  factory: (require) => {
32
32
  const React = require('react')
33
- const { Button, Switch } = require('@deepseek-ai/dsh-client-ui-primitives')
33
+ // The host's Switch and Button, vendored. A plain-JS client bundle must not
34
+ // require a Harness Client package: it changes without notice, we get no type
35
+ // check, and a throw blanks this slot entry. Markup, classes, token usage and
36
+ // the aria behaviour follow the host primitive; classes carry our own prefix.
37
+ const CONTROLS_CSS = [
38
+ '.lp-switch { box-sizing: border-box; position: relative; flex: 0 0 auto; width: 36px; height: 20px; padding: 2px; border: 0; border-radius: 999px; corner-shape: round; background: var(--dsw-alias-border-l3); cursor: pointer; }',
39
+ ".lp-switch[aria-checked='true'] { background: var(--dsw-alias-brand-primary); }",
40
+ '.lp-switch:disabled { cursor: default; opacity: 0.5; }',
41
+ '.lp-switch:focus-visible { outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: 2px; }',
42
+ '.lp-switch-thumb { display: block; width: 16px; height: 16px; border-radius: 50%; corner-shape: round; background: var(--dsw-alias-label-primary-foreground); transition: transform 120ms ease; }',
43
+ ".lp-switch[aria-checked='false'] .lp-switch-thumb { background: var(--dsw-alias-switch-thumb); }",
44
+ ".lp-switch[aria-checked='true'] .lp-switch-thumb { transform: translateX(16px); }",
45
+ '.lp-btn { box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; gap: 4px; border: none; border-radius: var(--dsw-radius-md); cursor: pointer; font-size: 14px; line-height: 22px; color: var(--dsw-alias-label-primary); background: transparent; padding: 0 14px; }',
46
+ '.lp-btn:disabled { cursor: not-allowed; opacity: 0.4; }',
47
+ '.lp-btn-sm { height: 28px; font-size: 12px; line-height: 18px; padding: 0 10px; border-radius: var(--dsw-radius-sm); }',
48
+ '.lp-btn-outline { border: 0.5px solid var(--dsw-alias-border-l3); background: transparent; }',
49
+ '.lp-btn-outline:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); }',
50
+ ].join('\n')
51
+
52
+ /** Rendered inside the component tree, so unmounting removes the styles. */
53
+ function Controls() {
54
+ return React.createElement('style', null, CONTROLS_CSS)
55
+ }
56
+
57
+ function Switch(props) {
58
+ return React.createElement(
59
+ 'button',
60
+ {
61
+ type: 'button',
62
+ role: 'switch',
63
+ 'aria-checked': props.checked === true,
64
+ 'aria-label': props.label,
65
+ className: 'lp-switch',
66
+ disabled: props.disabled === true,
67
+ onClick: () => {
68
+ if (props.disabled !== true) props.onChange(!(props.checked === true))
69
+ },
70
+ },
71
+ React.createElement('span', { className: 'lp-switch-thumb' }),
72
+ )
73
+ }
74
+
75
+ Switch.displayName = 'dsh-logicprobe:Switch'
76
+ Button.displayName = 'dsh-logicprobe:Button'
77
+
78
+ function Button(props) {
79
+ const classes = ['lp-btn']
80
+ if (props.size === 'sm') classes.push('lp-btn-sm')
81
+ if (props.variant === 'outline') classes.push('lp-btn-outline')
82
+ return React.createElement(
83
+ 'button',
84
+ { type: 'button', className: classes.join(' '), disabled: props.disabled === true, onClick: props.onClick },
85
+ props.children,
86
+ )
87
+ }
34
88
 
35
89
  /** Settings namespace: the Loader entry id this bundle's patch declares. */
36
90
  const NS = 'logicprobe'
@@ -122,6 +176,7 @@ window.__ModuleLoader__.load({
122
176
  const locked = state.writable !== true || pending
123
177
 
124
178
  const children = [
179
+ React.createElement(Controls, { key: 'styles' }),
125
180
  React.createElement('h4', { key: 'title', style: TITLE }, t('title')),
126
181
  React.createElement('div', { key: 'row', style: ROW }, [
127
182
  React.createElement('span', { key: 'label', style: LABEL }, t('label')),