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
package/lib/uml.js CHANGED
@@ -30,7 +30,7 @@
30
30
  *
31
31
  * @module logicprobe-uml
32
32
  */
33
- import { validateModel, modelHash } from './engine.js';
33
+ import { REPORT_SCHEMAS, validateModel, modelHash, metadataKeysOf, narrativeComplete, narrativeCoverageOf, verdictOfFindings, DEFAULT_HASH_SPEC } from './engine.js';
34
34
  export const UML_NOTATIONS = ['mermaid', 'plantuml'];
35
35
  export const UML_DIAGRAMS = ['state', 'activity', 'sequence'];
36
36
  /** Marker every generated diagram carries; renderers ignore it, the parser uses it. */
@@ -41,7 +41,16 @@ const UNSAFE_LABEL = /[[\]/\n\r\t]/;
41
41
  const RESERVED_NODE_IDS = new Set(['end', 'graph', 'subgraph', 'class', 'classDef', 'click', 'style', 'linkStyle', 'direction']);
42
42
  /** Ids the notation can spell without an alias. */
43
43
  const PLAIN_ID = /^[A-Za-z_][A-Za-z0-9_]*$/;
44
+ /**
45
+ * A UML front-end refusal. `code` lets the tool report *why* it refused instead of
46
+ * collapsing every refusal into a generic input error.
47
+ */
44
48
  export class UmlError extends Error {
49
+ code;
50
+ constructor(message, code = 'UML_INPUT') {
51
+ super(message);
52
+ this.code = code;
53
+ }
45
54
  }
46
55
  // ---------------------------------------------------------------------------
47
56
  // Shared helpers
@@ -538,11 +547,36 @@ function parseTransitionLabel(label, warnings) {
538
547
  throw new UmlError('transition label "' + label + '" carries no event name; label the arrow as `event [guard] / actions`');
539
548
  return { event, ...(guard === undefined ? {} : { guard }), ...(updates === undefined ? {} : { updates }) };
540
549
  }
550
+ /**
551
+ * Mermaid diagram-family headers that are neither a state machine nor a flow.
552
+ * Recognizing them matters twice: the text must not be reported as "cannot tell
553
+ * whether this is Mermaid or PlantUML", and it must not be parsed as a state
554
+ * diagram — every line of it would be ignored or misread, and the empty model that
555
+ * comes out would be presented as if it described the file.
556
+ */
557
+ const MERMAID_OTHER_FAMILIES = [
558
+ 'classDiagram', 'erDiagram', 'gantt', 'pie', 'journey', 'mindmap', 'gitGraph',
559
+ 'C4Context', 'C4Container', 'C4Component', 'C4Dynamic', 'C4Deployment',
560
+ 'requirementDiagram', 'timeline', 'quadrantChart', 'sankey-beta', 'block-beta',
561
+ 'packet-beta', 'architecture-beta', 'radar-beta', 'treemap-beta', 'xychart-beta',
562
+ ];
563
+ /** The other-family header this text declares, if any. */
564
+ function otherMermaidFamily(text) {
565
+ for (const family of MERMAID_OTHER_FAMILIES) {
566
+ if (new RegExp('^\\s*' + family + '\\b', 'm').test(text))
567
+ return family;
568
+ }
569
+ return undefined;
570
+ }
541
571
  function detectNotation(text) {
542
572
  if (/^\s*@start/m.test(text))
543
573
  return 'plantuml';
544
574
  if (/^\s*(stateDiagram|stateDiagram-v2|flowchart|graph|sequenceDiagram)\b/m.test(text))
545
575
  return 'mermaid';
576
+ // A known Mermaid family is still Mermaid: say so, and let detectDiagram refuse it
577
+ // by name instead of blaming the notation.
578
+ if (otherMermaidFamily(text) !== undefined)
579
+ return 'mermaid';
546
580
  throw new UmlError('cannot tell whether this is Mermaid or PlantUML text: expected `stateDiagram-v2` / `flowchart` / `sequenceDiagram`, or `@startuml`');
547
581
  }
