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.
Files changed (63) hide show
  1. package/README.en-US.md +128 -66
  2. package/README.md +128 -66
  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.js +14 -0
  7. package/lib/data-engine.js +42 -8
  8. package/lib/engine.js +3151 -2801
  9. package/lib/export-tool.js +27 -2
  10. package/lib/granularity.js +237 -0
  11. package/lib/index.js +63 -41
  12. package/lib/report-diff-tool.js +39 -0
  13. package/lib/structure-tool.js +64 -0
  14. package/lib/structure.js +648 -0
  15. package/lib/tool.js +3 -3
  16. package/lib/types/baseline.d.ts +69 -0
  17. package/lib/types/concurrency.d.ts +10 -0
  18. package/lib/types/data-engine.d.ts +13 -1
  19. package/lib/types/engine.d.ts +419 -277
  20. package/lib/types/granularity.d.ts +106 -0
  21. package/lib/types/index.d.ts +9 -8
  22. package/lib/types/report-diff-tool.d.ts +8 -0
  23. package/lib/types/structure-tool.d.ts +10 -0
  24. package/lib/types/structure.d.ts +156 -0
  25. package/lib/types/tool.d.ts +2 -2
  26. package/lib/types/uml.d.ts +89 -0
  27. package/lib/uml-tool.js +146 -104
  28. package/lib/uml.js +1748 -1544
  29. package/locale/en.json +6 -0
  30. package/locale/zh.json +6 -0
  31. package/package.json +16 -14
  32. package/skills/logicprobe/SKILL.md +84 -58
  33. package/skills/logicprobe/references/dsh-model-schema.md +102 -10
  34. package/skills/logicprobe/references/gap-routing-guide.md +37 -36
  35. package/skills/logicprobe/references/hash-spec.md +153 -0
  36. package/skills/logicprobe/references/logic-verification-guide.md +27 -250
  37. package/skills/logicprobe-concurrency/SKILL.md +43 -0
  38. package/skills/{logicprobe → logicprobe-concurrency}/references/concurrency-risk-guide.md +1 -1
  39. package/skills/logicprobe-datamodel/SKILL.md +5 -4
  40. package/skills/logicprobe-datamodel/references/data-model-guide.md +1 -1
  41. package/skills/logicprobe-datamodel/references/data-model-schema.md +1 -1
  42. package/skills/logicprobe-structure/SKILL.md +95 -0
  43. package/skills/logicprobe-structure/references/structure-review-guide.md +177 -0
  44. package/skills/logicprobe-uml/SKILL.md +57 -0
  45. package/skills/{logicprobe → logicprobe-uml}/references/uml-modeling-guide.md +121 -8
  46. package/src/baseline.ts +180 -0
  47. package/src/client.js +56 -1
  48. package/src/compose-tool.ts +47 -47
  49. package/src/concurrency.ts +23 -0
  50. package/src/data-engine.ts +51 -9
  51. package/src/engine.ts +503 -72
  52. package/src/export-tool.ts +73 -48
  53. package/src/granularity.ts +312 -0
  54. package/src/index.ts +63 -41
  55. package/src/report-diff-tool.ts +43 -0
  56. package/src/structure-tool.ts +68 -0
  57. package/src/structure.ts +724 -0
  58. package/src/tool.ts +3 -3
  59. package/src/uml-tool.ts +53 -10
  60. package/src/uml.ts +288 -24
  61. package/skills/logicprobe/references/logicprobe-engine.py +0 -4943
  62. package/skills/logicprobe/references/verification-harness.py +0 -959
  63. package/skills/logicprobe-datamodel/references/data-model-harness.py +0 -528
