dsh-logicprobe 0.9.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 +128 -66
- package/README.md +128 -66
- package/lib/baseline.js +142 -0
- package/lib/client.js +56 -1
- package/lib/compose-tool.js +1 -1
- package/lib/concurrency.js +14 -0
- package/lib/data-engine.js +42 -8
- package/lib/engine.js +3151 -2801
- package/lib/export-tool.js +27 -2
- package/lib/granularity.js +237 -0
- package/lib/index.js +63 -41
- package/lib/report-diff-tool.js +39 -0
- package/lib/structure-tool.js +64 -0
- package/lib/structure.js +648 -0
- package/lib/tool.js +3 -3
- package/lib/types/baseline.d.ts +69 -0
- package/lib/types/concurrency.d.ts +10 -0
- package/lib/types/data-engine.d.ts +13 -1
- package/lib/types/engine.d.ts +419 -277
- package/lib/types/granularity.d.ts +106 -0
- package/lib/types/index.d.ts +9 -8
- package/lib/types/report-diff-tool.d.ts +8 -0
- package/lib/types/structure-tool.d.ts +10 -0
- package/lib/types/structure.d.ts +156 -0
- package/lib/types/tool.d.ts +2 -2
- package/lib/types/uml.d.ts +89 -0
- package/lib/uml-tool.js +146 -104
- package/lib/uml.js +1748 -1544
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +16 -14
- package/skills/logicprobe/SKILL.md +84 -58
- package/skills/logicprobe/references/dsh-model-schema.md +102 -10
- package/skills/logicprobe/references/gap-routing-guide.md +37 -36
- package/skills/logicprobe/references/hash-spec.md +153 -0
- package/skills/logicprobe/references/logic-verification-guide.md +27 -250
- package/skills/logicprobe-concurrency/SKILL.md +43 -0
- package/skills/{logicprobe → logicprobe-concurrency}/references/concurrency-risk-guide.md +1 -1
- package/skills/logicprobe-datamodel/SKILL.md +5 -4
- package/skills/logicprobe-datamodel/references/data-model-guide.md +1 -1
- package/skills/logicprobe-datamodel/references/data-model-schema.md +1 -1
- package/skills/logicprobe-structure/SKILL.md +95 -0
- package/skills/logicprobe-structure/references/structure-review-guide.md +177 -0
- package/skills/logicprobe-uml/SKILL.md +57 -0
- package/skills/{logicprobe → logicprobe-uml}/references/uml-modeling-guide.md +121 -8
- package/src/baseline.ts +180 -0
- package/src/client.js +56 -1
- package/src/compose-tool.ts +47 -47
- package/src/concurrency.ts +23 -0
- package/src/data-engine.ts +51 -9
- package/src/engine.ts +503 -72
- package/src/export-tool.ts +73 -48
- package/src/granularity.ts +312 -0
- package/src/index.ts +63 -41
- package/src/report-diff-tool.ts +43 -0
- package/src/structure-tool.ts +68 -0
- package/src/structure.ts +724 -0
- package/src/tool.ts +3 -3
- package/src/uml-tool.ts +53 -10
- package/src/uml.ts +288 -24
- package/skills/logicprobe/references/logicprobe-engine.py +0 -4943
- package/skills/logicprobe/references/verification-harness.py +0 -959
- package/skills/logicprobe-datamodel/references/data-model-harness.py +0 -528
package/src/uml.ts
CHANGED
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
* @module logicprobe-uml
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
|
-
import { validateModel, modelHash } from './engine.js'
|
|
34
|
+
import { REPORT_SCHEMAS, validateModel, modelHash, metadataKeysOf, narrativeComplete, narrativeCoverageOf, verdictOfFindings, DEFAULT_HASH_SPEC, type HashSpec, type NarrativeCoverage, type ReportSchema, type Verdict } from './engine.js'
|
|
35
35
|
import type { GuardNode, GuardOp, LeafGuard, LogicModelV1, StateSpec, TransitionSpec, UpdateSpec, VariableSpec } from './engine.js'
|
|
36
36
|
|
|
37
37
|
export type UmlNotation = 'mermaid' | 'plantuml'
|
|
@@ -72,9 +72,31 @@ export interface UmlParseResult {
|
|
|
72
72
|
* symbol, which the review reports.
|
|
73
73
|
*/
|
|
74
74
|
labels: Record<string, string>
|
|
75
|
+
/**
|
|
76
|
+
* Declarations found in the text that LogicModelV1 cannot represent. A non-empty
|
|
77
|
+
* list means the parsed model is NOT this diagram: the text belongs to another
|
|
78
|
+
* diagram family (component, package, class, deployment, …), so whatever came
|
|
79
|
+
* out is a by-product of reading keywords the parser does not own.
|
|
80
|
+
*/
|
|
81
|
+
discardedConstructs: DiscardedConstruct[]
|
|
82
|
+
/**
|
|
83
|
+
* Arrows whose endpoints were declared by a discarded construct. They survive
|
|
84
|
+
* parsing as state transitions, which is exactly why they are counted out loud.
|
|
85
|
+
*/
|
|
86
|
+
discardedEdges: number
|
|
75
87
|
warnings: string[]
|
|
76
88
|
}
|
|
77
89
|
|
|
90
|
+
/** One declaration the parser could not represent, with the source line that carried it. */
|
|
91
|
+
export interface DiscardedConstruct {
|
|
92
|
+
/** The construct keyword, lower-cased (`component`, `package`, `classDiagram`). */
|
|
93
|
+
construct: string
|
|
94
|
+
/** 1-based line number in the diagram source. */
|
|
95
|
+
line: number
|
|
96
|
+
/** The source line, trimmed. */
|
|
97
|
+
text: string
|
|
98
|
+
}
|
|
99
|
+
|
|
78
100
|
export interface UmlFinding {
|
|
79
101
|
code: string
|
|
80
102
|
severity: 'error' | 'warning' | 'info'
|
|
@@ -83,12 +105,16 @@ export interface UmlFinding {
|
|
|
83
105
|
events?: string[]
|
|
84
106
|
transitions?: Array<{ from: string; event: string; to: string }>
|
|
85
107
|
detail?: string
|
|
108
|
+
/** Machine-readable context for the finding (ids, coverage, rule matches), same shape as the engine's findings. */
|
|
109
|
+
evidence?: Record<string, unknown>
|
|
86
110
|
}
|
|
87
111
|
|
|
88
112
|
export interface UmlRoundTripReport {
|
|
89
113
|
notation: UmlNotation
|
|
90
114
|
diagram: UmlDiagram
|
|
91
115
|
ok: boolean
|
|
116
|
+
/** Which published hash specification the two hashes follow (see references/hash-spec.md). */
|
|
117
|
+
hashSpec: HashSpec
|
|
92
118
|
modelHash: string
|
|
93
119
|
parsedHash: string
|
|
94
120
|
diffs: string[]
|
|
@@ -96,7 +122,13 @@ export interface UmlRoundTripReport {
|
|
|
96
122
|
}
|
|
97
123
|
|
|
98
124
|
export interface UmlReviewReport {
|
|
125
|
+
/** The review ran and produced this report. It does NOT mean the modelling passed — read `verdict`. */
|
|
99
126
|
ok: boolean
|
|
127
|
+
ran: boolean
|
|
128
|
+
verdict: Verdict
|
|
129
|
+
verdictReason: string
|
|
130
|
+
/** The versioned report contract this result follows. */
|
|
131
|
+
schema: ReportSchema
|
|
100
132
|
source: 'model' | 'diagram' | 'model+diagram'
|
|
101
133
|
summary: {
|
|
102
134
|
errors: number
|
|
@@ -117,11 +149,31 @@ export interface UmlReviewReport {
|
|
|
117
149
|
model?: LogicModelV1
|
|
118
150
|
/** Diagram rendered from the model, when only a model was given. */
|
|
119
151
|
primary?: string
|
|
152
|
+
/** Paths of the `_`-prefixed metadata keys found in the supplied model, when it carried any. */
|
|
153
|
+
metadataKeys?: string[]
|
|
154
|
+
/** How much of the model the narrative documents; absent when there is no narrative. */
|
|
155
|
+
narrativeCoverage?: NarrativeCoverage
|
|
156
|
+
/** Hashes of what was reviewed, in one place. */
|
|
157
|
+
hashes: { hashSpec: HashSpec; modelHash?: string; parsedHash?: string }
|
|
158
|
+
/** Declarations the parser could not represent; a non-empty list fails the review. */
|
|
159
|
+
discardedConstructs?: DiscardedConstruct[]
|
|
160
|
+
/** Arrows whose endpoints came from a discarded construct. */
|
|
161
|
+
discardedEdges?: number
|
|
120
162
|
warnings: string[]
|
|
121
163
|
nextSteps: string[]
|
|
122
164
|
}
|
|
123
165
|
|
|
124
|
-
|
|
166
|
+
/**
|
|
167
|
+
* A UML front-end refusal. `code` lets the tool report *why* it refused instead of
|
|
168
|
+
* collapsing every refusal into a generic input error.
|
|
169
|
+
*/
|
|
170
|
+
export class UmlError extends Error {
|
|
171
|
+
readonly code: string
|
|
172
|
+
constructor(message: string, code = 'UML_INPUT') {
|
|
173
|
+
super(message)
|
|
174
|
+
this.code = code
|
|
175
|
+
}
|
|
176
|
+
}
|
|
125
177
|
|
|
126
178
|
// ---------------------------------------------------------------------------
|
|
127
179
|
// Shared helpers
|
|
@@ -557,9 +609,34 @@ interface DiagramLine {
|
|
|
557
609
|
diagram: 'state' | 'activity' | 'sequence'
|
|
558
610
|
}
|
|
559
611
|
|
|
612
|
+
/**
|
|
613
|
+
* Mermaid diagram-family headers that are neither a state machine nor a flow.
|
|
614
|
+
* Recognizing them matters twice: the text must not be reported as "cannot tell
|
|
615
|
+
* whether this is Mermaid or PlantUML", and it must not be parsed as a state
|
|
616
|
+
* diagram — every line of it would be ignored or misread, and the empty model that
|
|
617
|
+
* comes out would be presented as if it described the file.
|
|
618
|
+
*/
|
|
619
|
+
const MERMAID_OTHER_FAMILIES: readonly string[] = [
|
|
620
|
+
'classDiagram', 'erDiagram', 'gantt', 'pie', 'journey', 'mindmap', 'gitGraph',
|
|
621
|
+
'C4Context', 'C4Container', 'C4Component', 'C4Dynamic', 'C4Deployment',
|
|
622
|
+
'requirementDiagram', 'timeline', 'quadrantChart', 'sankey-beta', 'block-beta',
|
|
623
|
+
'packet-beta', 'architecture-beta', 'radar-beta', 'treemap-beta', 'xychart-beta',
|
|
624
|
+
]
|
|
625
|
+
|
|
626
|
+
/** The other-family header this text declares, if any. */
|
|
627
|
+
function otherMermaidFamily(text: string): string | undefined {
|
|
628
|
+
for (const family of MERMAID_OTHER_FAMILIES) {
|
|
629
|
+
if (new RegExp('^\\s*' + family + '\\b', 'm').test(text)) return family
|
|
630
|
+
}
|
|
631
|
+
return undefined
|
|
632
|
+
}
|
|
633
|
+
|
|
560
634
|
function detectNotation(text: string): UmlNotation {
|
|
561
635
|
if (/^\s*@start/m.test(text)) return 'plantuml'
|
|
562
636
|
if (/^\s*(stateDiagram|stateDiagram-v2|flowchart|graph|sequenceDiagram)\b/m.test(text)) return 'mermaid'
|
|
637
|
+
// A known Mermaid family is still Mermaid: say so, and let detectDiagram refuse it
|
|
638
|
+
// by name instead of blaming the notation.
|
|
639
|
+
if (otherMermaidFamily(text) !== undefined) return 'mermaid'
|
|
563
640
|
throw new UmlError('cannot tell whether this is Mermaid or PlantUML text: expected `stateDiagram-v2` / `flowchart` / `sequenceDiagram`, or `@startuml`')
|
|
564
641
|
}
|
|
565
642
|
|
|
@@ -567,6 +644,10 @@ function detectDiagram(text: string): 'state' | 'activity' | 'sequence' {
|
|
|
567
644
|
if (/^\s*stateDiagram/m.test(text)) return 'state'
|
|
568
645
|
if (/^\s*(flowchart|graph)\b/m.test(text)) return 'activity'
|
|
569
646
|
if (/^\s*sequenceDiagram\b/m.test(text)) return 'sequence'
|
|
647
|
+
const family = otherMermaidFamily(text)
|
|
648
|
+
if (family !== undefined) {
|
|
649
|
+
throw new UmlError('`' + family + '` is not a state or activity diagram: logicprobe models state machines and flows, so this diagram family cannot be parsed into a LogicModelV1. Discarded: ' + family + ' ×1. Render the machine with logicprobe_uml action=render and keep this diagram as its own view.', 'UML_NOT_A_STATE_DIAGRAM')
|
|
650
|
+
}
|
|
570
651
|
if (/^\s*@startuml/m.test(text)) {
|
|
571
652
|
// PlantUML declares the diagram kind by its body; the state keyword is the only
|
|
572
653
|
// structural one logicprobe emits, everything else in that family is a state diagram too.
|
|
@@ -648,6 +729,10 @@ interface RawDiagram {
|
|
|
648
729
|
terminals: string[]
|
|
649
730
|
finalMarks: string[]
|
|
650
731
|
warnings: string[]
|
|
732
|
+
/** Declarations that belong to another diagram family (component, package, class, …). */
|
|
733
|
+
discarded: DiscardedConstruct[]
|
|
734
|
+
/** Node names those declarations introduced; arrows touching them are counted as discarded edges. */
|
|
735
|
+
discardedNodes: Set<string>
|
|
651
736
|
}
|
|
652
737
|
|
|
653
738
|
function rawToModel(raw: RawDiagram, directives: Directives, warnings: string[]): LogicModelV1 {
|
|
@@ -762,37 +847,107 @@ function inferVariables(transitions: TransitionSpec[], declared: Map<string, 'in
|
|
|
762
847
|
return [...kinds.entries()].map(([name, kind]) => ({ name, kind, init: kind === 'boolean' ? false : 0 }))
|
|
763
848
|
}
|
|
764
849
|
|
|
850
|
+
/**
|
|
851
|
+
* PlantUML keywords that declare a construct LogicModelV1 has no place for. Their
|
|
852
|
+
* presence marks the text as a component, package, class, deployment or database
|
|
853
|
+
* diagram — a different diagram family. A state parser still reads *something* out of
|
|
854
|
+
* such a file (the arrows look like transitions), which is why the discarded
|
|
855
|
+
* declarations are collected and reported instead of being ignored.
|
|
856
|
+
*/
|
|
857
|
+
const PLANTUML_OTHER_CONSTRUCT = /^(component|package|class|interface|enum|enumeration|object|actor|usecase|node|artifact|database|rectangle|folder|frame|cloud|storage|collections|queue|stack|agent|boundary|control|entity|deployment|protocol|struct|exception|metaclass|stereotype|circle|hexagon|label|port|portin|portout)\b/i
|
|
858
|
+
|
|
859
|
+
/** The node identifier a declaration introduces, when it has one (`… as ID`, or a bare `component ID`). */
|
|
860
|
+
function declaredIdentifier(text: string): string | undefined {
|
|
861
|
+
const alias = /\bas\s+([A-Za-z_][A-Za-z0-9_]*)\s*;?$/.exec(text)
|
|
862
|
+
if (alias !== null) return alias[1]
|
|
863
|
+
const bare = /^[A-Za-z]+\s+([A-Za-z_][A-Za-z0-9_]*)\s*;?$/.exec(text)
|
|
864
|
+
return bare === null ? undefined : bare[1]
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/**
|
|
868
|
+
* Findings a parse result carries on its own. A diagram from another family parses
|
|
869
|
+
* into something; without this finding that something is presented as a model of the
|
|
870
|
+
* file, which is a false guarantee of exactly the kind this plugin exists to prevent.
|
|
871
|
+
*/
|
|
872
|
+
export function parseFindings(parsed: UmlParseResult): UmlFinding[] {
|
|
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 []
|
|
878
|
+
const counts = new Map<string, number>()
|
|
879
|
+
for (const entry of unsupported) counts.set(entry.construct, (counts.get(entry.construct) ?? 0) + 1)
|
|
880
|
+
const summary = [...counts.entries()]
|
|
881
|
+
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
|
882
|
+
.map(([construct, count]) => construct + ' ×' + String(count))
|
|
883
|
+
.join(', ')
|
|
884
|
+
const shown = unsupported.slice(0, 12).map((entry) => 'line ' + String(entry.line) + ': ' + entry.text).join(' | ')
|
|
885
|
+
return [{
|
|
886
|
+
code: 'UML_NOT_A_STATE_DIAGRAM',
|
|
887
|
+
severity: 'error',
|
|
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' : ''),
|
|
890
|
+
}]
|
|
891
|
+
}
|
|
892
|
+
|
|
765
893
|
function parseStateDiagram(text: string, notation: UmlNotation): UmlParseResult {
|
|
766
894
|
const lines = text.split(/\r?\n/)
|
|
767
895
|
const { directives, body } = readDirectives(lines, notation)
|
|
768
|
-
const raw: RawDiagram = { states: [], display: new Map(), edges: [], terminals: [], finalMarks: [], warnings: [] }
|
|
896
|
+
const raw: RawDiagram = { states: [], display: new Map(), edges: [], terminals: [], finalMarks: [], warnings: [], discarded: [], discardedNodes: new Set() }
|
|
897
|
+
let discardedEdges = 0
|
|
769
898
|
const declared = new Set<string>()
|
|
770
899
|
const declare = (name: string): void => { if (!declared.has(name)) { declared.add(name); raw.states.push(name) } }
|
|
771
900
|
const skip = notation === 'mermaid'
|
|
772
901
|
? /^(stateDiagram|stateDiagram-v2|direction\b|classDef\b|class\b|style\b|linkStyle\b|click\b|hide\b|scale\b|title\b|accTitle\b|accDescr\b|%%\{)/
|
|
773
902
|
: /^(@startuml|@enduml|scale\b|skinparam\b|title\b|hide\b|left to right direction|top to bottom direction|autonumber|!theme)/
|
|
774
|
-
|
|
903
|
+
let inNote = false
|
|
904
|
+
body.forEach((rawLine, index) => {
|
|
775
905
|
const line = rawLine.trim()
|
|
776
|
-
if (line === '' || (line.startsWith('--') && !line.includes('-->')))
|
|
777
|
-
if (skip.test(line))
|
|
906
|
+
if (line === '' || (line.startsWith('--') && !line.includes('-->'))) return
|
|
907
|
+
if (skip.test(line)) return
|
|
908
|
+
const noteStart = /^note\b/i.test(line)
|
|
909
|
+
const noteEnd = /^end\s*note$/i.test(line)
|
|
910
|
+
if (notation === 'plantuml' && !inNote && !noteStart && !noteEnd) {
|
|
911
|
+
const other = PLANTUML_OTHER_CONSTRUCT.exec(line)
|
|
912
|
+
if (other !== null) {
|
|
913
|
+
raw.discarded.push({ construct: other[1].toLowerCase(), line: index + 1, text: line })
|
|
914
|
+
const id = declaredIdentifier(line)
|
|
915
|
+
if (id !== undefined) raw.discardedNodes.add(id)
|
|
916
|
+
return
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
if (noteEnd) { inNote = false; return }
|
|
920
|
+
// A multi-line note body is prose, not a statement: skip it silently rather than
|
|
921
|
+
// reporting one ignored line per line of text.
|
|
922
|
+
if (inNote) 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
|
+
}
|
|
778
933
|
const note = /^note\s+(?:over|right of|left of)\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.+)$/i.exec(line)
|
|
779
934
|
if (note !== null) {
|
|
780
935
|
declare(note[1])
|
|
781
936
|
const existing = raw.display.get(note[1])
|
|
782
937
|
if (existing === undefined) raw.display.set(note[1], note[2].trim())
|
|
783
|
-
|
|
938
|
+
return
|
|
784
939
|
}
|
|
785
|
-
if (
|
|
940
|
+
if (noteStart) return
|
|
786
941
|
const stateDecl = /^state\s+"([^"]*)"\s+as\s+([A-Za-z_][A-Za-z0-9_]*)$/.exec(line)
|
|
787
|
-
if (stateDecl !== null) { declare(stateDecl[2]); raw.display.set(stateDecl[2], stateDecl[1]);
|
|
942
|
+
if (stateDecl !== null) { declare(stateDecl[2]); raw.display.set(stateDecl[2], stateDecl[1]); return }
|
|
788
943
|
const bareState = /^state\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{?$/.exec(line)
|
|
789
|
-
if (bareState !== null) { declare(bareState[1]);
|
|
944
|
+
if (bareState !== null) { declare(bareState[1]); return }
|
|
790
945
|
const concurrency = /^\}\s*$|^--\s*$/.test(line)
|
|
791
|
-
if (concurrency) { raw.warnings.push('UML_PARSE_CONCURRENCY_FLATTENED: a concurrency region or composite block was flattened; LogicModelV1 has no region construct (use logicprobe_compose_verify for parallel machines).');
|
|
946
|
+
if (concurrency) { raw.warnings.push('UML_PARSE_CONCURRENCY_FLATTENED: a concurrency region or composite block was flattened; LogicModelV1 has no region construct (use logicprobe_compose_verify for parallel machines).'); return }
|
|
792
947
|
const composite = /^state\s+(.+)\s*\{$/.exec(line)
|
|
793
|
-
if (composite !== null) { raw.warnings.push('UML_PARSE_COMPOSITE_FLATTENED: composite state "' + composite[1].trim() + '" was flattened into its members.');
|
|
948
|
+
if (composite !== null) { raw.warnings.push('UML_PARSE_COMPOSITE_FLATTENED: composite state "' + composite[1].trim() + '" was flattened into its members.'); return }
|
|
794
949
|
const description = /^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.+)$/.exec(line)
|
|
795
|
-
if (description !== null) { declare(description[1]); if (!raw.display.has(description[1])) raw.display.set(description[1], description[2].trim());
|
|
950
|
+
if (description !== null) { declare(description[1]); if (!raw.display.has(description[1])) raw.display.set(description[1], description[2].trim()); return }
|
|
796
951
|
const edge = /^(.+?)\s*-->\s*(.+?)(?:\s*:\s*(.*))?$/.exec(line)
|
|
797
952
|
if (edge !== null) {
|
|
798
953
|
const from = edge[1].trim()
|
|
@@ -804,25 +959,26 @@ function parseStateDiagram(text: string, notation: UmlNotation): UmlParseResult
|
|
|
804
959
|
} else {
|
|
805
960
|
raw.initialState = to
|
|
806
961
|
}
|
|
807
|
-
|
|
962
|
+
return
|
|
808
963
|
}
|
|
809
|
-
if (to === '[*]') { raw.finalMarks.push(from);
|
|
964
|
+
if (to === '[*]') { raw.finalMarks.push(from); return }
|
|
810
965
|
declare(from)
|
|
811
966
|
declare(to)
|
|
967
|
+
if (raw.discardedNodes.has(from) || raw.discardedNodes.has(to)) discardedEdges += 1
|
|
812
968
|
raw.edges.push({ from, to, label })
|
|
813
|
-
|
|
969
|
+
return
|
|
814
970
|
}
|
|
815
971
|
raw.warnings.push('UML_PARSE_IGNORED_LINE: "' + line + '" is not a state diagram statement; it was ignored.')
|
|
816
|
-
}
|
|
972
|
+
})
|
|
817
973
|
const model = rawToModel(raw, directives, raw.warnings)
|
|
818
974
|
const labels = mapLabels(raw.display, directives)
|
|
819
|
-
return { notation, diagram: 'state', model, labels, warnings: raw.warnings }
|
|
975
|
+
return { notation, diagram: 'state', model, labels, discardedConstructs: raw.discarded, discardedEdges, warnings: raw.warnings }
|
|
820
976
|
}
|
|
821
977
|
|
|
822
978
|
function parseActivityDiagram(text: string, notation: UmlNotation): UmlParseResult {
|
|
823
979
|
const lines = text.split(/\r?\n/)
|
|
824
980
|
const { directives, body } = readDirectives(lines, notation)
|
|
825
|
-
const raw: RawDiagram = { states: [], display: new Map(), edges: [], terminals: [], finalMarks: [], warnings: [] }
|
|
981
|
+
const raw: RawDiagram = { states: [], display: new Map(), edges: [], terminals: [], finalMarks: [], warnings: [], discarded: [], discardedNodes: new Set() }
|
|
826
982
|
const declared = new Set<string>()
|
|
827
983
|
const declare = (name: string): void => { if (!declared.has(name)) { declared.add(name); raw.states.push(name) } }
|
|
828
984
|
const skip = /^(flowchart|graph)\b|^(classDef|class|style|linkStyle|click|direction)\b|^%%\{/
|
|
@@ -858,7 +1014,7 @@ function parseActivityDiagram(text: string, notation: UmlNotation): UmlParseResu
|
|
|
858
1014
|
}
|
|
859
1015
|
const model = rawToModel(raw, directives, raw.warnings)
|
|
860
1016
|
const labels = mapLabels(raw.display, directives)
|
|
861
|
-
return { notation, diagram: 'activity', model, labels, warnings: raw.warnings }
|
|
1017
|
+
return { notation, diagram: 'activity', model, labels, discardedConstructs: [], discardedEdges: 0, warnings: raw.warnings }
|
|
862
1018
|
}
|
|
863
1019
|
|
|
864
1020
|
/**
|
|
@@ -892,6 +1048,68 @@ function mapLabels(display: Map<string, string>, directives: Directives): Record
|
|
|
892
1048
|
return out
|
|
893
1049
|
}
|
|
894
1050
|
|
|
1051
|
+
/** One accepted way of writing a state's meaning into a diagram label. */
|
|
1052
|
+
export interface LabelForm {
|
|
1053
|
+
form: string
|
|
1054
|
+
example: string
|
|
1055
|
+
note: string
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
export interface LabelExplanation {
|
|
1059
|
+
notation: UmlNotation
|
|
1060
|
+
/** The comment prefix a `logicprobe:` directive uses in this notation. */
|
|
1061
|
+
directivePrefix: string
|
|
1062
|
+
directiveLines: Array<{ example: string; meaning: string }>
|
|
1063
|
+
/** Accepted label spellings, in the order `documentedMeaning` accepts them. */
|
|
1064
|
+
acceptedLabelForms: LabelForm[]
|
|
1065
|
+
/** What the renderer itself writes, so a hand-edited diagram can match it. */
|
|
1066
|
+
renderedForm: string
|
|
1067
|
+
ignoredLines: Array<{ pattern: string; behaviour: string }>
|
|
1068
|
+
rules: string[]
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/**
|
|
1072
|
+
* What the front end expects of labels and comments, so "why does my hand-drawn diagram
|
|
1073
|
+
* report label drift or ignored lines?" has a printed answer instead of a guess.
|
|
1074
|
+
* `tests/uml/run.mjs` feeds every claim here back through the parser, so this text cannot
|
|
1075
|
+
* drift away from the behaviour it describes.
|
|
1076
|
+
*/
|
|
1077
|
+
export function explainLabels(notation: UmlNotation = 'mermaid'): LabelExplanation {
|
|
1078
|
+
const prefix = commentPrefix(notation)
|
|
1079
|
+
return {
|
|
1080
|
+
notation,
|
|
1081
|
+
directivePrefix: prefix + DIRECTIVE_NAMESPACE,
|
|
1082
|
+
directiveLines: [
|
|
1083
|
+
{ example: prefix + DIRECTIVE_NAMESPACE + 'uml v1 notation=' + notation + ' diagram=state', meaning: 'declares the notation and diagram kind the file was rendered for' },
|
|
1084
|
+
{ example: prefix + DIRECTIVE_NAMESPACE + 'init ID', meaning: 'pins the initial state (otherwise it is inferred as the only state nothing enters)' },
|
|
1085
|
+
{ example: prefix + DIRECTIVE_NAMESPACE + 'terminal ID', meaning: 'marks a terminal state; comma-separate several' },
|
|
1086
|
+
{ example: prefix + DIRECTIVE_NAMESPACE + 'alias NOTATION_ID model_id', meaning: 'restores a state id the notation cannot spell verbatim' },
|
|
1087
|
+
{ example: prefix + DIRECTIVE_NAMESPACE + 'variable NAME integer', meaning: 'restores a variable and its kind' },
|
|
1088
|
+
],
|
|
1089
|
+
acceptedLabelForms: [
|
|
1090
|
+
{ form: 'bare id', example: 'state "IDLE" as IDLE', note: 'counts as NO meaning: the state stays undocumented (UML014)' },
|
|
1091
|
+
{ form: 'ID(meaning)', example: 'state "IDLE(waiting for power)" as IDLE', note: 'the form the renderer writes; full-width parentheses' },
|
|
1092
|
+
{ form: 'ID(meaning)', example: 'state "IDLE(waiting for power)" as IDLE', note: 'accepted as well' },
|
|
1093
|
+
{ form: 'a description line', example: 'IDLE : waiting for power', note: 'the same meaning written as a state description' },
|
|
1094
|
+
{ form: 'a single-line note', example: 'note right of IDLE : waiting for power', note: 'read as the state meaning, not as a comment' },
|
|
1095
|
+
{ form: 'any other text', example: 'state "waiting for power" as IDLE', note: 'taken as the meaning verbatim' },
|
|
1096
|
+
],
|
|
1097
|
+
renderedForm: 'state "ID(meaning)" as ID',
|
|
1098
|
+
ignoredLines: [
|
|
1099
|
+
{ pattern: 'a `' + prefix + '` comment line that is not a `logicprobe:` directive', behaviour: 'UML_PARSE_IGNORED_LINE warning: it carries no state-machine statement' },
|
|
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' },
|
|
1101
|
+
{ pattern: 'notation chrome: @startuml/@enduml, stateDiagram-v2, direction, classDef/style/linkStyle/click, scale, skinparam, title, hide, autonumber', behaviour: 'skipped silently' },
|
|
1102
|
+
{ pattern: 'anything else the parser cannot read', behaviour: 'UML_PARSE_IGNORED_LINE warning, and the model is built from what it did read' },
|
|
1103
|
+
],
|
|
1104
|
+
rules: [
|
|
1105
|
+
'Label drift (UML015) compares the meaning inside the label with narrative.states[id]: the wrapper is stripped, the remaining text must match character for character.',
|
|
1106
|
+
'A meaning that differs only in wording is still drift — one of the two is stale, and the review cannot tell which.',
|
|
1107
|
+
'Keep non-directive comments out of the diagram: `logicprobe:` directives are the only comment lines the parser consumes.',
|
|
1108
|
+
'A missing label is not an error: the state renders as its bare id and is reported as undocumented (UML014).',
|
|
1109
|
+
],
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
|
|
895
1113
|
/**
|
|
896
1114
|
* Parse a Mermaid or PlantUML diagram back into a LogicModelV1.
|
|
897
1115
|
*
|
|
@@ -907,7 +1125,7 @@ export function parseUml(text: string, notation: UmlNotation | 'auto' = 'auto'):
|
|
|
907
1125
|
const resolved = notation === 'auto' ? detectNotation(text) : notation
|
|
908
1126
|
const kind = detectDiagram(text)
|
|
909
1127
|
if (kind === 'sequence') {
|
|
910
|
-
throw new UmlError('a sequence diagram is a trace, not a machine: parsing it would drop every branch the trace did not walk. Render diagram "state" or "activity" and parse that instead.')
|
|
1128
|
+
throw new UmlError('a sequence diagram is a trace, not a machine: parsing it would drop every branch the trace did not walk. Render diagram "state" or "activity" and parse that instead.', 'UML_NOT_A_STATE_DIAGRAM')
|
|
911
1129
|
}
|
|
912
1130
|
return kind === 'state' ? parseStateDiagram(text, resolved) : parseActivityDiagram(text, resolved)
|
|
913
1131
|
}
|
|
@@ -1137,8 +1355,22 @@ function structuralFindings(model: LogicModelV1, labels: Record<string, string>
|
|
|
1137
1355
|
code: 'UML013_NO_NARRATIVE',
|
|
1138
1356
|
severity: 'info',
|
|
1139
1357
|
message: 'the model carries no narrative block: no state, event or scenario has a natural-language meaning, so a reader must re-derive every symbol from the source.',
|
|
1140
|
-
detail: 'Add narrative.states / narrative.events / narrative.scenarios —
|
|
1358
|
+
detail: 'Add narrative.states / narrative.events / narrative.scenarios — a partial narrative is valid and its coverage is reported; write the states first and fill the rest in later.',
|
|
1141
1359
|
})
|
|
1360
|
+
} else {
|
|
1361
|
+
// A narrative that covers part of the model is valid: states first, events and
|
|
1362
|
+
// scenarios later is the natural authoring order. The gap is reported as coverage
|
|
1363
|
+
// (and as this info finding), never as a validation failure.
|
|
1364
|
+
const coverage = narrativeCoverageOf(model)
|
|
1365
|
+
if (coverage !== undefined && !narrativeComplete(coverage)) {
|
|
1366
|
+
findings.push({
|
|
1367
|
+
code: 'UML027_NARRATIVE_PARTIAL',
|
|
1368
|
+
severity: 'info',
|
|
1369
|
+
message: 'the narrative covers part of the model: states ' + coverage.states + ', events ' + coverage.events + ', scenarios ' + coverage.scenarios + '. Every uncovered symbol still has to be re-derived from the source.',
|
|
1370
|
+
detail: 'narrativeCoverage carries the same numbers; complete the missing entries when the behaviour settles.',
|
|
1371
|
+
evidence: { narrativeCoverage: coverage },
|
|
1372
|
+
})
|
|
1373
|
+
}
|
|
1142
1374
|
}
|
|
1143
1375
|
|
|
1144
1376
|
const documented = labels === undefined ? undefined : Object.keys(labels).filter((id) => documentedMeaning(labels[id], id) !== undefined)
|
|
@@ -1229,7 +1461,7 @@ function compiledModel(input: unknown): LogicModelV1 {
|
|
|
1229
1461
|
return validation.model
|
|
1230
1462
|
}
|
|
1231
1463
|
|
|
1232
|
-
function roundTripOf(model: LogicModelV1, notation: UmlNotation, diagram: UmlDiagram, maxSteps: number): { report: UmlRoundTripReport; primary: string } {
|
|
1464
|
+
function roundTripOf(model: LogicModelV1, notation: UmlNotation, diagram: UmlDiagram, maxSteps: number): { report: UmlRoundTripReport; primary: string; parsed: UmlParseResult } {
|
|
1233
1465
|
const rendered = renderUml(model, notation, diagram, maxSteps)
|
|
1234
1466
|
const parsed = parseUml(rendered.primary, notation)
|
|
1235
1467
|
const diffs = diffModels(model, parsed.model)
|
|
@@ -1238,12 +1470,14 @@ function roundTripOf(model: LogicModelV1, notation: UmlNotation, diagram: UmlDia
|
|
|
1238
1470
|
notation,
|
|
1239
1471
|
diagram,
|
|
1240
1472
|
ok: diffs.length === 0,
|
|
1473
|
+
hashSpec: DEFAULT_HASH_SPEC,
|
|
1241
1474
|
modelHash: modelHash(model),
|
|
1242
1475
|
parsedHash: modelHash(parsed.model),
|
|
1243
1476
|
diffs,
|
|
1244
1477
|
warnings: [...rendered.warnings, ...parsed.warnings],
|
|
1245
1478
|
},
|
|
1246
1479
|
primary: rendered.primary,
|
|
1480
|
+
parsed,
|
|
1247
1481
|
}
|
|
1248
1482
|
}
|
|
1249
1483
|
|
|
@@ -1320,18 +1554,28 @@ export function reviewUml(options: UmlReviewOptions): UmlReviewReport {
|
|
|
1320
1554
|
let parsed: UmlParseResult | undefined
|
|
1321
1555
|
let primary: string | undefined
|
|
1322
1556
|
let roundTrip: UmlRoundTripReport | null = null
|
|
1557
|
+
const metadataKeys = metadataKeysOf(hasModel ? options.model : undefined)
|
|
1558
|
+
const metadata = metadataKeys.length === 0 ? {} : { metadataKeys }
|
|
1559
|
+
let discardedConstructs: DiscardedConstruct[] = []
|
|
1560
|
+
let discardedEdges = 0
|
|
1323
1561
|
|
|
1324
1562
|
if (hasDiagram) {
|
|
1325
1563
|
try {
|
|
1326
1564
|
parsed = parseUml(options.diagram as string, options.notation ?? 'auto')
|
|
1327
1565
|
} catch (error) {
|
|
1328
1566
|
const message = error instanceof Error ? error.message : String(error)
|
|
1567
|
+
const unreadable: UmlFinding = { code: 'UML001_DIAGRAM_UNREADABLE', severity: 'error', message, detail: 'The diagram could not be read as a Mermaid/PlantUML state or activity diagram.' }
|
|
1329
1568
|
return {
|
|
1330
1569
|
ok: false,
|
|
1570
|
+
ran: true,
|
|
1571
|
+
...verdictOfFindings([unreadable]),
|
|
1572
|
+
schema: REPORT_SCHEMAS.umlReview,
|
|
1331
1573
|
source: hasModel ? 'model+diagram' : 'diagram',
|
|
1332
1574
|
summary: { errors: 1, warnings: 0, info: 0, states: 0, events: 0, transitions: 0, terminalStates: 0, reachableStates: 0, documentedStates: 0 },
|
|
1333
|
-
findings: [
|
|
1575
|
+
findings: [unreadable],
|
|
1334
1576
|
roundTrip: null,
|
|
1577
|
+
...metadata,
|
|
1578
|
+
hashes: { hashSpec: DEFAULT_HASH_SPEC },
|
|
1335
1579
|
warnings,
|
|
1336
1580
|
nextSteps: ['Fix the diagram syntax (or render one from a model with logicprobe_uml action=render) and review again.'],
|
|
1337
1581
|
}
|
|
@@ -1339,6 +1583,13 @@ export function reviewUml(options: UmlReviewOptions): UmlReviewReport {
|
|
|
1339
1583
|
notation = parsed.notation
|
|
1340
1584
|
labels = parsed.labels
|
|
1341
1585
|
warnings.push(...parsed.warnings)
|
|
1586
|
+
// A diagram from another family parses into something; that something must never
|
|
1587
|
+
// be read as a model of this file, so the parse defects are review findings here too.
|
|
1588
|
+
findings.push(...parseFindings(parsed))
|
|
1589
|
+
if (parsed.discardedConstructs.length > 0) {
|
|
1590
|
+
discardedConstructs = parsed.discardedConstructs
|
|
1591
|
+
discardedEdges = parsed.discardedEdges
|
|
1592
|
+
}
|
|
1342
1593
|
if (hasModel) {
|
|
1343
1594
|
model = compiledModel(options.model)
|
|
1344
1595
|
const diffs = diffModels(model, parsed.model)
|
|
@@ -1346,6 +1597,7 @@ export function reviewUml(options: UmlReviewOptions): UmlReviewReport {
|
|
|
1346
1597
|
notation: parsed.notation,
|
|
1347
1598
|
diagram: parsed.diagram,
|
|
1348
1599
|
ok: diffs.length === 0,
|
|
1600
|
+
hashSpec: DEFAULT_HASH_SPEC,
|
|
1349
1601
|
modelHash: modelHash(model),
|
|
1350
1602
|
parsedHash: modelHash(parsed.model),
|
|
1351
1603
|
diffs,
|
|
@@ -1431,13 +1683,18 @@ export function reviewUml(options: UmlReviewOptions): UmlReviewReport {
|
|
|
1431
1683
|
? (model.narrative?.states === undefined ? 0 : Object.keys(model.narrative.states).length)
|
|
1432
1684
|
: Object.keys(labels).filter((id) => documentedMeaning(labels?.[id], id) !== undefined).length
|
|
1433
1685
|
const events = new Set(model.transitions.map((transition) => transition.event))
|
|
1686
|
+
const narrativeCoverage = narrativeCoverageOf(model)
|
|
1434
1687
|
const nextSteps: string[] = []
|
|
1435
1688
|
if (errors > 0) nextSteps.push('Resolve the error findings first — a diagram that cannot be read (or that disagrees with its model) will mislead every later review.')
|
|
1689
|
+
if (findings.some((finding) => finding.code === 'UML_NOT_A_STATE_DIAGRAM')) nextSteps.push('This text is not a state or activity diagram: keep it as its own view and model the machine as a state/activity diagram before reviewing it.')
|
|
1436
1690
|
nextSteps.push('Run logicprobe_verify on this model for the behavioural checks (S1-S8 structural, A1-A14 adversarial); the review above covers modelling, not behaviour.')
|
|
1437
1691
|
if (model.narrative === undefined) nextSteps.push('Add narrative.states/events/scenarios so the diagram is readable against the code.')
|
|
1438
1692
|
if (findings.some((finding) => finding.code === 'UML011_UNBOUNDED_VARIABLE')) nextSteps.push('Declare min/max (or boundaryChecks) before relying on A5 boundary probes.')
|
|
1439
1693
|
return {
|
|
1440
1694
|
ok: true,
|
|
1695
|
+
ran: true,
|
|
1696
|
+
...verdictOfFindings(findings),
|
|
1697
|
+
schema: REPORT_SCHEMAS.umlReview,
|
|
1441
1698
|
source: hasModel && hasDiagram ? 'model+diagram' : (hasDiagram ? 'diagram' : 'model'),
|
|
1442
1699
|
summary: {
|
|
1443
1700
|
errors,
|
|
@@ -1455,6 +1712,13 @@ export function reviewUml(options: UmlReviewOptions): UmlReviewReport {
|
|
|
1455
1712
|
...(labels === undefined ? {} : { labels }),
|
|
1456
1713
|
...(hasDiagram ? { model } : {}),
|
|
1457
1714
|
...(primary === undefined ? {} : { primary }),
|
|
1715
|
+
...metadata,
|
|
1716
|
+
...(narrativeCoverage === undefined ? {} : { narrativeCoverage }),
|
|
1717
|
+
hashes: {
|
|
1718
|
+
hashSpec: DEFAULT_HASH_SPEC,
|
|
1719
|
+
...(roundTrip === null ? {} : { modelHash: roundTrip.modelHash, parsedHash: roundTrip.parsedHash }),
|
|
1720
|
+
},
|
|
1721
|
+
...(discardedConstructs.length === 0 ? {} : { discardedConstructs, discardedEdges }),
|
|
1458
1722
|
warnings,
|
|
1459
1723
|
nextSteps,
|
|
1460
1724
|
}
|