Skip to content

Commit c118f21

Browse files
committed
feat: !-purity pass (check-purity!) — operative static-reasoning recovery, shipped dark
Phase 6 of combiner unification (thread 20260615034227): a whole-program check-purity! pass that flags a non-`!`-named defn/defn- whose body lexically contains a set! or a `!`-headed call (intraprocedural; descends let/if/do/fn but not across defn boundaries) — the operative thesis's "no `!` => safe to treat as pure" guarantee. Ships DARK: gated by BEAGLE_PURITY (off|warn|error, default off) + (define-mode strict) + profile-keyed severity; off short-circuits, so the pass is inert as shipped. New 'purity-leak kind (E019, type-error). Wired into both type-check! and check-all.rkt (the live consumer path). Inert-by-default proven: active tier 1374/1374 (incl. purity.rkt 14/14), consumers unchanged off — gjoa 0, chelonia 0, nixos 4 baseline. When enabled it is real: BEAGLE_PURITY=warn surfaces 36 leaks in gjoa, =error 20 (the rollout worklist; ships off). Phased rollout (warn -> opt-in -> default, earned) per thread 20260615034227.
1 parent 9a00afe commit c118f21

6 files changed

Lines changed: 373 additions & 3 deletions

File tree

beagle-lib/private/check-all.rkt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -487,10 +487,12 @@
487487
(let ([sink (open-output-string)])
488488
(parameterize ([current-error-port sink])
489489
(check-scalar-provenance! prog)
490+
(check-purity! prog)
490491
(run-semantic-analysis! prog #:file path))
491492
(set! lint-count (count-lint-warnings prog)))
492493
(begin
493494
(check-scalar-provenance! prog)
495+
(check-purity! prog)
494496
(run-semantic-analysis! prog #:file path)))))
495497

496498
(values error-count lint-count (reverse agent-errors)))

beagle-lib/private/check.rkt

Lines changed: 174 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,19 @@
3939
(string->number v)
4040
2))))
4141

42+
;; `!`-purity enforcement (Phase 6 — design-purity.md). Seeded from
43+
;; BEAGLE_PURITY ('off | 'warn | 'error). Phase 6.0 ships DARK: the default
44+
;; is 'off, which short-circuits check-purity! entirely so the pass is inert
45+
;; in every build and cannot turn into a new diagnostic for the live
46+
;; consumers. Mirrors the BEAGLE_CHECK_PROFILE env precedent exactly. Flip on
47+
;; per rollout stage (warn/error) without touching consumer source.
48+
(define current-purity-enforcement
49+
(make-parameter
50+
(case (getenv "BEAGLE_PURITY")
51+
[("warn") 'warn]
52+
[("error") 'error]
53+
[else 'off])))
54+
4255
(define (merge-types . ts)
4356
(define non-any (filter (λ (t) (not (any-type? t))) ts))
4457
(cond
@@ -314,6 +327,7 @@
314327
[(template-splice) "E016"]
315328
[(macro-expansion-type-error) "E017"]
316329
[(unresolved-alias) "E018"]
330+
[(purity-leak) "E019"]
317331
[else "E000"]))
318332

319333
;; Expected/actual detail pair carrying BOTH the human strings (kept verbatim,
@@ -490,7 +504,8 @@
490504
(check-form form env))))
491505
(check-qualified-resolution! prog env)
492506
(check-zig-world-escape! prog)
493-
(check-scalar-provenance! prog)))
507+
(check-scalar-provenance! prog)
508+
(check-purity! prog)))
494509

495510
;; --- environment -----------------------------------------------------------
496511

@@ -3212,6 +3227,162 @@
32123227
syms)
32133228

32143229