@@ -0,0 +1,142 @@
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
+ import { canonicalJson, REPORT_SCHEMAS, verdictOf } from './engine.js';
17
+ /**
18
+ * The stable identity of a finding: check + file + code + a canonical locator. Prose is
19
+ * excluded so a reworded message reads as `changed`, not as a new finding.
20
+ */
21
+ export function findingIdentity(finding) {
22
+ const locator = canonicalJson({
23
+ ...(finding.evidence === undefined ? {} : { evidence: finding.evidence }),
24
+ ...(finding.path === undefined ? {} : { path: finding.path }),
25
+ });
26
+ const where = locator === '{}' ? 'message:' + finding.message : locator;
27
+ const file = finding.file === undefined ? '' : finding.file + '|';
28
+ return (finding.check === undefined ? '' : finding.check + '|') + file + finding.code + '|' + where;
29
+ }
30
+ /** Flatten a report of any family into one finding list (`checks[].findings` or `findings[]`). */
31
+ export function flattenReportFindings(report) {
32
+ if (report === null || typeof report !== 'object')
33
+ return [];
34
+ const record = report;
35
+ const flat = [];
36
+ if (Array.isArray(record.checks)) {
37
+ for (const check of record.checks) {
38
+ const id = typeof check.id === 'string' ? check.id : undefined;
39
+ for (const finding of (Array.isArray(check.findings) ? check.findings : [])) {
40
+ flat.push(id === undefined ? finding : { ...finding, check: id });
41
+ }
42
+ }
43
+ }
44
+ if (Array.isArray(record.findings)) {
45
+ for (const finding of record.findings)
46
+ flat.push(finding);
47
+ }
48
+ return flat;
49
+ }
50
+ /** The report's own verdict, whatever family it came from. */
51
+ function verdictOfReport(report) {
52
+ if (report === null || typeof report !== 'object')
53
+ return 'fail';
54
+ const value = report.verdict;
55
+ return value === 'pass' || value === 'pass_with_findings' || value === 'fail' ? value : 'fail';
56
+ }
57
+ function diffOf(added, removed, addedErrors, firstCode) {
58
+ if (addedErrors > 0) {
59
+ return {
60
+ verdict: 'fail',
61
+ verdictReason: String(added) + ' new finding(s) (' + String(addedErrors) + ' error)' + (firstCode === undefined ? '' : ' (first: ' + firstCode + ')'),
62
+ };
63
+ }
64
+ if (added > 0) {
65
+ return { verdict: 'pass_with_findings', verdictReason: String(added) + ' new warning finding(s), no new errors' };
66
+ }
67
+ if (removed > 0) {
68
+ return { verdict: 'pass', verdictReason: 'no new findings; ' + String(removed) + ' finding(s) resolved' };
69
+ }
70
+ return { verdict: 'pass', verdictReason: 'no new findings, nothing resolved' };
71
+ }
72
+ /**
73
+ * Compare a baseline report with the current one. The verdict describes the *delta*
74
+ * (a new error fails it), while `currentVerdict` keeps the absolute result visible.
75
+ */
76
+ export function diffReports(baseline, current) {
77
+ const before = flattenReportFindings(baseline);
78
+ const after = flattenReportFindings(current);
79
+ const beforeByIdentity = new Map();
80
+ for (const finding of before)
81
+ beforeByIdentity.set(findingIdentity(finding), finding);
82
+ const seen = new Set();
83
+ const added = [];
84
+ const changed = [];
85
+ for (const finding of after) {
86
+ const identity = findingIdentity(finding);
87
+ seen.add(identity);
88
+ const previous = beforeByIdentity.get(identity);
89
+ if (previous === undefined) {
90
+ added.push(finding);
91
+ continue;
92
+ }
93
+ if (previous.severity !== finding.severity || previous.message !== finding.message) {
94
+ changed.push({
95
+ identity,
96
+ code: finding.code,
97
+ before: { severity: previous.severity, message: previous.message },
98
+ after: { severity: finding.severity, message: finding.message },
99
+ });
100
+ }
101
+ }
102
+ const removed = before.filter((finding) => !seen.has(findingIdentity(finding)));
103
+ const addedErrors = added.filter((finding) => finding.severity === 'error');
104
+ const addedWarnings = added.filter((finding) => finding.severity === 'warning');
105
+ const delta = diffOf(added.length, removed.length, addedErrors.length, addedErrors[0]?.code);
106
+ const nextSteps = [];
107
+ if (addedErrors.length > 0) {
108
+ 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.');
109
+ }
110
+ else if (added.length > 0) {
111
+ 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.');
112
+ }
113
+ else {
114
+ nextSteps.push('No new findings: re-record the baseline with this report if the slice is accepted, so the next comparison starts from it.');
115
+ }
116
+ if (changed.length > 0)
117
+ 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.');
118
+ if (removed.length > 0)
119
+ 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).');
120
+ if (verdictOfReport(current) === 'fail')
121
+ nextSteps.push('The current report still fails on its own terms; the delta being clean does not make the run clean.');
122
+ return {
123
+ ok: true,
124
+ ran: true,
125
+ ...delta,
126
+ schema: REPORT_SCHEMAS.baseline,
127
+ currentVerdict: verdictOfReport(current),
128
+ added,
129
+ removed,
130
+ changed,
131
+ summary: {
132
+ baseline: before.length,
133
+ current: after.length,
134
+ added: added.length,
135
+ removed: removed.length,
136
+ changed: changed.length,
137
+ addedErrors: addedErrors.length,
138
+ addedWarnings: addedWarnings.length,
139
+ },
140
+ nextSteps,
141
+ };
142
+ }
package/lib/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')),
@@ -7,7 +7,7 @@ export const LOGICPROBE_COMPOSE_TOOL_NAME = 'logicprobe_compose_verify';
7
7
  */
