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
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 {
@@ -204,8 +206,18 @@ export interface PathStep {
204
206
 
205
207
  export interface Finding {
206
208
  code: string
207
- severity: 'error' | 'warning'
209
+ /**
210
+ * `info` is a note, not a defect: it is reported so a machine-readable reader can see it,
211
+ * and it never contributes to `verdict`, `summary.errors` or `summary.warnings`.
212
+ */
213
+ severity: 'error' | 'warning' | 'info'
208
214
  message: string
215
+ /** Longer explanation: the shortest-path note, the per-machine reasons, the quoted source lines. */
216
+ detail?: string
217
+ /** The artefact the finding is about (a diagram name, a file path) — set when one report covers several. */
218
+ file?: string
219
+ /** 1-based line in that artefact, when the check knows it. */
220
+ line?: number
209
221
  path?: PathStep[]
210
222
  evidence?: Record<string, unknown>
211
223
  }
@@ -219,9 +231,29 @@ export interface CheckResult {
219
231
  }
220
232
 
221
233
  export interface VerificationReport {
234
+ /**
235
+ * The engine ran and the input was well-formed enough to produce this report.
236
+ * It does NOT mean the review passed — read `verdict` for that.
237
+ */
222
238
  ok: boolean
239
+ /** The tool executed and produced a report. False only when a tool refused the request before the engine ran. */
240
+ ran: boolean
241
+ /** The review outcome. `ok: true` with `verdict: "fail"` is a failing review, not a passing one. */
242
+ verdict: Verdict
243
+ /** Why the verdict came out that way, e.g. `2 error finding(s) (first: S2_NO_TRANSITIONS)`. */
244
+ verdictReason: string
245
+ /** The versioned report contract this result follows. */
246
+ schema: ReportSchema
223
247
  schemaVersion: 1
248
+ /** Which published specification `modelHash` follows (see references/hash-spec.md). */
249
+ hashSpec: HashSpec
224
250
  modelHash: string
251
+ /** Every hash this report carries, in one place, for a baseline diff or an archive record. */
252
+ hashes: { hashSpec: HashSpec; modelHash: string; beforeModelHash?: string; afterModelHash?: string }
253
+ /** Paths of the `_`-prefixed metadata keys found in the input and ignored by the schema. */
254
+ metadataKeys?: string[]
255
+ /** How much of the model the narrative documents; absent when there is no narrative. */
256
+ narrativeCoverage?: NarrativeCoverage
225
257
  /** Echo of the model's natural-language narrative, when present. */
226
258
  narrative?: ModelNarrative
227
259
  summary: {
@@ -234,11 +266,44 @@ export interface VerificationReport {
234
266
  }
235
267
  checks: CheckResult[]
236
268
  comparison?: ComparisonSummary
269
+ /** What to do next, derived from the findings — never empty. */
270
+ nextSteps: string[]
237
271
  /** Informational notes about semantic dimensions this model references (timing, preemption)
238
272
  * that this engine does not verify. Heuristic, vocabulary-based — never a substitute for the checks. */
239
273
  coverageNotes?: string[]
240
274
  }
241
275
 
276
+ /**
277
+ * The next-steps list every verification report carries. Derived only from the checks
278
+ * and the narrative coverage, so two runs over the same model produce the same list.
279
+ */
280
+ function verificationNextSteps(checks: CheckResult[], coverage: NarrativeCoverage | undefined): string[] {
281
+ const steps: string[] = []
282
+ const failed = checks.filter((check) => check.status === 'fail').map((check) => check.id + ' ' + check.name)
283
+ const warningCodes = [...new Set(checks.flatMap((check) => check.findings).filter((finding) => finding.severity === 'warning').map((finding) => finding.code))].sort()
284
+ if (failed.length > 0) steps.push('Resolve the failing checks first: ' + failed.join(', ') + '.')
285
+ if (warningCodes.length > 0) steps.push('Review the warning findings (' + warningCodes.join(', ') + '): they are not failures, but they are not silence either.')
286
+ if (coverage !== undefined && !narrativeComplete(coverage)) {
287
+ 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.')
288
+ }
289
+ steps.push('Re-run with beforeModel/stateMapping after the change to prove the behaviour did not regress (D1-D4).')
290
+ return steps
291
+ }
292
+
293
+ /**
294
+ * Repair guidance for a rejected model. A refusal reports the offending path and the rule
295
+ * it broke, but not what to write instead; for fields whose absence is a documented trap,
296
+ * that is the difference between "fix it" and "guess again". Derived only from the
297
+ * validation messages, so the list stays deterministic.
298
+ */
299
+ function refusalGuidance(errors: string[]): string[] {
300
+ const steps: string[] = []
301
+ if (errors.some((message) => message.includes('.description'))) {
302
+ steps.push('Add the required "description" string to every invariant (for example "description": "what this invariant guarantees"); a `_`-prefixed key is not a substitute, and the value takes part in `modelHash`.')
303
+ }
304
+ return steps
305
+ }
306
+
242
307
  export interface ComparisonSummary {
243
308
  beforeModelHash: string
244
309
  afterModelHash: string
@@ -272,8 +337,178 @@ function stableStringify(value: unknown): string {
272
337
  return '{' + Object.keys(record).sort().map((key) => JSON.stringify(key) + ':' + stableStringify(record[key])).join(',') + '}'
273
338
  }
274
339
 
275
- export function modelHash(model: LogicModelV1): string {
276
- return createHash('sha256').update(stableStringify(model)).digest('hex')
340
+ export function modelHash(model: LogicModelV1, spec: HashSpec = DEFAULT_HASH_SPEC): string {
341
+ return createHash('sha256').update(stableStringify(hashPayload(model, spec))).digest('hex')
342
+ }
343
+
344
+ /**
345
+ * Deterministic JSON (sorted keys, no insignificant whitespace) — the serialization the
346
+ * hash specification is defined over. Exported so every report hashes inputs the same way.
347
+ */
348
+ export function canonicalJson(value: unknown): string {
349
+ return stableStringify(value)
350
+ }
351
+
352
+ // ---------------------------------------------------------------------------
353
+ // Report contract: ran / verdict (P0-1) and metadata keys + hash specs (P0-3/P0-4)
354
+ // ---------------------------------------------------------------------------
355
+
356
+ /**
357
+ * Published model-hash specifications. The full normalization rules, the list of
358
+ * excluded keys and a one-line recompute command are in `references/hash-spec.md`.
359
+ *
360
+ * - `v1` (1.0.0) drops every `_`-prefixed metadata key at every level before hashing,
361
+ * so archiving `_source` / `_verified` next to a model cannot change its hash.
362
+ * - `v0` is the pre-1.0.0 behaviour (no metadata filtering), kept so an archived hash
363
+ * can still be checked against the old engine.
364
+ *
365
+ * For a model that carries no `_` keys the two specs are byte-identical.
366
+ */
367
+ export type HashSpec = 'v0' | 'v1'
368
+
369
+ export const PUBLISHED_HASH_SPECS: readonly HashSpec[] = ['v0', 'v1']
370
+ export const DEFAULT_HASH_SPEC: HashSpec = 'v1'
371
+
372
+ /**
373
+ * Versioned report contracts. Every tool result carries `schema`, so a consumer can
374
+ * branch on the contract instead of sniffing fields, and a change to a report's shape
375
+ * is a version bump here rather than a silent break. `findings[]` keeps the same
376
+ * stable core in every family: `{code, severity, message, detail?, evidence?, path?}`.
377
+ */
378
+ export const REPORT_SCHEMAS = {
379
+ verify: 'logicprobe/verify/v1',
380
+ compose: 'logicprobe/compose/v1',
381
+ datamodel: 'logicprobe/datamodel/v1',
382
+ concurrency: 'logicprobe/concurrency/v1',
383
+ export: 'logicprobe/export/v1',
384
+ umlRender: 'logicprobe/uml/render/v1',
385
+ umlParse: 'logicprobe/uml/parse/v1',
386
+ umlReview: 'logicprobe/uml/review/v1',
387
+ umlExplainLabels: 'logicprobe/uml/explain-labels/v1',
388
+ structure: 'logicprobe/structure/v1',
389
+ granularity: 'logicprobe/granularity/v1',
390
+ baseline: 'logicprobe/baseline/v1',
391
+ } as const
392
+
393
+ export type ReportSchema = typeof REPORT_SCHEMAS[keyof typeof REPORT_SCHEMAS]
394
+
395
+ /**
396
+ * How much of the model the `narrative` block actually documents, as `covered/total`
397
+ * per dimension. A partial narrative is valid — writing the states first and the
398
+ * events later is the natural order — so this is a coverage report, not a gate.
399
+ * `scenarios` counts distinct modelled (from, event) groups: one scenario per group.
400
+ */
401
+ export interface NarrativeCoverage {
402
+ states: string
403
+ events: string
404
+ scenarios: string
405
+ }
406
+
407
+ export function narrativeCoverageOf(model: LogicModelV1): NarrativeCoverage | undefined {
408
+ const narrative = model.narrative
409
+ if (narrative === undefined) return undefined
410
+ const describedStates = narrative.states === undefined ? 0 : Object.keys(narrative.states).length
411
+ const describedEvents = narrative.events === undefined ? 0 : Object.keys(narrative.events).length
412
+ const groups = new Set(model.transitions.map((transition) => transition.from + '|' + transition.event))
413
+ const describedGroups = narrative.scenarios === undefined
414
+ ? 0
415
+ : new Set(narrative.scenarios.map((scenario) => scenario.from + '|' + scenario.event)).size
416
+ const events = new Set(model.transitions.map((transition) => transition.event))
417
+ return {
418
+ states: String(describedStates) + '/' + String(model.states.length),
419
+ events: String(describedEvents) + '/' + String(events.size),
420
+ scenarios: String(describedGroups) + '/' + String(groups.size),
421
+ }
422
+ }
423
+
424
+ /** True when every dimension of the coverage is fully described. */
425
+ export function narrativeComplete(coverage: NarrativeCoverage | undefined): boolean {
426
+ if (coverage === undefined) return false
427
+ return coverage.states.split('/')[0] === coverage.states.split('/')[1]
428
+ && coverage.events.split('/')[0] === coverage.events.split('/')[1]
429
+ && coverage.scenarios.split('/')[0] === coverage.scenarios.split('/')[1]
430
+ }
431
+
432
+ /**
433
+ * The review outcome, kept separate from `ok` (= "the tool ran"). Collapsing the two
434
+ * is how a failed review gets read as a passing one.
435
+ */
436
+ export type Verdict = 'pass' | 'pass_with_findings' | 'fail'
437
+
438
+ export interface VerdictSummary {
439
+ verdict: Verdict
440
+ verdictReason: string
441
+ }
442
+
443
+ /** A tool that refused the request before running anything: it neither passed nor failed a review. */
444
+ export function refusalVerdict(reason: string): VerdictSummary {
445
+ return { verdict: 'fail', verdictReason: 'the tool did not run: ' + reason }
446
+ }
447
+
448
+ /**
449
+ * Decide the verdict from finding counts. Any error-severity finding fails the review
450
+ * even though the tool ran successfully — that gap is exactly what `verdict` closes.
451
+ */
452
+ export function verdictOf(errors: number, warnings: number, firstCode?: string): VerdictSummary {
453
+ if (errors > 0) {
454
+ return {
455
+ verdict: 'fail',
456
+ verdictReason: String(errors) + ' error finding(s)' + (firstCode === undefined ? '' : ' (first: ' + firstCode + ')'),
457
+ }
458
+ }
459
+ if (warnings > 0) {
460
+ return { verdict: 'pass_with_findings', verdictReason: 'no error findings; ' + String(warnings) + ' warning finding(s)' }
461
+ }
462
+ return { verdict: 'pass', verdictReason: 'no error or warning findings' }
463
+ }
464
+
465
+ export function verdictOfFindings(findings: Array<{ code: string; severity: string }>): VerdictSummary {
466
+ const errors = findings.filter((finding) => finding.severity === 'error')
467
+ const warnings = findings.filter((finding) => finding.severity === 'warning')
468
+ return verdictOf(errors.length, warnings.length, errors[0]?.code)
469
+ }
470
+
471
+ /**
472
+ * Paths of every `_`-prefixed key in the input, sorted (`_source`, `_verified`,
473
+ * `states[0]._note`; a metadata key's own children are not listed, since the subtree is
474
+ * metadata as a whole). The schema ignores these keys as annotation metadata; echoing
475
+ * them in the report makes the ignored metadata auditable instead of invisible.
476
+ */
477
+ export function metadataKeysOf(input: unknown): string[] {
478
+ const keys: string[] = []
479
+ const walk = (value: unknown, path: string): void => {
480
+ if (value === null || typeof value !== 'object') return
481
+ if (Array.isArray(value)) {
482
+ value.forEach((entry, index) => walk(entry, path + '[' + String(index) + ']'))
483
+ return
484
+ }
485
+ for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
486
+ const child = path === '' ? key : path + '.' + key
487
+ if (key.startsWith('_')) keys.push(child)
488
+ walk(entry, child)
489
+ }
490
+ }
491
+ walk(input, '')
492
+ return keys.sort()
493
+ }
494
+
495
+ function withoutMetadata(value: unknown): unknown {
496
+ if (value === null || typeof value !== 'object') return value
497
+ if (Array.isArray(value)) return value.map((entry) => withoutMetadata(entry))
498
+ const out: Record<string, unknown> = {}
499
+ for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
500
+ if (key.startsWith('_')) continue
501
+ out[key] = withoutMetadata(entry)
502
+ }
503
+ return out
504
+ }
505
+
506
+ /**
507
+ * The exact payload a hash spec hashes. Exported so the specification has one
508
+ * implementation and the documentation can be checked against it.
509
+ */
510
+ export function hashPayload(model: unknown, spec: HashSpec = DEFAULT_HASH_SPEC): unknown {
511
+ return spec === 'v1' ? withoutMetadata(model) : model
277
512
  }
278
513
 
279
514
  export function validateModel(input: unknown): { ok: true; model: LogicModelV1 } | { ok: false; errors: string[] } {
@@ -285,8 +520,12 @@ export function validateModel(input: unknown): { ok: true; model: LogicModelV1 }
285
520
  return { ok: false, errors: ['model: must be an object'] }
286
521
  }
287
522
  const root = input as Record<string, unknown>
523
+ // `_`-prefixed keys are annotation metadata (provenance, verification snapshots,
524
+ // extraction caveats). They are ignored here and excluded from the hash, so a model
525
+ // can carry its own archive record without a sidecar that drifts away from it.
526
+ // Everything else stays closed: a mistyped `sttes` must still be an error.
288
527
  const rejectUnknownKeys = (value: Record<string, unknown>, allowed: Set<string>, path: string): void => {
289
- const unexpected = Object.keys(value).filter((key) => !allowed.has(key))
528
+ const unexpected = Object.keys(value).filter((key) => !allowed.has(key) && !key.startsWith('_'))
290
529
  if (unexpected.length > 0) {
291
530
  bad(path + '.' + unexpected[0], 'unknown field (allowed: ' + [...allowed].join(', ') + ')')
292
531
  }
@@ -422,7 +661,7 @@ export function validateModel(input: unknown): { ok: true; model: LogicModelV1 }
422
661
  // older reader may not know are skipped so they still fail on `.kind` alone.
423
662
  if (KNOWN_INVARIANT_KINDS.has(invariant.kind as string)) {
424
663
  const allowed = INVARIANT_KEYS[invariant.kind as string]
425
- const unexpected = Object.keys(invariant).filter((key) => !allowed.has(key))
664
+ const unexpected = Object.keys(invariant).filter((key) => !allowed.has(key) && !key.startsWith('_'))
426
665
  if (unexpected.length > 0) {
427
666
  bad(path + '.' + unexpected[0], 'unknown field for kind ' + String(invariant.kind) + ' (allowed: ' + [...allowed].join(', ') + ')')
428
667
  }
@@ -445,7 +684,7 @@ export function validateModel(input: unknown): { ok: true; model: LogicModelV1 }
445
684
  } else if ('state' in when) {
446
685
  const scope = when as Record<string, unknown>
447
686
  for (const key of Object.keys(scope)) {
448
- if (key !== 'state') bad(path + '.when.' + key, 'unknown field for a state scope (allowed: state)')
687
+ if (key !== 'state' && !key.startsWith('_')) bad(path + '.when.' + key, 'unknown field for a state scope (allowed: state)')
449
688
  }
450
689
  const state = scope.state
451
690
  if (typeof state !== 'string' || state.length === 0) bad(path + '.when.state', 'must be a non-empty string')
@@ -574,57 +813,59 @@ export function validateModel(input: unknown): { ok: true; model: LogicModelV1 }
574
813
  rejectUnknownKeys(narrative, MODEL_KEYS.narrative, narrativePath)
575
814
  const stateIds = new Set<string>(Array.isArray(root.states) ? (root.states as StateSpec[]).map((state) => state.id) : [])
576
815
  const eventIds = new Set<string>(Array.isArray(root.transitions) ? (root.transitions as TransitionSpec[]).map((transition) => transition.event) : [])
577
- const fromEventGroups = new Set<string>(Array.isArray(root.transitions) ? (root.transitions as TransitionSpec[]).map((transition) => transition.from + '|' + transition.event) : [])
578
- if (typeof narrative.states !== 'object' || narrative.states === null || Array.isArray(narrative.states)) {
579
- bad(narrativePath + '.states', 'must be an object mapping state id -> natural-language description')
580
- } else {
581
- for (const [id, description] of Object.entries(narrative.states as Record<string, unknown>)) {
582
- if (!stateIds.has(id)) bad(narrativePath + '.states', 'references unknown state ' + id)
583
- if (typeof description !== 'string' || description.length === 0) bad(narrativePath + '.states.' + id, 'must be a non-empty string')
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
- }
816
+ // A narrative is allowed to cover part of the model: states first, events and
817
+ // scenarios later is the natural authoring order, and `narrativeCoverage` reports
818
+ // what is still missing instead of rejecting the file. What stays an error is a
819
+ // narrative that is *wrong* (unknown id, empty description, duplicate scenario) or
820
+ // empty — an empty block claims documentation that does not exist.
821
+ const declaredDimensions = (['states', 'events', 'scenarios'] as const).filter((key) => narrative[key] !== undefined)
822
+ if (declaredDimensions.length === 0) {
823
+ bad(narrativePath, 'declares no dimension: give at least one of states, events or scenarios (a partial narrative is fine)')
588
824
  }
589
- if (typeof narrative.events !== 'object' || narrative.events === null || Array.isArray(narrative.events)) {
590
- bad(narrativePath + '.events', 'must be an object mapping event id -> natural-language description')
591
- } else {
592
- for (const [id, description] of Object.entries(narrative.events as Record<string, unknown>)) {
593
- if (!eventIds.has(id)) bad(narrativePath + '.events', 'references unknown event ' + id)
594
- if (typeof description !== 'string' || description.length === 0) bad(narrativePath + '.events.' + id, 'must be a non-empty string')
595
- }
596
- for (const id of eventIds) {
597
- if (typeof (narrative.events as Record<string, unknown>)[id] !== 'string') bad(narrativePath + '.events', 'missing description for event ' + id)
825
+ if (narrative.states !== undefined) {
826
+ if (typeof narrative.states !== 'object' || narrative.states === null || Array.isArray(narrative.states)) {
827
+ bad(narrativePath + '.states', 'must be an object mapping state id -> natural-language description')
828
+ } else {
829
+ for (const [id, description] of Object.entries(narrative.states as Record<string, unknown>)) {
830
+ if (!stateIds.has(id)) bad(narrativePath + '.states', 'references unknown state ' + id)
831
+ if (typeof description !== 'string' || description.length === 0) bad(narrativePath + '.states.' + id, 'must be a non-empty string')
832
+ }
598
833
  }
599
834
  }
600
- if (!Array.isArray(narrative.scenarios)) {
601
- bad(narrativePath + '.scenarios', 'must be an array of { from, event, scenario }')
602
- } else {
603
- const seen = new Set<string>()
604
- narrative.scenarios.forEach((entry, index) => {
605
- const scenarioPath = narrativePath + '.scenarios[' + index + ']'
606
- if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
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) + ')')
835
+ if (narrative.events !== undefined) {
836
+ if (typeof narrative.events !== 'object' || narrative.events === null || Array.isArray(narrative.events)) {
837
+ bad(narrativePath + '.events', 'must be an object mapping event id -> natural-language description')
838
+ } else {
839
+ for (const [id, description] of Object.entries(narrative.events as Record<string, unknown>)) {
840
+ if (!eventIds.has(id)) bad(narrativePath + '.events', 'references unknown event ' + id)
841
+ if (typeof description !== 'string' || description.length === 0) bad(narrativePath + '.events.' + id, 'must be a non-empty string')
625
842
  }
626
843
  }
627
844
  }
845
+ if (narrative.scenarios !== undefined) {
846
+ if (!Array.isArray(narrative.scenarios)) {
847
+ bad(narrativePath + '.scenarios', 'must be an array of { from, event, scenario }')
848
+ } else {
849
+ const seen = new Set<string>()
850
+ narrative.scenarios.forEach((entry, index) => {
851
+ const scenarioPath = narrativePath + '.scenarios[' + index + ']'
852
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
853
+ bad(scenarioPath, 'must be an object')
854
+ return
855
+ }
856
+ const scenario = entry as Record<string, unknown>
857
+ rejectUnknownKeys(scenario, MODEL_KEYS.scenario, scenarioPath)
858
+ if (typeof scenario.from !== 'string' || scenario.from.length === 0) bad(scenarioPath + '.from', 'must be a non-empty string')
859
+ else if (!stateIds.has(scenario.from)) bad(scenarioPath + '.from', 'unknown state ' + scenario.from)
860
+ if (typeof scenario.event !== 'string' || scenario.event.length === 0) bad(scenarioPath + '.event', 'must be a non-empty string')
861
+ else if (!eventIds.has(scenario.event)) bad(scenarioPath + '.event', 'unknown event ' + scenario.event)
862
+ if (typeof scenario.scenario !== 'string' || scenario.scenario.length === 0) bad(scenarioPath + '.scenario', 'must be a non-empty string')
863
+ const key = String(scenario.from) + '|' + String(scenario.event)
864
+ if (seen.has(key)) bad(scenarioPath, 'duplicate scenario for (' + scenario.from + ', ' + scenario.event + ')')
865
+ seen.add(key)
866
+ })
867
+ }
868
+ }
628
869
  }
629
870
  }
630
871
  // Guard/update variable references were validated structurally; re-walk for references.
@@ -838,14 +1079,16 @@ function checkResult(id: string, name: string, findings: Finding[], detail: stri
838
1079
  return {
839
1080
  id,
840
1081
  name,
841
- status: findings.length === 0 ? 'pass' : 'fail',
1082
+ // An info finding is a note, not a defect: it must not flip a check to `fail`, or the
1083
+ // check would be dragged into the "resolve the failing checks" list for a remark.
1084
+ status: errors + warnings === 0 ? 'pass' : 'fail',
842
1085
  detail: detail + (errors > 0 ? ' (' + errors + ' errors' + (warnings > 0 ? ', ' + warnings + ' warnings' : '') + ')' : warnings > 0 ? ' (' + warnings + ' warnings)' : ''),
843
1086
  findings,
844
1087
  }
845
1088
  }
846
1089
 
847
1090
  // ---------------------------------------------------------------------------
848
- // S1-S7
1091
+ // S1-S8
849
1092
  // ---------------------------------------------------------------------------
850
1093
 
851
1094
  function S1_reachability(model: LogicModelV1, exploration: Exploration): CheckResult {
@@ -1271,7 +1514,7 @@ function S7_invariants(model: LogicModelV1, options: NormalizedOptions): CheckRe
1271
1514
  }
1272
1515
 
1273
1516
  // ---------------------------------------------------------------------------
1274
- // A1-A7
1517
+ // A1-A14
1275
1518
  // ---------------------------------------------------------------------------
1276
1519
 
1277
1520
  function A1_unexpectedEvents(model: LogicModelV1): CheckResult {
@@ -2646,20 +2889,32 @@ export function runVerification(input: unknown, options: VerificationOptions = {
2646
2889
  maxStates: options.maxStates ?? DEFAULT_MAX_STATES,
2647
2890
  maxPermutationEvents: options.maxPermutationEvents ?? DEFAULT_MAX_PERMUTATION_EVENTS,
2648
2891
  }
2892
+ const hashSpec = options.hashSpec ?? DEFAULT_HASH_SPEC
2893
+ const metadataKeys = metadataKeysOf(input)
2894
+ const metadata = metadataKeys.length === 0 ? {} : { metadataKeys }
2649
2895
  const validation = validateModel(input)
2650
2896
  if (!validation.ok) {
2897
+ const findings: Finding[] = validation.errors.map((message) => ({ code: 'MODEL_INVALID', severity: 'error', message }))
2898
+ const modelCheck: CheckResult = {
2899
+ id: 'MODEL',
2900
+ name: 'Model Validation',
2901
+ status: 'fail',
2902
+ detail: 'Model schema validation failed: ' + validation.errors.length + ' errors',
2903
+ findings,
2904
+ }
2651
2905
  return {
2652
2906
  ok: false,
2907
+ ran: true,
2908
+ ...verdictOfFindings(findings),
2909
+ schema: REPORT_SCHEMAS.verify,
2653
2910
  schemaVersion: 1,
2911
+ hashSpec,
2654
2912
  modelHash: '',
2913
+ hashes: { hashSpec, modelHash: '' },
2914
+ ...metadata,
2655
2915
  summary: { states: 0, transitions: 0, errors: validation.errors.length, warnings: 0, checksRun: 0 },
2656
- checks: [{
2657
- id: 'MODEL',
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
- }],
2916
+ checks: [modelCheck],
2917
+ nextSteps: [...refusalGuidance(validation.errors), ...verificationNextSteps([modelCheck], undefined)],
2663
2918
  }
2664
2919
  }
2665
2920
  const model = validation.model
@@ -2692,10 +2947,18 @@ export function runVerification(input: unknown, options: VerificationOptions = {
2692
2947
  if (options.beforeModel !== undefined) {
2693
2948
  const beforeValidation = validateModel(options.beforeModel)
2694
2949
  if (!beforeValidation.ok) {
2950
+ const beforeFindings: Finding[] = beforeValidation.errors.map((message) => ({ code: 'BEFORE_MODEL_INVALID', severity: 'error', message }))
2951
+ const allFindings: Finding[] = [...checks.flatMap((check) => check.findings), ...beforeFindings]
2695
2952
  return {
2696
2953
  ok: false,
2954
+ ran: true,
2955
+ ...verdictOfFindings(allFindings),
2956
+ schema: REPORT_SCHEMAS.verify,
2697
2957
  schemaVersion: 1,
2698
- modelHash: modelHash(model),
2958
+ hashSpec,
2959
+ modelHash: modelHash(model, hashSpec),
2960
+ hashes: { hashSpec, modelHash: modelHash(model, hashSpec), afterModelHash: modelHash(model, hashSpec) },
2961
+ ...metadata,
2699
2962
  summary: {
2700
2963
  states: model.states.length,
2701
2964
  transitions: model.transitions.length,
@@ -2711,9 +2974,10 @@ export function runVerification(input: unknown, options: VerificationOptions = {
2711
2974
  name: 'Before Model Validation',
2712
2975
  status: 'fail',
2713
2976
  detail: 'Before model schema validation failed: ' + beforeValidation.errors.length + ' errors',
2714
- findings: beforeValidation.errors.map((message) => ({ code: 'BEFORE_MODEL_INVALID', severity: 'error', message })),
2977
+ findings: beforeFindings,
2715
2978
  },
2716
2979
  ],
2980
+ nextSteps: verificationNextSteps(checks, narrativeCoverageOf(model)),
2717
2981
  }
2718
2982
  }
2719
2983
  const before = beforeValidation.model
@@ -2729,10 +2993,32 @@ export function runVerification(input: unknown, options: VerificationOptions = {
2729
2993
  const errors = checks.reduce((sum, check) => sum + check.findings.filter((finding) => finding.severity === 'error').length, 0)
2730
2994
  const warnings = checks.reduce((sum, check) => sum + check.findings.filter((finding) => finding.severity === 'warning').length, 0)
2731
2995
  const coverageNotes = computeCoverageNotes(model)
2996
+ const narrativeCoverage = narrativeCoverageOf(model)
2997
+ // R3: a partial narrative is valid, but the gap must be machine-readable instead of only a
2998
+ // sentence in `nextSteps`. It rides on S5 — the check that already reports undocumented
2999
+ // (state, event) combinations — so the documented check count stays 22.
3000
+ if (narrativeCoverage !== undefined && !narrativeComplete(narrativeCoverage)) {
3001
+ checks.find((check) => check.id === 'S5')?.findings.push({
3002
+ code: 'NARRATIVE_PARTIAL',
3003
+ severity: 'info',
3004
+ message: 'Narrative coverage is partial (states ' + narrativeCoverage.states + ', events ' + narrativeCoverage.events + ', scenarios ' + narrativeCoverage.scenarios + '): the checks still run over the whole model, but the undocumented symbols have to be re-derived by the reader.',
3005
+ })
3006
+ }
2732
3007
  return {
2733
3008
  ok: true,
3009
+ ran: true,
3010
+ ...verdictOfFindings(checks.flatMap((check) => check.findings)),
3011
+ schema: REPORT_SCHEMAS.verify,
2734
3012
  schemaVersion: 1,
2735
- modelHash: modelHash(model),
3013
+ hashSpec,
3014
+ modelHash: modelHash(model, hashSpec),
3015
+ hashes: {
3016
+ hashSpec,
3017
+ modelHash: modelHash(model, hashSpec),
3018
+ ...(comparison === undefined ? {} : { beforeModelHash: comparison.beforeModelHash, afterModelHash: comparison.afterModelHash }),
3019
+ },
3020
+ ...metadata,
3021
+ ...(narrativeCoverage === undefined ? {} : { narrativeCoverage }),
2736
3022
  summary: {
2737
3023
  states: model.states.length,
2738
3024
  transitions: model.transitions.length,
@@ -2742,6 +3028,7 @@ export function runVerification(input: unknown, options: VerificationOptions = {
2742
3028
  truncated: exploration.truncated,
2743
3029
  },
2744
3030
  checks,
3031
+ nextSteps: verificationNextSteps(checks, narrativeCoverage),
2745
3032
  ...(model.narrative === undefined ? {} : { narrative: model.narrative }),
2746
3033
  ...(comparison === undefined ? {} : { comparison }),
2747
3034
  ...(coverageNotes.length === 0 ? {} : { coverageNotes }),
@@ -2762,6 +3049,8 @@ export interface CompositionOptions {
2762
3049
  /** Events that require a synchronized multi-machine step (handshake). */
2763
3050
  rendezvous?: string[]
2764
3051
  maxStates?: number
3052
+ /** Hash specification used for the per-machine modelHash. Defaults to the current published spec (`v1`). */
3053
+ hashSpec?: HashSpec
2765
3054
  }
2766
3055
 
2767
3056
  export interface CompositionSummary {
@@ -2774,9 +3063,21 @@ export interface CompositionSummary {
2774
3063
  }
2775
3064
 
2776
3065
  export interface CompositionReport {
3066
+ /** Historical meaning: the composition has no error-severity finding. Read `verdict` for the review outcome. */
2777
3067
  ok: boolean
3068
+ ran: boolean
3069
+ verdict: Verdict
3070
+ verdictReason: string
3071
+ /** The versioned report contract this result follows. */
3072
+ schema: ReportSchema
3073
+ /** Which published specification the per-machine `modelHash` values follow. */
3074
+ hashSpec: HashSpec
3075
+ /** Every hash this report carries, in one place. */
3076
+ hashes: { hashSpec: HashSpec; machines: string[] }
2778
3077
  summary: CompositionSummary
2779
3078
  checks: CheckResult[]
3079
+ /** What to do next, derived from the findings — never empty. */
3080
+ nextSteps: string[]
2780
3081
  }
2781
3082
 
2782
3083
  interface CompositionNode {
@@ -2802,6 +3103,7 @@ interface CompositionMove {
2802
3103
  * least one is not terminal) and C2 rendezvous that can never fire.
2803
3104
  */
2804
3105
  export function runCompositionVerification(machinesInput: unknown[], options: CompositionOptions = {}): CompositionReport {
3106
+ const hashSpec = options.hashSpec ?? DEFAULT_HASH_SPEC
2805
3107
  const models: LogicModelV1[] = []
2806
3108
  const hashes: string[] = []
2807
3109
  const modelFindings: Finding[] = []
@@ -2811,7 +3113,7 @@ export function runCompositionVerification(machinesInput: unknown[], options: Co
2811
3113
  modelFindings.push({ code: 'MODEL_INVALID', severity: 'error', message: 'machine ' + index + ' invalid: ' + validation.errors.join('; ') })
2812
3114
  } else {
2813
3115
  models.push(validation.model)
2814
- hashes.push(modelHash(validation.model))
3116
+ hashes.push(modelHash(validation.model, hashSpec))
2815
3117
  }
2816
3118
  })
2817
3119
  const machineSummary = models.map((m, i) => ({ modelHash: hashes[i] ?? '', states: m.states.length, transitions: m.transitions.length }))
@@ -2821,8 +3123,14 @@ export function runCompositionVerification(machinesInput: unknown[], options: Co
2821
3123
  }
2822
3124
  return {
2823
3125
  ok: false,
3126
+ ran: true,
3127
+ ...verdictOfFindings(modelFindings),
3128
+ schema: REPORT_SCHEMAS.compose,
3129
+ hashSpec,
3130
+ hashes: { hashSpec, machines: hashes },
2824
3131
  summary: { machineCount: models.length, machines: machineSummary, compositeStates: 0, errors: modelFindings.length, warnings: 0, truncated: false },
2825
3132
  checks: [{ id: 'MODEL', name: 'Machine Validation', status: 'fail', detail: 'composition input validation failed', findings: modelFindings }],
3133
+ nextSteps: ['Fix the machine that failed validation, then re-run the composition.'],
2826
3134
  }
2827
3135
  }
2828
3136
  const rendezvous = new Set(options.rendezvous ?? [])
@@ -2881,6 +3189,17 @@ export function runCompositionVerification(machinesInput: unknown[], options: Co
2881
3189
  let truncated = false
2882
3190
  const firedCount = new Map<string, number>()
2883
3191
  for (const event of rendezvous) firedCount.set(event, 0)
3192
+ // Per-machine enablement, collected while the state space is explored: a rendezvous
3193
+ // that never fires needs to say *which* machine never had it enabled, not just that
3194
+ // the handshake did not happen.
3195
+ const everEnabled = new Map<string, Set<number>>()
3196
+ const enabledStates = new Map<string, string[][]>()
3197
+ const coEnabledNodes = new Map<string, number>()
3198
+ for (const event of rendezvous) {
3199
+ everEnabled.set(event, new Set<number>())
3200
+ enabledStates.set(event, models.map(() => []))
3201
+ coEnabledNodes.set(event, 0)
3202
+ }
2884
3203
  const c1Findings: Finding[] = []
2885
3204
  while (queue.length > 0) {
2886
3205
  const node = queue.shift()!
@@ -2894,12 +3213,63 @@ export function runCompositionVerification(machinesInput: unknown[], options: Co
2894
3213
  }
2895
3214
  const moves = compositionMoves(node)
2896
3215
  const allTerminal = node.runtimes.every((runtime, i) => isTerminal(models[i], runtime.state))
3216
+ // Enablement census for C2: which machines could fire each rendezvous here.
3217
+ for (const event of rendezvous) {
3218
+ const declaring: number[] = []
3219
+ for (let i = 0; i < models.length; i++) if (machineEventSets[i].has(event)) declaring.push(i)
3220
+ if (declaring.length === 0) continue
3221
+ let allEnabled = declaring.length >= 2
3222
+ for (const i of declaring) {
3223
+ const enabled = !isTerminal(models[i], node.runtimes[i].state) && stepRuntime(models[i], node.runtimes[i], event).length > 0
3224
+ if (!enabled) { allEnabled = false; continue }
3225
+ everEnabled.get(event)?.add(i)
3226
+ const states = enabledStates.get(event)?.[i]
3227
+ if (states !== undefined && !states.includes(node.runtimes[i].state) && states.length < 8) states.push(node.runtimes[i].state)
3228
+ }
3229
+ if (allEnabled) coEnabledNodes.set(event, (coEnabledNodes.get(event) ?? 0) + 1)
3230
+ }
2897
3231
  if (moves.length === 0 && !allTerminal) {
3232
+ // Why nobody can advance, per machine: its state, its alphabet, and for every event
3233
+ // either the rendezvous partner that is not ready or the transition that does not fire.
3234
+ const perMachine = node.runtimes.map((runtime, i) => {
3235
+ const terminal = isTerminal(models[i], runtime.state)
3236
+ const blocked = terminal ? [] : allEvents(models[i]).map((event) => {
3237
+ const enabled = stepRuntime(models[i], runtime, event).length > 0
3238
+ // A rendezvous event is not fireable just because this machine has a transition:
3239
+ // it needs every declaring machine enabled at the same time. Check that first, or
3240
+ // the deadlock census would report "enabled" for a handshake nobody can complete.
3241
+ if (!rendezvous.has(event)) return { event, reason: enabled ? 'enabled' : 'no-enabled-transition' }
3242
+ const declaring: number[] = []
3243
+ for (let j = 0; j < models.length; j++) if (machineEventSets[j].has(event)) declaring.push(j)
3244
+ if (declaring.length < 2) return { event, reason: 'rendezvous-needs-partner' }
3245
+ const partnersReady = declaring.every((j) => !isTerminal(models[j], node.runtimes[j].state)
3246
+ && stepRuntime(models[j], node.runtimes[j], event).length > 0)
3247
+ if (!partnersReady) return { event, reason: 'rendezvous-partner-not-ready' }
3248
+ return { event, reason: enabled ? 'enabled' : 'no-enabled-transition' }
3249
+ })
3250
+ return { index: i, state: runtime.state, terminal, blocked }
3251
+ })
3252
+ const reasons = perMachine
3253
+ .filter((machine) => !machine.terminal)
3254
+ .map((machine) => 'machine ' + String(machine.index) + ' at ' + machine.state + ': ' + (machine.blocked.length === 0
3255
+ ? 'empty event alphabet'
3256
+ : machine.blocked.map((entry) => entry.event + ' (' + entry.reason + ')').join(', ')))
3257
+ // A non-terminal state with no outgoing transition anywhere in its own model is a
3258
+ // modelling gap, not a design defect: without this note the census reads as
3259
+ // "the architecture deadlocks" when the model was simply left unfinished.
3260
+ const gaps = node.runtimes
3261
+ .map((runtime, index) => ({ index, state: runtime.state }))
3262
+ .filter((entry) => !isTerminal(models[entry.index], entry.state)
3263
+ && !models[entry.index].transitions.some((transition) => transition.from === entry.state))
3264
+ const gapNote = gaps.length === 0
3265
+ ? ''
3266
+ : ' Modelling note: ' + gaps.map((gap) => 'machine ' + String(gap.index) + ' state ' + gap.state).join(', ') + ' has no outgoing transition in its own model, so this deadlock may be a modelling gap rather than a design defect.'
2898
3267
  c1Findings.push({
2899
3268
  code: 'C1_COMPOSITION_DEADLOCK',
2900
3269
  severity: 'error',
2901
3270
  message: 'Composition deadlock: no machine can advance from (' + node.runtimes.map((runtime) => runtime.state).join(', ') + ') while at least one is not terminal.',
2902
- evidence: { steps: node.path, states: node.runtimes.map((runtime) => runtime.state) },
3271
+ detail: 'Shortest counterexample (breadth-first): ' + String(node.path.length) + ' step(s) to reach it. ' + reasons.join(' | ') + gapNote,
3272
+ evidence: { steps: node.path, states: node.runtimes.map((runtime) => runtime.state), depth: node.path.length, perMachine, reasons },
2903
3273
  })
2904
3274
  continue
2905
3275
  }
@@ -2916,22 +3286,83 @@ export function runCompositionVerification(machinesInput: unknown[], options: Co
2916
3286
  for (const event of rendezvous) {
2917
3287
  if (!allEventsSet.has(event)) continue
2918
3288
  if ((firedCount.get(event) ?? 0) === 0) {
3289
+ // Why the handshake never happens: who declares it, who ever had it enabled, and
3290
+ // whether the enabling states could ever coincide.
3291
+ const machines = models.map((model, index) => ({
3292
+ index,
3293
+ declares: machineEventSets[index].has(event),
3294
+ everEnabled: everEnabled.get(event)?.has(index) === true,
3295
+ enabledAt: enabledStates.get(event)?.[index] ?? [],
3296
+ }))
3297
+ const declaring = machines.filter((machine) => machine.declares)
3298
+ const neverEnabled = declaring.filter((machine) => !machine.everEnabled)
3299
+ const coEnabled = coEnabledNodes.get(event) ?? 0
3300
+ let reason: string
3301
+ if (declaring.length < 2) {
3302
+ reason = 'only ' + String(declaring.length) + ' machine(s) declare it (machine ' + declaring.map((machine) => machine.index).join(', ') + '); a handshake needs at least two'
3303
+ } else if (neverEnabled.length > 0) {
3304
+ reason = 'machine ' + neverEnabled.map((machine) => machine.index).join(', ') + ' declares it but never has it enabled (no transition, or the guard never holds)'
3305
+ } else {
3306
+ reason = 'every declaring machine enables it somewhere, but never at the same time (' + String(coEnabled) + ' composite state(s) had all of them ready)'
3307
+ }
3308
+ // A truncated exploration cannot prove that a handshake never happens: it may simply
3309
+ // not have reached the composite state where both machines are ready.
3310
+ 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
3311
  c2Findings.push({
2920
3312
  code: 'C2_RENDEZVOUS_NEVER_FIRES',
2921
- severity: 'warning',
2922
- message: 'Rendezvous event ' + event + ' can never fire: fewer than two machines ever jointly enable it.',
3313
+ severity: 'error',
3314
+ message: 'Rendezvous event ' + event + ' can never fire: ' + reason + '.' + caveat,
3315
+ detail: machines.map((machine) => 'machine ' + String(machine.index) + (machine.declares ? ' declares' : ' does not declare')
3316
+ + (machine.everEnabled ? ', enabled at ' + (machine.enabledAt.length === 0 ? 'no visited state' : machine.enabledAt.join('/')) : ', never enabled')).join(' | '),
3317
+ evidence: { event, machines, declaring: declaring.map((machine) => machine.index), coEnabledNodes: coEnabled, reason, truncated },
2923
3318
  })
2924
3319
  }
2925
3320
  }
2926
- const errors = c1Findings.length
2927
- const warnings = c2Findings.length
3321
+ // C2 is an error, not a warning: a rendezvous that can never fire means the composition
3322
+ // cannot advance through that handshake — the same "stuck forever" outcome C1 reports,
3323
+ // so a gate that only reads `verdict` must be able to stop on it.
3324
+ const errors = c1Findings.length + c2Findings.length
3325
+ const warnings = 0
3326
+ const c1Detail = c1Findings.length > 0
3327
+ ? 'Composition deadlocks: ' + String(c1Findings.length)
3328
+ : truncated
3329
+ ? 'No composition deadlock found, but the search was truncated at ' + String(maxStates) + ' composite states: absence is not proven'
3330
+ : 'No composition deadlock reachable'
3331
+ const c2Detail = c2Findings.length > 0
3332
+ ? 'Rendezvous errors: ' + String(c2Findings.length)
3333
+ : truncated
3334
+ ? 'All rendezvous events fired, but the search was truncated at ' + String(maxStates) + ' composite states'
3335
+ : 'All rendezvous events can fire'
2928
3336
  const checks: CheckResult[] = [
2929
- checkResult('C1', 'Composition Deadlock', c1Findings, c1Findings.length === 0 ? 'No composition deadlock reachable' : 'Composition deadlocks: ' + c1Findings.length),
2930
- checkResult('C2', 'Rendezvous Sync', c2Findings, c2Findings.length === 0 ? 'All rendezvous events can fire' : 'Rendezvous warnings: ' + c2Findings.length),
3337
+ checkResult('C1', 'Composition Deadlock', c1Findings, c1Detail),
3338
+ checkResult('C2', 'Rendezvous Sync', c2Findings, c2Detail),
2931
3339
  ]
3340
+ const nextSteps: string[] = []
3341
+ if (errors > 0) {
3342
+ const why = c1Findings.length > 0
3343
+ ? 'the composition deadlocks at a reachable state, so at least one machine is missing an exit or a handshake partner'
3344
+ : 'a rendezvous event can never fire, so the composition cannot advance through that handshake'
3345
+ nextSteps.push('Resolve the error findings first: ' + why + '.')
3346
+ }
3347
+ // Composition couples machines only through rendezvous: each machine's `variables` are
3348
+ // private to it, so two machines cannot share one predicate. Name them when the input
3349
+ // looks like it is trying to (two or more machines each declare variables).
3350
+ const owners = models
3351
+ .map((entry, index) => ({ index, names: (entry.variables ?? []).map((variable) => variable.name) }))
3352
+ .filter((entry) => entry.names.length > 0)
3353
+ if (owners.length >= 2) {
3354
+ nextSteps.push('Each machine\'s variables are private to it (' + owners.map((entry) => 'machine ' + String(entry.index) + ': ' + entry.names.join(', ')).join('; ') + '): composition couples machines only through rendezvous, so a value two machines must agree on has to be modelled as a handshake, or inside a single machine.')
3355
+ }
3356
+ nextSteps.push('Re-run the composition after the change; every machine must be able to advance, or explain why it is terminal.')
2932
3357
  return {
2933
3358
  ok: errors === 0,
3359
+ ran: true,
3360
+ ...verdictOf(errors, warnings, c1Findings[0]?.code ?? c2Findings[0]?.code),
3361
+ schema: REPORT_SCHEMAS.compose,
3362
+ hashSpec,
3363
+ hashes: { hashSpec, machines: hashes },
2934
3364
  summary: { machineCount: models.length, machines: machineSummary, compositeStates, errors, warnings, truncated },
2935
3365
  checks,
3366
+ nextSteps,
2936
3367
  }
2937
3368
  }