agent-formal-verify 0.0.0-stage → 0.4.2

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 (34) hide show
  1. package/CHANGES.md +157 -0
  2. package/LICENSE +232 -0
  3. package/README.md +92 -2
  4. package/opencode.mjs +45 -0
  5. package/package.json +42 -4
  6. package/skills/formal-verify/SKILL.md +125 -0
  7. package/skills/formal-verify/examples/kani-template/Cargo.lock +7 -0
  8. package/skills/formal-verify/examples/kani-template/Cargo.toml +7 -0
  9. package/skills/formal-verify/examples/kani-template/src/lib.mutations +11 -0
  10. package/skills/formal-verify/examples/kani-template/src/lib.rs +63 -0
  11. package/skills/formal-verify/examples/lean-template/Model/BoundedQueue.lean +98 -0
  12. package/skills/formal-verify/examples/lean-template/Model/BoundedQueue.mutations +11 -0
  13. package/skills/formal-verify/examples/lean-template/Model/BoundedQueue.statements +231 -0
  14. package/skills/formal-verify/examples/lean-template/lake-manifest.json +6 -0
  15. package/skills/formal-verify/examples/lean-template/lakefile.toml +6 -0
  16. package/skills/formal-verify/examples/lean-template/lean-toolchain +1 -0
  17. package/skills/formal-verify/examples/tla-template/BoundedQueue.matrix +20 -0
  18. package/skills/formal-verify/examples/tla-template/BoundedQueue.mutations +26 -0
  19. package/skills/formal-verify/examples/tla-template/BoundedQueue.tla +42 -0
  20. package/skills/formal-verify/references/lean-brief.md +57 -0
  21. package/skills/formal-verify/references/rust-brief.md +81 -0
  22. package/skills/formal-verify/references/setup.md +85 -0
  23. package/skills/formal-verify/references/tla-brief.md +62 -0
  24. package/skills/formal-verify/scripts/kani-check.sh +8 -0
  25. package/skills/formal-verify/scripts/kani.py +250 -0
  26. package/skills/formal-verify/scripts/lean-check.sh +171 -0
  27. package/skills/formal-verify/scripts/lean-statements.py +135 -0
  28. package/skills/formal-verify/scripts/lean-tools.sh +15 -0
  29. package/skills/formal-verify/scripts/mutate.py +274 -0
  30. package/skills/formal-verify/scripts/setup.sh +35 -0
  31. package/skills/formal-verify/scripts/tlc-matrix.sh +172 -0
  32. package/skills/formal-verify/scripts/tlc-tools.sh +47 -0
  33. package/skills/formal-verify/scripts/tlc-trace.py +64 -0
  34. package/skills/setup/SKILL.md +24 -0
