dsh-logicprobe 1.3.0 → 1.4.1

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/src/engine.ts CHANGED
@@ -329,6 +329,31 @@ type NormalizedOptions = Required<Pick<VerificationOptions, 'maxStates' | 'maxPe
329
329
 
330
330
  const DEFAULT_MAX_STATES = 10_000
331
331
  const DEFAULT_MAX_PERMUTATION_EVENTS = 5
332
+ /**
333
+ * Budget for the leads-to walk, as a multiple of `maxStates`. That walk is depth-first
334
+ * over run states, so `maxStates` does not bound it the way it bounds the breadth-first
335
+ * explorations: an `inc`/`dec` counter whose updates are not pinned by a guard has an
336
+ * unbounded reachable set, every run state is distinct, and the walk never revisits one.
337
+ * The budget is shared across every start state of a single check, so a wide search
338
+ * space cannot multiply it by the number of starts.
339
+ */
340
+ const LEADS_TO_WALK_FACTOR = 20
341
+
342
+ function leadsToWalkBudget(options: NormalizedOptions): number {
343
+ return Math.max(1_000, options.maxStates * LEADS_TO_WALK_FACTOR)
344
+ }
345
+
346
+ interface LeadsToWalk {
347
+ /** Nodes still allowed, shared across every start state of one check. */
348
+ remaining: number
349
+ }
350
+
351
+ interface LeadsToOutcome {
352
+ /** A run from the start state that reaches none of the targets. */
353
+ violation?: { path: PathStep[]; reason: string }
354
+ /** The budget ran out: the property is neither refuted nor proven. */
355
+ undecided: boolean
356
+ }
332
357
 
