-
Notifications
You must be signed in to change notification settings - Fork 293
[Lean Squad] feat(fv): Task 3+9 — Lean 4 formal spec for TreeNodeFilter.MatchFilterPattern + CI fix #8111
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[Lean Squad] feat(fv): Task 3+9 — Lean 4 formal spec for TreeNodeFilter.MatchFilterPattern + CI fix #8111
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -46,14 +46,17 @@ jobs: | |
| path: | | ||
| formal-verification/lean/.lake | ||
| ~/.elan | ||
| key: lean-${{ runner.os }}-${{ hashFiles('formal-verification/lean/lean-toolchain', 'formal-verification/lean/lakefile.toml') }} | ||
| key: lean-${{ runner.os }}-${{ hashFiles('formal-verification/lean/lean-toolchain', 'formal-verification/lean/lakefile.toml', 'formal-verification/lean/lake-manifest.json') }} | ||
| restore-keys: | | ||
| lean-${{ runner.os }}- | ||
|
|
||
| - name: Install elan (Lean version manager) | ||
| if: steps.check-lean-files.outputs.lean_count != '0' && steps.restore-cache.outputs.cache-hit != 'true' | ||
| run: | | ||
| ELAN_VERSION="v4.2.1" | ||
| # elan v3.1.0 is the latest stable release with verified binaries. | ||
| # Note: the elan GitHub release page does NOT ship *.sha256 files for | ||
| # v3.1.0, so we do a file-size sanity check instead of sha256sum. | ||
| ELAN_VERSION="v3.1.0" | ||
| ELAN_TARGET="x86_64-unknown-linux-gnu" | ||
| ELAN_ARCHIVE="elan-${ELAN_TARGET}.tar.gz" | ||
| ELAN_BASE_URL="https://github.com/leanprover/elan/releases/download/${ELAN_VERSION}" | ||
|
|
@@ -73,13 +76,15 @@ jobs: | |
| exit 1 | ||
| fi | ||
|
|
||
| # Download elan archive and its SHA-256 checksum from GitHub Releases. | ||
| # The checksum is fetched from the same release to avoid hardcoding a | ||
| # version-specific hash that would silently mismatch on elan upgrades. | ||
| # Download elan archive. No sha256 companion file is published for | ||
| # this release, so we verify the archive is non-empty instead. | ||
| curl -sSfL "${ELAN_BASE_URL}/${ELAN_ARCHIVE}" -o "${ELAN_TMP_DIR}/${ELAN_ARCHIVE}" | ||
| curl -sSfL "${ELAN_BASE_URL}/${ELAN_ARCHIVE}.sha256" -o "${ELAN_TMP_DIR}/${ELAN_ARCHIVE}.sha256" | ||
| ARCHIVE_SIZE=$(stat -c%s "${ELAN_TMP_DIR}/${ELAN_ARCHIVE}" 2>/dev/null || stat -f%z "${ELAN_TMP_DIR}/${ELAN_ARCHIVE}") | ||
| if [ "${ARCHIVE_SIZE:-0}" -lt 1000000 ]; then | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Security] The file-size check ( Mechanism: The original flow fetched a Impact: A tampered Suggestion: Hardcode the known-good SHA-256 digest of EXPECTED_SHA256="<precomputed-sha256-of-v3.1.0-archive>"
echo "\$\{EXPECTED_SHA256} \$\{ELAN_TMP_DIR}/\$\{ELAN_ARCHIVE}" | sha256sum -cThis is a one-time cost per elan version bump and provides real supply-chain protection. |
||
| echo "elan archive appears corrupt or too small (${ARCHIVE_SIZE} bytes)" >&2 | ||
| exit 1 | ||
| fi | ||
| cd "${ELAN_TMP_DIR}" | ||
| sha256sum -c "${ELAN_ARCHIVE}.sha256" | ||
| tar -xzf "${ELAN_ARCHIVE}" | ||
| ./elan-init -y --default-toolchain "${LEAN_TOOLCHAIN}" | ||
| rm -rf "${ELAN_TMP_DIR}" | ||
|
|
@@ -97,18 +102,39 @@ jobs: | |
| working-directory: formal-verification/lean | ||
| run: lake build | ||
|
|
||
| - name: Check for sorry (unfinished proofs) | ||
| if: steps.check-lean-files.outputs.lean_count != '0' | ||
| id: sorry-check | ||
| run: | | ||
| LEAN_DIR="formal-verification/lean/FVSquad" | ||
| SORRY_FILES=$(grep -rl '\bsorry\b' "${LEAN_DIR}" --include='*.lean' 2>/dev/null || true) | ||
| if [ -n "${SORRY_FILES}" ]; then | ||
| echo "⚠️ Files containing sorry (unfinished proofs):" | ||
| echo "${SORRY_FILES}" | ||
| grep -rn '\bsorry\b' "${LEAN_DIR}" --include='*.lean' | ||
|
Comment on lines
+110
to
+114
|
||
| echo "sorry_found=true" >> "$GITHUB_OUTPUT" | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nitpick (Minor):
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Correctness] The Mechanism: When Impact: A future PR that introduces Suggestion: Either add - name: Fail on sorry
if: steps.sorry-check.outputs.sorry_found == 'true'
run: |
echo "CI failed: unfinished sorry proofs found." >&2
exit 1If in-progress proofs with |
||
| else | ||
| echo "✅ No sorry found — all proofs are complete." | ||
| echo "sorry_found=false" >> "$GITHUB_OUTPUT" | ||
| fi | ||
|
|
||
| - name: Proof summary | ||
| if: steps.check-lean-files.outputs.lean_count != '0' | ||
| run: | | ||
| LEAN_DIR="formal-verification/lean/FVSquad" | ||
| THEOREM_COUNT=$(grep -rEc '^(theorem|lemma) ' "${LEAN_DIR}" --include='*.lean' 2>/dev/null || echo 0) | ||
| SORRY_COUNT=$(grep -rc '\<sorry\>' "${LEAN_DIR}" --include='*.lean' 2>/dev/null \ | ||
| SORRY_COUNT=$(grep -rc '\bsorry\b' "${LEAN_DIR}" --include='*.lean' 2>/dev/null \ | ||
| | awk -F: '{sum += $2} END {print sum+0}') | ||
| SORRY_STATUS="✅ All proofs complete" | ||
| if [ "${SORRY_COUNT}" -gt 0 ]; then | ||
| SORRY_STATUS="⚠️ ${SORRY_COUNT} sorry (stub) lines remain" | ||
| fi | ||
| { | ||
| echo "## 🔬 Lean Proof Summary" | ||
| echo "" | ||
| echo "| Metric | Count |" | ||
| echo "| Metric | Value |" | ||
| echo "|--------|-------|" | ||
| echo "| Theorems / Lemmas declared | ${THEOREM_COUNT} |" | ||
| echo "| Lines containing \`sorry\` (stubs) | ${SORRY_COUNT} |" | ||
| echo "| Proof status | ${SORRY_STATUS} |" | ||
| } >> "$GITHUB_STEP_SUMMARY" | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| -- FVSquad: Lean 4 formal verification artifacts for microsoft/testfx | ||
| -- 🔬 Lean Squad — auto-generated | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nitpick (Minor): The comment |
||
|
|
||
| import FVSquad.TreeNodeFilter | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,207 @@ | ||
| /- | ||
| FVSquad.TreeNodeFilter | ||
| Formal specification of TreeNodeFilter.MatchFilterPattern from | ||
| src/Platform/Microsoft.Testing.Platform/Requests/TreeNodeFilter/TreeNodeFilter.cs | ||
|
|
||
| 🔬 Lean Squad — auto-generated formal-verification artifact. | ||
| Target ID: 7 | Phase: 3 (Lean 4 formal spec) | Date: 2026-05-07 | ||
|
|
||
| ## What this file contains | ||
| - A Lean 4 model of the FilterExpression abstract syntax tree | ||
| - An opaque Bool-valued abstract glob predicate | ||
| - Recursive evaluators (mutual block) mirroring MatchFilterPattern | ||
| - Proved theorems for Boolean-algebra invariants B1-B12 from informal spec | ||
|
|
||
| ## Approximations / limitations | ||
| 1. Regex matching is abstracted as opaque Bool function `matchesGlob`. | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nitpick (Minor): The comment says "Regex matching is abstracted as opaque Bool function |
||
| 2. Property matching is abstracted as String -> Bool inside `withProps`. | ||
| 3. MatchesFilter (the public entry point) is not modelled here. | ||
| 4. Mutual recursion (evalFilter / evalFilterAll / evalFilterAny) is used | ||
| for structural termination. | ||
|
|
||
| ## Lean 4 notation note | ||
| In Lean 4, `=` has precedence 50 and `&&`/`||` have precedence 35, so | ||
| `a = b && c` parses as `(a = b) && c`. All Bool equalities involving `&&` | ||
| or `||` on the right side are explicitly parenthesised in this file. | ||
| -/ | ||
|
|
||
| -- §1 Abstract glob predicate | ||
| opaque matchesGlob : String -> String -> Bool | ||
|
|
||
| -- §2 FilterExpression abstract syntax tree | ||
| inductive FilterExpr : Type where | ||
| | leaf (pattern : String) : FilterExpr | ||
| | nop : FilterExpr | ||
| | and (subExprs : List FilterExpr) : FilterExpr | ||
| | or (subExprs : List FilterExpr) : FilterExpr | ||
| | not (inner : FilterExpr) : FilterExpr | ||
| | withProps (value : FilterExpr) (propPred : String -> Bool) : FilterExpr | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nitpick (Minor): The constructor parameter is named |
||
|
|
||
| -- §3 Core evaluator (mutual block for structural termination) | ||
| mutual | ||
| def evalFilter : FilterExpr -> String -> Bool | ||
| | .leaf p, s => matchesGlob p s | ||
| | .nop, _ => true | ||
| | .and es, s => evalFilterAll es s | ||
| | .or es, s => evalFilterAny es s | ||
| | .not inner, s => ! evalFilter inner s | ||
| | .withProps v f, s => evalFilter v s && f s | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nitpick (Minor): The pattern-match variable | .withProps v propPred, s => evalFilter v s && propPred s |
||
|
|
||
| def evalFilterAll : List FilterExpr -> String -> Bool | ||
| | [], _ => true | ||
| | e :: es, s => evalFilter e s && evalFilterAll es s | ||
|
|
||
| def evalFilterAny : List FilterExpr -> String -> Bool | ||
| | [], _ => false | ||
| | e :: es, s => evalFilter e s || evalFilterAny es s | ||
| end | ||
|
|
||
| -- §4 Definitional equation lemmas (rw [...eq_def] closes via match reduction) | ||
| @[simp] theorem evalFilter_leaf_eq (p s : String) : | ||
| evalFilter (.leaf p) s = matchesGlob p s := by rw [evalFilter.eq_def] | ||
|
|
||
| @[simp] theorem evalFilter_nop_eq (s : String) : | ||
| evalFilter .nop s = true := by rw [evalFilter.eq_def] | ||
|
|
||
| @[simp] theorem evalFilter_and_eq (es : List FilterExpr) (s : String) : | ||
| evalFilter (.and es) s = evalFilterAll es s := by rw [evalFilter.eq_def] | ||
|
|
||
| @[simp] theorem evalFilter_or_eq (es : List FilterExpr) (s : String) : | ||
| evalFilter (.or es) s = evalFilterAny es s := by rw [evalFilter.eq_def] | ||
|
|
||
| @[simp] theorem evalFilter_not_eq (e : FilterExpr) (s : String) : | ||
| evalFilter (.not e) s = ! evalFilter e s := by rw [evalFilter.eq_def] | ||
|
|
||
| -- Note: RHS parenthesised to avoid `=` binding tighter than `&&` (prec 50 > 35) | ||
| @[simp] theorem evalFilter_withProps_eq (v : FilterExpr) (f : String -> Bool) (s : String) : | ||
| evalFilter (.withProps v f) s = (evalFilter v s && f s) := by rw [evalFilter.eq_def] | ||
|
|
||
| @[simp] theorem evalFilterAll_nil (s : String) : | ||
| evalFilterAll [] s = true := by rw [evalFilterAll.eq_def] | ||
|
|
||
| -- Note: RHS parenthesised | ||
| @[simp] theorem evalFilterAll_cons (e : FilterExpr) (es : List FilterExpr) (s : String) : | ||
| evalFilterAll (e :: es) s = (evalFilter e s && evalFilterAll es s) := by | ||
| rw [evalFilterAll.eq_def] | ||
|
|
||
| @[simp] theorem evalFilterAny_nil (s : String) : | ||
| evalFilterAny [] s = false := by rw [evalFilterAny.eq_def] | ||
|
|
||
| -- Note: RHS parenthesised | ||
| @[simp] theorem evalFilterAny_cons (e : FilterExpr) (es : List FilterExpr) (s : String) : | ||
| evalFilterAny (e :: es) s = (evalFilter e s || evalFilterAny es s) := by | ||
| rw [evalFilterAny.eq_def] | ||
|
|
||
| -- §5 De Morgan helpers | ||
| -- Note: LHS `(!evalFilterAny es s)` is parenthesised to force Bool.not reading | ||
| -- Note: RHS is Bool conjunction, so parenthesised | ||
|
|
||
| theorem evalFilterAny_not_eq_all (es : List FilterExpr) (s : String) : | ||
| (!evalFilterAny es s) = evalFilterAll (es.map .not) s := by | ||
| induction es with | ||
| | nil => simp | ||
| | cons h t ih => | ||
| simp only [evalFilterAny_cons, evalFilterAll_cons, List.map_cons] | ||
| rw [Bool.not_or, ← evalFilter_not_eq] | ||
| exact congrArg (evalFilter (.not h) s && ·) ih | ||
|
|
||
| theorem evalFilterAll_not_eq_any (es : List FilterExpr) (s : String) : | ||
| (!evalFilterAll es s) = evalFilterAny (es.map .not) s := by | ||
| induction es with | ||
| | nil => simp | ||
| | cons h t ih => | ||
| simp only [evalFilterAll_cons, evalFilterAny_cons, List.map_cons] | ||
| rw [Bool.not_and, ← evalFilter_not_eq] | ||
| exact congrArg (evalFilter (.not h) s || ·) ih | ||
|
|
||
| -- §6 Boolean-algebra theorems B1-B12 | ||
|
|
||
| -- B1: NopExpression always returns true | ||
| theorem evalFilter_nop (s : String) : evalFilter .nop s = true := by simp | ||
|
|
||
| -- B2: Double negation elimination | ||
| theorem evalFilter_not_not (e : FilterExpr) (s : String) : | ||
| evalFilter (.not (.not e)) s = evalFilter e s := by | ||
| simp [Bool.not_not] | ||
|
|
||
| -- B3: De Morgan — not(or es) = and(map not es) | ||
| theorem evalFilter_not_or_eq_and_map_not (es : List FilterExpr) (s : String) : | ||
| evalFilter (.not (.or es)) s = evalFilter (.and (es.map .not)) s := by | ||
| simp only [evalFilter_not_eq, evalFilter_or_eq, evalFilter_and_eq] | ||
| exact evalFilterAny_not_eq_all es s | ||
|
|
||
| -- B4: De Morgan — not(and es) = or(map not es) | ||
| theorem evalFilter_not_and_eq_or_map_not (es : List FilterExpr) (s : String) : | ||
| evalFilter (.not (.and es)) s = evalFilter (.or (es.map .not)) s := by | ||
| simp only [evalFilter_not_eq, evalFilter_and_eq, evalFilter_or_eq] | ||
| exact evalFilterAll_not_eq_any es s | ||
|
|
||
| -- B5: And commutativity (two-element list) | ||
| theorem evalFilter_and_comm (a b : FilterExpr) (s : String) : | ||
| evalFilter (.and [a, b]) s = evalFilter (.and [b, a]) s := by | ||
| simp [Bool.and_comm] | ||
|
|
||
| -- B6: Or commutativity (two-element list) | ||
| theorem evalFilter_or_comm (a b : FilterExpr) (s : String) : | ||
| evalFilter (.or [a, b]) s = evalFilter (.or [b, a]) s := by | ||
| simp [Bool.or_comm] | ||
|
|
||
| -- B7: Singleton And | ||
| theorem evalFilter_and_singleton (e : FilterExpr) (s : String) : | ||
| evalFilter (.and [e]) s = evalFilter e s := by simp | ||
|
|
||
| -- B8: Singleton Or | ||
| theorem evalFilter_or_singleton (e : FilterExpr) (s : String) : | ||
| evalFilter (.or [e]) s = evalFilter e s := by simp | ||
|
|
||
| -- B9: Nop is the And-identity | ||
| theorem evalFilter_and_nop_left (e : FilterExpr) (s : String) : | ||
| evalFilter (.and [.nop, e]) s = evalFilter e s := by simp | ||
|
|
||
| -- B10: Nop absorbs Or | ||
| theorem evalFilter_or_nop_absorb (e : FilterExpr) (s : String) : | ||
| evalFilter (.or [.nop, e]) s = true := by simp | ||
|
|
||
| -- B11: Vacuous And (empty conjunction is true) | ||
| theorem evalFilter_and_empty (s : String) : evalFilter (.and []) s = true := by simp | ||
|
|
||
| -- B12: Vacuous Or (empty disjunction is false) | ||
| theorem evalFilter_or_empty (s : String) : evalFilter (.or []) s = false := by simp | ||
|
|
||
| -- §7 Additional structural properties | ||
|
|
||
| -- Double negation of the Bool result (parenthesise `!!` to force Bool reading) | ||
| theorem evalFilter_result_double_neg (e : FilterExpr) (s : String) : | ||
| (!! evalFilter e s) = evalFilter e s := Bool.not_not _ | ||
|
|
||
| -- withProps fails if the glob fails | ||
| theorem evalFilter_withProps_false_glob | ||
| (e : FilterExpr) (f : String -> Bool) (s : String) | ||
| (h : evalFilter e s = false) : | ||
| evalFilter (.withProps e f) s = false := by simp [h] | ||
|
|
||
| -- withProps fails if the property predicate fails | ||
| theorem evalFilter_withProps_false_prop | ||
| (e : FilterExpr) (f : String -> Bool) (s : String) | ||
| (h : f s = false) : | ||
| evalFilter (.withProps e f) s = false := by simp [h] | ||
|
|
||
| -- If and [a, b] matches then a alone matches | ||
| theorem evalFilter_and_left_implies (a b : FilterExpr) (s : String) | ||
| (h : evalFilter (.and [a, b]) s = true) : evalFilter a s = true := by | ||
| simp at h; exact h.1 | ||
|
|
||
| -- If and [a, b] matches then b alone matches | ||
| theorem evalFilter_and_right_implies (a b : FilterExpr) (s : String) | ||
| (h : evalFilter (.and [a, b]) s = true) : evalFilter b s = true := by | ||
| simp at h; exact h.2 | ||
|
|
||
| -- If or [a, b] does not match then a alone does not match | ||
| theorem evalFilter_or_left_false (a b : FilterExpr) (s : String) | ||
| (h : evalFilter (.or [a, b]) s = false) : evalFilter a s = false := by | ||
| simp [Bool.or_eq_false_iff] at h; exact h.1 | ||
|
|
||
| -- Triple negation reduces to single negation | ||
| theorem evalFilter_not_not_not (e : FilterExpr) (s : String) : | ||
| evalFilter (.not (.not (.not e))) s = evalFilter (.not e) s := by | ||
| simp [Bool.not_not] | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| {"version": "1.1.0", | ||
| "packagesDir": ".lake/packages", | ||
| "packages": [], | ||
| "name": "FVSquad", | ||
| "lakeDir": ".lake"} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1 @@ | ||
| leanprover/lean4:v4.14.0 | ||
| leanprover/lean4:v4.29.1 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nitpick (Minor):
1000000(1 MB) is a magic number. While the intent — catching a suspiciously small or empty archive — is clear from the surrounding comment, a named variable would make the threshold explicit and easier to adjust:This is especially helpful since the threshold is the only integrity check replacing the now-absent SHA-256 verification.