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.
- package/CHANGES.md +157 -0
- package/LICENSE +232 -0
- package/README.md +92 -2
- package/opencode.mjs +45 -0
- package/package.json +42 -4
- package/skills/formal-verify/SKILL.md +125 -0
- package/skills/formal-verify/examples/kani-template/Cargo.lock +7 -0
- package/skills/formal-verify/examples/kani-template/Cargo.toml +7 -0
- package/skills/formal-verify/examples/kani-template/src/lib.mutations +11 -0
- package/skills/formal-verify/examples/kani-template/src/lib.rs +63 -0
- package/skills/formal-verify/examples/lean-template/Model/BoundedQueue.lean +98 -0
- package/skills/formal-verify/examples/lean-template/Model/BoundedQueue.mutations +11 -0
- package/skills/formal-verify/examples/lean-template/Model/BoundedQueue.statements +231 -0
- package/skills/formal-verify/examples/lean-template/lake-manifest.json +6 -0
- package/skills/formal-verify/examples/lean-template/lakefile.toml +6 -0
- package/skills/formal-verify/examples/lean-template/lean-toolchain +1 -0
- package/skills/formal-verify/examples/tla-template/BoundedQueue.matrix +20 -0
- package/skills/formal-verify/examples/tla-template/BoundedQueue.mutations +26 -0
- package/skills/formal-verify/examples/tla-template/BoundedQueue.tla +42 -0
- package/skills/formal-verify/references/lean-brief.md +57 -0
- package/skills/formal-verify/references/rust-brief.md +81 -0
- package/skills/formal-verify/references/setup.md +85 -0
- package/skills/formal-verify/references/tla-brief.md +62 -0
- package/skills/formal-verify/scripts/kani-check.sh +8 -0
- package/skills/formal-verify/scripts/kani.py +250 -0
- package/skills/formal-verify/scripts/lean-check.sh +171 -0
- package/skills/formal-verify/scripts/lean-statements.py +135 -0
- package/skills/formal-verify/scripts/lean-tools.sh +15 -0
- package/skills/formal-verify/scripts/mutate.py +274 -0
- package/skills/formal-verify/scripts/setup.sh +35 -0
- package/skills/formal-verify/scripts/tlc-matrix.sh +172 -0
- package/skills/formal-verify/scripts/tlc-tools.sh +47 -0
- package/skills/formal-verify/scripts/tlc-trace.py +64 -0
- 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
|