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,180 @@
1
+ /**
2
+ * Report diffing: "did this slice add findings, or remove them?"
3
+ *
4
+ * The acceptance criterion of a refactoring slice is usually *not* "zero findings" —
5
+ * it is "no NEW findings, and none of the old ones got worse". Comparing two JSON
6
+ * reports by hand is how that gets fudged, so the comparison is code.
7
+ *
8
+ * A finding's identity is its machine-readable locator, never its prose:
9
+ * `check|code|<canonical evidence + path>`. Findings that carry neither evidence nor a
10
+ * path fall back to their message, because an engine that reports nothing structured
11
+ * has nothing stabler to match on. Two runs over the same input must produce zero
12
+ * added and zero removed findings.
13
+ *
14
+ * @module logicprobe-baseline
15
+ */
16
+
17
+ import { canonicalJson, REPORT_SCHEMAS, verdictOf, type Finding, type Verdict, type VerdictSummary } from './engine.js'
18
+
19
+ /** A finding as it appears in any report family, after flattening. */
20
+ export interface ReportFinding extends Finding {
21
+ /** The check that emitted it (verify/datamodel: `S2`, `C1`, …); absent for flat families. */
22
+ check?: string
23
+ }
24
+
25
+ export interface BaselineChange {
26
+ identity: string
27
+ code: string
28
+ before: { severity: string; message: string }
29
+ after: { severity: string; message: string }
30
+ }
31
+
32
+ export interface BaselineReport {
33
+ ok: boolean
34
+ ran: boolean
35
+ verdict: Verdict
36
+ verdictReason: string
37
+ /** The versioned report contract this result follows. */
38
+ schema: string
39
+ /** The current report's own verdict, echoed so "no new findings" cannot hide a failing run. */
40
+ currentVerdict: Verdict
41
+ added: ReportFinding[]
42
+ removed: ReportFinding[]
43
+ changed: BaselineChange[]
44
+ summary: {
45
+ baseline: number
46
+ current: number
47
+ added: number
48
+ removed: number
49
+ changed: number
50
+ addedErrors: number
51
+ addedWarnings: number
52
+ }
53
+ nextSteps: string[]
54
+ }
55
+
56
+ /**
57
+ * The stable identity of a finding: check + file + code + a canonical locator. Prose is
58
+ * excluded so a reworded message reads as `changed`, not as a new finding.
59
+ */
60
+ export function findingIdentity(finding: ReportFinding): string {
61
+ const locator = canonicalJson({
62
+ ...(finding.evidence === undefined ? {} : { evidence: finding.evidence }),
63
+ ...(finding.path === undefined ? {} : { path: finding.path }),
64
+ })
65
+ const where = locator === '{}' ? 'message:' + finding.message : locator
66
+ const file = finding.file === undefined ? '' : finding.file + '|'
67
+ return (finding.check === undefined ? '' : finding.check + '|') + file + finding.code + '|' + where
68
+ }
69
+
70
+ /** Flatten a report of any family into one finding list (`checks[].findings` or `findings[]`). */
71
+ export function flattenReportFindings(report: unknown): ReportFinding[] {
72
+ if (report === null || typeof report !== 'object') return []
73
+ const record = report as Record<string, unknown>
74
+ const flat: ReportFinding[] = []
75
+ if (Array.isArray(record.checks)) {
76
+ for (const check of record.checks as Array<Record<string, unknown>>) {
77
+ const id = typeof check.id === 'string' ? check.id : undefined
78
+ for (const finding of (Array.isArray(check.findings) ? check.findings : []) as Finding[]) {
79
+ flat.push(id === undefined ? finding : { ...finding, check: id })
80
+ }
81
+ }
82
+ }
83
+ if (Array.isArray(record.findings)) {
84
+ for (const finding of record.findings as Finding[]) flat.push(finding)
85
+ }
86
+ return flat
87
+ }
88
+
89
+ /** The report's own verdict, whatever family it came from. */
90
+ function verdictOfReport(report: unknown): Verdict {
91
+ if (report === null || typeof report !== 'object') return 'fail'
92
+ const value = (report as Record<string, unknown>).verdict
93
+ return value === 'pass' || value === 'pass_with_findings' || value === 'fail' ? value : 'fail'
94
+ }
95
+
96
+ function diffOf(added: number, removed: number, addedErrors: number, firstCode?: string): VerdictSummary {
97
+ if (addedErrors > 0) {
98
+ return {
99
+ verdict: 'fail',
100
+ verdictReason: String(added) + ' new finding(s) (' + String(addedErrors) + ' error)' + (firstCode === undefined ? '' : ' (first: ' + firstCode + ')'),
101
+ }
102
+ }
103
+ if (added > 0) {
104
+ return { verdict: 'pass_with_findings', verdictReason: String(added) + ' new warning finding(s), no new errors' }
105
+ }
106
+ if (removed > 0) {
107
+ return { verdict: 'pass', verdictReason: 'no new findings; ' + String(removed) + ' finding(s) resolved' }
108
+ }
109
+ return { verdict: 'pass', verdictReason: 'no new findings, nothing resolved' }
110
+ }
111
+
112
+ /**
113
+ * Compare a baseline report with the current one. The verdict describes the *delta*
114
+ * (a new error fails it), while `currentVerdict` keeps the absolute result visible.
115
+ */
116
+ export function diffReports(baseline: unknown, current: unknown): BaselineReport {
117
+ const before = flattenReportFindings(baseline)
118
+ const after = flattenReportFindings(current)
119
+ const beforeByIdentity = new Map<string, ReportFinding>()
120
+ for (const finding of before) beforeByIdentity.set(findingIdentity(finding), finding)
121
+ const seen = new Set<string>()
122
+ const added: ReportFinding[] = []
123
+ const changed: BaselineChange[] = []
124
+ for (const finding of after) {
125
+ const identity = findingIdentity(finding)
126
+ seen.add(identity)
127
+ const previous = beforeByIdentity.get(identity)
128
+ if (previous === undefined) {
129
+ added.push(finding)
130
+ continue
131
+ }
132
+ if (previous.severity !== finding.severity || previous.message !== finding.message) {
133
+ changed.push({
134
+ identity,
135
+ code: finding.code,
136
+ before: { severity: previous.severity, message: previous.message },
137
+ after: { severity: finding.severity, message: finding.message },
138
+ })
139
+ }
140
+ }
141
+ const removed = before.filter((finding) => !seen.has(findingIdentity(finding)))
142
+ const addedErrors = added.filter((finding) => finding.severity === 'error')
143
+ const addedWarnings = added.filter((finding) => finding.severity === 'warning')
144
+ const delta = diffOf(added.length, removed.length, addedErrors.length, addedErrors[0]?.code)
145
+
146
+ const nextSteps: string[] = []
147
+ if (addedErrors.length > 0) {
148
+ nextSteps.push('Fix or explicitly accept the ' + String(addedErrors.length) + ' new error finding(s) before calling this slice done: the baseline is what "not worse" is measured against.')
149
+ } else if (added.length > 0) {
150
+ nextSteps.push('The new warnings are not failures, but they are new: decide per finding whether the change introduced them or the baseline was incomplete.')
151
+ } else {
152
+ nextSteps.push('No new findings: re-record the baseline with this report if the slice is accepted, so the next comparison starts from it.')
153
+ }
154
+ if (changed.length > 0) nextSteps.push('Review the ' + String(changed.length) + ' changed finding(s): the same locator now reports something different, which usually means the fix moved the problem.')
155
+ if (removed.length > 0) nextSteps.push(String(removed.length) + ' finding(s) from the baseline are gone — confirm they were fixed rather than renamed out of the report (a renamed check or a reworded message changes the identity).')
156
+ if (verdictOfReport(current) === 'fail') nextSteps.push('The current report still fails on its own terms; the delta being clean does not make the run clean.')
157
+
158
+ return {
159
+ ok: true,
160
+ ran: true,
161
+ ...delta,
162
+ schema: REPORT_SCHEMAS.baseline,
163
+ currentVerdict: verdictOfReport(current),
164
+ added,
165
+ removed,
166
+ changed,
167
+ summary: {
168
+ baseline: before.length,
169
+ current: after.length,
170
+ added: added.length,
171
+ removed: removed.length,
172
+ changed: changed.length,
173
+ addedErrors: addedErrors.length,
174
+ addedWarnings: addedWarnings.length,
175
+ },
176
+ nextSteps,
177
+ }
178
+ }
179
+
180
+ export type { VerdictSummary }
package/src/client.js CHANGED
@@ -30,7 +30,61 @@ window.__ModuleLoader__.load({
30
30
  id: 'dsh-logicprobe',
31
31
  factory: (require) => {
32
32
  const React = require('react')
33
- const { Button, Switch } = require('@deepseek-ai/dsh-client-ui-primitives')
33
+ // The host's Switch and Button, vendored. A plain-JS client bundle must not
34
+ // require a Harness Client package: it changes without notice, we get no type
35
+ // check, and a throw blanks this slot entry. Markup, classes, token usage and
36
+ // the aria behaviour follow the host primitive; classes carry our own prefix.
37
+ const CONTROLS_CSS = [
38
+ '.lp-switch { box-sizing: border-box; position: relative; flex: 0 0 auto; width: 36px; height: 20px; padding: 2px; border: 0; border-radius: 999px; corner-shape: round; background: var(--dsw-alias-border-l3); cursor: pointer; }',
39
+ ".lp-switch[aria-checked='true'] { background: var(--dsw-alias-brand-primary); }",
40
+ '.lp-switch:disabled { cursor: default; opacity: 0.5; }',
41
+ '.lp-switch:focus-visible { outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: 2px; }',
42
+ '.lp-switch-thumb { display: block; width: 16px; height: 16px; border-radius: 50%; corner-shape: round; background: var(--dsw-alias-label-primary-foreground); transition: transform 120ms ease; }',
43
+ ".lp-switch[aria-checked='false'] .lp-switch-thumb { background: var(--dsw-alias-switch-thumb); }",
44
+ ".lp-switch[aria-checked='true'] .lp-switch-thumb { transform: translateX(16px); }",
45
+ '.lp-btn { box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; gap: 4px; border: none; border-radius: var(--dsw-radius-md); cursor: pointer; font-size: 14px; line-height: 22px; color: var(--dsw-alias-label-primary); background: transparent; padding: 0 14px; }',
46
+ '.lp-btn:disabled { cursor: not-allowed; opacity: 0.4; }',
47
+ '.lp-btn-sm { height: 28px; font-size: 12px; line-height: 18px; padding: 0 10px; border-radius: var(--dsw-radius-sm); }',
48
+ '.lp-btn-outline { border: 0.5px solid var(--dsw-alias-border-l3); background: transparent; }',
49
+ '.lp-btn-outline:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); }',
50
+ ].join('\n')
51
+
52
+ /** Rendered inside the component tree, so unmounting removes the styles. */
53
+ function Controls() {
54
+ return React.createElement('style', null, CONTROLS_CSS)
55
+ }
56
+
57
+ function Switch(props) {
58
+ return React.createElement(
59
+ 'button',
60
+ {
61
+ type: 'button',
62
+ role: 'switch',
63
+ 'aria-checked': props.checked === true,
64
+ 'aria-label': props.label,
65
+ className: 'lp-switch',
66
+ disabled: props.disabled === true,
67
+ onClick: () => {
68
+ if (props.disabled !== true) props.onChange(!(props.checked === true))
69
+ },
70
+ },
71
+ React.createElement('span', { className: 'lp-switch-thumb' }),
72
+ )
73
+ }
74
+
75
+ Switch.displayName = 'dsh-logicprobe:Switch'
76
+ Button.displayName = 'dsh-logicprobe:Button'
77
+
78
+ function Button(props) {
79
+ const classes = ['lp-btn']
80
+ if (props.size === 'sm') classes.push('lp-btn-sm')
81
+ if (props.variant === 'outline') classes.push('lp-btn-outline')
82
+ return React.createElement(
83
+ 'button',
84
+ { type: 'button', className: classes.join(' '), disabled: props.disabled === true, onClick: props.onClick },
85
+ props.children,
86
+ )
87
+ }
34
88
 
35
89
  /** Settings namespace: the Loader entry id this bundle's patch declares. */
36
90
  const NS = 'logicprobe'
@@ -122,6 +176,7 @@ window.__ModuleLoader__.load({
122
176
  const locked = state.writable !== true || pending
123
177
 
124
178
  const children = [
179
+ React.createElement(Controls, { key: 'styles' }),
125
180
  React.createElement('h4', { key: 'title', style: TITLE }, t('title')),
126
181
  React.createElement('div', { key: 'row', style: ROW }, [
127
182
  React.createElement('span', { key: 'label', style: LABEL }, t('label')),
@@ -1,47 +1,47 @@
1
- import { defineTool } from '@deepseek-ai/dsh-tools'
2
- import type { JsonValue } from './json-value.js'
3
- import { runCompositionVerification } from './engine.js'
4
-
5
- export const LOGICPROBE_COMPOSE_TOOL_NAME = 'logicprobe_compose_verify'
6
-
7
- /**
8
- * DSH tool wrapping multi-machine composition verification:
9
- * product-space reachability with rendezvous handshakes (C1 deadlock, C2 sync).
10
- */
11
- export const logicProbeComposeTool = defineTool({
12
- name: LOGICPROBE_COMPOSE_TOOL_NAME,
13
- description:
14
- '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.',
15
- parameters: {
16
- machines: {
17
- type: 'json',
18
- required: true,
19
- description: 'Array of LogicModelV1 state-machine models to compose (two or more).',
20
- },
21
- rendezvous: {
22
- type: 'json',
23
- description: 'Optional array of handshake event names shared by the machines.',
24
- },
25
- maxStates: {
26
- type: 'integer',
27
- description: 'Maximum composite states to explore. Default 10000.',
28
- },
29
- },
30
- output: {
31
- schema: {
32
- type: 'json',
33
- description: 'Composition verification report with C1/C2 findings.',
34
- },
35
- render(_args, value) {
36
- return [{ type: 'text' as const, text: JSON.stringify(value, null, 2) }]
37
- },
38
- },
39
- timeoutMs: 10_000,
40
- isConcurrencySafe: () => true,
41
- async execute(args) {
42
- return runCompositionVerification(args.machines as unknown[], {
43
- rendezvous: args.rendezvous as string[] | undefined,
44
- maxStates: args.maxStates,
45
- }) as unknown as JsonValue
46
- },
47
- })
1
+ import { defineTool } from '@deepseek-ai/dsh-tools'
2
+ import type { JsonValue } from './json-value.js'
3
+ import { runCompositionVerification } from './engine.js'
4
+
5
+ export const LOGICPROBE_COMPOSE_TOOL_NAME = 'logicprobe_compose_verify'
6
+
7
+ /**
8
+ * DSH tool wrapping multi-machine composition verification:
9
+ * product-space reachability with rendezvous handshakes (C1 deadlock, C2 sync).
10
+ */
11
+ export const logicProbeComposeTool = defineTool({
12
+ name: LOGICPROBE_COMPOSE_TOOL_NAME,
13
+ description:
14
+ '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`.',
15
+ parameters: {
16
+ machines: {
17
+ type: 'json',
18
+ required: true,
19
+ description: 'Array of LogicModelV1 state-machine models to compose (two or more).',
20
+ },
21
+ rendezvous: {
22
+ type: 'json',
23
+ description: 'Optional array of handshake event names shared by the machines.',
24
+ },
25
+ maxStates: {
26
+ type: 'integer',
27
+ description: 'Maximum composite states to explore. Default 10000.',
28
+ },
29
+ },
30
+ output: {
31
+ schema: {
32
+ type: 'json',
33
+ description: 'Composition verification report with C1/C2 findings.',
34
+ },
35
+ render(_args, value) {
36
+ return [{ type: 'text' as const, text: JSON.stringify(value, null, 2) }]
37
+ },
38
+ },
39
+ timeoutMs: 10_000,
40
+ isConcurrencySafe: () => true,
41
+ async execute(args) {
42
+ return runCompositionVerification(args.machines as unknown[], {
43
+ rendezvous: args.rendezvous as string[] | undefined,
44
+ maxStates: args.maxStates,
45
+ }) as unknown as JsonValue
46
+ },
47
+ })
@@ -1,3 +1,4 @@
1
+ import { REPORT_SCHEMAS, verdictOfFindings, type ReportSchema, type Verdict } from './engine.js'
1
2
  export interface ConcurrencyFinding {
2
3
  code: 'CONCURRENCY_KEYWORD' | 'CONCURRENCY_ABSOLUTE_CLAIM'
3
4
  severity: 'warning' | 'error'
@@ -11,6 +12,11 @@ export interface ConcurrencyFinding {
11
12
 
12
13
  export interface ConcurrencyScanReport {
13
14
  ok: boolean
15
+ ran: boolean
16
+ verdict: Verdict
17
+ verdictReason: string
18
+ /** The versioned report contract this result follows. */
19
+ schema: ReportSchema
14
20
  findings: ConcurrencyFinding[]
15
21
  summary: {
16
22
  lines: number
@@ -19,6 +25,10 @@ export interface ConcurrencyScanReport {
19
25
  warnings: number
20
26
  errors: number
21
27
  }
28
+ /** This scan hashes no model; the field is present so every report has the same shape. */
29
+ hashes: Record<string, string>
30
+ /** What to do next, derived from the findings — never empty. */
31
+ nextSteps: string[]
22
32
  }
23
33
 
24
34
  interface KeywordRule {
@@ -100,8 +110,19 @@ export function runConcurrencyScan(text: string): ConcurrencyScanReport {
100
110
  const errors = findings.filter((finding) => finding.severity === 'error').length
101
111
  const warnings = findings.filter((finding) => finding.severity === 'warning').length
102
112
  const absoluteClaims = findings.filter((finding) => finding.code === 'CONCURRENCY_ABSOLUTE_CLAIM').length
113
+ const nextSteps: string[] = []
114
+ if (absoluteClaims > 0) {
115
+ 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.')
116
+ }
117
+ if (findings.length > absoluteClaims) {
118
+ 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.')
119
+ }
120
+ nextSteps.push('This scan mines claims; it does not prove concurrency safety — route each one to the tool named in its suggestions.')
103
121
  return {
104
122
  ok: true,
123
+ ran: true,
124
+ ...verdictOfFindings(findings),
125
+ schema: REPORT_SCHEMAS.concurrency,
105
126
  findings,
106
127
  summary: {
107
128
  lines: lines.length,
@@ -110,5 +131,7 @@ export function runConcurrencyScan(text: string): ConcurrencyScanReport {
110
131
  warnings,
111
132
  errors,
112
133
  },
134
+ hashes: {},
135
+ nextSteps,
113
136
  }
114
137
  }
@@ -1,5 +1,5 @@
1
1
  import { createHash } from 'node:crypto'
2
- import type { CheckResult, Finding } from './engine.js'
2
+ import { REPORT_SCHEMAS, verdictOfFindings, type CheckResult, type Finding, type ReportSchema, type Verdict } from './engine.js'
3
3
 
4
4
  export const DATA_ENGINE_SCHEMA_VERSION = 1
5
5
 
@@ -129,9 +129,17 @@ export interface DataComparisonSummary {
129
129
  }
130
130
 
131
131
  export interface DataVerificationReport {
132
+ /** The engine ran and the input was well-formed enough to produce this report. Read `verdict` for the review outcome. */
132
133
  ok: boolean
134
+ ran: boolean
135
+ verdict: Verdict
136
+ verdictReason: string
137
+ /** The versioned report contract this result follows. */
138
+ schema: ReportSchema
133
139
  schemaVersion: 1
134
140
  modelHash: string
141
+ /** Every hash this report carries, in one place. The data-model hash follows the same normalization as the published spec, but is not covered by it. */
142
+ hashes: { modelHash: string }
135
143
  summary: {
136
144
  entities: number
137
145
  fields: number
@@ -142,6 +150,8 @@ export interface DataVerificationReport {
142
150
  }
143
151
  checks: CheckResult[]
144
152
  comparison?: DataComparisonSummary
153
+ /** What to do next, derived from the findings — never empty. */
154
+ nextSteps: string[]
145
155
  }
146
156
 
147
157
  const KNOWN_TYPES = new Set<string>([
@@ -913,18 +923,25 @@ function buildDataComparisonSummary(before: DataModelV1, after: DataModelV1, opt
913
923
  export function runDataVerification(input: unknown, options: DataVerificationOptions = {}): DataVerificationReport {
914
924
  const validation = validateDataModel(input)
915
925
  if (!validation.ok) {
926
+ const findings: Finding[] = validation.errors.map((message) => ({ code: 'DATA_MODEL_INVALID', severity: 'error', message }))
927
+ const modelCheck: CheckResult = {
928
+ id: 'DATA_MODEL',
929
+ name: 'Data Model Validation',
930
+ status: 'fail',
931
+ detail: 'Data model schema validation failed: ' + validation.errors.length + ' errors',
932
+ findings,
933
+ }
916
934
  return {
917
935
  ok: false,
936
+ ran: true,
937
+ ...verdictOfFindings(findings),
938
+ schema: REPORT_SCHEMAS.datamodel,
918
939
  schemaVersion: 1,
919
940
  modelHash: '',
941
+ hashes: { modelHash: '' },
920
942
  summary: { entities: 0, fields: 0, errors: validation.errors.length, warnings: 0, checksRun: 0 },
921
- checks: [{
922
- id: 'DATA_MODEL',
923
- name: 'Data Model Validation',
924
- status: 'fail',
925
- detail: 'Data model schema validation failed: ' + validation.errors.length + ' errors',
926
- findings: validation.errors.map((message) => ({ code: 'DATA_MODEL_INVALID', severity: 'error', message })),
927
- }],
943
+ checks: [modelCheck],
944
+ nextSteps: dataNextSteps([modelCheck]),
928
945
  }
929
946
  }
930
947
  const model = validation.model
@@ -950,10 +967,15 @@ export function runDataVerification(input: unknown, options: DataVerificationOpt
950
967
  if (options.beforeModel !== undefined) {
951
968
  const beforeValidation = validateDataModel(options.beforeModel)
952
969
  if (!beforeValidation.ok) {
970
+ const beforeFindings: Finding[] = beforeValidation.errors.map((message) => ({ code: 'BEFORE_DATA_MODEL_INVALID', severity: 'error', message }))
953
971
  return {
954
972
  ok: false,
973
+ ran: true,
974
+ ...verdictOfFindings([...checks.flatMap((check) => check.findings), ...beforeFindings]),
975
+ schema: REPORT_SCHEMAS.datamodel,
955
976
  schemaVersion: 1,
956
977
  modelHash: dataModelHash(model),
978
+ hashes: { modelHash: dataModelHash(model) },
957
979
  summary: {
958
980
  entities: model.entities.length,
959
981
  fields: allFieldPaths(model).length,
@@ -968,9 +990,10 @@ export function runDataVerification(input: unknown, options: DataVerificationOpt
968
990
  name: 'Before Data Model Validation',
969
991
  status: 'fail',
970
992
  detail: 'Before data model schema validation failed: ' + beforeValidation.errors.length + ' errors',
971
- findings: beforeValidation.errors.map((message) => ({ code: 'BEFORE_DATA_MODEL_INVALID', severity: 'error', message })),
993
+ findings: beforeFindings,
972
994
  },
973
995
  ],
996
+ nextSteps: dataNextSteps(checks),
974
997
  }
975
998
  }
976
999
  const before = beforeValidation.model
@@ -986,8 +1009,12 @@ export function runDataVerification(input: unknown, options: DataVerificationOpt
986
1009
  const warnings = checks.reduce((sum, check) => sum + check.findings.filter((finding) => finding.severity === 'warning').length, 0)
987
1010
  return {
988
1011
  ok: true,
1012
+ ran: true,
1013
+ ...verdictOfFindings(checks.flatMap((check) => check.findings)),
1014
+ schema: REPORT_SCHEMAS.datamodel,
989
1015
  schemaVersion: 1,
990
1016
  modelHash: dataModelHash(model),
1017
+ hashes: { modelHash: dataModelHash(model) },
991
1018
  summary: {
992
1019
  entities: model.entities.length,
993
1020
  fields: allFieldPaths(model).length,
@@ -996,6 +1023,21 @@ export function runDataVerification(input: unknown, options: DataVerificationOpt
996
1023
  checksRun: checks.length,
997
1024
  },
998
1025
  checks,
1026
+ nextSteps: dataNextSteps(checks),
999
1027
  ...(comparison === undefined ? {} : { comparison }),
1000
1028
  }
1001
1029
  }
1030
+
1031
+ /**
1032
+ * The next-steps list every data report carries, derived only from the checks so two
1033
+ * runs over the same model produce the same list.
1034
+ */
1035
+ function dataNextSteps(checks: CheckResult[]): string[] {
1036
+ const steps: string[] = []
1037
+ const failed = checks.filter((check) => check.status === 'fail').map((check) => check.id + ' ' + check.name)
1038
+ const warningCodes = [...new Set(checks.flatMap((check) => check.findings).filter((finding) => finding.severity === 'warning').map((finding) => finding.code))].sort()
1039
+ if (failed.length > 0) steps.push('Resolve the failing checks first: ' + failed.join(', ') + '.')
1040
+ if (warningCodes.length > 0) steps.push('Review the warning findings (' + warningCodes.join(', ') + '): a nullable field or a missing index is a decision, not silence.')
1041
+ 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).')
1042
+ return steps
1043
+ }