dsh-logicprobe 0.3.1 → 0.5.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 +7 -1
- package/README.md +9 -2
- package/lib/concurrency-tool.js +34 -0
- package/lib/concurrency.js +76 -0
- package/lib/data-engine.js +930 -0
- package/lib/data-tool.js +61 -0
- package/lib/engine.js +1622 -1103
- package/lib/index.js +291 -277
- package/lib/tool.js +57 -45
- package/lib/types/concurrency-tool.d.ts +8 -0
- package/lib/types/concurrency.d.ts +20 -0
- package/lib/types/data-engine.d.ts +199 -0
- package/lib/types/data-tool.d.ts +10 -0
- package/lib/types/engine.d.ts +171 -133
- package/lib/types/tool.d.ts +4 -2
- package/package.json +79 -78
- package/skills/logicprobe/SKILL.md +285 -266
- package/skills/logicprobe/references/__pycache__/verification-harness.cpython-312.pyc +0 -0
- package/skills/logicprobe/references/concurrency-risk-guide.md +54 -0
- package/skills/logicprobe/references/dsh-model-schema.md +145 -104
- package/skills/logicprobe/references/logic-verification-guide.md +463 -413
- package/skills/logicprobe/references/verification-harness.py +806 -582
- package/skills/logicprobe-datamodel/SKILL.md +124 -0
- package/skills/logicprobe-datamodel/references/__pycache__/data-model-harness.cpython-312.pyc +0 -0
- package/skills/logicprobe-datamodel/references/data-model-guide.md +62 -0
- package/skills/logicprobe-datamodel/references/data-model-harness.py +528 -0
- package/skills/logicprobe-datamodel/references/data-model-schema.md +128 -0
- package/src/concurrency-tool.ts +37 -0
- package/src/concurrency.ts +102 -0
- package/src/data-engine.ts +1001 -0
- package/src/data-tool.ts +65 -0
- package/src/engine.ts +533 -0
- package/src/index.ts +315 -301
- package/src/tool.ts +60 -48
package/src/data-tool.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { defineTool, type JsonValue } from '@deepseek-ai/dsh-tools'
|
|
2
|
+
import { runDataVerification, DATA_ENGINE_SCHEMA_VERSION } from './data-engine.js'
|
|
3
|
+
|
|
4
|
+
export const LOGICPROBE_DATAMODEL_VERIFY_TOOL_NAME = 'logicprobe_datamodel_verify'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Model-visible DSH tool wrapping the bundled data-model verification engine.
|
|
8
|
+
* The model passes a DataModelV1 object; the engine validates it and returns
|
|
9
|
+
* DS/DA/DD checks. Optional beforeModel/fieldMapping/copyPairs/migrationMappings
|
|
10
|
+
* enable before/after data-model regression and migration coverage checks.
|
|
11
|
+
*/
|
|
12
|
+
export const logicProbeDataModelVerifyTool = defineTool({
|
|
13
|
+
name: LOGICPROBE_DATAMODEL_VERIFY_TOOL_NAME,
|
|
14
|
+
description:
|
|
15
|
+
'Run executable data-model verification (logicprobe-datamodel). Takes a DataModelV1 object with schemaVersion=1, entities ({name, fields, primaryKey?, uniqueKeys?, indexes?}), relationships?, invariants?, boundaryChecks?. Optional beforeModel/fieldMapping/copyPairs/migrationMappings/backupPairs enable migration coverage, copy consistency, rollback symmetry, and before/after data regression (DD1-DD4). Returns a report with DS/DA/DD checks. See skills/logicprobe-datamodel/references/data-model-schema.md.',
|
|
16
|
+
parameters: {
|
|
17
|
+
model: {
|
|
18
|
+
type: 'json',
|
|
19
|
+
required: true,
|
|
20
|
+
description: 'DataModelV1 data model to verify.',
|
|
21
|
+
},
|
|
22
|
+
beforeModel: {
|
|
23
|
+
type: 'json',
|
|
24
|
+
description: 'Optional BEFORE DataModelV1 data model for regression comparison.',
|
|
25
|
+
},
|
|
26
|
+
fieldMapping: {
|
|
27
|
+
type: 'json',
|
|
28
|
+
description: 'Optional object mapping BEFORE "Entity.field" paths to AFTER "Entity.field" paths.',
|
|
29
|
+
},
|
|
30
|
+
copyPairs: {
|
|
31
|
+
type: 'json',
|
|
32
|
+
description: 'Optional copy pairs: { id, sourceEntity, targetEntity, mapping: {sourceField: targetField} }.',
|
|
33
|
+
},
|
|
34
|
+
migrationMappings: {
|
|
35
|
+
type: 'json',
|
|
36
|
+
description: 'Optional migration mappings: { from: "Entity.field", to: "Entity.field", transform?, note? }.',
|
|
37
|
+
},
|
|
38
|
+
backupPairs: {
|
|
39
|
+
type: 'json',
|
|
40
|
+
description: 'Optional backup/restore pairs for DA7 rollback symmetry.',
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
output: {
|
|
44
|
+
schema: {
|
|
45
|
+
type: 'json',
|
|
46
|
+
description: 'logicprobe-datamodel verification report with summary and per-check findings.',
|
|
47
|
+
},
|
|
48
|
+
render(_args, value) {
|
|
49
|
+
return [{ type: 'text' as const, text: JSON.stringify(value, null, 2) }]
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
timeoutMs: 10_000,
|
|
53
|
+
isConcurrencySafe: () => true,
|
|
54
|
+
async execute(args) {
|
|
55
|
+
return runDataVerification(args.model, {
|
|
56
|
+
beforeModel: args.beforeModel,
|
|
57
|
+
fieldMapping: args.fieldMapping as Record<string, string> | undefined,
|
|
58
|
+
copyPairs: args.copyPairs as Array<{ id: string; sourceEntity: string; targetEntity: string; mapping: Record<string, string> }> | undefined,
|
|
59
|
+
migrationMappings: args.migrationMappings as Array<{ from: string; to: string; transform?: 'copy' | 'rename' | 'split' | 'merge' | 'drop' | 'manual'; note?: string }> | undefined,
|
|
60
|
+
backupPairs: args.backupPairs as Array<{ id: string; sourceEntity: string; targetEntity: string; mapping: Record<string, string> }> | undefined,
|
|
61
|
+
}) as unknown as JsonValue
|
|
62
|
+
},
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
export { DATA_ENGINE_SCHEMA_VERSION }
|
package/src/engine.ts
CHANGED
|
@@ -52,12 +52,16 @@ export interface VariableSpec {
|
|
|
52
52
|
init: number | boolean
|
|
53
53
|
min?: number
|
|
54
54
|
max?: number
|
|
55
|
+
monotonic?: 'inc' | 'dec'
|
|
55
56
|
}
|
|
56
57
|
|
|
57
58
|
export type InvariantSpec =
|
|
58
59
|
| { id: string; description: string; kind: 'never-states'; states: string[] }
|
|
59
60
|
| { id: string; description: string; kind: 'var-in-range'; variable: string; min?: number; max?: number }
|
|
60
61
|
| { id: string; description: string; kind: 'event-before-state'; event: string; state: string }
|
|
62
|
+
| { id: string; description: string; kind: 'leads-to'; from: string; to: string }
|
|
63
|
+
| { id: string; description: string; kind: 'sequence'; events: string[] }
|
|
64
|
+
| { id: string; description: string; kind: 'atomicity'; events: string[]; commit: string; rollback?: string }
|
|
61
65
|
|
|
62
66
|
export interface ResourcePairSpec {
|
|
63
67
|
resource: string
|
|
@@ -81,11 +85,14 @@ export interface LogicModelV1 {
|
|
|
81
85
|
concurrentPairs?: [string, string][]
|
|
82
86
|
boundaryChecks?: BoundaryCheckSpec[]
|
|
83
87
|
resourcePairs?: ResourcePairSpec[]
|
|
88
|
+
idempotentEvents?: string[]
|
|
84
89
|
}
|
|
85
90
|
|
|
86
91
|
export interface VerificationOptions {
|
|
87
92
|
maxStates?: number
|
|
88
93
|
maxPermutationEvents?: number
|
|
94
|
+
beforeModel?: unknown
|
|
95
|
+
stateMapping?: Record<string, string>
|
|
89
96
|
}
|
|
90
97
|
|
|
91
98
|
export interface PathStep {
|
|
@@ -123,6 +130,23 @@ export interface VerificationReport {
|
|
|
123
130
|
truncated?: boolean
|
|
124
131
|
}
|
|
125
132
|
checks: CheckResult[]
|
|
133
|
+
comparison?: ComparisonSummary
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface ComparisonSummary {
|
|
137
|
+
beforeModelHash: string
|
|
138
|
+
afterModelHash: string
|
|
139
|
+
stateMapping: Record<string, string>
|
|
140
|
+
beforeStates: number
|
|
141
|
+
beforeTransitions: number
|
|
142
|
+
afterStates: number
|
|
143
|
+
afterTransitions: number
|
|
144
|
+
addedStates: string[]
|
|
145
|
+
removedStates: string[]
|
|
146
|
+
addedEvents: string[]
|
|
147
|
+
removedEvents: string[]
|
|
148
|
+
addedTransitions: TransitionSpec[]
|
|
149
|
+
removedTransitions: TransitionSpec[]
|
|
126
150
|
}
|
|
127
151
|
|
|
128
152
|
export interface RuntimeState {
|
|
@@ -226,6 +250,7 @@ export function validateModel(input: unknown): { ok: true; model: LogicModelV1 }
|
|
|
226
250
|
if (variable.min !== undefined && typeof variable.min !== 'number') bad(path + '.min', 'must be a number')
|
|
227
251
|
if (variable.max !== undefined && typeof variable.max !== 'number') bad(path + '.max', 'must be a number')
|
|
228
252
|
if (typeof variable.min === 'number' && typeof variable.max === 'number' && variable.min > variable.max) bad(path + '.max', 'must be >= min')
|
|
253
|
+
if (variable.monotonic !== undefined && variable.monotonic !== 'inc' && variable.monotonic !== 'dec') bad(path + '.monotonic', "must be 'inc' or 'dec'")
|
|
229
254
|
if (variable.kind === 'integer' && typeof variable.init === 'number') {
|
|
230
255
|
if (typeof variable.min === 'number' && variable.init < variable.min) bad(path + '.init', 'must be >= min')
|
|
231
256
|
if (typeof variable.max === 'number' && variable.init > variable.max) bad(path + '.init', 'must be <= max')
|
|
@@ -259,6 +284,21 @@ export function validateModel(input: unknown): { ok: true; model: LogicModelV1 }
|
|
|
259
284
|
} else if (invariant.kind === 'event-before-state') {
|
|
260
285
|
if (typeof invariant.event !== 'string' || invariant.event.length === 0) bad(path + '.event', 'must be a non-empty string')
|
|
261
286
|
if (typeof invariant.state !== 'string' || invariant.state.length === 0) bad(path + '.state', 'must be a non-empty string')
|
|
287
|
+
} else if (invariant.kind === 'leads-to') {
|
|
288
|
+
if (typeof invariant.from !== 'string' || invariant.from.length === 0) bad(path + '.from', 'must be a non-empty string')
|
|
289
|
+
if (typeof invariant.to !== 'string' || invariant.to.length === 0) bad(path + '.to', 'must be a non-empty string')
|
|
290
|
+
} else if (invariant.kind === 'sequence') {
|
|
291
|
+
if (!Array.isArray(invariant.events) || invariant.events.length === 0) bad(path + '.events', 'must be a non-empty array')
|
|
292
|
+
else invariant.events.forEach((event, eventIndex) => {
|
|
293
|
+
if (typeof event !== 'string' || event.length === 0) bad(path + '.events[' + eventIndex + ']', 'must be a non-empty string')
|
|
294
|
+
})
|
|
295
|
+
} else if (invariant.kind === 'atomicity') {
|
|
296
|
+
if (!Array.isArray(invariant.events) || invariant.events.length === 0) bad(path + '.events', 'must be a non-empty array')
|
|
297
|
+
else invariant.events.forEach((event, eventIndex) => {
|
|
298
|
+
if (typeof event !== 'string' || event.length === 0) bad(path + '.events[' + eventIndex + ']', 'must be a non-empty string')
|
|
299
|
+
})
|
|
300
|
+
if (typeof invariant.commit !== 'string' || invariant.commit.length === 0) bad(path + '.commit', 'must be a non-empty string')
|
|
301
|
+
if (invariant.rollback !== undefined && typeof invariant.rollback !== 'string') bad(path + '.rollback', 'must be a string')
|
|
262
302
|
} else {
|
|
263
303
|
bad(path + '.kind', 'unknown invariant kind')
|
|
264
304
|
}
|
|
@@ -286,6 +326,12 @@ export function validateModel(input: unknown): { ok: true; model: LogicModelV1 }
|
|
|
286
326
|
if (!Array.isArray(check.values) || check.values.some((value) => typeof value !== 'number')) bad(path + '.values', 'must be an array of numbers')
|
|
287
327
|
})
|
|
288
328
|
}
|
|
329
|
+
if (root.idempotentEvents !== undefined) {
|
|
330
|
+
if (!Array.isArray(root.idempotentEvents)) bad('idempotentEvents', 'must be an array')
|
|
331
|
+
else root.idempotentEvents.forEach((entry, index) => {
|
|
332
|
+
if (typeof entry !== 'string' || entry.length === 0) bad('idempotentEvents[' + index + ']', 'must be a non-empty string')
|
|
333
|
+
})
|
|
334
|
+
}
|
|
289
335
|
if (root.resourcePairs !== undefined) {
|
|
290
336
|
if (!Array.isArray(root.resourcePairs)) bad('resourcePairs', 'must be an array')
|
|
291
337
|
else root.resourcePairs.forEach((entry, index) => {
|
|
@@ -1102,6 +1148,449 @@ function A7_shortestViolations(model: LogicModelV1, options: NormalizedOptions):
|
|
|
1102
1148
|
return checkResult('A7', 'Minimal Counter-Example', findings, violations.length === 0 ? 'All invariants hold for all reachable paths' : 'Violated invariants: ' + findings.length)
|
|
1103
1149
|
}
|
|
1104
1150
|
|
|
1151
|
+
function mapStateId(mapping: Record<string, string>, state: string): string {
|
|
1152
|
+
return mapping[state] ?? state
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
function D1_behavioralPreservation(before: LogicModelV1, after: LogicModelV1, mapping: Record<string, string>): CheckResult {
|
|
1156
|
+
const findings: Finding[] = []
|
|
1157
|
+
const afterStates = new Set(after.states.map((state) => state.id))
|
|
1158
|
+
const afterByFromEvent = new Map<string, Set<string>>()
|
|
1159
|
+
for (const transition of after.transitions) {
|
|
1160
|
+
const key = transition.from + '|' + transition.event
|
|
1161
|
+
const set = afterByFromEvent.get(key) ?? new Set<string>()
|
|
1162
|
+
set.add(transition.to)
|
|
1163
|
+
afterByFromEvent.set(key, set)
|
|
1164
|
+
}
|
|
1165
|
+
const beforeInitMapped = mapStateId(mapping, before.init)
|
|
1166
|
+
if (beforeInitMapped !== after.init) {
|
|
1167
|
+
findings.push({
|
|
1168
|
+
code: 'D1_INIT_MISMATCH',
|
|
1169
|
+
severity: 'warning',
|
|
1170
|
+
message: 'Mapped BEFORE init ' + beforeInitMapped + ' does not match AFTER init ' + after.init + '.',
|
|
1171
|
+
evidence: { beforeInit: before.init, mappedInit: beforeInitMapped, afterInit: after.init },
|
|
1172
|
+
})
|
|
1173
|
+
}
|
|
1174
|
+
for (const state of before.states) {
|
|
1175
|
+
const mapped = mapStateId(mapping, state.id)
|
|
1176
|
+
if (!afterStates.has(mapped)) {
|
|
1177
|
+
findings.push({
|
|
1178
|
+
code: 'D1_MAPPED_STATE_MISSING',
|
|
1179
|
+
severity: 'error',
|
|
1180
|
+
message: 'BEFORE state ' + state.id + ' maps to ' + mapped + ', which is not declared in AFTER.',
|
|
1181
|
+
evidence: { beforeState: state.id, mappedState: mapped },
|
|
1182
|
+
})
|
|
1183
|
+
continue
|
|
1184
|
+
}
|
|
1185
|
+
for (const transition of before.transitions.filter((entry) => entry.from === state.id)) {
|
|
1186
|
+
const key = mapped + '|' + transition.event
|
|
1187
|
+
const targets = afterByFromEvent.get(key)
|
|
1188
|
+
if (targets === undefined || targets.size === 0) {
|
|
1189
|
+
findings.push({
|
|
1190
|
+
code: 'D1_EVENT_DISABLED',
|
|
1191
|
+
severity: 'error',
|
|
1192
|
+
message: 'BEFORE can fire event ' + transition.event + ' from ' + state.id + ' (mapped to ' + mapped + '), but AFTER has no transition for that (state, event).',
|
|
1193
|
+
path: [{ from: state.id, event: transition.event, to: transition.to }],
|
|
1194
|
+
evidence: { beforeState: state.id, mappedState: mapped, event: transition.event },
|
|
1195
|
+
})
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
return checkResult('D1', 'Behavioral Preservation', findings, findings.length === 0 ? 'BEFORE event behavior is preserved in AFTER' : 'Behavioral preservation findings: ' + findings.length)
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
function mapInvariantForComparison(invariant: InvariantSpec, mapping: Record<string, string>): InvariantSpec {
|
|
1203
|
+
if (invariant.kind === 'never-states') {
|
|
1204
|
+
return {
|
|
1205
|
+
...invariant,
|
|
1206
|
+
id: invariant.id + ':before',
|
|
1207
|
+
description: invariant.description + ' (from BEFORE)',
|
|
1208
|
+
states: invariant.states.map((state) => mapStateId(mapping, state)),
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
if (invariant.kind === 'event-before-state') {
|
|
1212
|
+
return {
|
|
1213
|
+
...invariant,
|
|
1214
|
+
id: invariant.id + ':before',
|
|
1215
|
+
description: invariant.description + ' (from BEFORE)',
|
|
1216
|
+
state: mapStateId(mapping, invariant.state),
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
return { ...invariant, id: invariant.id + ':before', description: invariant.description + ' (from BEFORE)' }
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
function D2_invariantContinuity(before: LogicModelV1, after: LogicModelV1, options: NormalizedOptions, mapping: Record<string, string>): CheckResult {
|
|
1223
|
+
const findings: Finding[] = []
|
|
1224
|
+
const afterStates = new Set(after.states.map((state) => state.id))
|
|
1225
|
+
const afterVariables = new Set((after.variables ?? []).map((variable) => variable.name))
|
|
1226
|
+
for (const invariant of before.invariants ?? []) {
|
|
1227
|
+
const mapped = mapInvariantForComparison(invariant, mapping)
|
|
1228
|
+
if (mapped.kind === 'never-states') {
|
|
1229
|
+
for (const state of mapped.states) {
|
|
1230
|
+
if (!afterStates.has(state)) {
|
|
1231
|
+
findings.push({
|
|
1232
|
+
code: 'D2_MAPPED_STATE_MISSING',
|
|
1233
|
+
severity: 'warning',
|
|
1234
|
+
message: 'BEFORE invariant "' + invariant.id + '" maps to state ' + state + ', which is not declared in AFTER.',
|
|
1235
|
+
evidence: { invariant: invariant.id, state },
|
|
1236
|
+
})
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
} else if (mapped.kind === 'event-before-state') {
|
|
1240
|
+
if (!afterStates.has(mapped.state)) {
|
|
1241
|
+
findings.push({
|
|
1242
|
+
code: 'D2_MAPPED_STATE_MISSING',
|
|
1243
|
+
severity: 'warning',
|
|
1244
|
+
message: 'BEFORE invariant "' + invariant.id + '" maps to state ' + mapped.state + ', which is not declared in AFTER.',
|
|
1245
|
+
evidence: { invariant: invariant.id, state: mapped.state },
|
|
1246
|
+
})
|
|
1247
|
+
}
|
|
1248
|
+
} else if (mapped.kind === 'var-in-range') {
|
|
1249
|
+
if (!afterVariables.has(mapped.variable)) {
|
|
1250
|
+
findings.push({
|
|
1251
|
+
code: 'D2_VARIABLE_MISSING',
|
|
1252
|
+
severity: 'warning',
|
|
1253
|
+
message: 'BEFORE invariant "' + invariant.id + '" references variable ' + mapped.variable + ', which is not declared in AFTER.',
|
|
1254
|
+
evidence: { invariant: invariant.id, variable: mapped.variable },
|
|
1255
|
+
})
|
|
1256
|
+
continue
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
const violation = shortestViolationForInvariant(after, options, mapped)
|
|
1260
|
+
if (violation !== undefined) {
|
|
1261
|
+
findings.push({
|
|
1262
|
+
code: 'D2_INVARIANT_REGRESSION',
|
|
1263
|
+
severity: 'error',
|
|
1264
|
+
message: 'BEFORE invariant "' + invariant.id + '" no longer holds in AFTER: ' + violation.reason,
|
|
1265
|
+
path: violation.path,
|
|
1266
|
+
evidence: { beforeInvariant: invariant, afterInvariant: mapped },
|
|
1267
|
+
})
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
return checkResult('D2', 'Invariant Continuity', findings, findings.length === 0 ? 'All BEFORE invariants continue to hold' : 'Invariant continuity findings: ' + findings.length)
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
function D3_regressionDelta(before: LogicModelV1, after: LogicModelV1, mapping: Record<string, string>): CheckResult {
|
|
1274
|
+
const afterStateIds = new Set(after.states.map((state) => state.id))
|
|
1275
|
+
const beforeMappedIds = new Set(before.states.map((state) => mapStateId(mapping, state.id)))
|
|
1276
|
+
const addedStates = after.states.filter((state) => !beforeMappedIds.has(state.id)).map((state) => state.id).sort()
|
|
1277
|
+
const removedStates = before.states.filter((state) => !afterStateIds.has(mapStateId(mapping, state.id))).map((state) => state.id).sort()
|
|
1278
|
+
const beforeEvents = new Set(before.transitions.map((transition) => transition.event))
|
|
1279
|
+
const afterEvents = new Set(after.transitions.map((transition) => transition.event))
|
|
1280
|
+
const addedEvents = [...afterEvents].filter((event) => !beforeEvents.has(event)).sort()
|
|
1281
|
+
const removedEvents = [...beforeEvents].filter((event) => !afterEvents.has(event)).sort()
|
|
1282
|
+
const removedTransitions = before.transitions.filter((transition) => !after.transitions.some((candidate) =>
|
|
1283
|
+
candidate.from === mapStateId(mapping, transition.from) &&
|
|
1284
|
+
candidate.event === transition.event &&
|
|
1285
|
+
candidate.to === mapStateId(mapping, transition.to)
|
|
1286
|
+
))
|
|
1287
|
+
const addedTransitions = after.transitions.filter((transition) => !before.transitions.some((candidate) =>
|
|
1288
|
+
mapStateId(mapping, candidate.from) === transition.from &&
|
|
1289
|
+
candidate.event === transition.event &&
|
|
1290
|
+
mapStateId(mapping, candidate.to) === transition.to
|
|
1291
|
+
))
|
|
1292
|
+
const findings: Finding[] = []
|
|
1293
|
+
for (const state of removedStates) {
|
|
1294
|
+
findings.push({ code: 'D3_REMOVED_STATE', severity: 'warning', message: 'BEFORE state ' + state + ' is not present in AFTER under the given mapping.', evidence: { state } })
|
|
1295
|
+
}
|
|
1296
|
+
for (const event of removedEvents) {
|
|
1297
|
+
findings.push({ code: 'D3_REMOVED_EVENT', severity: 'warning', message: 'BEFORE event ' + event + ' is not present in AFTER.', evidence: { event } })
|
|
1298
|
+
}
|
|
1299
|
+
for (const transition of removedTransitions) {
|
|
1300
|
+
findings.push({
|
|
1301
|
+
code: 'D3_REMOVED_TRANSITION',
|
|
1302
|
+
severity: 'warning',
|
|
1303
|
+
message: 'BEFORE transition ' + transition.from + ' -' + transition.event + '-> ' + transition.to + ' has no exact AFTER counterpart.',
|
|
1304
|
+
evidence: { transition },
|
|
1305
|
+
})
|
|
1306
|
+
}
|
|
1307
|
+
const detail = 'Delta: +' + addedStates.length + ' states, -' + removedStates.length + ' states, +' + addedEvents.length + ' events, -' + removedEvents.length + ' events, +' + addedTransitions.length + ' transitions, -' + removedTransitions.length + ' transitions'
|
|
1308
|
+
return checkResult('D3', 'Regression Delta', findings, detail)
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
function deadlockStateIds(model: LogicModelV1): string[] {
|
|
1312
|
+
const outgoing = new Set(model.transitions.map((transition) => transition.from))
|
|
1313
|
+
return model.states.filter((state) => state.terminal !== true && !outgoing.has(state.id)).map((state) => state.id)
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
function closedSccStateSets(model: LogicModelV1): string[][] {
|
|
1317
|
+
return sccs(model)
|
|
1318
|
+
.filter((component) => {
|
|
1319
|
+
const componentSet = new Set(component)
|
|
1320
|
+
const internalEdges = model.transitions.some((transition) => componentSet.has(transition.from) && componentSet.has(transition.to))
|
|
1321
|
+
const escapingEdges = model.transitions.some((transition) => componentSet.has(transition.from) && !componentSet.has(transition.to))
|
|
1322
|
+
return internalEdges && !escapingEdges && !component.some((state) => isTerminal(model, state))
|
|
1323
|
+
})
|
|
1324
|
+
.map((component) => component.sort())
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
function D4_deadlockLivenessRegression(before: LogicModelV1, after: LogicModelV1, mapping: Record<string, string>): CheckResult {
|
|
1328
|
+
const findings: Finding[] = []
|
|
1329
|
+
const beforeDeadlock = new Set(deadlockStateIds(before).map((state) => mapStateId(mapping, state)))
|
|
1330
|
+
const afterDeadlock = deadlockStateIds(after)
|
|
1331
|
+
for (const state of afterDeadlock) {
|
|
1332
|
+
if (!beforeDeadlock.has(state)) {
|
|
1333
|
+
findings.push({
|
|
1334
|
+
code: 'D4_DEADLOCK_REGRESSION',
|
|
1335
|
+
severity: 'error',
|
|
1336
|
+
message: 'AFTER introduces deadlock in state ' + state + ' that was not deadlocked in BEFORE.',
|
|
1337
|
+
evidence: { state },
|
|
1338
|
+
})
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
const beforeScc = new Set(closedSccStateSets(before).map((component) => component.map((state) => mapStateId(mapping, state)).sort().join(',')))
|
|
1342
|
+
const afterScc = closedSccStateSets(after)
|
|
1343
|
+
for (const component of afterScc) {
|
|
1344
|
+
const key = component.join(',')
|
|
1345
|
+
if (!beforeScc.has(key)) {
|
|
1346
|
+
findings.push({
|
|
1347
|
+
code: 'D4_LIVENESS_REGRESSION',
|
|
1348
|
+
severity: 'error',
|
|
1349
|
+
message: 'AFTER introduces a closed SCC with no exit and no terminal state: ' + key,
|
|
1350
|
+
evidence: { states: component },
|
|
1351
|
+
})
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
return checkResult('D4', 'Deadlock/Liveness Regression', findings, findings.length === 0 ? 'No new deadlock or liveness regressions' : 'Regression findings: ' + findings.length)
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
function buildComparisonSummary(before: LogicModelV1, after: LogicModelV1, mapping: Record<string, string>): ComparisonSummary {
|
|
1358
|
+
const afterStateIds = new Set(after.states.map((state) => state.id))
|
|
1359
|
+
const beforeMappedIds = new Set(before.states.map((state) => mapStateId(mapping, state.id)))
|
|
1360
|
+
const addedStates = after.states.filter((state) => !beforeMappedIds.has(state.id)).map((state) => state.id).sort()
|
|
1361
|
+
const removedStates = before.states.filter((state) => !afterStateIds.has(mapStateId(mapping, state.id))).map((state) => state.id).sort()
|
|
1362
|
+
const beforeEvents = new Set(before.transitions.map((transition) => transition.event))
|
|
1363
|
+
const afterEvents = new Set(after.transitions.map((transition) => transition.event))
|
|
1364
|
+
const addedEvents = [...afterEvents].filter((event) => !beforeEvents.has(event)).sort()
|
|
1365
|
+
const removedEvents = [...beforeEvents].filter((event) => !afterEvents.has(event)).sort()
|
|
1366
|
+
const removedTransitions = before.transitions.filter((transition) => !after.transitions.some((candidate) =>
|
|
1367
|
+
candidate.from === mapStateId(mapping, transition.from) &&
|
|
1368
|
+
candidate.event === transition.event &&
|
|
1369
|
+
candidate.to === mapStateId(mapping, transition.to)
|
|
1370
|
+
))
|
|
1371
|
+
const addedTransitions = after.transitions.filter((transition) => !before.transitions.some((candidate) =>
|
|
1372
|
+
mapStateId(mapping, candidate.from) === transition.from &&
|
|
1373
|
+
candidate.event === transition.event &&
|
|
1374
|
+
mapStateId(mapping, candidate.to) === transition.to
|
|
1375
|
+
))
|
|
1376
|
+
return {
|
|
1377
|
+
beforeModelHash: modelHash(before),
|
|
1378
|
+
afterModelHash: modelHash(after),
|
|
1379
|
+
stateMapping: mapping,
|
|
1380
|
+
beforeStates: before.states.length,
|
|
1381
|
+
beforeTransitions: before.transitions.length,
|
|
1382
|
+
afterStates: after.states.length,
|
|
1383
|
+
afterTransitions: after.transitions.length,
|
|
1384
|
+
addedStates,
|
|
1385
|
+
removedStates,
|
|
1386
|
+
addedEvents,
|
|
1387
|
+
removedEvents,
|
|
1388
|
+
addedTransitions,
|
|
1389
|
+
removedTransitions,
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
function A8_idempotentReplay(model: LogicModelV1, exploration: Exploration): CheckResult {
|
|
1393
|
+
const events = model.idempotentEvents ?? []
|
|
1394
|
+
const findings: Finding[] = []
|
|
1395
|
+
for (const event of events) {
|
|
1396
|
+
for (const runtime of exploration.reachable) {
|
|
1397
|
+
const onceOptions = stepRuntime(model, runtime, event)
|
|
1398
|
+
if (onceOptions.length === 0) continue
|
|
1399
|
+
for (const once of onceOptions) {
|
|
1400
|
+
const twiceOptions = stepRuntime(model, once, event)
|
|
1401
|
+
if (twiceOptions.length === 0) {
|
|
1402
|
+
findings.push({
|
|
1403
|
+
code: 'A8_NOT_REPLAYABLE',
|
|
1404
|
+
severity: 'warning',
|
|
1405
|
+
message: 'Idempotent event ' + event + ' is not replayable after first application from ' + runtime.state + '.',
|
|
1406
|
+
path: [{ from: runtime.state, event, to: once.state }],
|
|
1407
|
+
evidence: { state: runtime.state, event },
|
|
1408
|
+
})
|
|
1409
|
+
continue
|
|
1410
|
+
}
|
|
1411
|
+
for (const twice of twiceOptions) {
|
|
1412
|
+
if (runtimeKey(twice) !== runtimeKey(once)) {
|
|
1413
|
+
findings.push({
|
|
1414
|
+
code: 'A8_NOT_IDEMPOTENT',
|
|
1415
|
+
severity: 'error',
|
|
1416
|
+
message: 'Idempotent event ' + event + ' changes state when applied twice from ' + runtime.state + '.',
|
|
1417
|
+
path: [{ from: runtime.state, event, to: once.state }, { from: once.state, event, to: twice.state }],
|
|
1418
|
+
evidence: { state: runtime.state, event, afterOnce: once, afterTwice: twice },
|
|
1419
|
+
})
|
|
1420
|
+
break
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
return checkResult('A8', 'Idempotent Replay', findings, findings.length === 0 ? 'Idempotent events are replay-safe' : 'Idempotent replay findings: ' + findings.length)
|
|
1427
|
+
}
|
|
1428
|
+
function S8_monotonicVariables(model: LogicModelV1): CheckResult {
|
|
1429
|
+
const findings: Finding[] = []
|
|
1430
|
+
for (const variable of model.variables ?? []) {
|
|
1431
|
+
if (variable.monotonic === undefined) continue
|
|
1432
|
+
for (const transition of model.transitions) {
|
|
1433
|
+
for (const update of transition.updates ?? []) {
|
|
1434
|
+
if (update.variable !== variable.name) continue
|
|
1435
|
+
if (variable.monotonic === 'inc' && update.op === 'dec') {
|
|
1436
|
+
findings.push({ code: 'S8_MONOTONIC_DECREASE', severity: 'error', message: 'Monotonic (inc) variable ' + variable.name + ' is decreased by ' + transition.event + '.', evidence: { variable: variable.name, transition } })
|
|
1437
|
+
} else if (variable.monotonic === 'dec' && update.op === 'inc') {
|
|
1438
|
+
findings.push({ code: 'S8_MONOTONIC_INCREASE', severity: 'error', message: 'Monotonic (dec) variable ' + variable.name + ' is increased by ' + transition.event + '.', evidence: { variable: variable.name, transition } })
|
|
1439
|
+
} else if (update.op === 'set') {
|
|
1440
|
+
findings.push({ code: 'S8_MONOTONIC_SET_REVIEW', severity: 'warning', message: 'Monotonic variable ' + variable.name + ' uses set in ' + transition.event + '; verify it cannot move backwards.', evidence: { variable: variable.name, transition } })
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
return checkResult('S8', 'Monotonic Variables', findings, findings.length === 0 ? 'Monotonic variables are respected' : 'Monotonic findings: ' + findings.length)
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
function findLeadsToBadPath(model: LogicModelV1, start: RuntimeState, target: string): { path: PathStep[]; reason: string } | undefined {
|
|
1449
|
+
if (start.state === target) return undefined
|
|
1450
|
+
const visited = new Set<string>()
|
|
1451
|
+
const queue: Array<{ runtime: RuntimeState; path: PathStep[] }> = [{ runtime: start, path: [] }]
|
|
1452
|
+
while (queue.length > 0) {
|
|
1453
|
+
const entry = queue.shift()!
|
|
1454
|
+
const key = runtimeKey(entry.runtime)
|
|
1455
|
+
if (entry.runtime.state === target) continue
|
|
1456
|
+
if (visited.has(key)) return { path: entry.path, reason: 'Cycle avoids target ' + target }
|
|
1457
|
+
visited.add(key)
|
|
1458
|
+
const nexts: Array<{ next: RuntimeState; event: string }> = []
|
|
1459
|
+
for (const event of allEvents(model)) {
|
|
1460
|
+
for (const next of stepRuntime(model, entry.runtime, event)) nexts.push({ next, event })
|
|
1461
|
+
}
|
|
1462
|
+
if (nexts.length === 0) return { path: entry.path, reason: 'Dead end before target ' + target }
|
|
1463
|
+
for (const { next, event } of nexts) {
|
|
1464
|
+
queue.push({ runtime: next, path: [...entry.path, { from: entry.runtime.state, event, to: next.state }] })
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
return { path: [], reason: 'No path reaches target ' + target }
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
function A9_leadsTo(model: LogicModelV1, exploration: Exploration): CheckResult {
|
|
1471
|
+
const findings: Finding[] = []
|
|
1472
|
+
for (const invariant of model.invariants ?? []) {
|
|
1473
|
+
if (invariant.kind !== 'leads-to') continue
|
|
1474
|
+
for (const runtime of exploration.reachable) {
|
|
1475
|
+
if (runtime.state !== invariant.from) continue
|
|
1476
|
+
const bad = findLeadsToBadPath(model, runtime, invariant.to)
|
|
1477
|
+
if (bad !== undefined) {
|
|
1478
|
+
findings.push({
|
|
1479
|
+
code: 'A9_LEADS_TO_VIOLATION',
|
|
1480
|
+
severity: 'error',
|
|
1481
|
+
message: 'Leads-to invariant "' + invariant.id + '" violated from ' + invariant.from + ': ' + bad.reason,
|
|
1482
|
+
path: bad.path,
|
|
1483
|
+
evidence: { invariant },
|
|
1484
|
+
})
|
|
1485
|
+
break
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
return checkResult('A9', 'Leads-To', findings, findings.length === 0 ? 'All leads-to invariants hold' : 'Leads-to findings: ' + findings.length)
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
function findSequenceViolation(model: LogicModelV1, options: NormalizedOptions, invariant: Extract<InvariantSpec, { kind: 'sequence' }>): InvariantViolation | undefined {
|
|
1493
|
+
const events = invariant.events
|
|
1494
|
+
const init = initialState(model)
|
|
1495
|
+
const key = (runtime: RuntimeState, progress: number): string => runtimeKey(runtime) + '|' + progress
|
|
1496
|
+
const visited = new Set<string>([key(init, 0)])
|
|
1497
|
+
const queue: Array<{ runtime: RuntimeState; progress: number; path: PathStep[] }> = [{ runtime: init, progress: 0, path: [] }]
|
|
1498
|
+
let steps = 0
|
|
1499
|
+
while (queue.length > 0) {
|
|
1500
|
+
const entry = queue.shift()!
|
|
1501
|
+
if (++steps > options.maxStates) break
|
|
1502
|
+
for (const event of allEvents(model)) {
|
|
1503
|
+
for (const next of stepRuntime(model, entry.runtime, event)) {
|
|
1504
|
+
let progress = entry.progress
|
|
1505
|
+
let violation = false
|
|
1506
|
+
if (progress < events.length && event === events[progress]) {
|
|
1507
|
+
progress += 1
|
|
1508
|
+
} else {
|
|
1509
|
+
const index = events.indexOf(event)
|
|
1510
|
+
if (index > progress) violation = true
|
|
1511
|
+
}
|
|
1512
|
+
const path = [...entry.path, { from: entry.runtime.state, event, to: next.state }]
|
|
1513
|
+
if (violation) {
|
|
1514
|
+
return { invariant, path, reason: 'Event ' + event + ' occurred before ' + events[progress] }
|
|
1515
|
+
}
|
|
1516
|
+
const nextKey = key(next, progress)
|
|
1517
|
+
if (!visited.has(nextKey)) {
|
|
1518
|
+
visited.add(nextKey)
|
|
1519
|
+
queue.push({ runtime: next, progress, path })
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
return undefined
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
function A10_sequenceOrder(model: LogicModelV1, options: NormalizedOptions): CheckResult {
|
|
1528
|
+
const findings: Finding[] = []
|
|
1529
|
+
for (const invariant of model.invariants ?? []) {
|
|
1530
|
+
if (invariant.kind !== 'sequence') continue
|
|
1531
|
+
const violation = findSequenceViolation(model, options, invariant)
|
|
1532
|
+
if (violation !== undefined) {
|
|
1533
|
+
findings.push({
|
|
1534
|
+
code: 'A10_SEQUENCE_VIOLATION',
|
|
1535
|
+
severity: 'error',
|
|
1536
|
+
message: 'Sequence invariant "' + invariant.id + '" violated: ' + violation.reason,
|
|
1537
|
+
path: violation.path,
|
|
1538
|
+
evidence: { invariant },
|
|
1539
|
+
})
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
return checkResult('A10', 'Sequence Order', findings, findings.length === 0 ? 'All sequence invariants hold' : 'Sequence findings: ' + findings.length)
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
function findAtomicityViolation(model: LogicModelV1, options: NormalizedOptions, invariant: Extract<InvariantSpec, { kind: 'atomicity' }>): InvariantViolation | undefined {
|
|
1546
|
+
const atomic = new Set(invariant.events)
|
|
1547
|
+
const init = initialState(model)
|
|
1548
|
+
const key = (runtime: RuntimeState, started: boolean, closed: boolean): string => runtimeKey(runtime) + '|' + (started ? '1' : '0') + '|' + (closed ? '1' : '0')
|
|
1549
|
+
const visited = new Set<string>([key(init, false, false)])
|
|
1550
|
+
const queue: Array<{ runtime: RuntimeState; started: boolean; closed: boolean; path: PathStep[] }> = [{ runtime: init, started: false, closed: false, path: [] }]
|
|
1551
|
+
let steps = 0
|
|
1552
|
+
while (queue.length > 0) {
|
|
1553
|
+
const entry = queue.shift()!
|
|
1554
|
+
if (++steps > options.maxStates) break
|
|
1555
|
+
for (const event of allEvents(model)) {
|
|
1556
|
+
for (const next of stepRuntime(model, entry.runtime, event)) {
|
|
1557
|
+
const started = entry.started || atomic.has(event)
|
|
1558
|
+
const closed = entry.closed || event === invariant.commit || (invariant.rollback !== undefined && event === invariant.rollback)
|
|
1559
|
+
const path = [...entry.path, { from: entry.runtime.state, event, to: next.state }]
|
|
1560
|
+
if (entry.started && !entry.closed && !atomic.has(event) && event !== invariant.commit && event !== invariant.rollback) {
|
|
1561
|
+
return { invariant, path, reason: 'Left atomic scope via ' + event + ' without commit/rollback' }
|
|
1562
|
+
}
|
|
1563
|
+
if (started && !closed && isTerminal(model, next.state)) {
|
|
1564
|
+
return { invariant, path, reason: 'Terminal state reached with incomplete atomic group' }
|
|
1565
|
+
}
|
|
1566
|
+
const nextKey = key(next, started, closed)
|
|
1567
|
+
if (!visited.has(nextKey)) {
|
|
1568
|
+
visited.add(nextKey)
|
|
1569
|
+
queue.push({ runtime: next, started, closed, path })
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
return undefined
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
function A11_atomicity(model: LogicModelV1, options: NormalizedOptions): CheckResult {
|
|
1578
|
+
const findings: Finding[] = []
|
|
1579
|
+
for (const invariant of model.invariants ?? []) {
|
|
1580
|
+
if (invariant.kind !== 'atomicity') continue
|
|
1581
|
+
const violation = findAtomicityViolation(model, options, invariant)
|
|
1582
|
+
if (violation !== undefined) {
|
|
1583
|
+
findings.push({
|
|
1584
|
+
code: 'A11_ATOMICITY_VIOLATION',
|
|
1585
|
+
severity: 'error',
|
|
1586
|
+
message: 'Atomicity invariant "' + invariant.id + '" violated: ' + violation.reason,
|
|
1587
|
+
path: violation.path,
|
|
1588
|
+
evidence: { invariant },
|
|
1589
|
+
})
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
return checkResult('A11', 'Atomicity', findings, findings.length === 0 ? 'All atomicity invariants hold' : 'Atomicity findings: ' + findings.length)
|
|
1593
|
+
}
|
|
1105
1594
|
// ---------------------------------------------------------------------------
|
|
1106
1595
|
// main entry
|
|
1107
1596
|
// ---------------------------------------------------------------------------
|
|
@@ -1137,6 +1626,7 @@ export function runVerification(input: unknown, options: VerificationOptions = {
|
|
|
1137
1626
|
S5_eventCompleteness(model),
|
|
1138
1627
|
S6_guardCompleteness(model),
|
|
1139
1628
|
S7_invariants(model, normalized),
|
|
1629
|
+
S8_monotonicVariables(model),
|
|
1140
1630
|
A1_unexpectedEvents(model),
|
|
1141
1631
|
A2_raceInterleaving(model, exploration),
|
|
1142
1632
|
A3_orderPermutation(model, normalized),
|
|
@@ -1144,7 +1634,49 @@ export function runVerification(input: unknown, options: VerificationOptions = {
|
|
|
1144
1634
|
A5_boundaryBlast(model),
|
|
1145
1635
|
A6_resourceInjection(model),
|
|
1146
1636
|
A7_shortestViolations(model, normalized),
|
|
1637
|
+
A8_idempotentReplay(model, exploration),
|
|
1638
|
+
A9_leadsTo(model, exploration),
|
|
1639
|
+
A10_sequenceOrder(model, normalized),
|
|
1640
|
+
A11_atomicity(model, normalized),
|
|
1147
1641
|
]
|
|
1642
|
+
let comparison: ComparisonSummary | undefined
|
|
1643
|
+
if (options.beforeModel !== undefined) {
|
|
1644
|
+
const beforeValidation = validateModel(options.beforeModel)
|
|
1645
|
+
if (!beforeValidation.ok) {
|
|
1646
|
+
return {
|
|
1647
|
+
ok: false,
|
|
1648
|
+
schemaVersion: 1,
|
|
1649
|
+
modelHash: modelHash(model),
|
|
1650
|
+
summary: {
|
|
1651
|
+
states: model.states.length,
|
|
1652
|
+
transitions: model.transitions.length,
|
|
1653
|
+
errors: beforeValidation.errors.length,
|
|
1654
|
+
warnings: 0,
|
|
1655
|
+
checksRun: checks.length + 1,
|
|
1656
|
+
truncated: exploration.truncated,
|
|
1657
|
+
},
|
|
1658
|
+
checks: [
|
|
1659
|
+
...checks,
|
|
1660
|
+
{
|
|
1661
|
+
id: 'BEFORE_MODEL',
|
|
1662
|
+
name: 'Before Model Validation',
|
|
1663
|
+
status: 'fail',
|
|
1664
|
+
detail: 'Before model schema validation failed: ' + beforeValidation.errors.length + ' errors',
|
|
1665
|
+
findings: beforeValidation.errors.map((message) => ({ code: 'BEFORE_MODEL_INVALID', severity: 'error', message })),
|
|
1666
|
+
},
|
|
1667
|
+
],
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
const before = beforeValidation.model
|
|
1671
|
+
const mapping = options.stateMapping ?? {}
|
|
1672
|
+
checks.push(
|
|
1673
|
+
D1_behavioralPreservation(before, model, mapping),
|
|
1674
|
+
D2_invariantContinuity(before, model, normalized, mapping),
|
|
1675
|
+
D3_regressionDelta(before, model, mapping),
|
|
1676
|
+
D4_deadlockLivenessRegression(before, model, mapping),
|
|
1677
|
+
)
|
|
1678
|
+
comparison = buildComparisonSummary(before, model, mapping)
|
|
1679
|
+
}
|
|
1148
1680
|
const errors = checks.reduce((sum, check) => sum + check.findings.filter((finding) => finding.severity === 'error').length, 0)
|
|
1149
1681
|
const warnings = checks.reduce((sum, check) => sum + check.findings.filter((finding) => finding.severity === 'warning').length, 0)
|
|
1150
1682
|
return {
|
|
@@ -1160,5 +1692,6 @@ export function runVerification(input: unknown, options: VerificationOptions = {
|
|
|
1160
1692
|
truncated: exploration.truncated,
|
|
1161
1693
|
},
|
|
1162
1694
|
checks,
|
|
1695
|
+
...(comparison === undefined ? {} : { comparison }),
|
|
1163
1696
|
}
|
|
1164
1697
|
}
|