8
8
  export const logicProbeComposeTool = defineTool({
9
9
  name: LOGICPROBE_COMPOSE_TOOL_NAME,
10
- description: 'Run composition verification over two or more LogicModelV1 state machines (logicprobe). Pass machines as an array of models and optionally rendezvous: a list of handshake events that must fire simultaneously across all machines declaring them (at least two participants, all jointly enabled, guards held; a terminal machine stops participating). Non-rendezvous events advance exactly one firing machine. Returns C1 composition-deadlock findings (a reachable state where no machine can advance while at least one is not terminal) and C2 rendezvous-never-fires findings. Each machine validates against the same schema as logicprobe_verify.',
10
+ description: 'Check two or more LogicModelV1 machines together. Pass the models in `machines`. Pass `rendezvous` for handshake events: these fire only when every declaring machine is enabled at once, and a terminal machine stops taking part. Other events advance one machine, so a machine waiting on a handshake can take any number of its own steps first. There is no rate ratio and no fairness bound. Returns C1 deadlocks (no machine can advance, and one is not terminal) and C2 handshakes that never fire. Both name the reason per machine, and C1 gives the shortest counterexample. `maxStates` caps the search; a capped run says so. Read `verdict` and `verdictReason`.',
11
11
  parameters: {
12
12
  machines: {
13
13
  type: 'json',
@@ -1,3 +1,4 @@
1
+ import { REPORT_SCHEMAS, verdictOfFindings } from './engine.js';
1
2
  const KEYWORD_RULES = [
2
3
  { pattern: /\bthread\s*-?\s*safe\b/i, label: 'thread-safe', absolute: true },
3
4
  { pattern: /\block\s*-?\s*free\b/i, label: 'lock-free', absolute: true },
@@ -72,8 +73,19 @@ export function runConcurrencyScan(text) {
72
73
  const errors = findings.filter((finding) => finding.severity === 'error').length;
73
74
  const warnings = findings.filter((finding) => finding.severity === 'warning').length;
74
75
  const absoluteClaims = findings.filter((finding) => finding.code === 'CONCURRENCY_ABSOLUTE_CLAIM').length;
76
+ const nextSteps = [];
77
+ if (absoluteClaims > 0) {
78
+ nextSteps.push('Every absolute claim must either cite dedicated evidence (a TSan/Helgrind run, a CBMC harness, a TLA+ model of the interleaving) or be restated as a bounded, testable property.');
79
+ }
80
+ if (findings.length > absoluteClaims) {
81
+ nextSteps.push('Review each flagged keyword: a plan that names shared state, a mutex or an interrupt without a synchronization argument is incomplete, not merely verbose.');
82
+ }
83
+ nextSteps.push('This scan mines claims; it does not prove concurrency safety — route each one to the tool named in its suggestions.');
75
84
  return {
76
85
  ok: true,
86
+ ran: true,
87
+ ...verdictOfFindings(findings),
88
+ schema: REPORT_SCHEMAS.concurrency,
77
89
  findings,
78
90
  summary: {
79
91
  lines: lines.length,
@@ -82,5 +94,7 @@ export function runConcurrencyScan(text) {
82
94
  warnings,
83
95
  errors,
84
96
  },
97
+ hashes: {},
98
+ nextSteps,
85
99
  };
86
100
  }
@@ -1,4 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { REPORT_SCHEMAS, verdictOfFindings } from './engine.js';
2
3
  export const DATA_ENGINE_SCHEMA_VERSION = 1;
3
4
  const KNOWN_TYPES = new Set([
4
5
  'string', 'integer', 'number', 'boolean', 'uuid', 'date', 'datetime', 'timestamp',
@@ -847,18 +848,25 @@ function buildDataComparisonSummary(before, after, options) {
847
848
  export function runDataVerification(input, options = {}) {
848
849
  const validation = validateDataModel(input);
849
850
  if (!validation.ok) {
851
+ const findings = validation.errors.map((message) => ({ code: 'DATA_MODEL_INVALID', severity: 'error', message }));
852
+ const modelCheck = {
853
+ id: 'DATA_MODEL',
854
+ name: 'Data Model Validation',
855
+ status: 'fail',
856
+ detail: 'Data model schema validation failed: ' + validation.errors.length + ' errors',
857
+ findings,
858
+ };
850
859
  return {
851
860
  ok: false,
861
+ ran: true,
862
+ ...verdictOfFindings(findings),
863
+ schema: REPORT_SCHEMAS.datamodel,
852
864
  schemaVersion: 1,
853
865
  modelHash: '',
866
+ hashes: { modelHash: '' },
854
867
  summary: { entities: 0, fields: 0, errors: validation.errors.length, warnings: 0, checksRun: 0 },
855
- checks: [{
856
- id: 'DATA_MODEL',
857
- name: 'Data Model Validation',
858
- status: 'fail',
859
- detail: 'Data model schema validation failed: ' + validation.errors.length + ' errors',
860
- findings: validation.errors.map((message) => ({ code: 'DATA_MODEL_INVALID', severity: 'error', message })),
861
- }],
868
+ checks: [modelCheck],
869
+ nextSteps: dataNextSteps([modelCheck]),
862
870
  };
863
871
  }
864
872
  const model = validation.model;
@@ -884,10 +892,15 @@ export function runDataVerification(input, options = {}) {
884
892
  if (options.beforeModel !== undefined) {
885
893
  const beforeValidation = validateDataModel(options.beforeModel);
886
894
  if (!beforeValidation.ok) {
895
+ const beforeFindings = beforeValidation.errors.map((message) => ({ code: 'BEFORE_DATA_MODEL_INVALID', severity: 'error', message }));
887
896
  return {
888
897
  ok: false,
898
+ ran: true,
899
+ ...verdictOfFindings([...checks.flatMap((check) => check.findings), ...beforeFindings]),
900
+ schema: REPORT_SCHEMAS.datamodel,
889
901
  schemaVersion: 1,
890
902
  modelHash: dataModelHash(model),
903
+ hashes: { modelHash: dataModelHash(model) },
891
904
  summary: {
892
905
  entities: model.entities.length,
893
906
  fields: allFieldPaths(model).length,
@@ -902,9 +915,10 @@ export function runDataVerification(input, options = {}) {
902
915
  name: 'Before Data Model Validation',
903
916
  status: 'fail',
904
917
  detail: 'Before data model schema validation failed: ' + beforeValidation.errors.length + ' errors',
905
- findings: beforeValidation.errors.map((message) => ({ code: 'BEFORE_DATA_MODEL_INVALID', severity: 'error', message })),
918
+ findings: beforeFindings,
906
919
  },
907
920
  ],
921
+ nextSteps: dataNextSteps(checks),
908
922
  };
909
923
  }
910
924
  const before = beforeValidation.model;
@@ -915,8 +929,12 @@ export function runDataVerification(input, options = {}) {
915
929
  const warnings = checks.reduce((sum, check) => sum + check.findings.filter((finding) => finding.severity === 'warning').length, 0);
916
930
  return {
917
931
  ok: true,
932
+ ran: true,
933
+ ...verdictOfFindings(checks.flatMap((check) => check.findings)),
934
+ schema: REPORT_SCHEMAS.datamodel,
918
935
  schemaVersion: 1,
919
936
  modelHash: dataModelHash(model),
937
+ hashes: { modelHash: dataModelHash(model) },
920
938
  summary: {
921
939
  entities: model.entities.length,
922
940
  fields: allFieldPaths(model).length,
@@ -925,6 +943,22 @@ export function runDataVerification(input, options = {}) {
925
943
  checksRun: checks.length,
926
944
  },
927
945
  checks,
946
+ nextSteps: dataNextSteps(checks),
928
947
  ...(comparison === undefined ? {} : { comparison }),
929
948
  };
930
949
  }
950
+ /**
951
+ * The next-steps list every data report carries, derived only from the checks so two
952
+ * runs over the same model produce the same list.
953
+ */
954
+ function dataNextSteps(checks) {
955
+ const steps = [];
956
+ const failed = checks.filter((check) => check.status === 'fail').map((check) => check.id + ' ' + check.name);
957
+ const warningCodes = [...new Set(checks.flatMap((check) => check.findings).filter((finding) => finding.severity === 'warning').map((finding) => finding.code))].sort();
958
+ if (failed.length > 0)
959
+ steps.push('Resolve the failing checks first: ' + failed.join(', ') + '.');
960
+ if (warningCodes.length > 0)
961
+ steps.push('Review the warning findings (' + warningCodes.join(', ') + '): a nullable field or a missing index is a decision, not silence.');
962
+ steps.push('Re-run with beforeModel plus the mapping/copy/migration options after the change to prove the data behaviour did not regress (DD1-DD4).');
963
+ return steps;
964
+ }