@@ -0,0 +1,171 @@
1
+ #!/bin/bash
2
+ # Copyright (c) 2026 Michael Denyer
3
+ # SPDX-License-Identifier: GPL-3.0-only
4
+ # Builds a Lean 4 model with Lake, then audits every declaration of every built
5
+ # module. Fails when the build fails, a declaration uses sorry or rests on an
6
+ # axiom beyond propext, Classical.choice and Quot.sound, or no Lake library
7
+ # owns a .lean file in the project. Otherwise prints the build output and PASS.
8
+ # Named files limit the check to those sources, so agents that share a project
9
+ # can each check their own model; a run without them checks the whole project.
10
+ # Needs a pin of Lean 4.20.0 or later, the first whose Lake builds a module by
11
+ # its source path.
12
+ #
13
+ # Usage: lean-check.sh <lake-project-dir> [--freeze] [<file.lean> ...]
14
+ # A file is absolute or relative to the project.
15
+ set -uo pipefail
16
+ [ "$#" -gt 0 ] || { echo "usage: lean-check.sh <lake-project-dir> [--freeze] [<file.lean> ...]"; exit 2; }
17
+ dir=$1
18
+ shift
19
+ freeze=0
20
+ if [ "${1-}" = "--freeze" ]; then freeze=1; shift; fi
21
+ for arg; do
22
+ [[ $arg != --* ]] || { echo "usage: --freeze must precede source files"; exit 2; }
23
+ done
24
+ HERE=$(cd "$(dirname "$0")" && pwd)
25
+ [ -f "$dir/lakefile.toml" ] || [ -f "$dir/lakefile.lean" ] || { echo "FAIL $dir has no lakefile"; exit 1; }
26
+ dir=$(cd "$dir" && pwd)
27
+ # shellcheck source=skills/formal-verify/scripts/lean-tools.sh
28
+ source "$HERE/lean-tools.sh"
29
+ toolchain=$(lean_pin "$dir") || { status=$?; echo "$toolchain"; exit "$status"; }
30
+ lean_pin_ready "$toolchain" \
31
+ || { echo "UNAVAILABLE $toolchain is not ready; run bash $HERE/setup.sh lean $dir"; exit 3; }
32
+
33
+ work=$(mktemp -d)
34
+ trap 'rm -rf "$work"' EXIT
35
+ log=$work/build.log
36
+ command -v python3 > /dev/null || { echo "UNAVAILABLE python3 is required to check frozen Lean statements; install Python 3"; exit 3; }
37
+ # Lake builds a module named by its source path and rejects a path that no
38
+ # library owns, so naming every source leaves no file unchecked. Its query
39
+ # command prints the .olean of each, which names the modules to audit without
40
+ # picking up a module left in the build directory by a deleted source.
41
+ sources=()
42
+ scope=$dir
43
+ if [ "$#" -gt 0 ]; then
44
+ scope="$dir ($* only)"
45
+ for src; do
46
+ [[ $src == /* ]] || src=$dir/$src
47
+ [ -f "$src" ] || { echo "FAIL $src does not exist"; exit 1; }
48
+ sources+=("$src")
49
+ done
50
+ else
51
+ if [ "$freeze" -eq 0 ]; then
52
+ python3 "$HERE/lean-statements.py" orphans "$dir" > "$work/orphans.out"
53
+ orphan_status=$?
54
+ if [ "$orphan_status" -ne 0 ]; then
55
+ echo "FAIL $scope: frozen model sources are missing or their records are invalid"
56
+ cat "$work/orphans.out"
57
+ exit "$orphan_status"
58
+ fi
59
+ fi
60
+ while IFS= read -r src; do sources+=("$src"); done \
61
+ < <(find "$dir" -name '*.lean' -not -path "$dir/.lake/*" -not -name lakefile.lean | sort)
62
+ [ "${#sources[@]}" -gt 0 ] || { echo "FAIL $dir has no .lean file to check"; exit 1; }
63
+ fi
64
+ elan run "$toolchain" lake --dir "$dir" query "${sources[@]/%/:olean}" > "$work/oleans" 2> "$log"
65
+ status=$?
66
+ # Lean quotes sorry with backticks or straight quotes depending on the version.
67
+ sorry='^(warning|error): .*: declaration uses .sorry.'
68
+ sorries=$(grep -cE "$sorry" "$log")
69
+
70
+ # The audit imports those modules and collects the axioms under each of their
71
+ # declarations, so it needs no #print axioms line in the model. It skips the
72
+ # compiler's auxiliary declarations, which older pins store with opaque
73
+ # placeholders that would read as axioms.
74
+ audit_status=0
75
+ : > "$work/audit.out"
76
+ if [ "$status" -eq 0 ]; then
77
+ {
78
+ echo "import Lean"
79
+ sed -e 's|^.*/\.lake/build/lib/lean/||' -e 's|\.olean$||' -e 's|/|.|g' -e 's|^|import |' "$work/oleans"
80
+ cat << 'EOF'
81
+ open Lean in
82
+ private def hasTheoremParent (env : Environment) : Name → Bool
83
+ | .anonymous => false
84
+ | .str parent _ | .num parent _ =>
85
+ (match env.find? parent with | some (.thmInfo _) => true | _ => false) || hasTheoremParent env parent
86
+
87
+ open Lean Elab Command in
88
+ #eval show CommandElabM Unit from do
89
+ let env ← getEnv
90
+ let freezing := (← IO.getEnv "AFV_FREEZE") == some "1"
91
+ let modules := (env.imports.map (·.module)).erase `Lean
92
+ let standard : Array Name := #[``propext, ``Classical.choice, ``Quot.sound]
93
+ let names := env.constants.fold (fun acc name info => if info matches .axiomInfo _ then acc else acc.push name) #[]
94
+ let mut audited := 0
95
+ for name in names do
96
+ let some idx := env.getModuleIdxFor? name | continue
97
+ unless modules.contains env.header.moduleNames[idx.toNat]! do continue
98
+ if ((privateToUserName? name).getD name).isInternal then continue
99
+ audited := audited + 1
100
+ let extra := (← collectAxioms name).filter (!standard.contains ·)
101
+ let extra := if freezing then extra.filter (· != ``sorryAx) else extra
102
+ unless extra.isEmpty do IO.println s!"AXIOMS '{name}' depends on axioms: {extra}"
103
+ let userName := (privateToUserName? name).getD name
104
+ if userName.isInternalDetail && hasTheoremParent env name && (← findDeclarationRanges? name).isNone then continue
105
+ let info := (env.find? name).get!
106
+ let kind := match info with
107
+ | .thmInfo _ => "theorem"
108
+ | .defnInfo _ => "definition"
109
+ | .opaqueInfo _ => "opaque"
110
+ | .inductInfo _ => "inductive"
111
+ | .ctorInfo _ => "constructor"
112
+ | .recInfo _ => "recursor"
113
+ | .quotInfo _ => "quotient"
114
+ | .axiomInfo _ => "axiom"
115
+ let value := match info with
116
+ | .defnInfo v => toJson (reprStr v.value)
117
+ | _ => Json.null
118
+ let pp ← liftTermElabM <| Meta.ppExpr info.type
119
+ let record := Json.mkObj [
120
+ ("module", toJson env.header.moduleNames[idx.toNat]!.toString),
121
+ ("name", toJson userName.toString), ("kind", toJson kind),
122
+ ("typeExpr", toJson (reprStr (info.levelParams, info.type))),
123
+ ("valueExpr", value), ("type", toJson pp.pretty)]
124
+ IO.println s!"STATEMENT {record.compress}"
125
+ IO.println s!"AUDITED {audited} declarations"
126
+ EOF
127
+ } > "$work/Audit.lean"
128
+ AFV_FREEZE=$freeze elan run "$toolchain" lake --dir "$dir" env lean "$work/Audit.lean" > "$work/audit.out" 2>&1
129
+ audit_status=$?
130
+ grep -qE '^AUDITED [0-9]+ declarations$' "$work/audit.out" || audit_status=1
131
+ if grep -q '^AUDITED 0 declarations$' "$work/audit.out"; then
132
+ frozen=0
133
+ for src in "${sources[@]}"; do
134
+ [ ! -f "${src%.lean}.statements" ] || frozen=1
135
+ done
136
+ [ "$freeze" -eq 0 ] && [ "$frozen" -eq 1 ] || audit_status=1
137
+ fi
138
+ fi
139
+ axioms=$(grep -c '^AXIOMS ' "$work/audit.out")
140
+
141
+ if [ "$status" -ne 0 ] || { [ "$freeze" -eq 0 ] && [ "$sorries" -ne 0 ]; } || [ "$axioms" -ne 0 ] || [ "$audit_status" -ne 0 ]; then
142
+ echo "FAIL $scope: $sorries unfinished proofs (sorry), $axioms declarations on an added axiom, lake exit $status"
143
+ grep -E "$sorry" "$log"
144
+ grep '^AXIOMS ' "$work/audit.out"
145
+ [ "$status" -eq 0 ] || grep -v '^[[:space:]]*$' "$log" | tail -40
146
+ ! grep -q '^error: unknown module source path' "$log" \
147
+ || echo "no library owns that file; import it from a built module or move it out of the project"
148
+ ! grep -qE "^error: (unknown command 'query'|invalid script spec)" "$log" \
149
+ || echo "$toolchain cannot build a module by its source path; pin Lean 4.20.0 or later"
150
+ if [ "$audit_status" -ne 0 ]; then
151
+ echo "the axiom audit did not run or found no declarations:"
152
+ grep -v '^[[:space:]]*$' "$work/audit.out" | tail -40
153
+ ! grep -q 'environment already contains' "$work/audit.out" \
154
+ || echo "two modules declare the same name; put each model in a namespace of its own"
155
+ fi
156
+ exit 1
157
+ fi
158
+ mode=check
159
+ [ "$freeze" -eq 0 ] || mode=freeze
160
+ python3 "$HERE/lean-statements.py" "$mode" "$toolchain" "$work/audit.out" "$work/oleans" "${sources[@]}" > "$work/statements.out"
161
+ statement_status=$?
162
+ if [ "$statement_status" -ne 0 ]; then
163
+ echo "FAIL $scope: frozen statements differ or could not be checked"
164
+ cat "$work/statements.out"
165
+ exit "$statement_status"
166
+ fi
167
+ cat "$work/statements.out"
168
+ # #eval output arrives as info blocks that can span lines.
169
+ grep -v '^[[:space:]]*$' "$log"
170
+ [ "$freeze" -eq 0 ] || exit 0
171
+ echo "PASS $scope: $(grep '^AUDITED ' "$work/audit.out" | cut -d' ' -f2) declarations checked, no unfinished proof (sorry), no added axiom"
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env python3
2
+ # Copyright (c) 2026 Michael Denyer
3
+ # SPDX-License-Identifier: GPL-3.0-only
4
+ """Store or check declaration fingerprints emitted by the Lean audit."""
5
+
6
+ import hashlib
7
+ import json
8
+ import sys
9
+ import tempfile
10
+ from pathlib import Path
11
+
12
+
13
+ def fingerprint(expression):
14
+ return hashlib.sha256(expression.encode("utf-8")).hexdigest()
15
+
16
+
17
+ def declarations(audit):
18
+ modules = {}
19
+ for line in audit.read_text(encoding="utf-8").splitlines():
20
+ if not line.startswith("STATEMENT "):
21
+ continue
22
+ raw = json.loads(line.removeprefix("STATEMENT "))
23
+ record = {key: raw[key] for key in ("name", "kind", "type")}
24
+ record["type"] = " ".join(record["type"].split())
25
+ record["typeHash"] = fingerprint(raw["typeExpr"])
26
+ record["valueHash"] = None if raw["valueExpr"] is None else fingerprint(raw["valueExpr"])
27
+ modules.setdefault(raw["module"], []).append(record)
28
+ return modules
29
+
30
+
31
+ def read_record(path):
32
+ record = json.loads(path.read_text(encoding="utf-8"))
33
+ if not isinstance(record, dict) or record.get("format") != 1:
34
+ raise ValueError(f"{path}: unsupported statements format")
35
+ if not isinstance(record.get("toolchain"), str) or not isinstance(record.get("module"), str):
36
+ raise TypeError(f"{path}: missing toolchain or module")
37
+ entries = record.get("declarations")
38
+ if not isinstance(entries, list) or not entries:
39
+ raise ValueError(f"{path}: missing declarations")
40
+ seen = set()
41
+ for entry in entries:
42
+ if not isinstance(entry, dict) or not all(isinstance(entry.get(key), str) for key in ("name", "kind", "type", "typeHash")):
43
+ raise ValueError(f"{path}: malformed declaration")
44
+ if "valueHash" not in entry or entry["valueHash"] is not None and not isinstance(entry["valueHash"], str):
45
+ raise ValueError(f"{path}: malformed value hash")
46
+ if entry["name"] in seen:
47
+ raise ValueError(f"{path}: duplicate declaration {entry['name']}")
48
+ seen.add(entry["name"])
49
+ return record
50
+
51
+
52
+ def check_orphans(project):
53
+ failed = False
54
+ for path in sorted(project.rglob("*.statements")):
55
+ if ".lake" in path.relative_to(project).parts or path.with_suffix(".lean").is_file():
56
+ continue
57
+ record = read_record(path)
58
+ print(f"FROZEN {path}: source file is missing")
59
+ for declaration in record["declarations"]:
60
+ print(f"FROZEN {declaration['name']}: statement changed (source deleted)")
61
+ failed = True
62
+ return int(failed)
63
+
64
+
65
+ def main():
66
+ if sys.argv[1] == "orphans":
67
+ return check_orphans(Path(sys.argv[2]))
68
+ mode, toolchain, audit, oleans, *sources = sys.argv[1:]
69
+ modules = declarations(Path(audit))
70
+ paths = Path(oleans).read_text(encoding="utf-8").splitlines()
71
+ if len(paths) != len(sources):
72
+ raise ValueError("Lake did not return one module per source")
73
+ pending = []
74
+ failed = False
75
+ for source, olean in zip(sources, paths):
76
+ path = Path(source).with_suffix(".statements")
77
+ if mode != "freeze" and not path.exists():
78
+ continue
79
+ marker = "/.lake/build/lib/lean/"
80
+ if marker not in olean or not olean.endswith(".olean"):
81
+ raise ValueError(f"cannot identify module from {olean}")
82
+ module = olean.rsplit(marker, 1)[1].removesuffix(".olean").replace("/", ".")
83
+ current = {}
84
+ for record in modules.get(module, []):
85
+ if record["name"] in current:
86
+ raise ValueError(f"ambiguous declaration identity {record['name']} in {module}")
87
+ current[record["name"]] = record
88
+ if mode == "freeze" and not current:
89
+ raise ValueError(f"no declaration records for {source}")
90
+ if mode == "freeze":
91
+ record = {"format": 1, "toolchain": toolchain, "module": module,
92
+ "declarations": [current[name] for name in sorted(current)]}
93
+ pending.append((path, json.dumps(record, ensure_ascii=False, indent=2) + "\n"))
94
+ continue
95
+ frozen = read_record(path)
96
+ if frozen["toolchain"] != toolchain:
97
+ print(f"FROZEN {path}: toolchain changed from {frozen['toolchain']} to {toolchain}; review and run --freeze")
98
+ failed = True
99
+ continue
100
+ if frozen["module"] != module:
101
+ raise ValueError(f"{path}: expected module {module}, found {frozen['module']}")
102
+ changed = 0
103
+ for old in frozen["declarations"]:
104
+ new = current.get(old["name"])
105
+ if new is not None and all(old[key] == new[key] for key in ("kind", "typeHash", "valueHash")):
106
+ continue
107
+ changed += 1
108
+ print(f"FROZEN {old['name']}: statement changed")
109
+ print(f" old: {old['type']}")
110
+ print(f" new: {new['type'] if new else '(deleted)'}")
111
+ if new and old["valueHash"] != new["valueHash"]:
112
+ print(" definition value changed")
113
+ if changed == len(frozen["declarations"]):
114
+ print(f"FROZEN {path}: every recorded declaration changed; review the model and toolchain before --freeze")
115
+ failed |= changed > 0
116
+ for path, contents in pending:
117
+ temporary = None
118
+ try:
119
+ with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=path.parent, delete=False) as stream:
120
+ temporary = Path(stream.name)
121
+ stream.write(contents)
122
+ temporary.replace(path)
123
+ finally:
124
+ if temporary is not None:
125
+ temporary.unlink(missing_ok=True)
126
+ print(f"FROZEN {path}: statements recorded; proofs not checked")
127
+ return int(failed)
128
+
129
+
130
+ if __name__ == "__main__":
131
+ try:
132
+ sys.exit(main())
133
+ except (OSError, ValueError, KeyError, TypeError) as error:
134
+ print(f"FAIL statements: {error}")
135
+ sys.exit(1)
@@ -0,0 +1,15 @@
1
+ #!/bin/bash
2
+ # Copyright (c) 2026 Michael Denyer
3
+ # SPDX-License-Identifier: GPL-3.0-only
4
+ # Shared Lean pin lookup; sourcing this file installs nothing.
5
+
6
+ # Prints the toolchain pin of the Lake project in $1.
7
+ lean_pin() {
8
+ [ -f "$1/lean-toolchain" ] || { echo "FAIL $1 has no lean-toolchain pin"; return 1; }
9
+ command -v elan > /dev/null || { echo "UNAVAILABLE elan is not on PATH; install elan from https://github.com/leanprover/elan"; return 3; }
10
+ tr -d '\r\n' < "$1/lean-toolchain"
11
+ }
12
+
13
+ lean_pin_ready() {
14
+ elan run "$1" lake --version > /dev/null 2>&1
15
+ }
@@ -0,0 +1,274 @@
1
+ #!/usr/bin/env python3
2
+ # Copyright (c) 2026 Michael Denyer
3
+ # SPDX-License-Identifier: GPL-3.0-only
4
+ """Check that a model's properties detect the bugs in its mutations file.
5
+
6
+ Takes <Name>.mutations, which sits beside <Name>.tla and <Name>.matrix or
7
+ beside <Name>.lean in a Lake project or <Name>.rs in a Rust crate. Applies each mutation to a temporary
8
+ copy, so the model in the repository is never changed, and prints one line
9
+ per mutation:
10
+
11
+ DETECTED <label>: <what failed> the mutated model fails as it should
12
+ MISSED <label> the mutated model still passes
13
+ UNCOVERED <Property> a TLA+ property or Rust harness no mutation targets
14
+
15
+ A TLA+ mutation is checked against the property or reach operator it names,
16
+ using the matrix runs. A Lean mutation is elaborated in the project, the mutated
17
+ module alone, and reported with the theorems, lemmas, examples and #guard
18
+ lines that fail. Exits 1 on a MISSED or UNCOVERED line, or when a mutated
19
+ model fails for another reason, such as a syntax error or a definition that
20
+ no longer compiles.
21
+
22
+ Mutations file (a line starting with # is a comment):
23
+ mutation <label> starts a mutation
24
+ detects <Property> TLA+ property or fully qualified Rust harness that must fail
25
+ detects reach:<Operator> TLA+ only: the reach check that must fail
26
+ only <label-substring> TLA+ only, optional: limits the matrix runs
27
+ - <text> a line of the model to replace; the text of
28
+ consecutive lines must occur exactly once
29
+ + <text> a line of the replacement; none deletes the text
30
+
31
+ Usage: mutate.py <Name.mutations>
32
+ The environment settings of tlc-matrix.sh apply to TLA+ mutations.
33
+ """
34
+ import re
35
+ import shutil
36
+ import subprocess
37
+ import sys
38
+ import tempfile
39
+ from dataclasses import dataclass, field
40
+ from functools import partial
41
+ from pathlib import Path
42
+
43
+ # Importing kani must not write __pycache__ into the skill directory.
44
+ sys.dont_write_bytecode = True
45
+
46
+ import kani
47
+
48
+ HERE = Path(__file__).resolve().parent
49
+ LEAN_PROPERTY = ("theorem", "lemma", "example", "#guard")
50
+ LEAN_DECLARATION = re.compile(r"^(?:@\[.*?\] |private |protected |noncomputable )*((?:theorem|lemma|def|abbrev|instance|example|structure|inductive)\b(?: [^\s:(\[{]+)?|#guard.*|#eval.*)")
51
+ # Fails the same way as lean-check.sh when the pin is not ready, without installing it.
52
+ LEAN = """source "$1/lean-tools.sh"
53
+ toolchain=$(lean_pin "$2") || { status=$?; echo "$toolchain"; exit "$status"; }
54
+ lean_pin_ready "$toolchain" || { echo "UNAVAILABLE $toolchain is not ready; run bash $1/setup.sh lean $2"; exit 3; }
55
+ elan run "$toolchain" lake --dir "$2" env lean "$3"
56
+ """
57
+
58
+
59
+ @dataclass
60
+ class Mutation:
61
+ label: str
62
+ detects: str = ""
63
+ only: str = ""
64
+ old: list = field(default_factory=list)
65
+ new: list = field(default_factory=list)
66
+
67
+
68
+ def stop(message, status=1):
69
+ print(f"FAIL {message}")
70
+ sys.exit(status)
71
+
72
+
73
+ def parse(path):
74
+ if not path.is_file():
75
+ stop(f"{path} does not exist")
76
+ mutations = []
77
+ for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
78
+ if not line.strip() or line.startswith("#"):
79
+ continue
80
+ keyword, _, rest = line.partition(" ")
81
+ if keyword == "mutation":
82
+ mutations.append(Mutation(rest.strip()))
83
+ elif not mutations:
84
+ stop(f"{path}:{number}: '{line}' before any mutation line")
85
+ elif keyword in ("detects", "only"):
86
+ setattr(mutations[-1], keyword, rest.strip())
87
+ elif keyword in ("-", "+"):
88
+ (mutations[-1].old if keyword == "-" else mutations[-1].new).append(rest)
89
+ else:
90
+ stop(f"{path}:{number}: unknown directive: {line}")
91
+ if not mutations:
92
+ stop(f"{path} has no mutation")
93
+ return mutations
94
+
95
+
96
+ def mutated(model, mutation):
97
+ text, old = model.read_text(encoding="utf-8"), "\n".join(mutation.old)
98
+ count = text.count(old) if old else 0
99
+ if count != 1:
100
+ stop(f"mutation '{mutation.label}': its - text occurs {count} times in {model}, want 1")
101
+ return text.replace(old, "\n".join(mutation.new))
102
+
103
+
104
+ def matrix_of(spec):
105
+ """Return property kinds, reach directives by operator, and run lines for spec."""
106
+ matrix, kinds, reaches, runs, ours = spec.with_suffix(".matrix"), {}, {}, [], False
107
+ if not matrix.is_file():
108
+ stop(f"{matrix} is missing; mutations use its checks and runs")
109
+ for line in matrix.read_text(encoding="utf-8").splitlines():
110
+ keyword, _, rest = line.split("#")[0].strip().partition(" ")
111
+ if keyword == "spec":
112
+ ours = rest.strip() == spec.name
113
+ elif ours and keyword == "check":
114
+ parts = rest.split()
115
+ if len(parts) < 2 or parts[0] not in ("INVARIANT", "INVARIANTS", "PROPERTY", "PROPERTIES"):
116
+ stop(f"{matrix}: malformed check '{rest}'")
117
+ kind, *names = parts
118
+ kinds.update(dict.fromkeys(names, kind))
119
+ elif ours and keyword == "run":
120
+ runs.append(f"run {rest}")
121
+ elif ours and keyword == "reach":
122
+ operator, bar, label = rest.partition("|")
123
+ operator = operator.strip()
124
+ if not bar or not re.fullmatch(r"[A-Za-z_][A-Za-z_0-9]*", operator) or not label.strip():
125
+ stop(f"{matrix}: malformed reach '{rest}'")
126
+ reaches.setdefault(f"reach:{operator}", []).append(f"reach {rest}")
127
+ return kinds, reaches, runs
128
+
129
+
130
+ def check_tla(spec, kinds, reaches, runs, mutation, work):
131
+ """Return (verdict, detail) from the mutation's property or reach checks."""
132
+ kind = kinds.get(mutation.detects, "")
133
+ for module in spec.parent.glob("*.tla"):
134
+ (work / module.name).write_text(module.read_text(encoding="utf-8"), encoding="utf-8")
135
+ (work / spec.name).write_text(mutated(spec, mutation), encoding="utf-8")
136
+ matrix = work / spec.with_suffix(".matrix").name
137
+ checks = reaches.get(mutation.detects, [f"check {kind} {mutation.detects}"])
138
+ matrix.write_text("\n".join([f"spec {spec.name}", *checks, *runs]) + "\n", encoding="utf-8")
139
+ command = ["bash", str(HERE / "tlc-matrix.sh"), str(matrix), *([mutation.only] if mutation.only else [])]
140
+ done = subprocess.run(command, capture_output=True, text=True, check=False)
141
+ if done.returncode == 3:
142
+ return "UNAVAILABLE", done.stdout + done.stderr
143
+ if done.returncode == 0:
144
+ return "MISSED", ""
145
+ if mutation.detects in reaches:
146
+ prefix = f"FAIL {spec.stem} reach {mutation.detects.removeprefix('reach:')} "
147
+ suffix = ": the model never reaches this state"
148
+ for line in done.stdout.splitlines():
149
+ if line.startswith(prefix) and line.endswith(suffix):
150
+ label = line[len(prefix):-len(suffix)]
151
+ return "DETECTED", f"{mutation.detects} fails in run '{label}' (the model never reaches this state)"
152
+ return "ERROR", done.stdout + done.stderr
153
+ # A deadlock stops the system for good, so it breaks a temporal property but no invariant.
154
+ signs = ["violated"] + (["Deadlock reached"] if kind.startswith("PROPERT") else [])
155
+ for line in done.stdout.splitlines():
156
+ if line.startswith("FAIL ") and any(sign in line for sign in signs):
157
+ run, _, error = line.removeprefix(f"FAIL {spec.stem} ").partition(": Error: ")
158
+ return "DETECTED", f"{mutation.detects} fails in run '{run}' ({error.rstrip('.')})"
159
+ return "ERROR", done.stdout + done.stderr
160
+
161
+
162
+ def check_lean(model, project, mutation, work):
163
+ """Return (verdict, detail) from Lean on the mutated module."""
164
+ source, copy = mutated(model, mutation), work / model.name
165
+ copy.write_text(source, encoding="utf-8")
166
+ done = subprocess.run(["bash", "-c", LEAN, "bash", str(HERE), str(project), str(copy)],
167
+ capture_output=True, text=True, check=False)
168
+ output = done.stdout + done.stderr
169
+ if done.returncode == 3:
170
+ return "UNAVAILABLE", done.stdout + done.stderr
171
+ if done.returncode == 0:
172
+ return "MISSED", ""
173
+ lines, failed = source.splitlines(), []
174
+ for match in re.finditer(rf"^{re.escape(str(copy))}:(\d+):\d+: error", output, re.MULTILINE):
175
+ for line in reversed(lines[:int(match[1])]):
176
+ if declaration := LEAN_DECLARATION.match(line):
177
+ if declaration[1] not in failed:
178
+ failed.append(declaration[1])
179
+ break
180
+ if not failed or not all(name.startswith(LEAN_PROPERTY) for name in failed):
181
+ return "ERROR", output
182
+ return "DETECTED", "fails " + "; ".join(failed)
183
+
184
+
185
+ def check_rust(model, project, verifier, bounds, mutation, work):
186
+ """Return (verdict, detail) from Kani on the named harness in a mutated copy of the crate."""
187
+ copy = work / "crate"
188
+ shutil.copytree(project, copy, ignore=shutil.ignore_patterns("target", ".git"))
189
+ (copy / model.relative_to(project)).write_text(mutated(model, mutation), encoding="utf-8")
190
+ outcome = kani.verify(copy, work, verifier, mutation.detects, bounds[mutation.detects])
191
+ if outcome.verdict is kani.Verdict.PASS:
192
+ return "MISSED", ""
193
+ if outcome.verdict is kani.Verdict.REFUTED:
194
+ return "DETECTED", "; ".join(outcome.failures)
195
+ return "ERROR", "\n".join(outcome.failures) + "\n" + outcome.output
196
+
197
+
198
+ def project_root(model, *markers):
199
+ return next((parent for parent in model.parents if any((parent / marker).is_file() for marker in markers)), None)
200
+
201
+
202
+ def tla_runner(spec, mutations):
203
+ kinds, reaches, runs = matrix_of(spec)
204
+ for mutation in mutations:
205
+ if mutation.detects not in kinds and mutation.detects not in reaches:
206
+ stop(f"mutation '{mutation.label}': detects '{mutation.detects}', which {spec.with_suffix('.matrix')} does not check")
207
+ return set(kinds), partial(check_tla, spec, kinds, reaches, runs)
208
+
209
+
210
+ def lean_runner(model, mutations):
211
+ project = project_root(model, "lakefile.toml", "lakefile.lean")
212
+ if project is None:
213
+ stop(f"{model} is in no Lake project")
214
+ return set(), partial(check_lean, model, project)
215
+
216
+
217
+ def rust_runner(model, mutations):
218
+ project = project_root(model, "Cargo.toml")
219
+ if project is None:
220
+ stop(f"{model} is in no Rust crate")
221
+ try:
222
+ verifier = kani.Kani()
223
+ except kani.Unavailable as error:
224
+ print(f"UNAVAILABLE {error}")
225
+ sys.exit(3)
226
+ with tempfile.TemporaryDirectory(prefix="formal-kani-discover-") as temporary:
227
+ try:
228
+ bounds = kani.discover(project, Path(temporary), verifier)
229
+ except kani.CheckError as error:
230
+ stop(str(error))
231
+ for mutation in mutations:
232
+ if mutation.detects not in bounds:
233
+ stop(f"mutation '{mutation.label}': unknown harness '{mutation.detects}'")
234
+ if mutation.only:
235
+ stop("Rust mutations do not support 'only'")
236
+ return set(bounds), partial(check_rust, model, project, verifier, bounds)
237
+
238
+
239
+ # Each runner validates the mutations, then returns the names they must cover and a check for one mutation.
240
+ RUNNERS = {".tla": tla_runner, ".lean": lean_runner, ".rs": rust_runner}
241
+
242
+
243
+ def main():
244
+ if len(sys.argv) != 2:
245
+ stop("usage: mutate.py <Name.mutations>", 2)
246
+ path = Path(sys.argv[1]).resolve()
247
+ mutations = parse(path)
248
+ models = [path.with_suffix(suffix) for suffix in RUNNERS]
249
+ model = next((model for model in models if model.is_file()), None)
250
+ if model is None:
251
+ stop(f"no {', '.join(model.name for model in models[:-1])} or {models[-1].name} beside {path}")
252
+ targets, check = RUNNERS[model.suffix](model, mutations)
253
+
254
+ detected = 0
255
+ for mutation in mutations:
256
+ with tempfile.TemporaryDirectory() as work:
257
+ verdict, detail = check(mutation, Path(work))
258
+ if verdict == "UNAVAILABLE":
259
+ print(detail.strip())
260
+ sys.exit(3)
261
+ if verdict == "ERROR":
262
+ stop(f"mutation '{mutation.label}' did not reach a verdict:\n{detail.strip()}")
263
+ detected += verdict == "DETECTED"
264
+ print(f"{verdict} {mutation.label}" + (f": {detail}" if detail else ""))
265
+ uncovered = sorted(targets - {mutation.detects for mutation in mutations})
266
+ for name in uncovered:
267
+ print(f"UNCOVERED {name}")
268
+ print(f"SUMMARY {detected} of {len(mutations)} mutations detected"
269
+ + (f", {len(uncovered)} properties without a mutation" if uncovered else ""))
270
+ sys.exit(0 if detected == len(mutations) and not uncovered else 1)
271
+
272
+
273
+ if __name__ == "__main__":
274
+ main()
@@ -0,0 +1,35 @@
1
+ #!/bin/bash
2
+ # Copyright (c) 2026 Michael Denyer
3
+ # SPDX-License-Identifier: GPL-3.0-only
4
+ # Ensures one tool is ready; system runtimes must already be installed.
5
+ set -euo pipefail
6
+ HERE=$(cd "$(dirname "$0")" && pwd)
7
+ case ${1:-} in
8
+ tla)
9
+ # shellcheck source=skills/formal-verify/scripts/tlc-tools.sh
10
+ source "$HERE/tlc-tools.sh"
11
+ require_tlc_runtime
12
+ tlc_jar_ready
13
+ echo "READY TLC $TLC_JAR"
14
+ ;;
15
+ rust)
16
+ [ "$#" -eq 1 ] || { echo "usage: setup.sh rust"; exit 2; }
17
+ command -v python3 > /dev/null || { echo "UNAVAILABLE python3 is required; install Python 3"; exit 3; }
18
+ python3 "$HERE/kani.py" ready
19
+ ;;
20
+ lean)
21
+ [ "$#" -eq 2 ] || { echo "usage: setup.sh lean <lake-project-dir>"; exit 2; }
22
+ dir=$2
23
+ # shellcheck source=skills/formal-verify/scripts/lean-tools.sh
24
+ source "$HERE/lean-tools.sh"
25
+ toolchain=$(lean_pin "$dir") || { status=$?; echo "$toolchain"; exit "$status"; }
26
+ if ! lean_pin_ready "$toolchain"; then
27
+ install_log=$(elan toolchain install "$toolchain" 2>&1) \
28
+ || { echo "UNAVAILABLE cannot install $toolchain; run elan toolchain install $toolchain"; echo "$install_log"; exit 3; }
29
+ lean_pin_ready "$toolchain" || { echo "UNAVAILABLE $toolchain does not run after installation; check elan toolchain list and reinstall it"; echo "$install_log"; exit 3; }
30
+ [ -z "$install_log" ] || echo "$install_log"
31
+ fi
32
+ echo "READY Lean $toolchain"
33
+ ;;
34
+ *) echo "usage: setup.sh tla | setup.sh lean <lake-project-dir> | setup.sh rust"; exit 2 ;;
35
+ esac