548
582
  function detectDiagram(text) {
@@ -552,6 +586,10 @@ function detectDiagram(text) {
552
586
  return 'activity';
553
587
  if (/^\s*sequenceDiagram\b/m.test(text))
554
588
  return 'sequence';
589
+ const family = otherMermaidFamily(text);
590
+ if (family !== undefined) {
591
+ 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');
592
+ }
555
593
  if (/^\s*@startuml/m.test(text)) {
556
594
  // PlantUML declares the diagram kind by its body; the state keyword is the only
557
595
  // structural one logicprobe emits, everything else in that family is a state diagram too.
@@ -767,10 +805,50 @@ function inferVariables(transitions, declared) {
767
805
  }
768
806
  return [...kinds.entries()].map(([name, kind]) => ({ name, kind, init: kind === 'boolean' ? false : 0 }));
769
807
  }
808
+ /**
809
+ * PlantUML keywords that declare a construct LogicModelV1 has no place for. Their
810
+ * presence marks the text as a component, package, class, deployment or database
811
+ * diagram — a different diagram family. A state parser still reads *something* out of
812
+ * such a file (the arrows look like transitions), which is why the discarded
813
+ * declarations are collected and reported instead of being ignored.
814
+ */
815
+ 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;
816
+ /** The node identifier a declaration introduces, when it has one (`… as ID`, or a bare `component ID`). */
817
+ function declaredIdentifier(text) {
818
+ const alias = /\bas\s+([A-Za-z_][A-Za-z0-9_]*)\s*;?$/.exec(text);
819
+ if (alias !== null)
820
+ return alias[1];
821
+ const bare = /^[A-Za-z]+\s+([A-Za-z_][A-Za-z0-9_]*)\s*;?$/.exec(text);
822
+ return bare === null ? undefined : bare[1];
823
+ }
824
+ /**
825
+ * Findings a parse result carries on its own. A diagram from another family parses
826
+ * into something; without this finding that something is presented as a model of the
827
+ * file, which is a false guarantee of exactly the kind this plugin exists to prevent.
828
+ */
829
+ export function parseFindings(parsed) {
830
+ if (parsed.discardedConstructs.length === 0)
831
+ return [];
832
+ const counts = new Map();
833
+ for (const entry of parsed.discardedConstructs)
834
+ counts.set(entry.construct, (counts.get(entry.construct) ?? 0) + 1);
835
+ const summary = [...counts.entries()]
836
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
837
+ .map(([construct, count]) => construct + ' ×' + String(count))
838
+ .join(', ');
839
+ const shown = parsed.discardedConstructs.slice(0, 12).map((entry) => 'line ' + String(entry.line) + ': ' + entry.text).join(' | ');
840
+ return [{
841
+ code: 'UML_NOT_A_STATE_DIAGRAM',
842
+ severity: 'error',
843
+ message: 'the text is not a state or activity diagram: ' + String(parsed.discardedConstructs.length) + ' declaration(s) of unsupported construct(s) (' + summary + ') and ' + String(parsed.discardedEdges) + ' arrow(s) between them were read as states and transitions, so the parsed model is not this diagram.',
844
+ detail: shown + (parsed.discardedConstructs.length > 12 ? ' | … ' + String(parsed.discardedConstructs.length - 12) + ' more' : ''),
845
+ }];
846
+ }
770
847
  function parseStateDiagram(text, notation) {
771
848
  const lines = text.split(/\r?\n/);
772
849
  const { directives, body } = readDirectives(lines, notation);
773
- const raw = { states: [], display: new Map(), edges: [], terminals: [], finalMarks: [], warnings: [] };
850
+ const raw = { states: [], display: new Map(), edges: [], terminals: [], finalMarks: [], warnings: [], discarded: [], discardedNodes: new Set() };
851
+ let discardedEdges = 0;
774
852
  const declared = new Set();
775
853
  const declare = (name) => { if (!declared.has(name)) {
776
854
  declared.add(name);
@@ -779,49 +857,74 @@ function parseStateDiagram(text, notation) {
779
857
  const skip = notation === 'mermaid'
780
858
  ? /^(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|%%\{)/
781
859
  : /^(@startuml|@enduml|scale\b|skinparam\b|title\b|hide\b|left to right direction|top to bottom direction|autonumber|!theme)/;
782
- for (const rawLine of body) {
860
+ let inNote = false;
861
+ body.forEach((rawLine, index) => {
783
862
  const line = rawLine.trim();
784
863
  if (line === '' || (line.startsWith('--') && !line.includes('-->')))
785
- continue;
864
+ return;
786
865
  if (skip.test(line))
787
- continue;
866
+ return;
867
+ const noteStart = /^note\b/i.test(line);
868
+ const noteEnd = /^end\s*note$/i.test(line);
869
+ if (notation === 'plantuml' && !inNote && !noteStart && !noteEnd) {
870
+ const other = PLANTUML_OTHER_CONSTRUCT.exec(line);
871
+ if (other !== null) {
872
+ raw.discarded.push({ construct: other[1].toLowerCase(), line: index + 1, text: line });
873
+ const id = declaredIdentifier(line);
874
+ if (id !== undefined)
875
+ raw.discardedNodes.add(id);
876
+ return;
877
+ }
878
+ }
879
+ if (noteEnd) {
880
+ inNote = false;
881
+ return;
882
+ }
883
+ // A multi-line note body is prose, not a statement: skip it silently rather than
884
+ // reporting one ignored line per line of text.
885
+ if (inNote)
886
+ return;
887
+ if (noteStart && !/:\s*.+$/.test(line)) {
888
+ inNote = true;
889
+ return;
890
+ }
788
891
  const note = /^note\s+(?:over|right of|left of)\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.+)$/i.exec(line);
789
892
  if (note !== null) {
790
893
  declare(note[1]);
791
894
  const existing = raw.display.get(note[1]);
792
895
  if (existing === undefined)
793
896
  raw.display.set(note[1], note[2].trim());
794
- continue;
897
+ return;
795
898
  }
796
- if (/^note\b/i.test(line) || /^end\s*note$/i.test(line))
797
- continue;
899
+ if (noteStart)
900
+ return;
798
901
  const stateDecl = /^state\s+"([^"]*)"\s+as\s+([A-Za-z_][A-Za-z0-9_]*)$/.exec(line);
799
902
  if (stateDecl !== null) {
800
903
  declare(stateDecl[2]);
801
904
  raw.display.set(stateDecl[2], stateDecl[1]);
802
- continue;
905
+ return;
803
906
  }
804
907
  const bareState = /^state\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{?$/.exec(line);
805
908
  if (bareState !== null) {
806
909
  declare(bareState[1]);
807
- continue;
910
+ return;
808
911
  }
809
912
  const concurrency = /^\}\s*$|^--\s*$/.test(line);
810
913
  if (concurrency) {
811
914
  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).');
812
- continue;
915
+ return;
813
916
  }
814
917
  const composite = /^state\s+(.+)\s*\{$/.exec(line);
815
918
  if (composite !== null) {
816
919
  raw.warnings.push('UML_PARSE_COMPOSITE_FLATTENED: composite state "' + composite[1].trim() + '" was flattened into its members.');
817
- continue;
920
+ return;
818
921
  }
819
922
  const description = /^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.+)$/.exec(line);
820
923
  if (description !== null) {
821
924
  declare(description[1]);
822
925
  if (!raw.display.has(description[1]))
823
926
  raw.display.set(description[1], description[2].trim());
824
- continue;
927
+ return;
825
928
  }
826
929
  const edge = /^(.+?)\s*-->\s*(.+?)(?:\s*:\s*(.*))?$/.exec(line);
827
930
  if (edge !== null) {
@@ -835,27 +938,29 @@ function parseStateDiagram(text, notation) {
835
938
  else {
836
939
  raw.initialState = to;
837
940
  }
838
- continue;
941
+ return;
839
942
  }
840
943
  if (to === '[*]') {
841
944
  raw.finalMarks.push(from);
842
- continue;
945
+ return;
843
946
  }
844
947
  declare(from);
845
948
  declare(to);
949
+ if (raw.discardedNodes.has(from) || raw.discardedNodes.has(to))
950
+ discardedEdges += 1;
846
951
  raw.edges.push({ from, to, label });
847
- continue;
952
+ return;
848
953
  }
849
954
  raw.warnings.push('UML_PARSE_IGNORED_LINE: "' + line + '" is not a state diagram statement; it was ignored.');
850
- }
955
+ });
851
956
  const model = rawToModel(raw, directives, raw.warnings);
852
957
  const labels = mapLabels(raw.display, directives);
853
- return { notation, diagram: 'state', model, labels, warnings: raw.warnings };
958
+ return { notation, diagram: 'state', model, labels, discardedConstructs: raw.discarded, discardedEdges, warnings: raw.warnings };
854
959
  }
855
960
  function parseActivityDiagram(text, notation) {
856
961
  const lines = text.split(/\r?\n/);
857
962
  const { directives, body } = readDirectives(lines, notation);
858
- const raw = { states: [], display: new Map(), edges: [], terminals: [], finalMarks: [], warnings: [] };
963
+ const raw = { states: [], display: new Map(), edges: [], terminals: [], finalMarks: [], warnings: [], discarded: [], discardedNodes: new Set() };
859
964
  const declared = new Set();
860
965
  const declare = (name) => { if (!declared.has(name)) {
861
966
  declared.add(name);
@@ -906,7 +1011,7 @@ function parseActivityDiagram(text, notation) {
906
1011
  }
907
1012
  const model = rawToModel(raw, directives, raw.warnings);
908
1013
  const labels = mapLabels(raw.display, directives);
909
- return { notation, diagram: 'activity', model, labels, warnings: raw.warnings };
1014
+ return { notation, diagram: 'activity', model, labels, discardedConstructs: [], discardedEdges: 0, warnings: raw.warnings };
910
1015
  }
911
1016
  /**
912
1017
  * Read one node reference (`A`, `A["label"]`, `A(["label"])`) and register the id
@@ -944,6 +1049,47 @@ function mapLabels(display, directives) {
944
1049
  out[directives.aliases.get(name) ?? name] = label;
945
1050
  return out;
946
1051
  }
1052
+ /**
1053
+ * What the front end expects of labels and comments, so "why does my hand-drawn diagram
1054
+ * report label drift or ignored lines?" has a printed answer instead of a guess.
1055
+ * `tests/uml/run.mjs` feeds every claim here back through the parser, so this text cannot
1056
+ * drift away from the behaviour it describes.
1057
+ */
1058
+ export function explainLabels(notation = 'mermaid') {
1059
+ const prefix = commentPrefix(notation);
1060
+ return {
1061
+ notation,
1062
+ directivePrefix: prefix + DIRECTIVE_NAMESPACE,
1063
+ directiveLines: [
1064
+ { example: prefix + DIRECTIVE_NAMESPACE + 'uml v1 notation=' + notation + ' diagram=state', meaning: 'declares the notation and diagram kind the file was rendered for' },
1065
+ { example: prefix + DIRECTIVE_NAMESPACE + 'init ID', meaning: 'pins the initial state (otherwise it is inferred as the only state nothing enters)' },
1066
+ { example: prefix + DIRECTIVE_NAMESPACE + 'terminal ID', meaning: 'marks a terminal state; comma-separate several' },
1067
+ { example: prefix + DIRECTIVE_NAMESPACE + 'alias NOTATION_ID model_id', meaning: 'restores a state id the notation cannot spell verbatim' },
1068
+ { example: prefix + DIRECTIVE_NAMESPACE + 'variable NAME integer', meaning: 'restores a variable and its kind' },
1069
+ ],
1070
+ acceptedLabelForms: [
1071
+ { form: 'bare id', example: 'state "IDLE" as IDLE', note: 'counts as NO meaning: the state stays undocumented (UML014)' },
1072
+ { form: 'ID(meaning)', example: 'state "IDLE(waiting for power)" as IDLE', note: 'the form the renderer writes; full-width parentheses' },
1073
+ { form: 'ID(meaning)', example: 'state "IDLE(waiting for power)" as IDLE', note: 'accepted as well' },
1074
+ { form: 'a description line', example: 'IDLE : waiting for power', note: 'the same meaning written as a state description' },
1075
+ { form: 'a single-line note', example: 'note right of IDLE : waiting for power', note: 'read as the state meaning, not as a comment' },
1076
+ { form: 'any other text', example: 'state "waiting for power" as IDLE', note: 'taken as the meaning verbatim' },
1077
+ ],
1078
+ renderedForm: 'state "ID(meaning)" as ID',
1079
+ ignoredLines: [
1080
+ { pattern: 'a `' + prefix + '` comment line that is not a `logicprobe:` directive', behaviour: 'UML_PARSE_IGNORED_LINE warning: it carries no state-machine statement' },
1081
+ { pattern: 'a `note …` block written over several lines', behaviour: 'skipped silently (a block is not a state meaning)' },
1082
+ { pattern: 'notation chrome: @startuml/@enduml, stateDiagram-v2, direction, classDef/style/linkStyle/click, scale, skinparam, title, hide, autonumber', behaviour: 'skipped silently' },
1083
+ { pattern: 'anything else the parser cannot read', behaviour: 'UML_PARSE_IGNORED_LINE warning, and the model is built from what it did read' },
1084
+ ],
1085
+ rules: [
1086
+ '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.',
1087
+ 'A meaning that differs only in wording is still drift — one of the two is stale, and the review cannot tell which.',
1088
+ 'Keep non-directive comments out of the diagram: `logicprobe:` directives are the only comment lines the parser consumes.',
1089
+ 'A missing label is not an error: the state renders as its bare id and is reported as undocumented (UML014).',
1090
+ ],
1091
+ };
1092
+ }
947
1093
  /**
948
1094
  * Parse a Mermaid or PlantUML diagram back into a LogicModelV1.
949
1095
  *
@@ -959,7 +1105,7 @@ export function parseUml(text, notation = 'auto') {
959
1105
  const resolved = notation === 'auto' ? detectNotation(text) : notation;
960
1106
  const kind = detectDiagram(text);
961
1107
  if (kind === 'sequence') {
962
- 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.');
1108
+ 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');
963
1109
  }
964
1110
  return kind === 'state' ? parseStateDiagram(text, resolved) : parseActivityDiagram(text, resolved);
965
1111
  }
@@ -1198,9 +1344,24 @@ function structuralFindings(model, labels, warnings) {
1198
1344
  code: 'UML013_NO_NARRATIVE',
1199
1345
  severity: 'info',
1200
1346
  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.',
1201
- detail: 'Add narrative.states / narrative.events / narrative.scenarios — the schema requires all three and full coverage once the block is present.',
1347
+ 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.',
1202
1348
  });
1203
1349
  }
1350
+ else {
1351
+ // A narrative that covers part of the model is valid: states first, events and
1352
+ // scenarios later is the natural authoring order. The gap is reported as coverage
1353
+ // (and as this info finding), never as a validation failure.
1354
+ const coverage = narrativeCoverageOf(model);
1355
+ if (coverage !== undefined && !narrativeComplete(coverage)) {
1356
+ findings.push({
1357
+ code: 'UML027_NARRATIVE_PARTIAL',
1358
+ severity: 'info',
1359
+ 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.',
1360
+ detail: 'narrativeCoverage carries the same numbers; complete the missing entries when the behaviour settles.',
1361
+ evidence: { narrativeCoverage: coverage },
1362
+ });
1363
+ }
1364
+ }
1204
1365
  const documented = labels === undefined ? undefined : Object.keys(labels).filter((id) => documentedMeaning(labels[id], id) !== undefined);
1205
1366
  if (documented !== undefined) {
1206
1367
  const undocumented = model.states.map((state) => state.id).filter((id) => documentedMeaning(labels?.[id], id) === undefined);
@@ -1314,12 +1475,14 @@ function roundTripOf(model, notation, diagram, maxSteps) {
1314
1475
  notation,
1315
1476
  diagram,
1316
1477
  ok: diffs.length === 0,
1478
+ hashSpec: DEFAULT_HASH_SPEC,
1317
1479
  modelHash: modelHash(model),
1318
1480
  parsedHash: modelHash(parsed.model),
1319
1481
  diffs,
1320
1482
  warnings: [...rendered.warnings, ...parsed.warnings],
1321
1483
  },
1322
1484
  primary: rendered.primary,
1485
+ parsed,
1323
1486
  };
1324
1487
  }
1325
1488
  /** Compare two machines by structure — the fidelity measure behind the round-trip check. */
@@ -1395,18 +1558,28 @@ export function reviewUml(options) {
1395
1558
  let parsed;
1396
1559
  let primary;
1397
1560
  let roundTrip = null;
1561
+ const metadataKeys = metadataKeysOf(hasModel ? options.model : undefined);
1562
+ const metadata = metadataKeys.length === 0 ? {} : { metadataKeys };
1563
+ let discardedConstructs = [];
1564
+ let discardedEdges = 0;
1398
1565
  if (hasDiagram) {
1399
1566
  try {
1400
1567
  parsed = parseUml(options.diagram, options.notation ?? 'auto');
1401
1568
  }
1402
1569
  catch (error) {
1403
1570
  const message = error instanceof Error ? error.message : String(error);
1571
+ const unreadable = { code: 'UML001_DIAGRAM_UNREADABLE', severity: 'error', message, detail: 'The diagram could not be read as a Mermaid/PlantUML state or activity diagram.' };
1404
1572
  return {
1405
1573
  ok: false,
1574
+ ran: true,
1575
+ ...verdictOfFindings([unreadable]),
1576
+ schema: REPORT_SCHEMAS.umlReview,
1406
1577
  source: hasModel ? 'model+diagram' : 'diagram',
1407
1578
  summary: { errors: 1, warnings: 0, info: 0, states: 0, events: 0, transitions: 0, terminalStates: 0, reachableStates: 0, documentedStates: 0 },
1408
- findings: [{ code: 'UML001_DIAGRAM_UNREADABLE', severity: 'error', message, detail: 'The diagram could not be read as a Mermaid/PlantUML state or activity diagram.' }],
1579
+ findings: [unreadable],
1409
1580
  roundTrip: null,
1581
+ ...metadata,
1582
+ hashes: { hashSpec: DEFAULT_HASH_SPEC },
1410
1583
  warnings,
1411
1584
  nextSteps: ['Fix the diagram syntax (or render one from a model with logicprobe_uml action=render) and review again.'],
1412
1585
  };
@@ -1414,6 +1587,13 @@ export function reviewUml(options) {
1414
1587
  notation = parsed.notation;
1415
1588
  labels = parsed.labels;
1416
1589
  warnings.push(...parsed.warnings);
1590
+ // A diagram from another family parses into something; that something must never
1591
+ // be read as a model of this file, so the parse defects are review findings here too.
1592
+ findings.push(...parseFindings(parsed));
1593
+ if (parsed.discardedConstructs.length > 0) {
1594
+ discardedConstructs = parsed.discardedConstructs;
1595
+ discardedEdges = parsed.discardedEdges;
1596
+ }
1417
1597
  if (hasModel) {
1418
1598
  model = compiledModel(options.model);
1419
1599
  const diffs = diffModels(model, parsed.model);
@@ -1421,6 +1601,7 @@ export function reviewUml(options) {
1421
1601
  notation: parsed.notation,
1422
1602
  diagram: parsed.diagram,
1423
1603
  ok: diffs.length === 0,
1604
+ hashSpec: DEFAULT_HASH_SPEC,
1424
1605
  modelHash: modelHash(model),
1425
1606
  parsedHash: modelHash(parsed.model),
1426
1607
  diffs,
@@ -1511,9 +1692,12 @@ export function reviewUml(options) {
1511
1692
  ? (model.narrative?.states === undefined ? 0 : Object.keys(model.narrative.states).length)
1512
1693
  : Object.keys(labels).filter((id) => documentedMeaning(labels?.[id], id) !== undefined).length;
1513
1694
  const events = new Set(model.transitions.map((transition) => transition.event));
1695
+ const narrativeCoverage = narrativeCoverageOf(model);
1514
1696
  const nextSteps = [];
1515
1697
  if (errors > 0)
1516
1698
  nextSteps.push('Resolve the error findings first — a diagram that cannot be read (or that disagrees with its model) will mislead every later review.');
1699
+ if (findings.some((finding) => finding.code === 'UML_NOT_A_STATE_DIAGRAM'))
1700
+ 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.');
1517
1701
  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.');
1518
1702
  if (model.narrative === undefined)
1519
1703
  nextSteps.push('Add narrative.states/events/scenarios so the diagram is readable against the code.');
@@ -1521,6 +1705,9 @@ export function reviewUml(options) {
1521
1705
  nextSteps.push('Declare min/max (or boundaryChecks) before relying on A5 boundary probes.');
1522
1706
  return {
1523
1707
  ok: true,
1708
+ ran: true,
1709
+ ...verdictOfFindings(findings),
1710
+ schema: REPORT_SCHEMAS.umlReview,
1524
1711
  source: hasModel && hasDiagram ? 'model+diagram' : (hasDiagram ? 'diagram' : 'model'),
1525
1712
  summary: {
1526
1713
  errors,
@@ -1538,6 +1725,13 @@ export function reviewUml(options) {
1538
1725
  ...(labels === undefined ? {} : { labels }),
1539
1726
  ...(hasDiagram ? { model } : {}),
1540
1727
  ...(primary === undefined ? {} : { primary }),
1728
+ ...metadata,
1729
+ ...(narrativeCoverage === undefined ? {} : { narrativeCoverage }),
1730
+ hashes: {
1731
+ hashSpec: DEFAULT_HASH_SPEC,
1732
+ ...(roundTrip === null ? {} : { modelHash: roundTrip.modelHash, parsedHash: roundTrip.parsedHash }),
1733
+ },
1734
+ ...(discardedConstructs.length === 0 ? {} : { discardedConstructs, discardedEdges }),
1541
1735
  warnings,
1542
1736
  nextSteps,
1543
1737
  };
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Logic Probe",
4
+ "description": "Verifies claims about designs and code. Facts go against the source. Behaviour goes through an executable model."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "逻辑探针",
4
+ "description": "核验设计与代码里的声称:事实类对着源码查,行为类跑可执行模型。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-logicprobe",
3
- "version": "0.8.1",
4
- "description": "Design document & plan claim verification — enumerate claims, verify against codebase facts, then escalate to state-machine verification (S1-S8/A1-A12, including budget/worst-case path-cost checks) and data-model verification (DS/DA/DD) for behavioral claims. Supports before/after regression, idempotency/monotonic/sequence/leads-to/atomicity constraints, and concurrency risk mining. Ships a native DeepSeek Harness (dsh) bundle that injects the claim-verification gate into the first model step.",
3
+ "version": "1.0.0",
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",
7
7
  "types": "lib/types/index.d.ts",
@@ -12,13 +12,15 @@
12
12
  },
13
13
  "./cordis.patch.yml": "./cordis.patch.yml",
14
14
  "./client": "./lib/client.js",
15
- "./package.json": "./package.json"
15
+ "./package.json": "./package.json",
16
+ "./locale/*.json": "./locale/*.json"
16
17
  },
17
18
  "files": [
18
19
  "lib",
19
20
  "src",
20
21
  "skills",
21
- "cordis.patch.yml"
22
+ "cordis.patch.yml",
23
+ "locale"
22
24
  ],
23
25
  "dsh": {
24
26
  "engines": {
@@ -38,7 +40,7 @@
38
40
  ]
39
41
  },
40
42
  "compatibility": {
41
- "dsh": "^0.1.0-rc.7 || ^0.1.1-rc.1 || ^0.1.2-alpha.2 || ^0.1.2-alpha.3 || ^0.1.2-alpha.4 || ^0.1.2-alpha.5 || ^0.1.2-rc.1 || ^0.1.3-alpha.1 || ^0.1.3-alpha.2 || ^0.1.5-alpha.1 || ^0.1.5-rc.1 || ^0.1.5-alpha.2 || ^0.1.5-rc.2 || ^0.1.5-rc.3 || ^0.1.6-alpha.1 || ^0.1.6-alpha.2 || ^0.1.7-alpha.1 || ^0.1.7-alpha.2 || ^0.1.7-rc.1 || ^0.1.7-rc.2 || ^0.2.0-rc.1 || ^0.2.1-alpha.1",
43
+ "dsh": "^0.1.0-rc.7 || ^0.1.1-rc.1 || ^0.1.2-alpha.2 || ^0.1.2-alpha.3 || ^0.1.2-alpha.4 || ^0.1.2-alpha.5 || ^0.1.2-rc.1 || ^0.1.3-alpha.1 || ^0.1.3-alpha.2 || ^0.1.5-alpha.1 || ^0.1.5-rc.1 || ^0.1.5-alpha.2 || ^0.1.5-rc.2 || ^0.1.5-rc.3 || ^0.1.6-alpha.1 || ^0.1.6-alpha.2 || ^0.1.7-alpha.1 || ^0.1.7-alpha.2 || ^0.1.7-rc.1 || ^0.1.7-rc.2 || ^0.2.0-rc.1 || ^0.2.1-alpha.1 || ^0.2.1-alpha.2",
42
44
  "dshReleases": {
43
45
  "0.1.0-rc.7": "compatible",
44
46
  "0.1.0-rc.8": "compatible",
@@ -64,7 +66,8 @@
64
66
  "0.1.7-rc.2": "compatible",
65
67
  "0.2.0-rc.1": "compatible",
66
68
  "0.2.0-rc.2": "compatible",
67
- "0.2.1-alpha.1": "compatible"
69
+ "0.2.1-alpha.1": "compatible",
70
+ "0.2.1-alpha.2": "compatible"
68
71
  },
69
72
  "profiles": [
70
73
  "headless",
@@ -76,17 +79,17 @@
76
79
  "build": "tsc -p tsconfig.json && node scripts/build-client.mjs",
77
80
  "typecheck": "tsc -p tsconfig.json --noEmit",
78
81
  "bump": "node scripts/bump-version.mjs",
79
- "test:engine": "npm run build && node tests/engine/run.mjs && node tests/data-engine/run.mjs && node tests/concurrency/run.mjs && node tests/uml/run.mjs && node tests/apply-smoke.mjs && node tests/dsh-client-half.test.mjs && node tests/dsh-volatile-fallback.test.mjs && node tests/exporters/run.mjs && node tests/external/run.mjs && node tests/python/run.mjs",
82
+ "test:engine": "npm run build && node tests/engine/run.mjs && node tests/data-engine/run.mjs && node tests/concurrency/run.mjs && node tests/uml/run.mjs && node tests/apply-smoke.mjs && node tests/dsh-client-half.test.mjs && node tests/dsh-volatile-fallback.test.mjs && node tests/claude-session-start-hook.test.mjs && node tests/skills/run.mjs && node tests/structure/run.mjs && node tests/baseline/run.mjs && node tests/granularity/run.mjs && node tests/exporters/run.mjs && node tests/external/run.mjs && node tests/python/run.mjs",
80
83
  "test:full": "npm run build && node tests/full-suite.mjs",
81
84
  "test:python": "npm run build && node tests/python/run.mjs"
82
85
  },
83
86
  "peerDependencies": {
84
87
  "@deepseek-ai/cordis": "^4.0.4",
85
- "@deepseek-ai/dsh-agent": "^0.1.0-rc.6 || ^0.2.0-rc.1 || ^0.2.1-alpha.1",
86
- "@deepseek-ai/dsh-llm": "^0.1.0-rc.6 || ^0.2.0-rc.1 || ^0.2.1-alpha.1",
87
- "@deepseek-ai/dsh-session": "^0.1.0-rc.6 || ^0.2.0-rc.1 || ^0.2.1-alpha.1",
88
- "@deepseek-ai/dsh-skill-filesystem": "^0.1.0-rc.8 || ^0.2.0-rc.1 || ^0.2.1-alpha.1",
89
- "@deepseek-ai/dsh-tools": "^0.1.0-rc.6 || ^0.2.0-rc.1 || ^0.2.1-alpha.1",
88
+ "@deepseek-ai/dsh-agent": "^0.1.0-rc.6 || ^0.2.0-rc.1 || ^0.2.1-alpha.1 || ^0.2.1-alpha.2",
89
+ "@deepseek-ai/dsh-llm": "^0.1.0-rc.6 || ^0.2.0-rc.1 || ^0.2.1-alpha.1 || ^0.2.1-alpha.2",
90
+ "@deepseek-ai/dsh-session": "^0.1.0-rc.6 || ^0.2.0-rc.1 || ^0.2.1-alpha.1 || ^0.2.1-alpha.2",
91
+ "@deepseek-ai/dsh-skill-filesystem": "^0.1.0-rc.8 || ^0.2.0-rc.1 || ^0.2.1-alpha.1 || ^0.2.1-alpha.2",
92
+ "@deepseek-ai/dsh-tools": "^0.1.0-rc.6 || ^0.2.0-rc.1 || ^0.2.1-alpha.1 || ^0.2.1-alpha.2",
90
93
  "@deepseek-ai/schemastery": "^3.18.4"
91
94
  },
92
95
  "devDependencies": {
@@ -104,11 +107,10 @@
104
107
  "@deepseek-ai/dsh-skill": "^0.2.0-rc.2",
105
108
  "@deepseek-ai/dsh-skill-filesystem": "^0.2.0-rc.2",
106
109
  "@deepseek-ai/dsh-system-prompt": "^0.2.0-rc.2",
107
- "@deepseek-ai/dsh-timeout": "^0.2.0-rc.2",
108
110
  "@deepseek-ai/dsh-tools": "^0.2.0-rc.2",
109
111
  "@deepseek-ai/dsh-user-approval": "^0.2.0-rc.2",
110
112
  "@deepseek-ai/schemastery": "^3.18.4",
111
- "@types/node": "^26.6.3",
113
+ "@types/node": "^26.6.4",
112
114
  "typescript": "^7.0.2"
113
115
  },
114
116
  "author": {