dsh-logicprobe 0.9.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en-US.md +79 -14
- package/README.md +79 -14
- package/lib/baseline.js +142 -0
- package/lib/client.js +56 -1
- package/lib/compose-tool.js +1 -1
- package/lib/concurrency.js +14 -0
- package/lib/data-engine.js +42 -8
- package/lib/engine.js +379 -79
- package/lib/export-tool.js +27 -2
- package/lib/granularity.js +237 -0
- package/lib/index.js +63 -41
- package/lib/report-diff-tool.js +39 -0
- package/lib/structure-tool.js +64 -0
- package/lib/structure.js +648 -0
- package/lib/tool.js +3 -3
- package/lib/types/baseline.d.ts +69 -0
- package/lib/types/concurrency.d.ts +10 -0
- package/lib/types/data-engine.d.ts +13 -1
- package/lib/types/engine.d.ts +138 -1
- package/lib/types/granularity.d.ts +106 -0
- package/lib/types/index.d.ts +9 -8
- package/lib/types/report-diff-tool.d.ts +8 -0
- package/lib/types/structure-tool.d.ts +10 -0
- package/lib/types/structure.d.ts +156 -0
- package/lib/types/tool.d.ts +2 -2
- package/lib/types/uml.d.ts +89 -0
- package/lib/uml-tool.js +36 -7
- package/lib/uml.js +217 -23
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +16 -14
- package/skills/logicprobe/SKILL.md +84 -58
- package/skills/logicprobe/references/dsh-model-schema.md +81 -10
- package/skills/logicprobe/references/gap-routing-guide.md +37 -36
- package/skills/logicprobe/references/hash-spec.md +128 -0
- package/skills/logicprobe/references/logic-verification-guide.md +27 -250
- package/skills/logicprobe-concurrency/SKILL.md +43 -0
- package/skills/{logicprobe → logicprobe-concurrency}/references/concurrency-risk-guide.md +1 -1
- package/skills/logicprobe-datamodel/SKILL.md +5 -4
- package/skills/logicprobe-datamodel/references/data-model-guide.md +1 -1
- package/skills/logicprobe-datamodel/references/data-model-schema.md +1 -1
- package/skills/logicprobe-structure/SKILL.md +95 -0
- package/skills/logicprobe-structure/references/structure-review-guide.md +177 -0
- package/skills/logicprobe-uml/SKILL.md +55 -0
- package/skills/{logicprobe → logicprobe-uml}/references/uml-modeling-guide.md +120 -8
- package/src/baseline.ts +180 -0
- package/src/client.js +56 -1
- package/src/compose-tool.ts +47 -47
- package/src/concurrency.ts +23 -0
- package/src/data-engine.ts +51 -9
- package/src/engine.ts +441 -67
- package/src/export-tool.ts +73 -48
- package/src/granularity.ts +312 -0
- package/src/index.ts +63 -41
- package/src/report-diff-tool.ts +43 -0
- package/src/structure-tool.ts +68 -0
- package/src/structure.ts +724 -0
- package/src/tool.ts +3 -3
- package/src/uml-tool.ts +38 -8
- package/src/uml.ts +275 -24
- package/skills/logicprobe/references/logicprobe-engine.py +0 -4943
- package/skills/logicprobe/references/verification-harness.py +0 -959
- package/skills/logicprobe-datamodel/references/data-model-harness.py +0 -528
package/src/engine.ts
CHANGED
|
@@ -194,6 +194,8 @@ export interface VerificationOptions {
|
|
|
194
194
|
maxPermutationEvents?: number
|
|
195
195
|
beforeModel?: unknown
|
|
196
196
|
stateMapping?: Record<string, string>
|
|
197
|
+
/** Hash specification used for modelHash. Defaults to the current published spec (`v1`). */
|
|
198
|
+
hashSpec?: HashSpec
|
|
197
199
|
}
|
|
198
200
|
|
|
199
201
|
export interface PathStep {
|
|
@@ -206,6 +208,12 @@ export interface Finding {
|
|
|
206
208
|
code: string
|
|
207
209
|
severity: 'error' | 'warning'
|
|
208
210
|
message: string
|
|
211
|
+
/** Longer explanation: the shortest-path note, the per-machine reasons, the quoted source lines. */
|
|
212
|
+
detail?: string
|
|
213
|
+
/** The artefact the finding is about (a diagram name, a file path) — set when one report covers several. */
|
|
214
|
+
file?: string
|
|
215
|
+
/** 1-based line in that artefact, when the check knows it. */
|
|
216
|
+
line?: number
|
|
209
217
|
path?: PathStep[]
|
|
210
218
|
evidence?: Record<string, unknown>
|
|
211
219
|
}
|
|
@@ -219,9 +227,29 @@ export interface CheckResult {
|
|
|
219
227
|
}
|
|
220
228
|
|
|
221
229
|
export interface VerificationReport {
|
|
230
|
+
/**
|
|
231
|
+
* The engine ran and the input was well-formed enough to produce this report.
|
|
232
|
+
* It does NOT mean the review passed — read `verdict` for that.
|
|
233
|
+
*/
|
|
222
234
|
ok: boolean
|
|
235
|
+
/** The tool executed and produced a report. False only when a tool refused the request before the engine ran. */
|
|
236
|
+
ran: boolean
|
|
237
|
+
/** The review outcome. `ok: true` with `verdict: "fail"` is a failing review, not a passing one. */
|
|
238
|
+
verdict: Verdict
|
|
239
|
+
/** Why the verdict came out that way, e.g. `2 error finding(s) (first: S2_NO_TRANSITIONS)`. */
|
|
240
|
+
verdictReason: string
|
|
241
|
+
/** The versioned report contract this result follows. */
|
|
242
|
+
schema: ReportSchema
|
|
223
243
|
schemaVersion: 1
|
|
244
|
+
/** Which published specification `modelHash` follows (see references/hash-spec.md). */
|
|
245
|
+
hashSpec: HashSpec
|
|
224
246
|
modelHash: string
|
|
247
|
+
/** Every hash this report carries, in one place, for a baseline diff or an archive record. */
|
|
248
|
+
hashes: { hashSpec: HashSpec; modelHash: string; beforeModelHash?: string; afterModelHash?: string }
|
|
249
|
+
/** Paths of the `_`-prefixed metadata keys found in the input and ignored by the schema. */
|
|
250
|
+
metadataKeys?: string[]
|
|
251
|
+
/** How much of the model the narrative documents; absent when there is no narrative. */
|
|
252
|
+
narrativeCoverage?: NarrativeCoverage
|
|
225
253
|
/** Echo of the model's natural-language narrative, when present. */
|
|
226
254
|
narrative?: ModelNarrative
|
|
227
255
|
summary: {
|
|
@@ -234,11 +262,30 @@ export interface VerificationReport {
|
|
|
234
262
|
}
|
|
235
263
|
checks: CheckResult[]
|
|
236
264
|
comparison?: ComparisonSummary
|
|
265
|
+
/** What to do next, derived from the findings — never empty. */
|
|
266
|
+
nextSteps: string[]
|
|
237
267
|
/** Informational notes about semantic dimensions this model references (timing, preemption)
|
|
238
268
|
* that this engine does not verify. Heuristic, vocabulary-based — never a substitute for the checks. */
|
|
239
269
|
coverageNotes?: string[]
|
|
240
270
|
}
|
|
241
271
|
|
|
272
|
+
/**
|
|
273
|
+
* The next-steps list every verification report carries. Derived only from the checks
|
|
274
|
+
* and the narrative coverage, so two runs over the same model produce the same list.
|
|
275
|
+
*/
|
|
276
|
+
function verificationNextSteps(checks: CheckResult[], coverage: NarrativeCoverage | undefined): string[] {
|
|
277
|
+
const steps: string[] = []
|
|
278
|
+
const failed = checks.filter((check) => check.status === 'fail').map((check) => check.id + ' ' + check.name)
|
|
279
|
+
const warningCodes = [...new Set(checks.flatMap((check) => check.findings).filter((finding) => finding.severity === 'warning').map((finding) => finding.code))].sort()
|
|
280
|
+
if (failed.length > 0) steps.push('Resolve the failing checks first: ' + failed.join(', ') + '.')
|
|
281
|
+
if (warningCodes.length > 0) steps.push('Review the warning findings (' + warningCodes.join(', ') + '): they are not failures, but they are not silence either.')
|
|
282
|
+
if (coverage !== undefined && !narrativeComplete(coverage)) {
|
|
283
|
+
steps.push('Complete the narrative (states ' + coverage.states + ', events ' + coverage.events + ', scenarios ' + coverage.scenarios + ') — a partial narrative is valid, but a reader still has to re-derive the missing symbols.')
|
|
284
|
+
}
|
|
285
|
+
steps.push('Re-run with beforeModel/stateMapping after the change to prove the behaviour did not regress (D1-D4).')
|
|
286
|
+
return steps
|
|
287
|
+
}
|
|
288
|
+
|
|
242
289
|
export interface ComparisonSummary {
|
|
243
290
|
beforeModelHash: string
|
|
244
291
|
afterModelHash: string
|
|
@@ -272,8 +319,177 @@ function stableStringify(value: unknown): string {
|
|
|
272
319
|
return '{' + Object.keys(record).sort().map((key) => JSON.stringify(key) + ':' + stableStringify(record[key])).join(',') + '}'
|
|
273
320
|
}
|
|
274
321
|
|
|
275
|
-
export function modelHash(model: LogicModelV1): string {
|
|
276
|
-
return createHash('sha256').update(stableStringify(model)).digest('hex')
|
|
322
|
+
export function modelHash(model: LogicModelV1, spec: HashSpec = DEFAULT_HASH_SPEC): string {
|
|
323
|
+
return createHash('sha256').update(stableStringify(hashPayload(model, spec))).digest('hex')
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Deterministic JSON (sorted keys, no insignificant whitespace) — the serialization the
|
|
328
|
+
* hash specification is defined over. Exported so every report hashes inputs the same way.
|
|
329
|
+
*/
|
|
330
|
+
export function canonicalJson(value: unknown): string {
|
|
331
|
+
return stableStringify(value)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
// Report contract: ran / verdict (P0-1) and metadata keys + hash specs (P0-3/P0-4)
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Published model-hash specifications. The full normalization rules, the list of
|
|
340
|
+
* excluded keys and a one-line recompute command are in `references/hash-spec.md`.
|
|
341
|
+
*
|
|
342
|
+
* - `v1` (1.0.0) drops every `_`-prefixed metadata key at every level before hashing,
|
|
343
|
+
* so archiving `_source` / `_verified` next to a model cannot change its hash.
|
|
344
|
+
* - `v0` is the pre-1.0.0 behaviour (no metadata filtering), kept so an archived hash
|
|
345
|
+
* can still be checked against the old engine.
|
|
346
|
+
*
|
|
347
|
+
* For a model that carries no `_` keys the two specs are byte-identical.
|
|
348
|
+
*/
|
|
349
|
+
export type HashSpec = 'v0' | 'v1'
|
|
350
|
+
|
|
351
|
+
export const PUBLISHED_HASH_SPECS: readonly HashSpec[] = ['v0', 'v1']
|
|
352
|
+
export const DEFAULT_HASH_SPEC: HashSpec = 'v1'
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Versioned report contracts. Every tool result carries `schema`, so a consumer can
|
|
356
|
+
* branch on the contract instead of sniffing fields, and a change to a report's shape
|
|
357
|
+
* is a version bump here rather than a silent break. `findings[]` keeps the same
|
|
358
|
+
* stable core in every family: `{code, severity, message, detail?, evidence?, path?}`.
|
|
359
|
+
*/
|
|
360
|
+
export const REPORT_SCHEMAS = {
|
|
361
|
+
verify: 'logicprobe/verify/v1',
|
|
362
|
+
compose: 'logicprobe/compose/v1',
|
|
363
|
+
datamodel: 'logicprobe/datamodel/v1',
|
|
364
|
+
concurrency: 'logicprobe/concurrency/v1',
|
|
365
|
+
export: 'logicprobe/export/v1',
|
|
366
|
+
umlRender: 'logicprobe/uml/render/v1',
|
|
367
|
+
umlParse: 'logicprobe/uml/parse/v1',
|
|
368
|
+
umlReview: 'logicprobe/uml/review/v1',
|
|
369
|
+
structure: 'logicprobe/structure/v1',
|
|
370
|
+
granularity: 'logicprobe/granularity/v1',
|
|
371
|
+
baseline: 'logicprobe/baseline/v1',
|
|
372
|
+
} as const
|
|
373
|
+
|
|
374
|
+
export type ReportSchema = typeof REPORT_SCHEMAS[keyof typeof REPORT_SCHEMAS]
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* How much of the model the `narrative` block actually documents, as `covered/total`
|
|
378
|
+
* per dimension. A partial narrative is valid — writing the states first and the
|
|
379
|
+
* events later is the natural order — so this is a coverage report, not a gate.
|
|
380
|
+
* `scenarios` counts distinct modelled (from, event) groups: one scenario per group.
|
|
381
|
+
*/
|
|
382
|
+
export interface NarrativeCoverage {
|
|
383
|
+
states: string
|
|
384
|
+
events: string
|
|
385
|
+
scenarios: string
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export function narrativeCoverageOf(model: LogicModelV1): NarrativeCoverage | undefined {
|
|
389
|
+
const narrative = model.narrative
|
|
390
|
+
if (narrative === undefined) return undefined
|
|
391
|
+
const describedStates = narrative.states === undefined ? 0 : Object.keys(narrative.states).length
|
|
392
|
+
const describedEvents = narrative.events === undefined ? 0 : Object.keys(narrative.events).length
|
|
393
|
+
const groups = new Set(model.transitions.map((transition) => transition.from + '|' + transition.event))
|
|
394
|
+
const describedGroups = narrative.scenarios === undefined
|
|
395
|
+
? 0
|
|
396
|
+
: new Set(narrative.scenarios.map((scenario) => scenario.from + '|' + scenario.event)).size
|
|
397
|
+
const events = new Set(model.transitions.map((transition) => transition.event))
|
|
398
|
+
return {
|
|
399
|
+
states: String(describedStates) + '/' + String(model.states.length),
|
|
400
|
+
events: String(describedEvents) + '/' + String(events.size),
|
|
401
|
+
scenarios: String(describedGroups) + '/' + String(groups.size),
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** True when every dimension of the coverage is fully described. */
|
|
406
|
+
export function narrativeComplete(coverage: NarrativeCoverage | undefined): boolean {
|
|
407
|
+
if (coverage === undefined) return false
|
|
408
|
+
return coverage.states.split('/')[0] === coverage.states.split('/')[1]
|
|
409
|
+
&& coverage.events.split('/')[0] === coverage.events.split('/')[1]
|
|
410
|
+
&& coverage.scenarios.split('/')[0] === coverage.scenarios.split('/')[1]
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* The review outcome, kept separate from `ok` (= "the tool ran"). Collapsing the two
|
|
415
|
+
* is how a failed review gets read as a passing one.
|
|
416
|
+
*/
|
|
417
|
+
export type Verdict = 'pass' | 'pass_with_findings' | 'fail'
|
|
418
|
+
|
|
419
|
+
export interface VerdictSummary {
|
|
420
|
+
verdict: Verdict
|
|
421
|
+
verdictReason: string
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** A tool that refused the request before running anything: it neither passed nor failed a review. */
|
|
425
|
+
export function refusalVerdict(reason: string): VerdictSummary {
|
|
426
|
+
return { verdict: 'fail', verdictReason: 'the tool did not run: ' + reason }
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Decide the verdict from finding counts. Any error-severity finding fails the review
|
|
431
|
+
* even though the tool ran successfully — that gap is exactly what `verdict` closes.
|
|
432
|
+
*/
|
|
433
|
+
export function verdictOf(errors: number, warnings: number, firstCode?: string): VerdictSummary {
|
|
434
|
+
if (errors > 0) {
|
|
435
|
+
return {
|
|
436
|
+
verdict: 'fail',
|
|
437
|
+
verdictReason: String(errors) + ' error finding(s)' + (firstCode === undefined ? '' : ' (first: ' + firstCode + ')'),
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
if (warnings > 0) {
|
|
441
|
+
return { verdict: 'pass_with_findings', verdictReason: 'no error findings; ' + String(warnings) + ' warning finding(s)' }
|
|
442
|
+
}
|
|
443
|
+
return { verdict: 'pass', verdictReason: 'no error or warning findings' }
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export function verdictOfFindings(findings: Array<{ code: string; severity: string }>): VerdictSummary {
|
|
447
|
+
const errors = findings.filter((finding) => finding.severity === 'error')
|
|
448
|
+
const warnings = findings.filter((finding) => finding.severity === 'warning')
|
|
449
|
+
return verdictOf(errors.length, warnings.length, errors[0]?.code)
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Paths of every `_`-prefixed key in the input, sorted (`_source`, `_verified`,
|
|
454
|
+
* `states[0]._note`; a metadata key's own children are not listed, since the subtree is
|
|
455
|
+
* metadata as a whole). The schema ignores these keys as annotation metadata; echoing
|
|
456
|
+
* them in the report makes the ignored metadata auditable instead of invisible.
|
|
457
|
+
*/
|
|
458
|
+
export function metadataKeysOf(input: unknown): string[] {
|
|
459
|
+
const keys: string[] = []
|
|
460
|
+
const walk = (value: unknown, path: string): void => {
|
|
461
|
+
if (value === null || typeof value !== 'object') return
|
|
462
|
+
if (Array.isArray(value)) {
|
|
463
|
+
value.forEach((entry, index) => walk(entry, path + '[' + String(index) + ']'))
|
|
464
|
+
return
|
|
465
|
+
}
|
|
466
|
+
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
|
|
467
|
+
const child = path === '' ? key : path + '.' + key
|
|
468
|
+
if (key.startsWith('_')) keys.push(child)
|
|
469
|
+
walk(entry, child)
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
walk(input, '')
|
|
473
|
+
return keys.sort()
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function withoutMetadata(value: unknown): unknown {
|
|
477
|
+
if (value === null || typeof value !== 'object') return value
|
|
478
|
+
if (Array.isArray(value)) return value.map((entry) => withoutMetadata(entry))
|
|
479
|
+
const out: Record<string, unknown> = {}
|
|
480
|
+
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
|
|
481
|
+
if (key.startsWith('_')) continue
|
|
482
|
+
out[key] = withoutMetadata(entry)
|
|
483
|
+
}
|
|
484
|
+
return out
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* The exact payload a hash spec hashes. Exported so the specification has one
|
|
489
|
+
* implementation and the documentation can be checked against it.
|
|
490
|
+
*/
|
|
491
|
+
export function hashPayload(model: unknown, spec: HashSpec = DEFAULT_HASH_SPEC): unknown {
|
|
492
|
+
return spec === 'v1' ? withoutMetadata(model) : model
|
|
277
493
|
}
|
|
278
494
|
|
|
279
495
|
export function validateModel(input: unknown): { ok: true; model: LogicModelV1 } | { ok: false; errors: string[] } {
|
|
@@ -285,8 +501,12 @@ export function validateModel(input: unknown): { ok: true; model: LogicModelV1 }
|
|
|
285
501
|
return { ok: false, errors: ['model: must be an object'] }
|
|
286
502
|
}
|
|
287
503
|
const root = input as Record<string, unknown>
|
|
504
|
+
// `_`-prefixed keys are annotation metadata (provenance, verification snapshots,
|
|
505
|
+
// extraction caveats). They are ignored here and excluded from the hash, so a model
|
|
506
|
+
// can carry its own archive record without a sidecar that drifts away from it.
|
|
507
|
+
// Everything else stays closed: a mistyped `sttes` must still be an error.
|
|
288
508
|
const rejectUnknownKeys = (value: Record<string, unknown>, allowed: Set<string>, path: string): void => {
|
|
289
|
-
const unexpected = Object.keys(value).filter((key) => !allowed.has(key))
|
|
509
|
+
const unexpected = Object.keys(value).filter((key) => !allowed.has(key) && !key.startsWith('_'))
|
|
290
510
|
if (unexpected.length > 0) {
|
|
291
511
|
bad(path + '.' + unexpected[0], 'unknown field (allowed: ' + [...allowed].join(', ') + ')')
|
|
292
512
|
}
|
|
@@ -422,7 +642,7 @@ export function validateModel(input: unknown): { ok: true; model: LogicModelV1 }
|
|
|
422
642
|
// older reader may not know are skipped so they still fail on `.kind` alone.
|
|
423
643
|
if (KNOWN_INVARIANT_KINDS.has(invariant.kind as string)) {
|
|
424
644
|
const allowed = INVARIANT_KEYS[invariant.kind as string]
|
|
425
|
-
const unexpected = Object.keys(invariant).filter((key) => !allowed.has(key))
|
|
645
|
+
const unexpected = Object.keys(invariant).filter((key) => !allowed.has(key) && !key.startsWith('_'))
|
|
426
646
|
if (unexpected.length > 0) {
|
|
427
647
|
bad(path + '.' + unexpected[0], 'unknown field for kind ' + String(invariant.kind) + ' (allowed: ' + [...allowed].join(', ') + ')')
|
|
428
648
|
}
|
|
@@ -445,7 +665,7 @@ export function validateModel(input: unknown): { ok: true; model: LogicModelV1 }
|
|
|
445
665
|
} else if ('state' in when) {
|
|
446
666
|
const scope = when as Record<string, unknown>
|
|
447
667
|
for (const key of Object.keys(scope)) {
|
|
448
|
-
if (key !== 'state') bad(path + '.when.' + key, 'unknown field for a state scope (allowed: state)')
|
|
668
|
+
if (key !== 'state' && !key.startsWith('_')) bad(path + '.when.' + key, 'unknown field for a state scope (allowed: state)')
|
|
449
669
|
}
|
|
450
670
|
const state = scope.state
|
|
451
671
|
if (typeof state !== 'string' || state.length === 0) bad(path + '.when.state', 'must be a non-empty string')
|
|
@@ -574,57 +794,59 @@ export function validateModel(input: unknown): { ok: true; model: LogicModelV1 }
|
|
|
574
794
|
rejectUnknownKeys(narrative, MODEL_KEYS.narrative, narrativePath)
|
|
575
795
|
const stateIds = new Set<string>(Array.isArray(root.states) ? (root.states as StateSpec[]).map((state) => state.id) : [])
|
|
576
796
|
const eventIds = new Set<string>(Array.isArray(root.transitions) ? (root.transitions as TransitionSpec[]).map((transition) => transition.event) : [])
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
for (const id of stateIds) {
|
|
586
|
-
if (typeof (narrative.states as Record<string, unknown>)[id] !== 'string') bad(narrativePath + '.states', 'missing description for state ' + id)
|
|
587
|
-
}
|
|
797
|
+
// A narrative is allowed to cover part of the model: states first, events and
|
|
798
|
+
// scenarios later is the natural authoring order, and `narrativeCoverage` reports
|
|
799
|
+
// what is still missing instead of rejecting the file. What stays an error is a
|
|
800
|
+
// narrative that is *wrong* (unknown id, empty description, duplicate scenario) or
|
|
801
|
+
// empty — an empty block claims documentation that does not exist.
|
|
802
|
+
const declaredDimensions = (['states', 'events', 'scenarios'] as const).filter((key) => narrative[key] !== undefined)
|
|
803
|
+
if (declaredDimensions.length === 0) {
|
|
804
|
+
bad(narrativePath, 'declares no dimension: give at least one of states, events or scenarios (a partial narrative is fine)')
|
|
588
805
|
}
|
|
589
|
-
if (
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
if (typeof (narrative.events as Record<string, unknown>)[id] !== 'string') bad(narrativePath + '.events', 'missing description for event ' + id)
|
|
806
|
+
if (narrative.states !== undefined) {
|
|
807
|
+
if (typeof narrative.states !== 'object' || narrative.states === null || Array.isArray(narrative.states)) {
|
|
808
|
+
bad(narrativePath + '.states', 'must be an object mapping state id -> natural-language description')
|
|
809
|
+
} else {
|
|
810
|
+
for (const [id, description] of Object.entries(narrative.states as Record<string, unknown>)) {
|
|
811
|
+
if (!stateIds.has(id)) bad(narrativePath + '.states', 'references unknown state ' + id)
|
|
812
|
+
if (typeof description !== 'string' || description.length === 0) bad(narrativePath + '.states.' + id, 'must be a non-empty string')
|
|
813
|
+
}
|
|
598
814
|
}
|
|
599
815
|
}
|
|
600
|
-
if (
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
bad(scenarioPath, 'must be an object')
|
|
608
|
-
return
|
|
609
|
-
}
|
|
610
|
-
const scenario = entry as Record<string, unknown>
|
|
611
|
-
rejectUnknownKeys(scenario, MODEL_KEYS.scenario, scenarioPath)
|
|
612
|
-
if (typeof scenario.from !== 'string' || scenario.from.length === 0) bad(scenarioPath + '.from', 'must be a non-empty string')
|
|
613
|
-
else if (!stateIds.has(scenario.from)) bad(scenarioPath + '.from', 'unknown state ' + scenario.from)
|
|
614
|
-
if (typeof scenario.event !== 'string' || scenario.event.length === 0) bad(scenarioPath + '.event', 'must be a non-empty string')
|
|
615
|
-
else if (!eventIds.has(scenario.event)) bad(scenarioPath + '.event', 'unknown event ' + scenario.event)
|
|
616
|
-
if (typeof scenario.scenario !== 'string' || scenario.scenario.length === 0) bad(scenarioPath + '.scenario', 'must be a non-empty string')
|
|
617
|
-
const key = String(scenario.from) + '|' + String(scenario.event)
|
|
618
|
-
if (seen.has(key)) bad(scenarioPath, 'duplicate scenario for (' + scenario.from + ', ' + scenario.event + ')')
|
|
619
|
-
seen.add(key)
|
|
620
|
-
})
|
|
621
|
-
for (const key of fromEventGroups) {
|
|
622
|
-
if (!seen.has(key)) {
|
|
623
|
-
const sep = key.indexOf('|')
|
|
624
|
-
bad(narrativePath + '.scenarios', 'missing scenario for (' + key.slice(0, sep) + ', ' + key.slice(sep + 1) + ')')
|
|
816
|
+
if (narrative.events !== undefined) {
|
|
817
|
+
if (typeof narrative.events !== 'object' || narrative.events === null || Array.isArray(narrative.events)) {
|
|
818
|
+
bad(narrativePath + '.events', 'must be an object mapping event id -> natural-language description')
|
|
819
|
+
} else {
|
|
820
|
+
for (const [id, description] of Object.entries(narrative.events as Record<string, unknown>)) {
|
|
821
|
+
if (!eventIds.has(id)) bad(narrativePath + '.events', 'references unknown event ' + id)
|
|
822
|
+
if (typeof description !== 'string' || description.length === 0) bad(narrativePath + '.events.' + id, 'must be a non-empty string')
|
|
625
823
|
}
|
|
626
824
|
}
|
|
627
825
|
}
|
|
826
|
+
if (narrative.scenarios !== undefined) {
|
|
827
|
+
if (!Array.isArray(narrative.scenarios)) {
|
|
828
|
+
bad(narrativePath + '.scenarios', 'must be an array of { from, event, scenario }')
|
|
829
|
+
} else {
|
|
830
|
+
const seen = new Set<string>()
|
|
831
|
+
narrative.scenarios.forEach((entry, index) => {
|
|
832
|
+
const scenarioPath = narrativePath + '.scenarios[' + index + ']'
|
|
833
|
+
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
|
|
834
|
+
bad(scenarioPath, 'must be an object')
|
|
835
|
+
return
|
|
836
|
+
}
|
|
837
|
+
const scenario = entry as Record<string, unknown>
|
|
838
|
+
rejectUnknownKeys(scenario, MODEL_KEYS.scenario, scenarioPath)
|
|
839
|
+
if (typeof scenario.from !== 'string' || scenario.from.length === 0) bad(scenarioPath + '.from', 'must be a non-empty string')
|
|
840
|
+
else if (!stateIds.has(scenario.from)) bad(scenarioPath + '.from', 'unknown state ' + scenario.from)
|
|
841
|
+
if (typeof scenario.event !== 'string' || scenario.event.length === 0) bad(scenarioPath + '.event', 'must be a non-empty string')
|
|
842
|
+
else if (!eventIds.has(scenario.event)) bad(scenarioPath + '.event', 'unknown event ' + scenario.event)
|
|
843
|
+
if (typeof scenario.scenario !== 'string' || scenario.scenario.length === 0) bad(scenarioPath + '.scenario', 'must be a non-empty string')
|
|
844
|
+
const key = String(scenario.from) + '|' + String(scenario.event)
|
|
845
|
+
if (seen.has(key)) bad(scenarioPath, 'duplicate scenario for (' + scenario.from + ', ' + scenario.event + ')')
|
|
846
|
+
seen.add(key)
|
|
847
|
+
})
|
|
848
|
+
}
|
|
849
|
+
}
|
|
628
850
|
}
|
|
629
851
|
}
|
|
630
852
|
// Guard/update variable references were validated structurally; re-walk for references.
|
|
@@ -845,7 +1067,7 @@ function checkResult(id: string, name: string, findings: Finding[], detail: stri
|
|
|
845
1067
|
}
|
|
846
1068
|
|
|
847
1069
|
// ---------------------------------------------------------------------------
|
|
848
|
-
// S1-
|
|
1070
|
+
// S1-S8
|
|
849
1071
|
// ---------------------------------------------------------------------------
|
|
850
1072
|
|
|
851
1073
|
function S1_reachability(model: LogicModelV1, exploration: Exploration): CheckResult {
|
|
@@ -1271,7 +1493,7 @@ function S7_invariants(model: LogicModelV1, options: NormalizedOptions): CheckRe
|
|
|
1271
1493
|
}
|
|
1272
1494
|
|
|
1273
1495
|
// ---------------------------------------------------------------------------
|
|
1274
|
-
// A1-
|
|
1496
|
+
// A1-A14
|
|
1275
1497
|
// ---------------------------------------------------------------------------
|
|
1276
1498
|
|
|
1277
1499
|
function A1_unexpectedEvents(model: LogicModelV1): CheckResult {
|
|
@@ -2646,20 +2868,32 @@ export function runVerification(input: unknown, options: VerificationOptions = {
|
|
|
2646
2868
|
maxStates: options.maxStates ?? DEFAULT_MAX_STATES,
|
|
2647
2869
|
maxPermutationEvents: options.maxPermutationEvents ?? DEFAULT_MAX_PERMUTATION_EVENTS,
|
|
2648
2870
|
}
|
|
2871
|
+
const hashSpec = options.hashSpec ?? DEFAULT_HASH_SPEC
|
|
2872
|
+
const metadataKeys = metadataKeysOf(input)
|
|
2873
|
+
const metadata = metadataKeys.length === 0 ? {} : { metadataKeys }
|
|
2649
2874
|
const validation = validateModel(input)
|
|
2650
2875
|
if (!validation.ok) {
|
|
2876
|
+
const findings: Finding[] = validation.errors.map((message) => ({ code: 'MODEL_INVALID', severity: 'error', message }))
|
|
2877
|
+
const modelCheck: CheckResult = {
|
|
2878
|
+
id: 'MODEL',
|
|
2879
|
+
name: 'Model Validation',
|
|
2880
|
+
status: 'fail',
|
|
2881
|
+
detail: 'Model schema validation failed: ' + validation.errors.length + ' errors',
|
|
2882
|
+
findings,
|
|
2883
|
+
}
|
|
2651
2884
|
return {
|
|
2652
2885
|
ok: false,
|
|
2886
|
+
ran: true,
|
|
2887
|
+
...verdictOfFindings(findings),
|
|
2888
|
+
schema: REPORT_SCHEMAS.verify,
|
|
2653
2889
|
schemaVersion: 1,
|
|
2890
|
+
hashSpec,
|
|
2654
2891
|
modelHash: '',
|
|
2892
|
+
hashes: { hashSpec, modelHash: '' },
|
|
2893
|
+
...metadata,
|
|
2655
2894
|
summary: { states: 0, transitions: 0, errors: validation.errors.length, warnings: 0, checksRun: 0 },
|
|
2656
|
-
checks: [
|
|
2657
|
-
|
|
2658
|
-
name: 'Model Validation',
|
|
2659
|
-
status: 'fail',
|
|
2660
|
-
detail: 'Model schema validation failed: ' + validation.errors.length + ' errors',
|
|
2661
|
-
findings: validation.errors.map((message) => ({ code: 'MODEL_INVALID', severity: 'error', message })),
|
|
2662
|
-
}],
|
|
2895
|
+
checks: [modelCheck],
|
|
2896
|
+
nextSteps: verificationNextSteps([modelCheck], undefined),
|
|
2663
2897
|
}
|
|
2664
2898
|
}
|
|
2665
2899
|
const model = validation.model
|
|
@@ -2692,10 +2926,18 @@ export function runVerification(input: unknown, options: VerificationOptions = {
|
|
|
2692
2926
|
if (options.beforeModel !== undefined) {
|
|
2693
2927
|
const beforeValidation = validateModel(options.beforeModel)
|
|
2694
2928
|
if (!beforeValidation.ok) {
|
|
2929
|
+
const beforeFindings: Finding[] = beforeValidation.errors.map((message) => ({ code: 'BEFORE_MODEL_INVALID', severity: 'error', message }))
|
|
2930
|
+
const allFindings: Finding[] = [...checks.flatMap((check) => check.findings), ...beforeFindings]
|
|
2695
2931
|
return {
|
|
2696
2932
|
ok: false,
|
|
2933
|
+
ran: true,
|
|
2934
|
+
...verdictOfFindings(allFindings),
|
|
2935
|
+
schema: REPORT_SCHEMAS.verify,
|
|
2697
2936
|
schemaVersion: 1,
|
|
2698
|
-
|
|
2937
|
+
hashSpec,
|
|
2938
|
+
modelHash: modelHash(model, hashSpec),
|
|
2939
|
+
hashes: { hashSpec, modelHash: modelHash(model, hashSpec), afterModelHash: modelHash(model, hashSpec) },
|
|
2940
|
+
...metadata,
|
|
2699
2941
|
summary: {
|
|
2700
2942
|
states: model.states.length,
|
|
2701
2943
|
transitions: model.transitions.length,
|
|
@@ -2711,9 +2953,10 @@ export function runVerification(input: unknown, options: VerificationOptions = {
|
|
|
2711
2953
|
name: 'Before Model Validation',
|
|
2712
2954
|
status: 'fail',
|
|
2713
2955
|
detail: 'Before model schema validation failed: ' + beforeValidation.errors.length + ' errors',
|
|
2714
|
-
findings:
|
|
2956
|
+
findings: beforeFindings,
|
|
2715
2957
|
},
|
|
2716
2958
|
],
|
|
2959
|
+
nextSteps: verificationNextSteps(checks, narrativeCoverageOf(model)),
|
|
2717
2960
|
}
|
|
2718
2961
|
}
|
|
2719
2962
|
const before = beforeValidation.model
|
|
@@ -2729,10 +2972,22 @@ export function runVerification(input: unknown, options: VerificationOptions = {
|
|
|
2729
2972
|
const errors = checks.reduce((sum, check) => sum + check.findings.filter((finding) => finding.severity === 'error').length, 0)
|
|
2730
2973
|
const warnings = checks.reduce((sum, check) => sum + check.findings.filter((finding) => finding.severity === 'warning').length, 0)
|
|
2731
2974
|
const coverageNotes = computeCoverageNotes(model)
|
|
2975
|
+
const narrativeCoverage = narrativeCoverageOf(model)
|
|
2732
2976
|
return {
|
|
2733
2977
|
ok: true,
|
|
2978
|
+
ran: true,
|
|
2979
|
+
...verdictOfFindings(checks.flatMap((check) => check.findings)),
|
|
2980
|
+
schema: REPORT_SCHEMAS.verify,
|
|
2734
2981
|
schemaVersion: 1,
|
|
2735
|
-
|
|
2982
|
+
hashSpec,
|
|
2983
|
+
modelHash: modelHash(model, hashSpec),
|
|
2984
|
+
hashes: {
|
|
2985
|
+
hashSpec,
|
|
2986
|
+
modelHash: modelHash(model, hashSpec),
|
|
2987
|
+
...(comparison === undefined ? {} : { beforeModelHash: comparison.beforeModelHash, afterModelHash: comparison.afterModelHash }),
|
|
2988
|
+
},
|
|
2989
|
+
...metadata,
|
|
2990
|
+
...(narrativeCoverage === undefined ? {} : { narrativeCoverage }),
|
|
2736
2991
|
summary: {
|
|
2737
2992
|
states: model.states.length,
|
|
2738
2993
|
transitions: model.transitions.length,
|
|
@@ -2742,6 +2997,7 @@ export function runVerification(input: unknown, options: VerificationOptions = {
|
|
|
2742
2997
|
truncated: exploration.truncated,
|
|
2743
2998
|
},
|
|
2744
2999
|
checks,
|
|
3000
|
+
nextSteps: verificationNextSteps(checks, narrativeCoverage),
|
|
2745
3001
|
...(model.narrative === undefined ? {} : { narrative: model.narrative }),
|
|
2746
3002
|
...(comparison === undefined ? {} : { comparison }),
|
|
2747
3003
|
...(coverageNotes.length === 0 ? {} : { coverageNotes }),
|
|
@@ -2762,6 +3018,8 @@ export interface CompositionOptions {
|
|
|
2762
3018
|
/** Events that require a synchronized multi-machine step (handshake). */
|
|
2763
3019
|
rendezvous?: string[]
|
|
2764
3020
|
maxStates?: number
|
|
3021
|
+
/** Hash specification used for the per-machine modelHash. Defaults to the current published spec (`v1`). */
|
|
3022
|
+
hashSpec?: HashSpec
|
|
2765
3023
|
}
|
|
2766
3024
|
|
|
2767
3025
|
export interface CompositionSummary {
|
|
@@ -2774,9 +3032,21 @@ export interface CompositionSummary {
|
|
|
2774
3032
|
}
|
|
2775
3033
|
|
|
2776
3034
|
export interface CompositionReport {
|
|
3035
|
+
/** Historical meaning: the composition has no error-severity finding. Read `verdict` for the review outcome. */
|
|
2777
3036
|
ok: boolean
|
|
3037
|
+
ran: boolean
|
|
3038
|
+
verdict: Verdict
|
|
3039
|
+
verdictReason: string
|
|
3040
|
+
/** The versioned report contract this result follows. */
|
|
3041
|
+
schema: ReportSchema
|
|
3042
|
+
/** Which published specification the per-machine `modelHash` values follow. */
|
|
3043
|
+
hashSpec: HashSpec
|
|
3044
|
+
/** Every hash this report carries, in one place. */
|
|
3045
|
+
hashes: { hashSpec: HashSpec; machines: string[] }
|
|
2778
3046
|
summary: CompositionSummary
|
|
2779
3047
|
checks: CheckResult[]
|
|
3048
|
+
/** What to do next, derived from the findings — never empty. */
|
|
3049
|
+
nextSteps: string[]
|
|
2780
3050
|
}
|
|
2781
3051
|
|
|
2782
3052
|
interface CompositionNode {
|
|
@@ -2802,6 +3072,7 @@ interface CompositionMove {
|
|
|
2802
3072
|
* least one is not terminal) and C2 rendezvous that can never fire.
|
|
2803
3073
|
*/
|
|
2804
3074
|
export function runCompositionVerification(machinesInput: unknown[], options: CompositionOptions = {}): CompositionReport {
|
|
3075
|
+
const hashSpec = options.hashSpec ?? DEFAULT_HASH_SPEC
|
|
2805
3076
|
const models: LogicModelV1[] = []
|
|
2806
3077
|
const hashes: string[] = []
|
|
2807
3078
|
const modelFindings: Finding[] = []
|
|
@@ -2811,7 +3082,7 @@ export function runCompositionVerification(machinesInput: unknown[], options: Co
|
|
|
2811
3082
|
modelFindings.push({ code: 'MODEL_INVALID', severity: 'error', message: 'machine ' + index + ' invalid: ' + validation.errors.join('; ') })
|
|
2812
3083
|
} else {
|
|
2813
3084
|
models.push(validation.model)
|
|
2814
|
-
hashes.push(modelHash(validation.model))
|
|
3085
|
+
hashes.push(modelHash(validation.model, hashSpec))
|
|
2815
3086
|
}
|
|
2816
3087
|
})
|
|
2817
3088
|
const machineSummary = models.map((m, i) => ({ modelHash: hashes[i] ?? '', states: m.states.length, transitions: m.transitions.length }))
|
|
@@ -2821,8 +3092,14 @@ export function runCompositionVerification(machinesInput: unknown[], options: Co
|
|
|
2821
3092
|
}
|
|
2822
3093
|
return {
|
|
2823
3094
|
ok: false,
|
|
3095
|
+
ran: true,
|
|
3096
|
+
...verdictOfFindings(modelFindings),
|
|
3097
|
+
schema: REPORT_SCHEMAS.compose,
|
|
3098
|
+
hashSpec,
|
|
3099
|
+
hashes: { hashSpec, machines: hashes },
|
|
2824
3100
|
summary: { machineCount: models.length, machines: machineSummary, compositeStates: 0, errors: modelFindings.length, warnings: 0, truncated: false },
|
|
2825
3101
|
checks: [{ id: 'MODEL', name: 'Machine Validation', status: 'fail', detail: 'composition input validation failed', findings: modelFindings }],
|
|
3102
|
+
nextSteps: ['Fix the machine that failed validation, then re-run the composition.'],
|
|
2826
3103
|
}
|
|
2827
3104
|
}
|
|
2828
3105
|
const rendezvous = new Set(options.rendezvous ?? [])
|
|
@@ -2881,6 +3158,17 @@ export function runCompositionVerification(machinesInput: unknown[], options: Co
|
|
|
2881
3158
|
let truncated = false
|
|
2882
3159
|
const firedCount = new Map<string, number>()
|
|
2883
3160
|
for (const event of rendezvous) firedCount.set(event, 0)
|
|
3161
|
+
// Per-machine enablement, collected while the state space is explored: a rendezvous
|
|
3162
|
+
// that never fires needs to say *which* machine never had it enabled, not just that
|
|
3163
|
+
// the handshake did not happen.
|
|
3164
|
+
const everEnabled = new Map<string, Set<number>>()
|
|
3165
|
+
const enabledStates = new Map<string, string[][]>()
|
|
3166
|
+
const coEnabledNodes = new Map<string, number>()
|
|
3167
|
+
for (const event of rendezvous) {
|
|
3168
|
+
everEnabled.set(event, new Set<number>())
|
|
3169
|
+
enabledStates.set(event, models.map(() => []))
|
|
3170
|
+
coEnabledNodes.set(event, 0)
|
|
3171
|
+
}
|
|
2884
3172
|
const c1Findings: Finding[] = []
|
|
2885
3173
|
while (queue.length > 0) {
|
|
2886
3174
|
const node = queue.shift()!
|
|
@@ -2894,12 +3182,53 @@ export function runCompositionVerification(machinesInput: unknown[], options: Co
|
|
|
2894
3182
|
}
|
|
2895
3183
|
const moves = compositionMoves(node)
|
|
2896
3184
|
const allTerminal = node.runtimes.every((runtime, i) => isTerminal(models[i], runtime.state))
|
|
3185
|
+
// Enablement census for C2: which machines could fire each rendezvous here.
|
|
3186
|
+
for (const event of rendezvous) {
|
|
3187
|
+
const declaring: number[] = []
|
|
3188
|
+
for (let i = 0; i < models.length; i++) if (machineEventSets[i].has(event)) declaring.push(i)
|
|
3189
|
+
if (declaring.length === 0) continue
|
|
3190
|
+
let allEnabled = declaring.length >= 2
|
|
3191
|
+
for (const i of declaring) {
|
|
3192
|
+
const enabled = !isTerminal(models[i], node.runtimes[i].state) && stepRuntime(models[i], node.runtimes[i], event).length > 0
|
|
3193
|
+
if (!enabled) { allEnabled = false; continue }
|
|
3194
|
+
everEnabled.get(event)?.add(i)
|
|
3195
|
+
const states = enabledStates.get(event)?.[i]
|
|
3196
|
+
if (states !== undefined && !states.includes(node.runtimes[i].state) && states.length < 8) states.push(node.runtimes[i].state)
|
|
3197
|
+
}
|
|
3198
|
+
if (allEnabled) coEnabledNodes.set(event, (coEnabledNodes.get(event) ?? 0) + 1)
|
|
3199
|
+
}
|
|
2897
3200
|
if (moves.length === 0 && !allTerminal) {
|
|
3201
|
+
// Why nobody can advance, per machine: its state, its alphabet, and for every event
|
|
3202
|
+
// either the rendezvous partner that is not ready or the transition that does not fire.
|
|
3203
|
+
const perMachine = node.runtimes.map((runtime, i) => {
|
|
3204
|
+
const terminal = isTerminal(models[i], runtime.state)
|
|
3205
|
+
const blocked = terminal ? [] : allEvents(models[i]).map((event) => {
|
|
3206
|
+
const enabled = stepRuntime(models[i], runtime, event).length > 0
|
|
3207
|
+
// A rendezvous event is not fireable just because this machine has a transition:
|
|
3208
|
+
// it needs every declaring machine enabled at the same time. Check that first, or
|
|
3209
|
+
// the deadlock census would report "enabled" for a handshake nobody can complete.
|
|
3210
|
+
if (!rendezvous.has(event)) return { event, reason: enabled ? 'enabled' : 'no-enabled-transition' }
|
|
3211
|
+
const declaring: number[] = []
|
|
3212
|
+
for (let j = 0; j < models.length; j++) if (machineEventSets[j].has(event)) declaring.push(j)
|
|
3213
|
+
if (declaring.length < 2) return { event, reason: 'rendezvous-needs-partner' }
|
|
3214
|
+
const partnersReady = declaring.every((j) => !isTerminal(models[j], node.runtimes[j].state)
|
|
3215
|
+
&& stepRuntime(models[j], node.runtimes[j], event).length > 0)
|
|
3216
|
+
if (!partnersReady) return { event, reason: 'rendezvous-partner-not-ready' }
|
|
3217
|
+
return { event, reason: enabled ? 'enabled' : 'no-enabled-transition' }
|
|
3218
|
+
})
|
|
3219
|
+
return { index: i, state: runtime.state, terminal, blocked }
|
|
3220
|
+
})
|
|
3221
|
+
const reasons = perMachine
|
|
3222
|
+
.filter((machine) => !machine.terminal)
|
|
3223
|
+
.map((machine) => 'machine ' + String(machine.index) + ' at ' + machine.state + ': ' + (machine.blocked.length === 0
|
|
3224
|
+
? 'empty event alphabet'
|
|
3225
|
+
: machine.blocked.map((entry) => entry.event + ' (' + entry.reason + ')').join(', ')))
|
|
2898
3226
|
c1Findings.push({
|
|
2899
3227
|
code: 'C1_COMPOSITION_DEADLOCK',
|
|
2900
3228
|
severity: 'error',
|
|
2901
3229
|
message: 'Composition deadlock: no machine can advance from (' + node.runtimes.map((runtime) => runtime.state).join(', ') + ') while at least one is not terminal.',
|
|
2902
|
-
|
|
3230
|
+
detail: 'Shortest counterexample (breadth-first): ' + String(node.path.length) + ' step(s) to reach it. ' + reasons.join(' | '),
|
|
3231
|
+
evidence: { steps: node.path, states: node.runtimes.map((runtime) => runtime.state), depth: node.path.length, perMachine, reasons },
|
|
2903
3232
|
})
|
|
2904
3233
|
continue
|
|
2905
3234
|
}
|
|
@@ -2916,22 +3245,67 @@ export function runCompositionVerification(machinesInput: unknown[], options: Co
|
|
|
2916
3245
|
for (const event of rendezvous) {
|
|
2917
3246
|
if (!allEventsSet.has(event)) continue
|
|
2918
3247
|
if ((firedCount.get(event) ?? 0) === 0) {
|
|
3248
|
+
// Why the handshake never happens: who declares it, who ever had it enabled, and
|
|
3249
|
+
// whether the enabling states could ever coincide.
|
|
3250
|
+
const machines = models.map((model, index) => ({
|
|
3251
|
+
index,
|
|
3252
|
+
declares: machineEventSets[index].has(event),
|
|
3253
|
+
everEnabled: everEnabled.get(event)?.has(index) === true,
|
|
3254
|
+
enabledAt: enabledStates.get(event)?.[index] ?? [],
|
|
3255
|
+
}))
|
|
3256
|
+
const declaring = machines.filter((machine) => machine.declares)
|
|
3257
|
+
const neverEnabled = declaring.filter((machine) => !machine.everEnabled)
|
|
3258
|
+
const coEnabled = coEnabledNodes.get(event) ?? 0
|
|
3259
|
+
let reason: string
|
|
3260
|
+
if (declaring.length < 2) {
|
|
3261
|
+
reason = 'only ' + String(declaring.length) + ' machine(s) declare it (machine ' + declaring.map((machine) => machine.index).join(', ') + '); a handshake needs at least two'
|
|
3262
|
+
} else if (neverEnabled.length > 0) {
|
|
3263
|
+
reason = 'machine ' + neverEnabled.map((machine) => machine.index).join(', ') + ' declares it but never has it enabled (no transition, or the guard never holds)'
|
|
3264
|
+
} else {
|
|
3265
|
+
reason = 'every declaring machine enables it somewhere, but never at the same time (' + String(coEnabled) + ' composite state(s) had all of them ready)'
|
|
3266
|
+
}
|
|
3267
|
+
// A truncated exploration cannot prove that a handshake never happens: it may simply
|
|
3268
|
+
// not have reached the composite state where both machines are ready.
|
|
3269
|
+
const caveat = truncated ? ' The search was truncated at ' + String(maxStates) + ' composite states, so this may be an artefact of the cap rather than a property of the model.' : ''
|
|
2919
3270
|
c2Findings.push({
|
|
2920
3271
|
code: 'C2_RENDEZVOUS_NEVER_FIRES',
|
|
2921
3272
|
severity: 'warning',
|
|
2922
|
-
message: 'Rendezvous event ' + event + ' can never fire:
|
|
3273
|
+
message: 'Rendezvous event ' + event + ' can never fire: ' + reason + '.' + caveat,
|
|
3274
|
+
detail: machines.map((machine) => 'machine ' + String(machine.index) + (machine.declares ? ' declares' : ' does not declare')
|
|
3275
|
+
+ (machine.everEnabled ? ', enabled at ' + (machine.enabledAt.length === 0 ? 'no visited state' : machine.enabledAt.join('/')) : ', never enabled')).join(' | '),
|
|
3276
|
+
evidence: { event, machines, declaring: declaring.map((machine) => machine.index), coEnabledNodes: coEnabled, reason, truncated },
|
|
2923
3277
|
})
|
|
2924
3278
|
}
|
|
2925
3279
|
}
|
|
2926
3280
|
const errors = c1Findings.length
|
|
2927
3281
|
const warnings = c2Findings.length
|
|
3282
|
+
const c1Detail = c1Findings.length > 0
|
|
3283
|
+
? 'Composition deadlocks: ' + String(c1Findings.length)
|
|
3284
|
+
: truncated
|
|
3285
|
+
? 'No composition deadlock found, but the search was truncated at ' + String(maxStates) + ' composite states: absence is not proven'
|
|
3286
|
+
: 'No composition deadlock reachable'
|
|
3287
|
+
const c2Detail = c2Findings.length > 0
|
|
3288
|
+
? 'Rendezvous warnings: ' + String(c2Findings.length)
|
|
3289
|
+
: truncated
|
|
3290
|
+
? 'All rendezvous events fired, but the search was truncated at ' + String(maxStates) + ' composite states'
|
|
3291
|
+
: 'All rendezvous events can fire'
|
|
2928
3292
|
const checks: CheckResult[] = [
|
|
2929
|
-
checkResult('C1', 'Composition Deadlock', c1Findings,
|
|
2930
|
-
checkResult('C2', 'Rendezvous Sync', c2Findings,
|
|
3293
|
+
checkResult('C1', 'Composition Deadlock', c1Findings, c1Detail),
|
|
3294
|
+
checkResult('C2', 'Rendezvous Sync', c2Findings, c2Detail),
|
|
2931
3295
|
]
|
|
3296
|
+
const nextSteps: string[] = []
|
|
3297
|
+
if (errors > 0) nextSteps.push('Resolve the error findings first: the composition deadlocks at a reachable state, so at least one machine is missing an exit or a handshake partner.')
|
|
3298
|
+
if (warnings > 0) nextSteps.push('Review the warning findings: a rendezvous event that can never fire means the handshake is declared but unreachable.')
|
|
3299
|
+
nextSteps.push('Re-run the composition after the change; every machine must be able to advance, or explain why it is terminal.')
|
|
2932
3300
|
return {
|
|
2933
3301
|
ok: errors === 0,
|
|
3302
|
+
ran: true,
|
|
3303
|
+
...verdictOf(errors, warnings, c1Findings[0]?.code ?? c2Findings[0]?.code),
|
|
3304
|
+
schema: REPORT_SCHEMAS.compose,
|
|
3305
|
+
hashSpec,
|
|
3306
|
+
hashes: { hashSpec, machines: hashes },
|
|
2934
3307
|
summary: { machineCount: models.length, machines: machineSummary, compositeStates, errors, warnings, truncated },
|
|
2935
3308
|
checks,
|
|
3309
|
+
nextSteps,
|
|
2936
3310
|
}
|
|
2937
3311
|
}
|