333
358
  function stableStringify(value: unknown): string {
334
359
  if (value === null || typeof value !== 'object') return JSON.stringify(value)
@@ -468,6 +493,17 @@ export function verdictOfFindings(findings: Array<{ code: string; severity: stri
468
493
  return verdictOf(errors.length, warnings.length, errors[0]?.code)
469
494
  }
470
495
 
496
+ /**
497
+ * Finding codes that mean "the search stopped before deciding", as opposed to "the
498
+ * model is wrong". They must reach `summary.truncated`, or a caller reading only that
499
+ * flag would take an unfinished search for a clean one.
500
+ */
501
+ const UNDECIDED_CODES = new Set(['S7_UNDECIDED', 'A7_UNDECIDED', 'A9_LEADS_TO_UNDECIDED', 'D2_UNDECIDED'])
502
+
503
+ function hasUndecidedFinding(checks: CheckResult[]): boolean {
504
+ return checks.some((check) => check.findings.some((finding) => UNDECIDED_CODES.has(finding.code)))
505
+ }
506
+
471
507
  /**
472
508
  * Paths of every `_`-prefixed key in the input, sorted (`_source`, `_verified`,
473
509
  * `states[0]._note`; a metadata key's own children are not listed, since the subtree is
@@ -1226,6 +1262,99 @@ function collectGuardConstants(model: LogicModelV1): number[] {
1226
1262
  return guardLeaves(model).filter((leaf) => typeof leaf.value === 'number').map((leaf) => leaf.value as number)
1227
1263
  }
1228
1264
 
1265
+ /** How a variable value prints in a message. Shared by every finding that quotes one. */
1266
+ function varValueLabel(value: VarValue): string {
1267
+ return typeof value === 'boolean' ? (value ? 'true' : 'false') : String(value)
1268
+ }
1269
+
1270
+ /** Deterministic value order: booleans first, then numbers ascending. */
1271
+ function compareVarValues(a: VarValue, b: VarValue): number {
1272
+ const rank = (value: VarValue): [number, number] => typeof value === 'number' ? [1, value] : [0, value ? 1 : 0]
1273
+ const [rankA, numberA] = rank(a)
1274
+ const [rankB, numberB] = rank(b)
1275
+ return rankA === rankB ? numberA - numberB : rankA - rankB
1276
+ }
1277
+
1278
+ /**
1279
+ * Values each variable can actually hold, as an over-approximation: `init` plus every
1280
+ * `set` constant the model writes. `null` means "unknown", which is what an `inc` or
1281
+ * `dec` update produces — the reachable set is then unbounded and nothing can be
1282
+ * concluded from it.
1283
+ *
1284
+ * The union is taken across the whole model rather than per state, so the answer can
1285
+ * only be too large, never too small. A guard this function calls dead is therefore
1286
+ * dead in every state, while a live guard may still be unreachable in a particular one.
1287
+ */
1288
+ function attainableValues(model: LogicModelV1): Map<string, Set<VarValue> | null> {
1289
+ const attainable = new Map<string, Set<VarValue> | null>()
1290
+ for (const variable of model.variables ?? []) attainable.set(variable.name, new Set<VarValue>([variable.init]))
1291
+ for (const transition of model.transitions) {
1292
+ for (const update of transition.updates ?? []) {
1293
+ if (!attainable.has(update.variable)) continue
1294
+ if (update.op === 'inc' || update.op === 'dec') {
1295
+ attainable.set(update.variable, null)
1296
+ continue
1297
+ }
1298
+ const values = attainable.get(update.variable)
1299
+ if (values === null || values === undefined) continue
1300
+ values.add(update.value ?? 0)
1301
+ }
1302
+ }
1303
+ return attainable
1304
+ }
1305
+
1306
+ /**
1307
+ * Guarded transitions whose guard no reachable variable value can satisfy: the branch
1308
+ * is dead code, and a model that relies on it is not the model the author wrote. This
1309
+ * is the dual of S6_INCOMPLETE_GUARD — a group that never reaches a branch, rather
1310
+ * than one that falls through every branch.
1311
+ *
1312
+ * The test runs over {@link attainableValues}, not over the declared domain: a guard
1313
+ * on a variable the model only ever sets to one of its declared values is satisfiable
1314
+ * on paper and dead in practice, which is exactly the defect worth reporting.
1315
+ */
1316
+ function deadGuardFindings(model: LogicModelV1): Finding[] {
1317
+ const attainable = attainableValues(model)
1318
+ const findings: Finding[] = []
1319
+ for (const [key, group] of groupTransitions(model)) {
1320
+ const [from, event] = key.split('|')
1321
+ for (const transition of group) {
1322
+ if (transition.guard === undefined) continue
1323
+ const names = [...new Set(guardVariables(transition.guard))].filter((name) => attainable.has(name)).sort()
1324
+ if (names.length === 0) continue
1325
+ const domains: Array<Set<VarValue>> = []
1326
+ let known = true
1327
+ for (const name of names) {
1328
+ const values = attainable.get(name)
1329
+ if (values === undefined || values === null || values.size === 0) { known = false; break }
1330
+ domains.push(values)
1331
+ }
1332
+ if (!known) continue
1333
+ let size = 1
1334
+ for (const values of domains) size *= values.size
1335
+ if (size > 10_000) continue
1336
+ const assignment: Record<string, VarValue> = {}
1337
+ const satisfiable = (index: number): boolean => {
1338
+ if (index === names.length) return evalGuard(transition.guard!, assignment)
1339
+ for (const value of [...domains[index]].sort(compareVarValues)) {
1340
+ assignment[names[index]] = value
1341
+ if (satisfiable(index + 1)) return true
1342
+ }
1343
+ return false
1344
+ }
1345
+ if (satisfiable(0)) continue
1346
+ findings.push({
1347
+ code: 'S6_DEAD_GUARD',
1348
+ severity: 'warning',
1349
+ message: from + ' + ' + event + ' has a guard that can never hold: ' + names.map((name) =>
1350
+ name + ' is only ever ' + [...(attainable.get(name) as Set<VarValue>)].sort(compareVarValues).map(varValueLabel).join('/')).join(', ') + '. The branch is dead.',
1351
+ evidence: { guard: transition.guard, attainable: names.map((name) => ({ variable: name, values: [...(attainable.get(name) as Set<VarValue>)].sort(compareVarValues).map(varValueLabel) })) },
1352
+ })
1353
+ }
1354
+ }
1355
+ return findings
1356
+ }
1357
+
1229
1358
  interface GuardAnalysis {
1230
1359
  determinism: Finding[]
1231
1360
  completeness: Finding[]
@@ -1282,6 +1411,7 @@ function analyzeGuards(model: LogicModelV1): GuardAnalysis {
1282
1411
  }
1283
1412
  if (truncated) break
1284
1413
  }
1414
+ completeness.push(...deadGuardFindings(model))
1285
1415
  return { determinism, completeness, truncated }
1286
1416
  }
1287
1417
 
@@ -1292,7 +1422,11 @@ function S4_determinism(model: LogicModelV1): CheckResult {
1292
1422
 
1293
1423
  function S6_guardCompleteness(model: LogicModelV1): CheckResult {
1294
1424
  const analysis = analyzeGuards(model)
1295
- return checkResult('S6', 'Guard Completeness', analysis.completeness, analysis.completeness.length === 0 ? 'All guard branches defined' : 'Guard findings: ' + analysis.completeness.length)
1425
+ const dead = analysis.completeness.filter((finding) => finding.code === 'S6_DEAD_GUARD').length
1426
+ const detail = analysis.completeness.length === 0
1427
+ ? 'All guard branches defined'
1428
+ : dead === analysis.completeness.length ? 'Dead guard branches: ' + dead : 'Guard findings: ' + analysis.completeness.length
1429
+ return checkResult('S6', 'Guard Completeness', analysis.completeness, detail)
1296
1430
  }
1297
1431
 
1298
1432
  function S5_eventCompleteness(model: LogicModelV1): CheckResult {
@@ -1318,6 +1452,13 @@ interface InvariantViolation {
1318
1452
  invariant: InvariantSpec
1319
1453
  path: PathStep[]
1320
1454
  reason: string
1455
+ /**
1456
+ * The search hit its budget before deciding the property, so neither "holds" nor
1457
+ * "violated" is proven. Every caller must report this as undecided rather than fold
1458
+ * it into a pass: a cap that reads as a pass is the false green the report contract
1459
+ * exists to prevent.
1460
+ */
1461
+ undecided?: boolean
1321
1462
  }
1322
1463
 
1323
1464
  /** Whether a `var-in-range` `when` scope selects this runtime state. */
@@ -1427,10 +1568,15 @@ function pathPropertyViolation(model: LogicModelV1, options: NormalizedOptions,
1427
1568
  if (invariant.kind === 'leads-to') {
1428
1569
  const targets = new Set(leadsToTargets(invariant))
1429
1570
  const label = leadsToTargetLabel(invariant)
1571
+ const budget = leadsToWalkBudget(options)
1572
+ const walk: LeadsToWalk = { remaining: budget }
1430
1573
  for (const runtime of reachableRuntimeStates(model, options)) {
1431
1574
  if (runtime.state !== invariant.from) continue
1432
- const bad = findLeadsToBadPath(model, runtime, targets, label)
1433
- if (bad !== undefined) return { invariant, path: bad.path, reason: bad.reason }
1575
+ const outcome = findLeadsToBadPath(model, runtime, targets, label, walk)
1576
+ if (outcome.undecided) {
1577
+ return { invariant, path: [], reason: 'the walk spent its budget of ' + budget + ' steps without finding a run that avoids ' + label + ', or proving that none exists', undecided: true }
1578
+ }
1579
+ if (outcome.violation !== undefined) return { invariant, path: outcome.violation.path, reason: outcome.violation.reason }
1434
1580
  }
1435
1581
  return undefined
1436
1582
  }
@@ -1503,14 +1649,22 @@ function runInvariants(model: LogicModelV1, options: NormalizedOptions, includeP
1503
1649
 
1504
1650
  function S7_invariants(model: LogicModelV1, options: NormalizedOptions): CheckResult {
1505
1651
  const violations = runInvariants(model, options)
1506
- const findings: Finding[] = violations.map((violation) => ({
1652
+ const findings: Finding[] = violations.map((violation) => violation.undecided ? {
1653
+ code: 'S7_UNDECIDED',
1654
+ severity: 'warning' as const,
1655
+ message: 'Invariant "' + violation.invariant.id + '" (' + violation.invariant.description + ') could not be decided: ' + violation.reason + '. Absence is not proven.',
1656
+ evidence: { invariant: violation.invariant },
1657
+ } : {
1507
1658
  code: 'S7_INVARIANT_VIOLATION',
1508
- severity: 'error',
1659
+ severity: 'error' as const,
1509
1660
  message: 'Invariant "' + violation.invariant.id + '" (' + violation.invariant.description + ') violated: ' + violation.reason,
1510
1661
  path: violation.path,
1511
1662
  evidence: { invariant: violation.invariant },
1512
- }))
1513
- return checkResult('S7', 'Invariant Validity', findings, violations.length === 0 ? 'All invariants hold' : 'Invariant violations: ' + violations.length)
1663
+ })
1664
+ const undecided = findings.some((finding) => finding.code === 'S7_UNDECIDED')
1665
+ return checkResult('S7', 'Invariant Validity', findings, violations.length === 0
1666
+ ? 'All invariants hold'
1667
+ : undecided && findings.every((finding) => finding.code === 'S7_UNDECIDED') ? 'Invariants undecided: the search budget ran out' : 'Invariant violations: ' + violations.length)
1514
1668
  }
1515
1669
 
1516
1670
  // ---------------------------------------------------------------------------
@@ -1869,6 +2023,14 @@ function A7_shortestViolations(model: LogicModelV1, options: NormalizedOptions):
1869
2023
  // found instead of claiming minimality.
1870
2024
  const violations = runInvariants(model, options, true)
1871
2025
  const findings: Finding[] = violations.map((violation) => {
2026
+ if (violation.undecided) {
2027
+ return {
2028
+ code: 'A7_UNDECIDED',
2029
+ severity: 'warning' as const,
2030
+ message: 'Invariant "' + violation.invariant.id + '" could not be decided: ' + violation.reason + '. Absence is not proven, so there is no shortest counterexample to report.',
2031
+ evidence: { invariant: violation.invariant.id },
2032
+ }
2033
+ }
1872
2034
  const pathLength = violation.path.length
1873
2035
  const message = isPathPropertyInvariant(violation.invariant)
1874
2036
  ? 'Invariant "' + violation.invariant.id + '" violated: ' + violation.reason
@@ -2051,9 +2213,14 @@ function D2_invariantContinuity(before: LogicModelV1, after: LogicModelV1, optio
2051
2213
  ? pathPropertyViolation(after, options, mapped)
2052
2214
  : shortestViolationForInvariant(after, options, mapped)
2053
2215
  if (violation !== undefined) {
2054
- findings.push({
2216
+ findings.push(violation.undecided ? {
2217
+ code: 'D2_UNDECIDED',
2218
+ severity: 'warning' as const,
2219
+ message: 'BEFORE invariant "' + invariant.id + '" could not be re-decided against AFTER: ' + violation.reason + '. Absence is not proven.',
2220
+ evidence: { beforeInvariant: invariant, afterInvariant: mapped },
2221
+ } : {
2055
2222
  code: 'D2_INVARIANT_REGRESSION',
2056
- severity: 'error',
2223
+ severity: 'error' as const,
2057
2224
  message: 'BEFORE invariant "' + invariant.id + '" no longer holds in AFTER: ' + violation.reason,
2058
2225
  path: violation.path,
2059
2226
  evidence: { beforeInvariant: invariant, afterInvariant: mapped },
@@ -2218,6 +2385,99 @@ function A8_idempotentReplay(model: LogicModelV1, exploration: Exploration): Che
2218
2385
  }
2219
2386
  return checkResult('A8', 'Idempotent Replay', findings, findings.length === 0 ? 'Idempotent events are replay-safe' : 'Idempotent replay findings: ' + findings.length)
2220
2387
  }
2388
+ /**
2389
+ * Whether the guard proves `variable` stays at or below `bound` wherever it holds.
2390
+ *
2391
+ * Deliberately conservative in one direction only: a guard this function cannot read
2392
+ * answers `false`, so an unreadable guard is reported as "not proven" rather than as
2393
+ * "enforced". A conjunction bounds the value if any conjunct does; a disjunction has to
2394
+ * bound it on every branch; a negation is never reasoned about.
2395
+ */
2396
+ function guardImpliesUpperBound(guard: GuardNode | undefined, variable: string, bound: number): boolean {
2397
+ if (guard === undefined) return false
2398
+ if ('variable' in guard) {
2399
+ if (guard.variable !== variable || typeof guard.value !== 'number') return false
2400
+ if (guard.op === '<') return guard.value - 1 <= bound
2401
+ if (guard.op === '<=' || guard.op === '==') return guard.value <= bound
2402
+ return false
2403
+ }
2404
+ if ('all' in guard) return guard.all.some((child) => guardImpliesUpperBound(child, variable, bound))
2405
+ if ('any' in guard) return guard.any.every((child) => guardImpliesUpperBound(child, variable, bound))
2406
+ return false
2407
+ }
2408
+
2409
+ /** Mirror of {@link guardImpliesUpperBound} for the lower bound. */
2410
+ function guardImpliesLowerBound(guard: GuardNode | undefined, variable: string, bound: number): boolean {
2411
+ if (guard === undefined) return false
2412
+ if ('variable' in guard) {
2413
+ if (guard.variable !== variable || typeof guard.value !== 'number') return false
2414
+ if (guard.op === '>') return guard.value + 1 >= bound
2415
+ if (guard.op === '>=' || guard.op === '==') return guard.value >= bound
2416
+ return false
2417
+ }
2418
+ if ('all' in guard) return guard.all.some((child) => guardImpliesLowerBound(child, variable, bound))
2419
+ if ('any' in guard) return guard.any.every((child) => guardImpliesLowerBound(child, variable, bound))
2420
+ return false
2421
+ }
2422
+
2423
+ /**
2424
+ * Whether the model's own declarations about a variable hold against what its
2425
+ * transitions do. `inc` and `dec` are never clamped to `min`/`max` — the declared
2426
+ * range is a property to check, not a bound on the search — so a counter whose guard
2427
+ * does not pin it ranges past its own declaration, and the reachable set is unbounded
2428
+ * however narrow the declaration looks.
2429
+ *
2430
+ * Reporting the declaration rather than silently clamping is deliberate. Clamping would
2431
+ * delete the very states a range violation is witnessed by, turning a counterexample
2432
+ * into a false pass.
2433
+ */
2434
+ function declaredDomainFindings(model: LogicModelV1): Finding[] {
2435
+ const findings: Finding[] = []
2436
+ for (const transition of model.transitions) {
2437
+ for (const update of transition.updates ?? []) {
2438
+ const variable = (model.variables ?? []).find((entry) => entry.name === update.variable)
2439
+ if (variable === undefined) continue
2440
+ const delta = typeof update.value === 'number' ? update.value : 1
2441
+ const at = transition.from + ' + ' + transition.event
2442
+ if (update.op === 'inc' && variable.max !== undefined && !guardImpliesUpperBound(transition.guard, variable.name, variable.max - delta)) {
2443
+ findings.push({
2444
+ code: 'S8_DOMAIN_NOT_ENFORCED',
2445
+ severity: 'warning',
2446
+ message: at + ' increments ' + variable.name + ', but ' + variable.name + ' declares max ' + variable.max + ' and no guard keeps it there: the declared range is not a bound on reachable values.',
2447
+ evidence: { variable: variable.name, declaredMax: variable.max, transition: at },
2448
+ })
2449
+ }
2450
+ if (update.op === 'dec' && variable.min !== undefined && !guardImpliesLowerBound(transition.guard, variable.name, variable.min + delta)) {
2451
+ findings.push({
2452
+ code: 'S8_DOMAIN_NOT_ENFORCED',
2453
+ severity: 'warning',
2454
+ message: at + ' decrements ' + variable.name + ', but ' + variable.name + ' declares min ' + variable.min + ' and no guard keeps it there: the declared range is not a bound on reachable values.',
2455
+ evidence: { variable: variable.name, declaredMin: variable.min, transition: at },
2456
+ })
2457
+ }
2458
+ if (update.op === 'set' && typeof update.value === 'number') {
2459
+ if (variable.min !== undefined && update.value < variable.min) {
2460
+ findings.push({
2461
+ code: 'S8_DOMAIN_SET_OUTSIDE',
2462
+ severity: 'error',
2463
+ message: at + ' sets ' + variable.name + ' to ' + update.value + ', below its declared min ' + variable.min + '.',
2464
+ evidence: { variable: variable.name, value: update.value, declaredMin: variable.min },
2465
+ })
2466
+ }
2467
+ if (variable.max !== undefined && update.value > variable.max) {
2468
+ findings.push({
2469
+ code: 'S8_DOMAIN_SET_OUTSIDE',
2470
+ severity: 'error',
2471
+ message: at + ' sets ' + variable.name + ' to ' + update.value + ', above its declared max ' + variable.max + '.',
2472
+ evidence: { variable: variable.name, value: update.value, declaredMax: variable.max },
2473
+ })
2474
+ }
2475
+ }
2476
+ }
2477
+ }
2478
+ return findings
2479
+ }
2480
+
2221
2481
  function S8_monotonicVariables(model: LogicModelV1): CheckResult {
2222
2482
  const findings: Finding[] = []
2223
2483
  for (const variable of model.variables ?? []) {
@@ -2235,7 +2495,8 @@ function S8_monotonicVariables(model: LogicModelV1): CheckResult {
2235
2495
  }
2236
2496
  }
2237
2497
  }
2238
- return checkResult('S8', 'Monotonic Variables', findings, findings.length === 0 ? 'Monotonic variables are respected' : 'Monotonic findings: ' + findings.length)
2498
+ findings.push(...declaredDomainFindings(model))
2499
+ return checkResult('S8', 'Variable Declarations', findings, findings.length === 0 ? 'Variable declarations are respected' : 'Variable declaration findings: ' + findings.length)
2239
2500
  }
2240
2501
 
2241
2502
  /**
@@ -2271,22 +2532,45 @@ function leadsToTargetLabel(invariant: Extract<InvariantSpec, { kind: 'leads-to'
2271
2532
  * (two branches rejoining) is acyclic and must pass, while a genuine cycle must not.
2272
2533
  * A run is identified by its state plus its variable values, so the same state id
2273
2534
  * with different values is a different node.
2535
+ *
2536
+ * Two properties keep the walk from taking the host down, and both are load-bearing:
2537
+ * the walk is **budgeted**, and it stores a **parent link** per node instead of a copy
2538
+ * of the path. Without the budget an unbounded counter makes the run graph infinite, so
2539
+ * the three colours above never repeat a key and the walk never returns; without the
2540
+ * parent link every frame retains its own copy of the path, which grows the walk's
2541
+ * memory quadratically in its depth. Either one alone turns "the model is wrong" into
2542
+ * an out-of-memory abort, which is the failure this function must not have.
2543
+ *
2544
+ * An exhausted budget is reported as `undecided`, never as `undefined`: a walk that
2545
+ * did not finish has not proven that every run reaches a target.
2274
2546
  */
2275
- function findLeadsToBadPath(model: LogicModelV1, start: RuntimeState, targets: Set<string>, label: string): { path: PathStep[]; reason: string } | undefined {
2276
- if (targets.has(start.state)) return undefined
2547
+ function findLeadsToBadPath(model: LogicModelV1, start: RuntimeState, targets: Set<string>, label: string, walk: LeadsToWalk): LeadsToOutcome {
2548
+ if (targets.has(start.state)) return { undecided: false }
2277
2549
  const GRAY = 1
2278
2550
  const BLACK = 2
2279
2551
  interface LeadFrame {
2280
2552
  runtime: RuntimeState
2281
- path: PathStep[]
2553
+ parent: LeadFrame | undefined
2554
+ step: PathStep | undefined
2282
2555
  /** Outgoing steps, filled on first expansion; `undefined` means "not expanded yet". */
2283
2556
  nexts: Array<{ next: RuntimeState; event: string }> | undefined
2284
2557
  index: number
2285
2558
  }
2559
+ const pathOf = (frame: LeadFrame): PathStep[] => {
2560
+ const steps: PathStep[] = []
2561
+ let node: LeadFrame | undefined = frame
2562
+ while (node !== undefined && node.step !== undefined) {
2563
+ steps.push(node.step)
2564
+ node = node.parent
2565
+ }
2566
+ return steps.reverse()
2567
+ }
2286
2568
  const color = new Map<string, number>()
2287
- const stack: LeadFrame[] = [{ runtime: start, path: [], nexts: undefined, index: 0 }]
2569
+ const stack: LeadFrame[] = [{ runtime: start, parent: undefined, step: undefined, nexts: undefined, index: 0 }]
2288
2570
  color.set(runtimeKey(start), GRAY)
2289
2571
  while (stack.length > 0) {
2572
+ if (walk.remaining <= 0) return { undecided: true }
2573
+ walk.remaining -= 1
2290
2574
  const frame = stack[stack.length - 1]
2291
2575
  if (frame.nexts === undefined) {
2292
2576
  const nexts: Array<{ next: RuntimeState; event: string }> = []
@@ -2294,7 +2578,7 @@ function findLeadsToBadPath(model: LogicModelV1, start: RuntimeState, targets: S
2294
2578
  for (const next of stepRuntime(model, frame.runtime, event)) nexts.push({ next, event })
2295
2579
  }
2296
2580
  frame.nexts = nexts
2297
- if (nexts.length === 0) return { path: frame.path, reason: 'Dead end before target ' + label }
2581
+ if (nexts.length === 0) return { violation: { path: pathOf(frame), reason: 'Dead end before target ' + label }, undecided: false }
2298
2582
  }
2299
2583
  if (frame.index >= frame.nexts.length) {
2300
2584
  color.set(runtimeKey(frame.runtime), BLACK)
@@ -2307,37 +2591,51 @@ function findLeadsToBadPath(model: LogicModelV1, start: RuntimeState, targets: S
2307
2591
  if (targets.has(next.state)) continue
2308
2592
  const key = runtimeKey(next)
2309
2593
  const seen = color.get(key)
2310
- if (seen === GRAY) return { path: [...frame.path, step], reason: 'Cycle avoids target ' + label }
2594
+ if (seen === GRAY) return { violation: { path: [...pathOf(frame), step], reason: 'Cycle avoids target ' + label }, undecided: false }
2311
2595
  if (seen === BLACK) continue
2312
2596
  color.set(key, GRAY)
2313
- stack.push({ runtime: next, path: [...frame.path, step], nexts: undefined, index: 0 })
2597
+ stack.push({ runtime: next, parent: frame, step, nexts: undefined, index: 0 })
2314
2598
  }
2315
2599
  // Every branch either reached a target or rejoined a branch that did.
2316
- return undefined
2600
+ return { undecided: false }
2317
2601
  }
2318
2602
 
2319
- function A9_leadsTo(model: LogicModelV1, exploration: Exploration): CheckResult {
2603
+ function A9_leadsTo(model: LogicModelV1, exploration: Exploration, options: NormalizedOptions): CheckResult {
2320
2604
  const findings: Finding[] = []
2321
2605
  for (const invariant of model.invariants ?? []) {
2322
2606
  if (invariant.kind !== 'leads-to') continue
2323
2607
  const targets = new Set(leadsToTargets(invariant))
2324
2608
  const label = leadsToTargetLabel(invariant)
2609
+ const budget = leadsToWalkBudget(options)
2610
+ const walk: LeadsToWalk = { remaining: budget }
2325
2611
  for (const runtime of exploration.reachable) {
2326
2612
  if (runtime.state !== invariant.from) continue
2327
- const bad = findLeadsToBadPath(model, runtime, targets, label)
2328
- if (bad !== undefined) {
2613
+ const outcome = findLeadsToBadPath(model, runtime, targets, label, walk)
2614
+ if (outcome.undecided) {
2615
+ findings.push({
2616
+ code: 'A9_LEADS_TO_UNDECIDED',
2617
+ severity: 'warning',
2618
+ message: 'Leads-to invariant "' + invariant.id + '" is undecided: the walk spent its budget of ' + budget + ' steps from ' + invariant.from + ' without finding a run that avoids ' + label + ', or proving that none exists. Absence is not proven.',
2619
+ evidence: { invariant },
2620
+ })
2621
+ break
2622
+ }
2623
+ if (outcome.violation !== undefined) {
2329
2624
  findings.push({
2330
2625
  code: 'A9_LEADS_TO_VIOLATION',
2331
2626
  severity: 'error',
2332
- message: 'Leads-to invariant "' + invariant.id + '" violated from ' + invariant.from + ': ' + bad.reason,
2333
- path: bad.path,
2627
+ message: 'Leads-to invariant "' + invariant.id + '" violated from ' + invariant.from + ': ' + outcome.violation.reason,
2628
+ path: outcome.violation.path,
2334
2629
  evidence: { invariant },
2335
2630
  })
2336
2631
  break
2337
2632
  }
2338
2633
  }
2339
2634
  }
2340
- return checkResult('A9', 'Leads-To', findings, findings.length === 0 ? 'All leads-to invariants hold' : 'Leads-to findings: ' + findings.length)
2635
+ const undecided = findings.some((finding) => finding.code === 'A9_LEADS_TO_UNDECIDED')
2636
+ return checkResult('A9', 'Leads-To', findings, findings.length === 0
2637
+ ? 'All leads-to invariants hold'
2638
+ : undecided ? 'Leads-to undecided: the search budget ran out' : 'Leads-to findings: ' + findings.length)
2341
2639
  }
2342
2640
 
2343
2641
  function findSequenceViolation(model: LogicModelV1, options: NormalizedOptions, invariant: Extract<InvariantSpec, { kind: 'sequence' }>): InvariantViolation | undefined {
@@ -2936,7 +3234,7 @@ export function runVerification(input: unknown, options: VerificationOptions = {
2936
3234
  A6_resourceInjection(model),
2937
3235
  A7_shortestViolations(model, normalized),
2938
3236
  A8_idempotentReplay(model, exploration),
2939
- A9_leadsTo(model, exploration),
3237
+ A9_leadsTo(model, exploration, normalized),
2940
3238
  A10_sequenceOrder(model, normalized),
2941
3239
  A11_atomicity(model, normalized),
2942
3240
  A12_budget(model, normalized),
@@ -2965,7 +3263,7 @@ export function runVerification(input: unknown, options: VerificationOptions = {
2965
3263
  errors: beforeValidation.errors.length,
2966
3264
  warnings: 0,
2967
3265
  checksRun: checks.length + 1,
2968
- truncated: exploration.truncated,
3266
+ truncated: exploration.truncated || hasUndecidedFinding(checks),
2969
3267
  },
2970
3268
  checks: [
2971
3269
  ...checks,
@@ -3025,7 +3323,7 @@ export function runVerification(input: unknown, options: VerificationOptions = {
3025
3323
  errors,
3026
3324
  warnings,
3027
3325
  checksRun: checks.length,
3028
- truncated: exploration.truncated,
3326
+ truncated: exploration.truncated || hasUndecidedFinding(checks),
3029
3327
  },
3030
3328
  checks,
3031
3329
  nextSteps: verificationNextSteps(checks, narrativeCoverage),
package/src/uml.ts CHANGED
@@ -697,7 +697,7 @@ interface DiagramLine {
697
697
  * comes out would be presented as if it described the file.
698
698
  */
699
699
  const MERMAID_OTHER_FAMILIES: readonly string[] = [
700
- 'classDiagram', 'erDiagram', 'gantt', 'pie', 'journey', 'mindmap', 'gitGraph',
700
+ 'classDiagram', 'erDiagram', 'gantt', 'pie', 'journey', 'mindmap', 'gitGraph', 'usecase-beta',
701
701
  'C4Context', 'C4Container', 'C4Component', 'C4Dynamic', 'C4Deployment',
702
702
  'requirementDiagram', 'timeline', 'quadrantChart', 'sankey-beta', 'block-beta',
703
703
  'packet-beta', 'architecture-beta', 'radar-beta', 'treemap-beta', 'xychart-beta',
@@ -720,6 +720,13 @@ function detectNotation(text: string): UmlNotation {
720
720
  throw new UmlError('cannot tell whether this is Mermaid or PlantUML text: expected `stateDiagram-v2` / `flowchart` / `sequenceDiagram`, or `@startuml`')
721
721
  }
722
722
 
723
+ /**
724
+ * PlantUML use case syntax that carries no keyword: a colon actor, or a parenthesised
725
+ * use case, with an optional alias and an optional relation on the same line.
726
+ * `:User: --> (Login)` and `(Login) as (L)` are the whole header of such a file.
727
+ */
728
+ const USECASE_SYNTAX = /^[ \t]*(?::[^:\n]+:(?:[ \t]+as[ \t]+[A-Za-z_][A-Za-z0-9_]*)?|\([^()\n]+\)(?:[ \t]+as[ \t]+\(?[^()\n]+\)?)?)[ \t]*(?:$|-->|->|\.\.>|\.>|--\|>|<\|--|--)/m
729
+
723
730
  function detectDiagram(text: string): 'state' | 'activity' | 'sequence' {
724
731
  if (/^\s*stateDiagram/m.test(text)) return 'state'
725
732
  if (/^\s*(flowchart|graph)\b/m.test(text)) return 'activity'
@@ -732,6 +739,13 @@ function detectDiagram(text: string): 'state' | 'activity' | 'sequence' {
732
739
  // PlantUML declares the diagram kind by its body; the state keyword is the only
733
740
  // structural one logicprobe emits, everything else in that family is a state diagram too.
734
741
  if (/^\s*participant\b/m.test(text) || /->>\s*/.test(text)) return 'sequence'
742
+ // A use case diagram has no keyword header at all. The state parser below would read
743
+ // its arrows as transitions and return a plausible machine with no finding, so the
744
+ // family is refused by name. Its own model, and the tool that reads it, live in the
745
+ // dsh-model-everything plugin.
746
+ if (USECASE_SYNTAX.test(text)) {
747
+ throw new UmlError('this text is a use case diagram (`:Actor: --> (UseCase)`, `(UseCase) as (U)`), which this plugin does not model: it models state machines and flows, and reading it as one returns a machine the diagram never drew. The dsh-model-everything plugin renders, reads and reviews use case diagrams; keep this diagram as its own view.', 'UML_NOT_A_STATE_DIAGRAM')
748
+ }
735
749
  return 'state'
736
750
  }
737
751
  throw new UmlError('cannot tell which diagram kind this text declares')