3230+
;; --- `!`-purity enforcement (Phase 6 — design-purity.md) -------------------
3231+
;;
3232+
;; The operative thesis's load-bearing promise is static-reasoning recovery:
3233+
;; "the absence of mutation markers in a piece of code means that code is
3234+
;; functionally pure." check-purity! makes the `!`-suffix naming convention a
3235+
;; checked invariant, one direction only and purely syntactically:
3236+
;;
3237+
;; A defn/defn- whose NAME does not end in `!` must have a PURE BODY — its
3238+
;; body must contain no mutation marker (no set!-form, and no call whose head
3239+
;; is a symbol ending in `!`). If it does, that is a 'purity-leak.
3240+
;;
3241+
;; Intraprocedural and syntactic only: it descends let/if/do/fn/when/cond/…
3242+
;; (an inner fn's effects still run when this function is called) but never
3243+
;; across defn/def boundaries — those are separate definitions. No
3244+
;; interprocedural inference, no effect rows; the converse (a `!`-named defn
3245+
;; with a pure body) is allowed.
3246+
;;
3247+
;; GATING (so it never breaks the live consumers):
3248+
;; * mode gate — runs only under (define-mode strict);
3249+
;; * env/feature flag — current-purity-enforcement, seeded from BEAGLE_PURITY,
3250+
;; default 'off. 'off short-circuits the whole pass: it ships DARK.
3251+
;; * severity is profile-keyed: profile < 3 => warn-only (never blocks the
3252+
;; build); profile >= 3 => hard error via raise-diag. 'off overrides both.
3253+
3254+
(define (bang-name? sym)
3255+
(and (symbol? sym)
3256+
(let ([s (symbol->string sym)])
3257+
(and (> (string-length s) 0)
3258+
(char=? (string-ref s (sub1 (string-length s))) #\!)))))
3259+
3260+
;; Collect every mutation marker (the symbol 'set!, or the head symbol of a
3261+
;; `!`-headed call) lexically present in an AST subtree. Reuses the same
3262+
;; sub-expression descent as walk-for-provenance/symbols-in so it tracks new
3263+
;; forms automatically; it does NOT recurse across nested defn/def boundaries.
3264+
(define (collect-markers node)
3265+
(define markers '())
3266+
(define (note! m) (set! markers (cons m markers)))
3267+
(define (walk e)
3268+
(cond
3269+
[(set!-form? e)
3270+
(note! 'set!)
3271+
(walk (set!-form-target e))
3272+
(walk (set!-form-value e))]
3273+
[(call-form? e)
3274+
(define fn (call-form-fn e))
3275+
(when (bang-name? fn) (note! fn))
3276+
(walk fn)
3277+
(for-each walk (call-form-args e))]
3278+
[(let-form? e)
3279+
(for ([b (in-list (let-form-bindings e))]) (walk (let-binding-value b)))
3280+
(for-each walk (let-form-body e))]
3281+
[(if-form? e)
3282+
(walk (if-form-cond-expr e))
3283+
(walk (if-form-then-expr e))
3284+
(when (if-form-else-expr e) (walk (if-form-else-expr e)))]
3285+
[(when-form? e)
3286+
(walk (when-form-cond-expr e))
3287+
(for-each walk (when-form-body e))]
3288+
[(do-form? e)
3289+
(for-each walk (do-form-body e))]
3290+
;; Inner fns count — their effects execute in this function's call.
3291+
[(fn-form? e)
3292+
(for-each walk (fn-form-body e))]
3293+
[(cond-form? e)
3294+
(for ([c (in-list (cond-form-clauses e))])
3295+
(walk (cond-clause-test c))
3296+
(for-each walk (cond-clause-body c)))]
3297+
[(for-form? e)
3298+
(for ([c (in-list (for-form-clauses e))])
3299+
(when (for-binding? c) (walk (for-binding-expr c))))
3300+
(for-each walk (for-form-body e))]
3301+
[(doseq-form? e)
3302+
(for ([c (in-list (doseq-form-clauses e))])
3303+
(when (for-binding? c) (walk (for-binding-expr c))))
3304+
(for-each walk (doseq-form-body e))]
3305+
[(case-form? e)
3306+
(walk (case-form-test e))
3307+
(for ([c (in-list (case-form-clauses e))]) (walk (case-clause-body c)))
3308+
(when (case-form-default e) (walk (case-form-default e)))]
3309+
[(loop-form? e)
3310+
(for-each walk (loop-form-body e))]
3311+
[(match-form? e)
3312+
(walk (match-form-target e))
3313+
(for ([c (in-list (match-form-clauses e))])
3314+
(for-each walk (match-clause-body c)))]
3315+
[(try-form? e)
3316+
(for-each walk (try-form-body e))
3317+
(for ([c (in-list (try-form-catches e))]) (for-each walk (catch-clause-body c)))
3318+
(when (try-form-finally-body e) (for-each walk (try-form-finally-body e)))]
3319+
[(with-form? e)
3320+
(walk (with-form-target e))
3321+
(for ([u (in-list (with-form-updates e))]) (walk (with-update-value u)))]
3322+
[(vec-form? e)
3323+
(for-each walk (vec-form-items e))]
3324+
[(map-form? e)
3325+
(for ([p (in-list (map-form-pairs e))]) (walk (car p)) (walk (cdr p)))]
3326+
;; Stop at nested definitions — those are separate (intraprocedural rule).
3327+
[(defn-form? e) (void)]
3328+
[(defn-multi? e) (void)]
3329+
[(def-form? e) (void)]
3330+
[(pair? e) (for-each walk e)]
3331+
[else (void)]))
3332+
(for-each walk (if (list? node) node (list node)))
3333+
(remove-duplicates (reverse markers)))
3334+
3335+
;; Effective severity from the two enforcement dials.
3336+
;; 'off flag -> 'off (nothing fires; the pass is dark)
3337+
;; 'error flag -> 'error (author pins a hard stop)
3338+
;; 'warn flag -> 'warn below profile 3, escalated to 'error at >= 3
3339+
;; (severity profile-keyed per design-purity.md §b)
3340+
(define (purity-severity)
3341+
(case (current-purity-enforcement)
3342+
[(off) 'off]
3343+
[(error) 'error]
3344+
[(warn) (if (>= (current-check-profile) 3) 'error 'warn)]
3345+
[else 'off]))
3346+
3347+
(define (check-defn-purity name body src-table node)
3348+
(define markers (collect-markers body))
3349+
(when (and (not (bang-name? name)) (pair? markers))
3350+
(define src (and src-table (hash-ref src-table node #f)))
3351+
(define msg
3352+
(format "purity leak: '~a' has no '!' suffix but its body uses ~a — rename to '~a!' or remove the effect"
3353+
name
3354+
(string-join (map (lambda (m) (format "~a" m)) markers) ", ")
3355+
name))
3356+
(case (purity-severity)
3357+
[(warn)
3358+
(fprintf (current-error-port)
3359+
"warning: ~a~a\n"
3360+
msg
3361+
(if src (format "\n --> ~a:~a" (or (src-loc-source src) "?") (src-loc-line src)) ""))]
3362+
[(error)
3363+
(raise-diag 'purity-leak msg (hasheq) #:src src)]
3364+
[else (void)])))
3365+
3366+
(define (check-purity! prog)
3367+
(when (and (eq? (program-mode prog) 'strict)
3368+
(not (eq? (current-purity-enforcement) 'off)))
3369+
;; Mode + flag gate passed; per-diagnostic severity is decided by
3370+
;; purity-severity (warn below profile 3, hard error at >= 3).
3371+
(define st (program-src-table prog))
3372+
(for ([form (in-list (program-forms prog))])
3373+
(let walk ([f form])
3374+
(cond
3375+
[(defn-form? f)
3376+
(check-defn-purity (defn-form-name f) (defn-form-body f) st f)]
3377+
[(defn-multi? f)
3378+
(for ([a (in-list (defn-multi-arities f))])
3379+
(check-defn-purity (defn-multi-name f) (arity-clause-body a) st f))]
3380+
;; Descend through wrapper forms (js/export, target-case, …) that may
3381+
;; carry a defn payload — same transitive walk type-check! uses.
3382+
[(and (pair? f) (list? f)) (for-each walk (filter pair? (cdr f)))]
3383+
[else (void)])))))
3384+
3385+
32153386
;; --- zig world-escape check (thread 20260612232001, Phase 2) ---------------
32163387
;;
32173388
;; Convention B, generalized to systems (ECS direction, 2026-06-13):
@@ -3507,8 +3678,10 @@
35073678

35083679
(provide type-check! type-check-with-locs!
35093680
check-scalar-provenance!
3681+
check-purity!
35103682
beagle-diagnostic beagle-diagnostic?
35113683
beagle-diagnostic-kind beagle-diagnostic-details
35123684
kind->error-code
35133685
current-check-profile
3686+
current-purity-enforcement
35143687
check-form infer-expr build-initial-env)

beagle-lib/private/diagnostic-kind.rkt

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,14 @@
8686
;; from generic type-error so the histogram can
8787
;; track "macro hygiene/typing bugs" separately
8888
;; from author-written type errors.
89+
;; purity-leak : a defn/defn- whose name lacks a `!` suffix has a
90+
;; body that lexically uses a mutation marker (set! or
91+
;; a `!`-headed call). The `!`-suffix convention is the
92+
;; author's promise that an unmarked name is pure (safe
93+
;; to compile-time-evaluate / reorder); a leak breaks
94+
;; the static-reasoning guarantee. Type-error: the form
95+
;; parses fine; the rejection is a name/body
96+
;; consistency check. Off by default (BEAGLE_PURITY).
8997
(define check-kind-cause-table
9098
(hasheq
9199
'target-form 'surface-divergence
@@ -104,7 +112,8 @@
104112
'sql-column 'type-error
105113
'sql-type 'type-error
106114
'nixos-unknown-option 'type-error
107-
'macro-expansion-type-error 'type-error))
115+
'macro-expansion-type-error 'type-error
116+
'purity-leak 'type-error))
108117

109118
;; parse.rkt kinds — emitted by raise-parse-error helper that we add
110119
;; in this phase to the high-traffic subset (removed-forms,

beagle-lib/private/error-explanation.rkt

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,16 @@
171171
"(fs/exists? p) ;; ERROR: unresolved alias `fs` — add a require"
172172
"(require [babashka.fs :as fs])\n(fs/exists? p)"
173173
"Add the missing require, or fix the alias to one that is required."
174-
#:since "0.16")))
174+
#:since "0.16")
175+
176+
(E "E019" "Purity leak"
177+
"A defn/defn- whose name lacks a `!` suffix has a body that mutates."
178+
"The `!`-suffix convention is the author's promise that an unmarked name is pure (safe to compile-time-evaluate / inline / reorder). Mixing a mutation (a set! or a `!`-headed call like swap!/reset!/conj!) into a pure-named function breaks the static-reasoning guarantee the compiler relies on."
179+
"(defn save [box v] (reset! box v)) ;; ERROR: 'save' has no '!' but its body uses reset!"
180+
"(defn save! [box v] (reset! box v))"
181+
"Rename the function to end in `!` (save → save!), or remove the effect so the body is pure. Off by default; gated by BEAGLE_PURITY (off/warn/error) and the check profile (warn below 3, error at >= 3)."
182+
#:severity 'warning
183+
#:since "0.17")))
175184

176185
(define CODE->EXPL
177186
(let ([h (make-hash)])

0 commit comments

Comments
 (0)