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.
- package/README.en-US.md +128 -66
- package/README.md +128 -66
- 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 +3151 -2801
- 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 +419 -277
- 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 +146 -104
- package/lib/uml.js +1748 -1544
- 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 +102 -10
- package/skills/logicprobe/references/gap-routing-guide.md +37 -36
- package/skills/logicprobe/references/hash-spec.md +153 -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 +57 -0
- package/skills/{logicprobe → logicprobe-uml}/references/uml-modeling-guide.md +121 -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 +503 -72
- 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 +53 -10
- package/src/uml.ts +288 -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 {
|
|
@@ -204,8 +206,18 @@ export interface PathStep {
|
|
|
204
206
|
|
|
205
207
|
export interface Finding {
|
|
206
208
|
code: string
|
|
207
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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 (
|
|
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)
|
|
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 (
|
|
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) + ')')
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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: '
|
|
2922
|
-
message: 'Rendezvous event ' + event + ' can never fire:
|
|
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
|
-
|
|
2927
|
-
|
|
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,
|
|
2930
|
-
checkResult('C2', 'Rendezvous Sync', c2Findings,
|
|
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
|
}
|