From 5a145f0250735d8d6da1e2156e030f2cfdc1163e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 22:51:55 +0000 Subject: [PATCH 1/8] =?UTF-8?q?tools:=20lint-pnet-registration=20=E2=80=94?= =?UTF-8?q?=20flag=20AST=20nodes=20missing=20.pnet=20serialization=20regis?= =?UTF-8?q?tration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Static guard for pipeline.md § New AST Node step 6: every struct in syntax.rkt must appear in pnet-serialize.rkt's registration code, else cached module bodies deserialize it as a raw vector impostor that fails a distant struct match (the Numerics Q11 failure mode). Reads both modules via the module reader (comments stripped), compares struct names against pnet-serialize's code symbols, and flags NEW gaps against a baseline of the 133 pre-existing unregistered structs (latent debt, now tracked; the baseline should only shrink). --strict exits non-zero on new gaps (for hooks/CI); --save-baseline regenerates. Self-tested: injected probe struct is caught, clean tree passes strict. No tests: standalone read-only lint script, validated by direct run. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GDBXgAcZS43bsR2p6WF1vi --- .../prologos/tools/lint-pnet-registration.rkt | 196 ++++++++++++++++++ .../tools/pnet-registration-baseline.txt | 141 +++++++++++++ 2 files changed, 337 insertions(+) create mode 100644 racket/prologos/tools/lint-pnet-registration.rkt create mode 100644 racket/prologos/tools/pnet-registration-baseline.txt diff --git a/racket/prologos/tools/lint-pnet-registration.rkt b/racket/prologos/tools/lint-pnet-registration.rkt new file mode 100644 index 00000000..36f435bf --- /dev/null +++ b/racket/prologos/tools/lint-pnet-registration.rkt @@ -0,0 +1,196 @@ +#lang racket/base + +;;; +;;; lint-pnet-registration.rkt — AST nodes must be registered for .pnet caching +;;; +;;; Purpose: static guard for pipeline.md § "New AST Node" step 6. Every +;;; struct defined in syntax.rkt must be registered in pnet-serialize.rkt +;;; (reg0!/reg1!/reg2!/reg3!/regN!/auto-cache!/cache-ctor!) or it hits the +;;; .pnet reader's unknown-tag fallback, which silently returns a raw VECTOR +;;; impostor that fails the first struct `match` to touch it — arbitrarily +;;; far from the real cause, with an error that PRINTS like the real struct +;;; (Numerics Q11, 2026-07-01). The gap stays latent until the node first +;;; appears in — or is first INVOKED from — a cached module body, so a green +;;; suite is no defence. +;;; +;;; Detection: reads syntax.rkt for (struct NAME ...) definitions, reads +;;; pnet-serialize.rkt for every symbol appearing in CODE (comments are +;;; stripped by the reader, so a name mentioned only in a comment does NOT +;;; count as registered). A struct name absent from pnet-serialize.rkt's +;;; code cannot possibly be registered. The check is necessary-not- +;;; sufficient: a name may appear without being registered (e.g. in a +;;; helper) — but the common failure is the clean miss, and that is caught. +;;; +;;; Baseline: tools/pnet-registration-baseline.txt holds the pre-existing +;;; unregistered set (latent debt, tracked). Only NEW additions are flagged. +;;; +;;; Usage: +;;; racket tools/lint-pnet-registration.rkt # report, exit 0 +;;; racket tools/lint-pnet-registration.rkt --strict # exit 1 on NEW gaps +;;; racket tools/lint-pnet-registration.rkt --save-baseline +;;; + +(require racket/cmdline + racket/file + racket/list + racket/path + racket/string + syntax/modread) + +(define strict-mode? (make-parameter #f)) +(define save-baseline? (make-parameter #f)) + +(define this-file (path->string (simplify-path (syntax-source #'here)))) +(define tools-dir (path-only this-file)) +(define project-root (simplify-path (build-path tools-dir 'up))) + +(define syntax-rkt (build-path project-root "syntax.rkt")) +(define pnet-serialize-rkt (build-path project-root "pnet-serialize.rkt")) +(define baseline-path (build-path tools-dir "pnet-registration-baseline.txt")) + +;; ============================================================ +;; Module reading +;; ============================================================ + +;; Read a #lang module file as one syntax object (comments stripped). +(define (read-module-stx path) + (with-module-reading-parameterization + (lambda () + (call-with-input-file path + (lambda (p) + (port-count-lines! p) + (read-syntax path p)))))) + +;; Generic walk over a datum tree. +(define (walk-datum f form) + (f form) + (cond + [(pair? form) (walk-datum f (car form)) (walk-datum f (cdr form))] + [(vector? form) (for ([x (in-vector form)]) (walk-datum f x))] + [else (void)])) + +;; ============================================================ +;; Struct definitions in syntax.rkt (with line numbers) +;; ============================================================ + +;; Recognize (struct NAME (field ...) opt ...) and +;; (struct NAME PARENT (field ...) opt ...) forms. +(define (struct-def-name form) + (and (pair? form) + (eq? (car form) 'struct) + (pair? (cdr form)) + (symbol? (cadr form)) + (pair? (cddr form)) + (or (list? (caddr form)) + (and (symbol? (caddr form)) + (pair? (cdddr form)) + (list? (cadddr form)))) + (cadr form))) + +;; Walk syntax objects (not datums) so we can report line numbers. +;; Returns (listof (cons name line)). +(define (collect-struct-defs stx) + (define acc '()) + (let loop ([s stx]) + (define e (if (syntax? s) (syntax-e s) s)) + (when (pair? e) + (define datum (if (syntax? s) (syntax->datum s) s)) + (define name (struct-def-name datum)) + (when name + (set! acc (cons (cons name (and (syntax? s) (syntax-line s))) acc))) + (let inner ([rest e]) + (cond + [(pair? rest) (loop (car rest)) (inner (cdr rest))] + [(syntax? rest) (loop rest)] + [else (void)])))) + (reverse acc)) + +;; ============================================================ +;; Registered symbols in pnet-serialize.rkt +;; ============================================================ + +(define (collect-code-symbols path) + (define syms (make-hasheq)) + (walk-datum (lambda (f) (when (symbol? f) (hash-set! syms f #t))) + (syntax->datum (read-module-stx path))) + syms) + +;; ============================================================ +;; Baseline +;; ============================================================ + +(define (read-baseline) + (cond + [(file-exists? baseline-path) + (for/hash ([line (in-list (string-split (file->string baseline-path) "\n"))] + #:when (and (not (string=? (string-trim line) "")) + (not (regexp-match? #px"^\\s*#" line)))) + (values (string-trim line) #t))] + [else (hash)])) + +(define (write-baseline names) + (with-output-to-file baseline-path #:exists 'replace + (lambda () + (displayln "# pnet-registration-baseline.txt") + (displayln "# syntax.rkt structs NOT registered in pnet-serialize.rkt as of the") + (displayln "# last baseline save — latent .pnet vector-impostor debt (see") + (displayln "# pipeline.md § New AST Node step 6). New struct additions missing") + (displayln "# registration are flagged; this list should only SHRINK as gaps") + (displayln "# are registered.") + (displayln "# Regenerate with: racket tools/lint-pnet-registration.rkt --save-baseline") + (displayln "") + (for ([n (in-list (sort names stringstring (car d))) missing))) + + (when (save-baseline?) + (write-baseline missing-names) + (printf "Baseline saved: ~a unregistered structs recorded.\n" (length missing-names)) + (printf "File: ~a\n" (path->string baseline-path)) + (exit 0)) + + (define new-missing + (filter (lambda (d) (not (hash-ref baseline (symbol->string (car d)) #f))) missing)) + (define baselined-count (- (length missing) (length new-missing))) + + (printf "pnet registration check (~a structs in syntax.rkt):\n" (length struct-defs)) + (printf " registered (name appears in pnet-serialize.rkt code): ~a\n" + (- (length struct-defs) (length missing))) + (printf " unregistered: ~a (baselined: ~a, NEW: ~a)\n" + (length missing) baselined-count (length new-missing)) + + (when (pair? new-missing) + (printf "\n⚠ NEW unregistered structs (not in baseline):\n") + (for ([d (in-list new-missing)]) + (printf " ~a (syntax.rkt:~a)\n" (car d) (or (cdr d) "?"))) + (printf "\nEvery AST node needs a pnet-serialize.rkt registration (reg0!/reg1!/regN!\n") + (printf "or the auto-cache! block) — otherwise cached module bodies deserialize the\n") + (printf "node as a raw vector impostor that fails a distant struct match.\n") + (printf "See .claude/rules/pipeline.md § New AST Node, step 6.\n")) + + (if (and (strict-mode?) (pair? new-missing)) + (exit 1) + (exit 0))) + +(main) diff --git a/racket/prologos/tools/pnet-registration-baseline.txt b/racket/prologos/tools/pnet-registration-baseline.txt new file mode 100644 index 00000000..060254b2 --- /dev/null +++ b/racket/prologos/tools/pnet-registration-baseline.txt @@ -0,0 +1,141 @@ +# pnet-registration-baseline.txt +# syntax.rkt structs NOT registered in pnet-serialize.rkt as of the +# last baseline save — latent .pnet vector-impostor debt (see +# pipeline.md § New AST Node step 6). New struct additions missing +# registration are flagged; this list should only SHRINK as gaps +# are registered. +# Regenerate with: racket tools/lint-pnet-registration.rkt --save-baseline + +expr-Fin +expr-Quire16 +expr-Quire32 +expr-Quire64 +expr-Quire8 +expr-TMap +expr-TSet +expr-TVec +expr-Vec +expr-all-different +expr-answer-type +expr-cell-id +expr-clause +expr-cumulative +expr-cut +expr-defr +expr-defr-variant +expr-derivation-type +expr-element +expr-explain +expr-explain-with +expr-fact-block +expr-fact-row +expr-fsuc +expr-fzero +expr-generic-abs +expr-generic-add +expr-generic-div +expr-generic-eq +expr-generic-ge +expr-generic-gt +expr-generic-le +expr-generic-lt +expr-generic-mod +expr-generic-mul +expr-generic-negate +expr-generic-sub +expr-goal-app +expr-goal-type +expr-guard +expr-int-le +expr-int-mod +expr-is-goal +expr-logic-var +expr-map-map-vals +expr-map-size +expr-minimize +expr-narrow +expr-net-add-prop +expr-net-cell-read +expr-net-cell-write +expr-net-contradiction +expr-net-new +expr-net-run +expr-net-snapshot +expr-nil-safe-get +expr-not-goal +expr-opaque +expr-p16-from-nat +expr-p16-sqrt +expr-p32-from-nat +expr-p32-sqrt +expr-p64-from-nat +expr-p64-sqrt +expr-p8-from-nat +expr-p8-sqrt +expr-persist +expr-persist-map +expr-persist-set +expr-persist-vec +expr-prop-id +expr-prop-id-type +expr-prop-network +expr-pvec-concat +expr-pvec-filter +expr-pvec-pop +expr-pvec-slice +expr-quire16-fma +expr-quire16-to +expr-quire16-val +expr-quire32-fma +expr-quire32-to +expr-quire32-val +expr-quire64-fma +expr-quire64-to +expr-quire64-val +expr-quire8-fma +expr-quire8-to +expr-quire8-val +expr-rel +expr-relation-type +expr-set-filter +expr-set-intersect +expr-set-size +expr-solve +expr-solve-one +expr-solve-with +expr-solver-config +expr-solver-type +expr-symbol +expr-table-add +expr-table-answers +expr-table-complete +expr-table-freeze +expr-table-lookup +expr-table-new +expr-table-register +expr-table-run +expr-table-store-type +expr-table-store-val +expr-tmap-assoc! +expr-tmap-dissoc! +expr-transient +expr-transient-map +expr-transient-set +expr-transient-vec +expr-tset-delete! +expr-tset-insert! +expr-tvec-push! +expr-tvec-update! +expr-uf-empty +expr-uf-find +expr-uf-make-set +expr-uf-store +expr-uf-type +expr-uf-union +expr-uf-value +expr-unify-goal +expr-vcons +expr-vhead +expr-vindex +expr-vnil +expr-vtail From 368005943ff05e50d8c94e06f99fcccc698fd8bc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 22:54:11 +0000 Subject: [PATCH 2/8] =?UTF-8?q?tools:=20lint-fire-fn-capture=20=E2=80=94?= =?UTF-8?q?=20flag=20fire=20functions=20using=20captured=20stale=20network?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Static guard for propagator-design.md § Fire Function Network Parameter: a fire fn that reads/writes cells through a variable captured from the installation-time scope operates on a stale network, and BSP's merge of the returned network silently drops other propagators' writes (Track 2 Bug #2; BSP-LE 2B Phase 1a). Fire scopes are found two ways — lambdas inside installer-call arguments (net-add-propagator and friends, elab-add-propagator) and define/let bindings matching #px"fire" — then every net-cell-*/elab-cell-* call whose network argument is not bound anywhere inside the scope is flagged. Binder collection (lambda formals, define, let family, named let, for clauses, match patterns) deliberately over-approximates so flags are high-signal. Production tree scans clean (132 files, 0 findings; baseline empty). Self-tested against a fixture reproducing both documented bug shapes (for/fold-accumulator capture and installer-position anon lambda) — both flagged; the corrected twin is not. No tests: standalone read-only lint script, validated by fixture run. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GDBXgAcZS43bsR2p6WF1vi --- .../tools/fire-fn-capture-baseline.txt | 7 + .../prologos/tools/lint-fire-fn-capture.rkt | 325 ++++++++++++++++++ 2 files changed, 332 insertions(+) create mode 100644 racket/prologos/tools/fire-fn-capture-baseline.txt create mode 100644 racket/prologos/tools/lint-fire-fn-capture.rkt diff --git a/racket/prologos/tools/fire-fn-capture-baseline.txt b/racket/prologos/tools/fire-fn-capture-baseline.txt new file mode 100644 index 00000000..30e29b54 --- /dev/null +++ b/racket/prologos/tools/fire-fn-capture-baseline.txt @@ -0,0 +1,7 @@ +# fire-fn-capture-baseline.txt +# Accepted fire-scope captured-network findings as of last baseline +# save (key: file::op::variable). Each entry should be audited: a +# genuine capture is the silent stale-network bug of +# propagator-design.md § Fire Function Network Parameter. +# Regenerate with: racket tools/lint-fire-fn-capture.rkt --save-baseline + diff --git a/racket/prologos/tools/lint-fire-fn-capture.rkt b/racket/prologos/tools/lint-fire-fn-capture.rkt new file mode 100644 index 00000000..01a2ef17 --- /dev/null +++ b/racket/prologos/tools/lint-fire-fn-capture.rkt @@ -0,0 +1,325 @@ +#lang racket/base + +;;; +;;; lint-fire-fn-capture.rkt — fire functions must not capture stale networks +;;; +;;; Purpose: static guard for propagator-design.md § "Fire Function Network +;;; Parameter (CRITICAL)". A propagator's fire function MUST use its `net` +;;; parameter for ALL cell reads and writes. A fire function that reads or +;;; writes through a variable captured from the INSTALLATION-TIME scope +;;; operates on a stale network; BSP merges the returned stale network over +;;; the snapshot and silently loses other propagators' writes. The bug is +;;; silent — no error, no crash (Track 2 Bug #2; Track 2B Phase 1a). +;;; +;;; Detection: a "fire scope" is +;;; (a) any lambda appearing inside the arguments of a propagator +;;; installer call (net-add-propagator, net-add-fire-once-propagator, +;;; net-add-broadcast-propagator, net-add-threshold, +;;; elab-add-propagator), or +;;; (b) any define / let-family binding whose NAME matches #px"fire" +;;; (fire-fn, fire, make-*-fire-fn, ...). +;;; Within a fire scope, every cell-op call (net-cell-read, net-cell-write, +;;; elab-cell-read, elab-cell-write, ...) whose network argument is an +;;; identifier NOT bound anywhere inside that scope is flagged: the +;;; identifier necessarily comes from the enclosing (installation-time) +;;; scope. +;;; +;;; Binder collection is deliberately OVER-approximate (lambda formals, +;;; define/define-values, let family, named let, for family clauses, match +;;; family patterns): over-approximation can only produce false negatives, +;;; never false positives, so every flag is worth reading. +;;; +;;; Known limitation: a factory like +;;; (define (make-fire net) (lambda (net2) (net-cell-write net ...))) +;;; captures the factory's own parameter — bound inside the scope, so not +;;; flagged, though it is the same hazard when the factory runs at install +;;; time. The lint catches the documented bug shape (capture from a +;;; sibling/outer installation binding), not every possible staleness. +;;; +;;; Usage: +;;; racket tools/lint-fire-fn-capture.rkt # scan production, exit 0 +;;; racket tools/lint-fire-fn-capture.rkt --strict # exit 1 on NEW findings +;;; racket tools/lint-fire-fn-capture.rkt --save-baseline +;;; racket tools/lint-fire-fn-capture.rkt FILE.rkt ... # scan specific files +;;; + +(require racket/cmdline + racket/file + racket/list + racket/path + racket/string + syntax/modread) + +(define strict-mode? (make-parameter #f)) +(define save-baseline? (make-parameter #f)) + +(define this-file (path->string (simplify-path (syntax-source #'here)))) +(define tools-dir (path-only this-file)) +(define project-root (simplify-path (build-path tools-dir 'up))) +(define baseline-path (build-path tools-dir "fire-fn-capture-baseline.txt")) + +;; ============================================================ +;; Configuration +;; ============================================================ + +(define installer-heads + '(net-add-propagator + net-add-fire-once-propagator + net-add-broadcast-propagator + net-add-threshold + elab-add-propagator)) + +;; Cell ops whose FIRST argument is the network. +(define net-ops + '(net-cell-read net-cell-read-raw net-cell-write net-cell-write-widen + net-cell-reset net-cell-replace net-cell-direction + net-cell-decomp-insert net-cell-decomp-lookup + elab-cell-read elab-cell-write elab-cell-replace + elab-cell-read-worldview elab-cell-read-or elab-cell-solved?)) + +(define fire-name-rx #px"fire") + +;; ============================================================ +;; Module reading + generic walks +;; ============================================================ + +(define (read-module-stx path) + (with-module-reading-parameterization + (lambda () + (call-with-input-file path + (lambda (p) + (port-count-lines! p) + (read-syntax path p)))))) + +;; Walk a syntax object, calling (f stx datum) on every syntax pair node. +(define (walk-stx f stx) + (let loop ([s stx]) + (define e (if (syntax? s) (syntax-e s) s)) + (when (pair? e) + (when (syntax? s) (f s (syntax->datum s))) + (let inner ([rest e]) + (cond + [(pair? rest) (loop (car rest)) (inner (cdr rest))] + [(syntax? rest) (loop rest)] + [else (void)]))))) + +(define (walk-datum f form) + (f form) + (cond + [(pair? form) (walk-datum f (car form)) (walk-datum f (cdr form))] + [(vector? form) (for ([x (in-vector form)]) (walk-datum f x))] + [else (void)])) + +;; ============================================================ +;; Binder collection (over-approximate) +;; ============================================================ + +;; Collect every symbol in a formals/pattern datum (skips keywords). +(define (collect-symbols! acc form) + (walk-datum (lambda (f) (when (symbol? f) (hash-set! acc f #t))) form)) + +;; Collect ids bound anywhere within a scope's datum subtree. +(define (collect-binders form) + (define acc (make-hasheq)) + (walk-datum + (lambda (f) + (when (and (pair? f) (symbol? (car f))) + (define head (car f)) + (define rest (cdr f)) + (cond + ;; (lambda formals body ...) / (λ ...) + [(and (memq head '(lambda λ)) (pair? rest)) + (collect-symbols! acc (car rest))] + ;; (case-lambda [formals body ...] ...) + [(eq? head 'case-lambda) + (for ([clause (in-list rest)] #:when (pair? clause)) + (collect-symbols! acc (car clause)))] + ;; (define (name . args) ...) / (define name expr) + [(and (memq head '(define define-values match-define match-define-values)) + (pair? rest)) + (collect-symbols! acc (car rest))] + ;; let family: (let ([x e] ...) ...) / named let (let loop ([x e] ...) ...) + [(and (memq head '(let let* letrec let-values let*-values letrec-values)) + (pair? rest)) + (cond + [(symbol? (car rest)) ;; named let + (hash-set! acc (car rest) #t) + (when (pair? (cdr rest)) + (for ([b (in-list (if (list? (cadr rest)) (cadr rest) '()))] + #:when (pair? b)) + (collect-symbols! acc (car b))))] + [(list? (car rest)) + (for ([b (in-list (car rest))] #:when (pair? b)) + (collect-symbols! acc (car b)))])] + ;; for family: (for CLAUSES body ...) / (for/fold ACCUM CLAUSES body ...) + [(regexp-match? #px"^for(\\*|/|$)" (symbol->string head)) + ;; over-approximate: harvest binding position of every clause-shaped + ;; element in the first two argument positions + (for ([arg (in-list (take rest (min 2 (length (filter (lambda (_) #t) rest)))))] + #:when (list? arg)) + (for ([clause (in-list arg)] #:when (pair? clause)) + (collect-symbols! acc (car clause))))] + ;; match family: harvest ALL symbols in clause patterns + [(and (memq head '(match match* match-let match-let*)) (pair? rest)) + (for ([clause (in-list (cdr rest))] #:when (pair? clause)) + (collect-symbols! acc (car clause)))] + [else (void)]))) + form) + acc) + +;; ============================================================ +;; Fire-scope discovery +;; ============================================================ + +;; Returns a list of (cons stx datum) fire scopes found in the module. +(define (find-fire-scopes stx) + (define scopes '()) + (walk-stx + (lambda (s datum) + (define head (and (pair? datum) (car datum))) + (cond + ;; installer call → every lambda within its arguments is a fire scope + [(and (symbol? head) (memq head installer-heads)) + (walk-stx + (lambda (inner-s inner-d) + (when (and (pair? inner-d) (memq (car inner-d) '(lambda λ case-lambda))) + (set! scopes (cons inner-s scopes)))) + s)] + ;; (define fire-ish ...) / (define (fire-ish ...) ...) / let-binding + [(and (memq head '(define define-values)) (pair? (cdr datum))) + (define target (cadr datum)) + (define name (cond [(symbol? target) target] + [(and (pair? target) (symbol? (car target))) (car target)] + [else #f])) + (when (and name (regexp-match? fire-name-rx (symbol->string name))) + (set! scopes (cons s scopes)))] + [(and (memq head '(let let* letrec)) (pair? (cdr datum)) + (list? (cadr datum))) + (for ([b (in-list (cadr datum))]) + (when (and (pair? b) (symbol? (car b)) + (regexp-match? fire-name-rx (symbol->string (car b)))) + (set! scopes (cons s scopes))))] + [else (void)])) + stx) + scopes) + +;; ============================================================ +;; Violation check +;; ============================================================ + +;; A finding: (list file line op netvar) +(define (check-fire-scope scope-stx) + (define binders (collect-binders (syntax->datum scope-stx))) + (define findings '()) + (walk-stx + (lambda (s datum) + (when (and (pair? datum) + (symbol? (car datum)) + (memq (car datum) net-ops) + (pair? (cdr datum)) + (symbol? (cadr datum)) + (not (hash-ref binders (cadr datum) #f))) + (set! findings + (cons (list (syntax-line s) (car datum) (cadr datum)) findings)))) + scope-stx) + findings) + +(define (scan-file path) + (define rel (path->string (find-relative-path project-root (simplify-path path)))) + (with-handlers ([exn:fail? (lambda (e) + (eprintf "SKIP ~a (read error: ~a)\n" rel (exn-message e)) + '())]) + (define stx (read-module-stx path)) + (for*/list ([scope (in-list (remove-duplicates (find-fire-scopes stx) eq?))] + [f (in-list (check-fire-scope scope))]) + (list rel (car f) (cadr f) (caddr f))))) + +;; ============================================================ +;; Baseline +;; ============================================================ + +;; Baseline key: file::op::var (line numbers drift; this is stable enough). +(define (finding-key f) + (format "~a::~a::~a" (car f) (caddr f) (cadddr f))) + +(define (read-baseline) + (cond + [(file-exists? baseline-path) + (for/hash ([line (in-list (string-split (file->string baseline-path) "\n"))] + #:when (and (not (string=? (string-trim line) "")) + (not (regexp-match? #px"^\\s*#" line)))) + (values (string-trim line) #t))] + [else (hash)])) + +(define (write-baseline findings) + (define keys (sort (remove-duplicates (map finding-key findings)) stringstring f)) + (not (regexp-match? #rx"/(tests|tools|benchmarks|compiled|examples|lsp)/" + (path->string f))))) + f)) + +(define (main) + (define explicit-files + (command-line + #:program "lint-fire-fn-capture" + #:once-each + ["--strict" "Exit non-zero if NEW findings appear (not in baseline)" + (strict-mode? #t)] + ["--save-baseline" "Regenerate the baseline from current findings" + (save-baseline? #t)] + #:args files + files)) + + (define targets + (if (pair? explicit-files) + (map (lambda (f) (simplify-path (path->complete-path f))) explicit-files) + (production-files))) + + (define findings (append-map scan-file targets)) + (define baseline (read-baseline)) + + (when (save-baseline?) + (write-baseline findings) + (printf "Baseline saved: ~a findings recorded.\n" + (length (remove-duplicates (map finding-key findings)))) + (exit 0)) + + (define new-findings + (filter (lambda (f) (not (hash-ref baseline (finding-key f) #f))) findings)) + + (printf "fire-fn capture check (~a files): ~a findings (baselined: ~a, NEW: ~a)\n" + (length targets) (length findings) + (- (length findings) (length new-findings)) (length new-findings)) + + (when (pair? new-findings) + (printf "\n⚠ NEW captured-network reads/writes inside fire scopes:\n") + (for ([f (in-list (sort new-findings string Date: Wed, 5 Aug 2026 22:55:55 +0000 Subject: [PATCH 3/8] =?UTF-8?q?tools:=20lint-memo-hash=20=E2=80=94=20flag?= =?UTF-8?q?=20equal-based=20hashes=20near=20memo/cache=20context?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Static guard for pipeline.md § New Memo / Cache Keyed on an AST Node (the GitHub #58 class): equal-hash-code is depth-bounded (~17 levels), so an equal-based (make-hash) memo keyed on expr trees collapses deep terms into a handful of buckets and degenerates to O(N^3) — while hiding behind a green suite. Line-based scan (comments count — that is where 'memoization' lives) for make-hash/make-weak-hash within +-2 lines of memo/cache/seen context, keyed by enclosing define name, gated against a baseline. The 7 baselined findings were each audited and are sound as-is: lsp/server.rkt state tables key on URI STRINGS (equal-based required), macros.rkt coercion-fn-cache keys on freshly-consed shallow pairs (equal-based required, depth ~2 so the depth bound is harmless), and pnet-serialize.rkt dynamic-ctor-cache keys on symbols (cold fallback path). No expr-tree-keyed equal hashes exist at HEAD; the lint gates new ones. No tests: standalone read-only lint script, validated by fixture run. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GDBXgAcZS43bsR2p6WF1vi --- racket/prologos/tools/lint-memo-hash.rkt | 161 +++++++++++++++++++ racket/prologos/tools/memo-hash-baseline.txt | 10 ++ 2 files changed, 171 insertions(+) create mode 100644 racket/prologos/tools/lint-memo-hash.rkt create mode 100644 racket/prologos/tools/memo-hash-baseline.txt diff --git a/racket/prologos/tools/lint-memo-hash.rkt b/racket/prologos/tools/lint-memo-hash.rkt new file mode 100644 index 00000000..2168f185 --- /dev/null +++ b/racket/prologos/tools/lint-memo-hash.rkt @@ -0,0 +1,161 @@ +#lang racket/base + +;;; +;;; lint-memo-hash.rkt — equal-based hashes near memo/cache context +;;; +;;; Purpose: static guard for pipeline.md § "New Memo / Cache Keyed on an +;;; AST Node". Racket's equal-hash-code is DEPTH-BOUNDED (~17 levels), so +;;; an equal-based (make-hash) memo keyed on expr trees collapses deep- +;;; term families into a handful of buckets and degenerates into O(N³) +;;; linear scans running full structural equal? — the memo costs vastly +;;; more than the work it saves (GitHub #58: 647,773x vs hasheq at N=512, +;;; hidden behind a green suite and a misleading counter). eq? keying is +;;; SOUND for expr memos (no expr struct is #:mutable), so `make-hasheq` +;;; is the default answer. +;;; +;;; Detection: line-scan (comments intentionally count — "memoization" +;;; usually lives in a comment) for make-hash / make-weak-hash sites +;;; whose surrounding +-2-line window mentions memo/cache/seen. Each finding is keyed by file + nearest enclosing +;;; define name (line numbers drift). Baselined findings should each have +;;; been audited: fine when keys are NOT expr trees (symbols, strings, +;;; small fixed-shape keys); a real hazard when they are. +;;; +;;; Usage: +;;; racket tools/lint-memo-hash.rkt # report, exit 0 +;;; racket tools/lint-memo-hash.rkt --strict # exit 1 on NEW findings +;;; racket tools/lint-memo-hash.rkt --save-baseline +;;; racket tools/lint-memo-hash.rkt FILE.rkt ... # scan specific files +;;; + +(require racket/cmdline + racket/file + racket/list + racket/path + racket/string) + +(define strict-mode? (make-parameter #f)) +(define save-baseline? (make-parameter #f)) + +(define this-file (path->string (simplify-path (syntax-source #'here)))) +(define tools-dir (path-only this-file)) +(define project-root (simplify-path (build-path tools-dir 'up))) +(define baseline-path (build-path tools-dir "memo-hash-baseline.txt")) + +(define hash-site-rx #px"\\(make-(weak-)?hash[\\s)]") +(define context-rx #px"(?i:memo|cache|seen)") +(define context-window 2) +(define define-name-rx #px"\\(define(?:-values)?\\s+\\(?\\s*([a-zA-Z][a-zA-Z0-9!?*/<>+=:._-]*)") + +;; ============================================================ +;; Scan +;; ============================================================ + +;; Returns (listof (list rel-path line-num define-name line-text)). +(define (scan-file path) + (define rel (path->string (find-relative-path project-root (simplify-path path)))) + (define lines (string-split (file->string path) "\n" #:trim? #f)) + (define vec (list->vector lines)) + (define n (vector-length vec)) + ;; nearest preceding define name for stable keying + (define (enclosing-define i) + (let loop ([j i]) + (cond + [(< j 0) "top-level"] + [(regexp-match define-name-rx (vector-ref vec j)) => cadr] + [else (loop (sub1 j))]))) + (for/list ([line (in-list lines)] + [i (in-naturals)] + #:when (regexp-match? hash-site-rx line) + #:when (for/or ([j (in-range (max 0 (- i context-window)) + (min n (+ i context-window 1)))]) + (regexp-match? context-rx (vector-ref vec j)))) + (list rel (add1 i) (enclosing-define i) (string-trim line)))) + +;; ============================================================ +;; Baseline +;; ============================================================ + +(define (finding-key f) + (format "~a::~a" (car f) (caddr f))) + +(define (read-baseline) + (cond + [(file-exists? baseline-path) + (for/hash ([line (in-list (string-split (file->string baseline-path) "\n"))] + #:when (and (not (string=? (string-trim line) "")) + (not (regexp-match? #px"^\\s*#" line)))) + (values (string-trim line) #t))] + [else (hash)])) + +(define (write-baseline findings) + (define keys (sort (remove-duplicates (map finding-key findings)) stringstring f)) + (not (regexp-match? #rx"/(tests|tools|benchmarks|compiled|examples)/" + (path->string f))))) + f)) + +(define (main) + (define explicit-files + (command-line + #:program "lint-memo-hash" + #:once-each + ["--strict" "Exit non-zero if NEW findings appear (not in baseline)" + (strict-mode? #t)] + ["--save-baseline" "Regenerate the baseline from current findings" + (save-baseline? #t)] + #:args files + files)) + + (define targets + (if (pair? explicit-files) + (map (lambda (f) (simplify-path (path->complete-path f))) explicit-files) + (production-files))) + + (define findings (append-map scan-file targets)) + (define baseline (read-baseline)) + + (when (save-baseline?) + (write-baseline findings) + (printf "Baseline saved: ~a findings recorded.\n" + (length (remove-duplicates (map finding-key findings)))) + (exit 0)) + + (define new-findings + (filter (lambda (f) (not (hash-ref baseline (finding-key f) #f))) findings)) + + (printf "memo-hash check (~a files): ~a findings (baselined: ~a, NEW: ~a)\n" + (length targets) (length findings) + (- (length findings) (length new-findings)) (length new-findings)) + + (when (pair? new-findings) + (printf "\n⚠ NEW equal-based hashes near memo/cache context:\n") + (for ([f (in-list (sort new-findings string Date: Wed, 5 Aug 2026 23:19:05 +0000 Subject: [PATCH 4/8] =?UTF-8?q?tools:=20lint-hygiene.sh=20=E2=80=94=20one-?= =?UTF-8?q?shot=20correctness=20lint=20gate=20(pre-commit=20Gate=203=20+?= =?UTF-8?q?=20CI=20job)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Orchestrates the correctness-focused lints: the four custom lints run BLOCKING (baseline-gated — only NEW findings fail): lint-pnet- registration, lint-fire-fn-capture, lint-memo-hash, lint-parameters. raco review (if installed: raco pkg install review) runs REPORT-ONLY over the target files — unused identifiers/requires, shadowing — with require-ordering warnings filtered out (formatting, not correctness). Target selection: modified (default) / --staged / --all / explicit files. Total custom-lint wall time ~2s, pre-commit viable. Wiring: - tools/git-hooks/pre-commit gains Gate 3 (lint-hygiene.sh --staged) - .github/workflows/test.yml gains a lint job (custom lints block, review reports; no project compile needed — the lints are read-only) - check-parens.sh + pre-commit now resolve Racket portably ($RACKET env → project-standard macOS path → PATH), so hooks and CI work on Linux/web sessions too - .claude/rules/testing.md documents the gate at the ambient tier Note: the gate currently fails on main by design — lint-parameters --strict catches 2 parameters added since the last baseline save (current-check-fire-invariants?, current-residuation-enabled?); the follow-up commit resolves them. No tests: tooling/CI change; validated by direct runs in both passing and failing configurations (exit codes verified unpiped). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GDBXgAcZS43bsR2p6WF1vi --- .claude/rules/testing.md | 1 + .github/workflows/test.yml | 18 ++++++ tools/check-parens.sh | 14 ++++- tools/git-hooks/pre-commit | 24 +++++++- tools/lint-hygiene.sh | 123 +++++++++++++++++++++++++++++++++++++ 5 files changed, 177 insertions(+), 3 deletions(-) create mode 100755 tools/lint-hygiene.sh diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index 3bb53920..d9c3e8a5 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -49,3 +49,4 @@ - **Pre-push gate**: A git pre-push hook (`.git/hooks/pre-push`) runs the full suite if no `timings.jsonl` entry exists for HEAD. If a run already exists for the current commit, the hook skips (no redundant re-run). Bypass with `git push --no-verify` in emergencies. - **Pre-commit gate**: A git pre-commit hook (`tools/git-hooks/pre-commit`, installed via `tools/install-git-hooks.sh`) runs `tools/check-parens.sh` on staged `.rkt` files. ~100ms per file (read-syntax via Racket); blocks the commit on delimiter mismatch with exact line:column. Closes the bug class where mechanical edits (sed surgery, batch refactors) introduce unbalanced parens that break `raco pkg install` downstream — a pattern that hit `main` once already (commit `d7bd97a4`, fixed in PR #29). Bypass with `git commit --no-verify` for genuine emergencies; failures otherwise are real bugs to fix before committing. - **Parameter-leakage lint** (A3-static-lint, BSP-LE Track 2B addendum) -- `racket tools/lint-parameters.rkt` classifies each `make-parameter` call as private / test-registered / unclassified. Uses a baseline file (`tools/parameter-lint-baseline.txt`) to track currently-accepted unclassified parameters; only flags NEW additions. Run: `racket tools/lint-parameters.rkt` (report), `--strict` (exit non-zero if new unclassified found — for CI / manual audit), `--save-baseline` (accept current state as new baseline). Architectural answer is PM Track 12 (parameters → cells for module loading) which obsoletes this lint. Longitudinal pattern 7 (two-context boundary bugs, 6+ PIRs) — tactical near-term protection against silent regressions. +- **Hygiene lint gate** (2026-08-05) -- `tools/lint-hygiene.sh` runs the correctness lints in one shot; it is pre-commit Gate 3 and a CI job. BLOCKING (baseline-gated, only NEW findings fail): `tools/lint-pnet-registration.rkt` (AST node missing pnet-serialize registration → the vector-impostor class, pipeline.md § New AST Node step 6), `tools/lint-fire-fn-capture.rkt` (fire fn reads/writes a captured stale network → the silent write-loss class, propagator-design.md § Fire Function Network Parameter), `tools/lint-memo-hash.rkt` (equal-based hash near memo/cache context → the depth-bounded-hash O(N³) class, GitHub #58), plus `lint-parameters --strict`. REPORT-ONLY: `raco review` (unused identifiers/requires, shadowing; install once with `raco pkg install review`; require-ordering noise is filtered). Each lint also runs standalone with `--strict` / `--save-baseline`; baselines live next to the lints in `tools/` and should only shrink. When a blocking lint fires on your commit, fix the finding (the report names the rule doc) — baseline updates are for audited-safe cases only, stated in the commit message. diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3d54c492..8fd9cafb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,6 +6,24 @@ on: pull_request: jobs: + lint: + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - uses: actions/checkout@v4 + + - name: Install Racket + uses: Bogdanp/setup-racket@v1.11 + with: + version: '9.0' + + - name: Install review linter + run: raco pkg install --auto --skip-installed review + + - name: Hygiene lints (custom lints blocking, raco review report-only) + run: tools/lint-hygiene.sh --all + test: runs-on: ubuntu-latest timeout-minutes: 30 diff --git a/tools/check-parens.sh b/tools/check-parens.sh index c0c94609..78a9923f 100755 --- a/tools/check-parens.sh +++ b/tools/check-parens.sh @@ -8,7 +8,19 @@ # # Exit 0 = all balanced. Exit 1 = error (with location). -RACKET="/Applications/Racket v9.0/bin/racket" +# Racket resolution: $RACKET env var, else the project-standard macOS +# path, else `racket` on PATH (Linux/CI/web sessions). +if [ -z "${RACKET:-}" ]; then + if [ -x "/Applications/Racket v9.0/bin/racket" ]; then + RACKET="/Applications/Racket v9.0/bin/racket" + else + RACKET="$(command -v racket || true)" + fi +fi +if [ -z "$RACKET" ]; then + echo "check-parens: no racket found (set \$RACKET)" >&2 + exit 1 +fi check_file() { local f="$1" diff --git a/tools/git-hooks/pre-commit b/tools/git-hooks/pre-commit index 3b035035..027bb2d6 100755 --- a/tools/git-hooks/pre-commit +++ b/tools/git-hooks/pre-commit @@ -2,9 +2,12 @@ # # pre-commit — fast lint gates on staged .rkt files # -# Two gates, both fast (each ~1s for typical staged set): +# Three gates, all fast (~1-2s each for typical staged set): # 1. tools/check-parens.sh — delimiter balance via Racket's reader # 2. tools/check-stdout-clean.rkt — module-load stdout pollution +# 3. tools/lint-hygiene.sh — correctness lints (pnet registration, +# fire-fn capture, memo-hash, parameters), baseline-gated: only NEW +# findings block. raco review output is report-only. # # Bypass: git commit --no-verify (emergencies only). # @@ -25,7 +28,20 @@ set -e -RACKET="/Applications/Racket v9.0/bin/racket" +# Racket resolution: $RACKET env var, else the project-standard macOS +# path, else `racket` on PATH (Linux/CI/web sessions). +if [ -z "${RACKET:-}" ]; then + if [ -x "/Applications/Racket v9.0/bin/racket" ]; then + RACKET="/Applications/Racket v9.0/bin/racket" + else + RACKET="$(command -v racket || true)" + fi +fi +if [ -z "$RACKET" ]; then + echo "pre-commit: no racket found (set \$RACKET)" >&2 + exit 1 +fi +export RACKET REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" if [ -z "$REPO_ROOT" ]; then @@ -51,3 +67,7 @@ prod_files=$(echo "$files" | grep -v -E '/(tests|benchmarks|examples|tools|lib)/ if [ -n "$prod_files" ]; then "$RACKET" "$REPO_ROOT/tools/check-stdout-clean.rkt" $prod_files fi + +# Gate 3: hygiene lints on the staged files (custom lints blocking on NEW +# findings only; raco review report-only) +"$REPO_ROOT/tools/lint-hygiene.sh" --staged diff --git a/tools/lint-hygiene.sh b/tools/lint-hygiene.sh new file mode 100755 index 00000000..e78c7320 --- /dev/null +++ b/tools/lint-hygiene.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +# +# lint-hygiene.sh — correctness-focused hygiene gate for the Racket tree +# +# Runs, in order: +# 1. Custom project lints (BLOCKING, baseline-gated — only NEW findings fail): +# lint-pnet-registration — AST nodes missing .pnet serialization +# registration (vector-impostor class) +# lint-fire-fn-capture — fire fns reading/writing a captured +# stale network (silent write-loss class) +# lint-memo-hash — equal-based hashes near memo/cache +# context (depth-bounded-hash O(N^3) class) +# lint-parameters — make-parameter sites missing test +# isolation registration (leakage class) +# 2. raco review (REPORT-ONLY, if installed: raco pkg install review) on +# the target files — unused identifiers/requires, shadowing, suspicious +# code. Require-ORDERING warnings are filtered out (formatting, not +# correctness). Report-only because review has stylistic false +# positives; the signal is for the author, not the gate. +# +# Usage: +# tools/lint-hygiene.sh # lints + review over modified .rkt files +# tools/lint-hygiene.sh --staged # lints + review over staged .rkt files +# tools/lint-hygiene.sh --all # lints + review over ALL production .rkt files +# tools/lint-hygiene.sh FILE.rkt ... # lints + review over the given files +# +# Exit: non-zero iff a BLOCKING custom lint found NEW (non-baselined) issues. +# +# Racket resolution: $RACKET env var, else the project-standard macOS path, +# else `racket` on PATH. + +set -u + +REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "lint-hygiene: not inside a git repository" >&2; exit 1; } +PROLOGOS_DIR="$REPO_ROOT/racket/prologos" + +# --- Racket resolution (portable: owner macOS path, CI/Linux PATH) --------- +if [ -z "${RACKET:-}" ]; then + if [ -x "/Applications/Racket v9.0/bin/racket" ]; then + RACKET="/Applications/Racket v9.0/bin/racket" + elif command -v racket >/dev/null 2>&1; then + RACKET="$(command -v racket)" + else + echo "lint-hygiene: no racket found (set \$RACKET)" >&2; exit 1 + fi +fi + +# --- Target file selection ------------------------------------------------- +mode="modified" +explicit_files=() +for arg in "$@"; do + case "$arg" in + --staged) mode="staged" ;; + --all) mode="all" ;; + -h|--help) sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) mode="explicit"; explicit_files+=("$arg") ;; + esac +done + +case "$mode" in + staged) files=$(git -C "$REPO_ROOT" diff --cached --name-only --diff-filter=AM -- '*.rkt') ;; + modified) files=$(git -C "$REPO_ROOT" diff --name-only --diff-filter=AM HEAD -- '*.rkt' 2>/dev/null) ;; + all) files=$(cd "$REPO_ROOT" && find racket/prologos -name '*.rkt' \ + -not -path '*/compiled/*' -not -path '*/tests/*' \ + -not -path '*/benchmarks/*' -not -path '*/tools/*') ;; + explicit) files=$(printf '%s\n' "${explicit_files[@]}") ;; +esac + +failures=0 + +# --- 1. Custom lints (blocking, baseline-gated) ---------------------------- +# These scan their own fixed surfaces (whole production tree) regardless of +# the target file list — they are fast (~2s total) and baseline-gated, so +# they only fail on NEW findings. +echo "== custom lints (blocking; only NEW findings fail) ==" +for lint in lint-pnet-registration lint-fire-fn-capture lint-memo-hash lint-parameters; do + out=$(cd "$PROLOGOS_DIR" && "$RACKET" "tools/$lint.rkt" --strict 2>&1) + if [ $? -ne 0 ]; then + echo "FAIL: $lint" + echo "$out" + echo "" + failures=$((failures + 1)) + else + echo "ok: $lint" + fi +done + +# --- 2. raco review (report-only, correctness-filtered) -------------------- +if [ -n "$files" ]; then + if "$RACKET" -l raco -- review --help >/dev/null 2>&1; then + echo "" + echo "== raco review (report-only; require-ordering noise filtered) ==" + review_out="" + while IFS= read -r f; do + [ -z "$f" ] && continue + [ -f "$REPO_ROOT/$f" ] || [ -f "$f" ] && true || continue + target="$f"; [ -f "$REPO_ROOT/$f" ] && target="$REPO_ROOT/$f" + out=$("$RACKET" -l raco -- review "$target" 2>&1 \ + | grep -v 'should come before' || true) + [ -n "$out" ] && review_out="$review_out$out"$'\n' + done <<< "$files" + if [ -n "$review_out" ]; then + printf '%s' "$review_out" + echo "(report-only — fix what is real, ignore what is not)" + else + echo "clean." + fi + else + echo "" + echo "note: raco review not installed — skipping (raco pkg install review)" + fi +else + echo "" + echo "no target .rkt files for review ($mode)." +fi + +if [ $failures -ne 0 ]; then + echo "" + echo "$failures blocking lint(s) failed." + exit 1 +fi +exit 0 From 359b4cb43b9f060a39a2da32f9e5ef07aa967e1e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 23:19:39 +0000 Subject: [PATCH 5/8] =?UTF-8?q?lint:=20re-baseline=20parameter-leakage=20l?= =?UTF-8?q?int=20=E2=80=94=202=20audited=20flags=20in,=206=20stale=20entri?= =?UTF-8?q?es=20out?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new hygiene gate caught lint-parameters --strict failing at HEAD: current-check-fire-invariants? (propagator.rkt, Scheduler O(diff) S-b debug gate) and current-residuation-enabled? (global-env.rkt, PPN 4C Addendum 4B.5.a DQ4 residuation gate) were added after the last baseline save. Audit: both are #f-default flags that are only ever parameterize'd (test-scheduler-odiff.rkt / driver.rkt process-file path) — dynamic scope self-unwinds, no imperative mutation sites exist, so no cross-test leakage path; recorded in the baseline per the lint's resolution option 4. Regeneration also drops 6 entries that are no longer unclassified (current-schema/selection/session/strategy/process-registry + current-elaborating-name are since registered in test-support.rkt's parameterize blocks) and a hand-added dated section comment — the file is generated (its header says so); durable audit notes belong in commit messages. No tests: baseline data file only; verified by lint-parameters --strict going red -> green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GDBXgAcZS43bsR2p6WF1vi --- racket/prologos/tools/parameter-lint-baseline.txt | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/racket/prologos/tools/parameter-lint-baseline.txt b/racket/prologos/tools/parameter-lint-baseline.txt index f53e7812..970f2de5 100644 --- a/racket/prologos/tools/parameter-lint-baseline.txt +++ b/racket/prologos/tools/parameter-lint-baseline.txt @@ -20,6 +20,7 @@ current-capability-warnings current-capability-warnings-cell-id current-cell-id-namespace current-cfa-result +current-check-fire-invariants? current-clock-cell-id current-coercion-registry current-coercion-registry-cell-id @@ -40,7 +41,6 @@ current-deprecation-warnings current-deprecation-warnings-cell-id current-domain-classification-lookup current-effect-executor -current-elaborating-name current-emit-error-diagnostics current-error-descriptor-cell-id current-foreign-handler @@ -96,7 +96,6 @@ current-phase-timings current-pnet-write-enabled? current-preparse-registry-cell-id current-process-id -current-process-registry current-process-registry-cell-id current-prop-add-propagator current-prop-add-unify-constraint @@ -128,14 +127,12 @@ current-relation-store current-relation-store-version current-relational-env current-repl-mode +current-residuation-enabled? current-resolution-executor-pure current-retry-unify -current-schema-registry current-schema-registry-cell-id -current-selection-registry current-selection-registry-cell-id current-session-meta-universe-cell-id -current-session-registry current-session-registry-cell-id current-session-universe-merge current-solver-strategy-override @@ -150,7 +147,6 @@ current-speculation-failures current-sre-classify-enabled? current-sre-debug? current-strata-cache -current-strategy-registry current-strategy-registry-cell-id current-structural-meta-lookup current-structural-mult-bridge @@ -191,5 +187,3 @@ current-worker-pool current-worldview-bitmask current-worldview-hasse-registry-handle current-zonk-call-counts - -# --- CIU T6 D4.P4c-4a (2026-08-02) --- From 8392130f07c3f6ab24da49189d6960fc37fe9829 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 23:26:55 +0000 Subject: [PATCH 6/8] =?UTF-8?q?tools:=20lint-hygiene.sh=20=E2=80=94=20batc?= =?UTF-8?q?h=20raco=20review=20into=20one=20invocation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One Racket startup for the whole file set instead of one per file; --all drops from ~135 startups to 1 (full-tree gate ~2.5min, dominated by review's per-module analysis; --staged/default stay seconds since they only review changed files). No tests: tooling change, validated by timed --all run (exit 0, output identical modulo batching). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GDBXgAcZS43bsR2p6WF1vi --- tools/lint-hygiene.sh | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/tools/lint-hygiene.sh b/tools/lint-hygiene.sh index e78c7320..6e5d6220 100755 --- a/tools/lint-hygiene.sh +++ b/tools/lint-hygiene.sh @@ -91,17 +91,22 @@ if [ -n "$files" ]; then if "$RACKET" -l raco -- review --help >/dev/null 2>&1; then echo "" echo "== raco review (report-only; require-ordering noise filtered) ==" - review_out="" + # collect existing files (repo-relative or absolute) into one invocation + targets=() while IFS= read -r f; do [ -z "$f" ] && continue - [ -f "$REPO_ROOT/$f" ] || [ -f "$f" ] && true || continue - target="$f"; [ -f "$REPO_ROOT/$f" ] && target="$REPO_ROOT/$f" - out=$("$RACKET" -l raco -- review "$target" 2>&1 \ - | grep -v 'should come before' || true) - [ -n "$out" ] && review_out="$review_out$out"$'\n' + if [ -f "$REPO_ROOT/$f" ]; then targets+=("$REPO_ROOT/$f") + elif [ -f "$f" ]; then targets+=("$f") + fi done <<< "$files" + if [ ${#targets[@]} -gt 0 ]; then + review_out=$("$RACKET" -l raco -- review "${targets[@]}" 2>&1 \ + | grep -v 'should come before' || true) + else + review_out="" + fi if [ -n "$review_out" ]; then - printf '%s' "$review_out" + printf '%s\n' "$review_out" echo "(report-only — fix what is real, ignore what is not)" else echo "clean." From 10cd78629c8558735fd8178bede065d03e4bd4a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 09:19:23 +0000 Subject: [PATCH 7/8] ci: diff-scope + time-cap the report-only raco review step The lint job's review sweep over all ~140 production files blew the job's 10-min timeout: raco review costs ~10-15s PER FILE on this tree's large modules (measured: zonk.rkt alone 16.7s; a full sweep is ~25 min). The blocking custom lints were green in 2.5s; only the report-only step overran. - lint-hygiene.sh: new --diff REF mode (merge-base when computable, REF itself in shallow CI clones) + LINT_REVIEW_TIMEOUT bound (default 240s, coreutils timeout when present) with an explicit TRUNCATED note. - test.yml lint job: PRs fetch the base sha and run --diff over exactly the PR's changed files (~seconds); push-to-main runs blocking lints only, review already reported per-PR. - pre-commit Gate 3: LINT_REVIEW_TIMEOUT=60 so commits stay fast; comment no longer claims review is ~1-2s. - testing.md: record the per-file cost + the capping/diff-scoping so the next session doesn't re-learn it. No tests: CI/tooling shell + docs only, no production .rkt semantics. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GDBXgAcZS43bsR2p6WF1vi --- .claude/rules/testing.md | 2 +- .github/workflows/test.yml | 18 ++++++++++++++++- tools/git-hooks/pre-commit | 15 ++++++++------ tools/lint-hygiene.sh | 41 +++++++++++++++++++++++++++++++++++--- 4 files changed, 65 insertions(+), 11 deletions(-) diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index d9c3e8a5..245aed7f 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -49,4 +49,4 @@ - **Pre-push gate**: A git pre-push hook (`.git/hooks/pre-push`) runs the full suite if no `timings.jsonl` entry exists for HEAD. If a run already exists for the current commit, the hook skips (no redundant re-run). Bypass with `git push --no-verify` in emergencies. - **Pre-commit gate**: A git pre-commit hook (`tools/git-hooks/pre-commit`, installed via `tools/install-git-hooks.sh`) runs `tools/check-parens.sh` on staged `.rkt` files. ~100ms per file (read-syntax via Racket); blocks the commit on delimiter mismatch with exact line:column. Closes the bug class where mechanical edits (sed surgery, batch refactors) introduce unbalanced parens that break `raco pkg install` downstream — a pattern that hit `main` once already (commit `d7bd97a4`, fixed in PR #29). Bypass with `git commit --no-verify` for genuine emergencies; failures otherwise are real bugs to fix before committing. - **Parameter-leakage lint** (A3-static-lint, BSP-LE Track 2B addendum) -- `racket tools/lint-parameters.rkt` classifies each `make-parameter` call as private / test-registered / unclassified. Uses a baseline file (`tools/parameter-lint-baseline.txt`) to track currently-accepted unclassified parameters; only flags NEW additions. Run: `racket tools/lint-parameters.rkt` (report), `--strict` (exit non-zero if new unclassified found — for CI / manual audit), `--save-baseline` (accept current state as new baseline). Architectural answer is PM Track 12 (parameters → cells for module loading) which obsoletes this lint. Longitudinal pattern 7 (two-context boundary bugs, 6+ PIRs) — tactical near-term protection against silent regressions. -- **Hygiene lint gate** (2026-08-05) -- `tools/lint-hygiene.sh` runs the correctness lints in one shot; it is pre-commit Gate 3 and a CI job. BLOCKING (baseline-gated, only NEW findings fail): `tools/lint-pnet-registration.rkt` (AST node missing pnet-serialize registration → the vector-impostor class, pipeline.md § New AST Node step 6), `tools/lint-fire-fn-capture.rkt` (fire fn reads/writes a captured stale network → the silent write-loss class, propagator-design.md § Fire Function Network Parameter), `tools/lint-memo-hash.rkt` (equal-based hash near memo/cache context → the depth-bounded-hash O(N³) class, GitHub #58), plus `lint-parameters --strict`. REPORT-ONLY: `raco review` (unused identifiers/requires, shadowing; install once with `raco pkg install review`; require-ordering noise is filtered). Each lint also runs standalone with `--strict` / `--save-baseline`; baselines live next to the lints in `tools/` and should only shrink. When a blocking lint fires on your commit, fix the finding (the report names the rule doc) — baseline updates are for audited-safe cases only, stated in the commit message. +- **Hygiene lint gate** (2026-08-05) -- `tools/lint-hygiene.sh` runs the correctness lints in one shot; it is pre-commit Gate 3 and a CI job. BLOCKING (baseline-gated, only NEW findings fail): `tools/lint-pnet-registration.rkt` (AST node missing pnet-serialize registration → the vector-impostor class, pipeline.md § New AST Node step 6), `tools/lint-fire-fn-capture.rkt` (fire fn reads/writes a captured stale network → the silent write-loss class, propagator-design.md § Fire Function Network Parameter), `tools/lint-memo-hash.rkt` (equal-based hash near memo/cache context → the depth-bounded-hash O(N³) class, GitHub #58), plus `lint-parameters --strict`. REPORT-ONLY: `raco review` (unused identifiers/requires, shadowing; install once with `raco pkg install review`; require-ordering noise is filtered). ⚠ review costs ~10-15s PER FILE on this tree's large modules (a full-tree sweep is ~25 min — it blew CI's 10-min lint job on PR #81), so the review step is time-capped via `LINT_REVIEW_TIMEOUT` (default 240s; pre-commit uses 60s) and CI diff-scopes it to the PR's changed files (`--diff REF`); the blocking custom lints always scan the whole tree (~2s). Each lint also runs standalone with `--strict` / `--save-baseline`; baselines live next to the lints in `tools/` and should only shrink. When a blocking lint fires on your commit, fix the finding (the report names the rule doc) — baseline updates are for audited-safe cases only, stated in the commit message. diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8fd9cafb..9fd08b93 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -21,8 +21,24 @@ jobs: - name: Install review linter run: raco pkg install --auto --skip-installed review + # raco review costs ~10-15s PER FILE on this tree's large modules — a + # full-tree sweep is ~25 min and blew this job's 10-min timeout (PR #81). + # The blocking custom lints always scan the whole tree (~2s); only the + # report-only review step is diff-scoped to what the PR touched. + - name: Fetch PR base (for diff-scoped review) + if: github.event_name == 'pull_request' + run: git fetch --no-tags --depth=1 origin ${{ github.event.pull_request.base.sha }} + - name: Hygiene lints (custom lints blocking, raco review report-only) - run: tools/lint-hygiene.sh --all + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + tools/lint-hygiene.sh --diff ${{ github.event.pull_request.base.sha }} + else + # push to main: blocking lints over the whole tree; review skipped + # (clean checkout has no modified files — the per-PR report already + # covered these changes on the way in). + tools/lint-hygiene.sh + fi test: runs-on: ubuntu-latest diff --git a/tools/git-hooks/pre-commit b/tools/git-hooks/pre-commit index 027bb2d6..f3c6fec2 100755 --- a/tools/git-hooks/pre-commit +++ b/tools/git-hooks/pre-commit @@ -2,12 +2,15 @@ # # pre-commit — fast lint gates on staged .rkt files # -# Three gates, all fast (~1-2s each for typical staged set): -# 1. tools/check-parens.sh — delimiter balance via Racket's reader -# 2. tools/check-stdout-clean.rkt — module-load stdout pollution +# Three gates: +# 1. tools/check-parens.sh — delimiter balance via Racket's reader (~1s) +# 2. tools/check-stdout-clean.rkt — module-load stdout pollution (~1s) # 3. tools/lint-hygiene.sh — correctness lints (pnet registration, # fire-fn capture, memo-hash, parameters), baseline-gated: only NEW -# findings block. raco review output is report-only. +# findings block (~2s). raco review output is report-only and costs +# ~10-15s PER staged file — capped at 60s here so commits stay fast +# (a truncated report says so; run tools/lint-hygiene.sh manually for +# the full sweep). # # Bypass: git commit --no-verify (emergencies only). # @@ -69,5 +72,5 @@ if [ -n "$prod_files" ]; then fi # Gate 3: hygiene lints on the staged files (custom lints blocking on NEW -# findings only; raco review report-only) -"$REPO_ROOT/tools/lint-hygiene.sh" --staged +# findings only; raco review report-only, capped at 60s so commits stay fast) +LINT_REVIEW_TIMEOUT="${LINT_REVIEW_TIMEOUT:-60}" "$REPO_ROOT/tools/lint-hygiene.sh" --staged diff --git a/tools/lint-hygiene.sh b/tools/lint-hygiene.sh index 6e5d6220..1e3a416c 100755 --- a/tools/lint-hygiene.sh +++ b/tools/lint-hygiene.sh @@ -22,8 +22,17 @@ # tools/lint-hygiene.sh # lints + review over modified .rkt files # tools/lint-hygiene.sh --staged # lints + review over staged .rkt files # tools/lint-hygiene.sh --all # lints + review over ALL production .rkt files +# tools/lint-hygiene.sh --diff REF # lints + review over .rkt files changed vs REF +# # (merge-base when computable, else REF itself — +# # the CI mode: review only what the PR touched) # tools/lint-hygiene.sh FILE.rkt ... # lints + review over the given files # +# Review time budget: raco review costs ~10-15s PER FILE on this tree's large +# modules (measured 2026-08-06 — a full-tree sweep is ~25 min, which blew CI's +# 10-min lint job). The review step is therefore bounded by LINT_REVIEW_TIMEOUT +# seconds (default 240) when the `timeout` command exists; on expiry the report +# is truncated with a note. Blocking lints are never bounded — they are ~2s. +# # Exit: non-zero iff a BLOCKING custom lint found NEW (non-baselined) issues. # # Racket resolution: $RACKET env var, else the project-standard macOS path, @@ -48,15 +57,24 @@ fi # --- Target file selection ------------------------------------------------- mode="modified" +diff_ref="" explicit_files=() +expect_diff_ref=0 for arg in "$@"; do + if [ "$expect_diff_ref" -eq 1 ]; then + diff_ref="$arg"; expect_diff_ref=0; continue + fi case "$arg" in --staged) mode="staged" ;; --all) mode="all" ;; - -h|--help) sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + --diff) mode="diff"; expect_diff_ref=1 ;; + -h|--help) sed -n '2,40p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; *) mode="explicit"; explicit_files+=("$arg") ;; esac done +if [ "$mode" = "diff" ] && [ -z "$diff_ref" ]; then + echo "lint-hygiene: --diff requires a ref argument" >&2; exit 1 +fi case "$mode" in staged) files=$(git -C "$REPO_ROOT" diff --cached --name-only --diff-filter=AM -- '*.rkt') ;; @@ -64,6 +82,11 @@ case "$mode" in all) files=$(cd "$REPO_ROOT" && find racket/prologos -name '*.rkt' \ -not -path '*/compiled/*' -not -path '*/tests/*' \ -not -path '*/benchmarks/*' -not -path '*/tools/*') ;; + diff) # merge-base when computable (local three-dot semantics); in a + # shallow CI clone merge-base fails — fall back to REF itself, + # which against the PR merge commit is exactly the PR's changes. + base=$(git -C "$REPO_ROOT" merge-base "$diff_ref" HEAD 2>/dev/null || echo "$diff_ref") + files=$(git -C "$REPO_ROOT" diff --name-only --diff-filter=AM "$base" HEAD -- '*.rkt' 2>/dev/null) ;; explicit) files=$(printf '%s\n' "${explicit_files[@]}") ;; esac @@ -99,9 +122,18 @@ if [ -n "$files" ]; then elif [ -f "$f" ]; then targets+=("$f") fi done <<< "$files" + review_timed_out=0 if [ ${#targets[@]} -gt 0 ]; then - review_out=$("$RACKET" -l raco -- review "${targets[@]}" 2>&1 \ - | grep -v 'should come before' || true) + # Bound the report-only step: review costs ~10-15s/file here, and an + # unbounded sweep once ate CI's whole 10-min lint budget (PR #81). + budget="${LINT_REVIEW_TIMEOUT:-240}" + if command -v timeout >/dev/null 2>&1; then + raw_out=$(timeout "$budget" "$RACKET" -l raco -- review "${targets[@]}" 2>&1) + [ $? -eq 124 ] && review_timed_out=1 + else + raw_out=$("$RACKET" -l raco -- review "${targets[@]}" 2>&1) + fi + review_out=$(printf '%s\n' "$raw_out" | grep -v 'should come before' || true) else review_out="" fi @@ -111,6 +143,9 @@ if [ -n "$files" ]; then else echo "clean." fi + if [ "$review_timed_out" = "1" ]; then + echo "note: review report TRUNCATED at ${budget}s (LINT_REVIEW_TIMEOUT) — ${#targets[@]} file(s) requested" + fi else echo "" echo "note: raco review not installed — skipping (raco pkg install review)" From a2576ff766d6fcecae4b672965a57239a37a8400 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 11:41:23 +0000 Subject: [PATCH 8/8] =?UTF-8?q?lints:=20adversarial-review=20fixes=20?= =?UTF-8?q?=E2=80=94=204=20confirmed=20gaps=20closed=20+=20regression=20te?= =?UTF-8?q?sts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Antagonistic review of the PR's own tooling, every finding confirmed by a live probe before fixing: 1. fire-fn-capture missed the historical bug shape when the fire fn is a NAMED helper passed by reference (no "fire" in the name — the Track 2B discrimination-propagator form). New rule (c): any define whose name appears in an installer call's argument positions is a fire scope. Tree-wide findings stay 0 — pure coverage, zero noise. 2. memo-hash regex required a char after "make-hash", so '(make-hash' with its argument on the next line evaded the scan entirely. Plain bug, fixed with an EOL alternative. 3. memo-hash +-2-line context window missed the ordinary comment-block + blank + define layout. Widened to +-4; measured tree-wide cost: one extra finding, already covered by an existing baseline key. 4. lint-hygiene review filter dropped only 'should come before' — 'should come after' require-ordering noise leaked through (observed live on zonk.rkt). Filter now covers both spellings. 5. The pre-commit 60s review cap was FICTION on macOS (no coreutils timeout there — the primary dev machine ran uncapped). perl-alarm fallback (alarm survives exec; rc 142) with the same truncation note. Tests: the PR's original fixtures lived only in the session scratchpad — nothing pinned detection behavior. tests/test-hygiene-lints.rkt now runs each lint against committed fixtures (bug flagged / corrected twin clean / rule-c helper flagged / EOL memo flagged) plus a pnet-lint smoke test asserting struct discovery still sees 300+ structs. Fixtures are .rktl DELIBERATELY: precompile-modules! recursively raco-makes every tests/**/*.rkt and raco test would execute them — a .rkt fixture with unbound identifiers breaks both (verified live). Known limitation left standing (documented, by design): the over-approximate binder set means an unrelated in-scope binding of the captured name (e.g. a match pattern) suppresses a true positive — the false-negative-only direction keeps flag noise at zero. Validated: 6/6 via raco test; tree-wide strict runs green (0 NEW on all lints); batch-runner DEAD-WORKERS banner reproduces on an untouched known-good test in this container, i.e. environmental, not this change. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GDBXgAcZS43bsR2p6WF1vi --- .../tests/lint-fixtures/fire-clean.rktl | 9 ++ .../tests/lint-fixtures/fire-doc-bug.rktl | 10 ++ .../lint-fixtures/fire-named-helper.rktl | 8 ++ .../tests/lint-fixtures/memo-clean.rktl | 3 + .../tests/lint-fixtures/memo-eol.rktl | 6 + racket/prologos/tests/test-hygiene-lints.rkt | 106 ++++++++++++++++++ .../prologos/tools/lint-fire-fn-capture.rkt | 59 +++++++--- racket/prologos/tools/lint-memo-hash.rkt | 11 +- tools/lint-hygiene.sh | 13 ++- 9 files changed, 206 insertions(+), 19 deletions(-) create mode 100644 racket/prologos/tests/lint-fixtures/fire-clean.rktl create mode 100644 racket/prologos/tests/lint-fixtures/fire-doc-bug.rktl create mode 100644 racket/prologos/tests/lint-fixtures/fire-named-helper.rktl create mode 100644 racket/prologos/tests/lint-fixtures/memo-clean.rktl create mode 100644 racket/prologos/tests/lint-fixtures/memo-eol.rktl create mode 100644 racket/prologos/tests/test-hygiene-lints.rkt diff --git a/racket/prologos/tests/lint-fixtures/fire-clean.rktl b/racket/prologos/tests/lint-fixtures/fire-clean.rktl new file mode 100644 index 00000000..a507dabb --- /dev/null +++ b/racket/prologos/tests/lint-fixtures/fire-clean.rktl @@ -0,0 +1,9 @@ +#lang racket/base +;; FIXTURE: corrected twin — every cell op goes through the fire fn's own +;; `net` parameter. Must produce zero findings. +(define n (make-network)) +(define (install!) + (net-add-propagator n (list some-cid) (list result-cid) + (lambda (net) + (define val (net-cell-read net some-cid)) + (net-cell-write net result-cid val)))) diff --git a/racket/prologos/tests/lint-fixtures/fire-doc-bug.rktl b/racket/prologos/tests/lint-fixtures/fire-doc-bug.rktl new file mode 100644 index 00000000..ed732bc5 --- /dev/null +++ b/racket/prologos/tests/lint-fixtures/fire-doc-bug.rktl @@ -0,0 +1,10 @@ +#lang racket/base +;; FIXTURE (read by lint, never compiled): the exact WRONG example from +;; propagator-design.md § Fire Function Network Parameter — the fire fn +;; writes through the captured installation-time network `n`. +(define n (make-network)) +(define (install!) + (net-add-propagator n (list some-cid) (list result-cid) + (lambda (net) + (define val (net-cell-read net some-cid)) + (net-cell-write n result-cid val)))) diff --git a/racket/prologos/tests/lint-fixtures/fire-named-helper.rktl b/racket/prologos/tests/lint-fixtures/fire-named-helper.rktl new file mode 100644 index 00000000..2d4e3e82 --- /dev/null +++ b/racket/prologos/tests/lint-fixtures/fire-named-helper.rktl @@ -0,0 +1,8 @@ +#lang racket/base +;; FIXTURE: the Track 2B "discrimination propagator" shape written as a +;; NAMED helper (no "fire" in the name) passed by reference — rule (c). +(define n (make-network)) +(define (discriminate-step net2) + (net-cell-write n out-cid 42)) +(define (install!) + (net-add-propagator n (list in-cid) (list out-cid) discriminate-step)) diff --git a/racket/prologos/tests/lint-fixtures/memo-clean.rktl b/racket/prologos/tests/lint-fixtures/memo-clean.rktl new file mode 100644 index 00000000..eb443dd4 --- /dev/null +++ b/racket/prologos/tests/lint-fixtures/memo-clean.rktl @@ -0,0 +1,3 @@ +#lang racket/base +;; memoization table — FIXTURE: eq-keyed twin, must produce zero findings. +(define memo-table (make-hasheq)) diff --git a/racket/prologos/tests/lint-fixtures/memo-eol.rktl b/racket/prologos/tests/lint-fixtures/memo-eol.rktl new file mode 100644 index 00000000..cde5606b --- /dev/null +++ b/racket/prologos/tests/lint-fixtures/memo-eol.rktl @@ -0,0 +1,6 @@ +#lang racket/base +;; memoization table for whnf results — FIXTURE: hash constructor with its +;; argument on the next line; the end-of-line regex case. +(define memo-table + (make-hash + )) diff --git a/racket/prologos/tests/test-hygiene-lints.rkt b/racket/prologos/tests/test-hygiene-lints.rkt new file mode 100644 index 00000000..4346f7f4 --- /dev/null +++ b/racket/prologos/tests/test-hygiene-lints.rkt @@ -0,0 +1,106 @@ +#lang racket/base + +;;; +;;; test-hygiene-lints.rkt — regression tests for the custom hygiene lints +;;; +;;; The lints (tools/lint-fire-fn-capture.rkt, tools/lint-memo-hash.rkt, +;;; tools/lint-pnet-registration.rkt) are static guards for documented bug +;;; classes. These tests pin their DETECTION behavior against committed +;;; fixtures in tests/lint-fixtures/ — bug shapes flagged, corrected twins +;;; clean — so a lint refactor cannot silently lose the very case each lint +;;; exists for. (PR #81's original validation ran against session-local +;;; fixtures that were never committed; this file closes that gap.) +;;; +;;; Fixtures are READ by the lints, never compiled — they reference unbound +;;; identifiers freely. They use the .rktl extension DELIBERATELY: the suite +;;; runner's precompile-modules! walks tests/ RECURSIVELY compiling every +;;; *.rkt (bench-lib.rkt), and `raco test tests/` would try to run them — +;;; a .rkt fixture with unbound identifiers breaks both. .rktl is invisible +;;; to every \.rkt$ glob while the lints read file CONTENT, not extension. +;;; + +(require rackunit + racket/port + racket/system + racket/path) + +(define here (path-only (path->complete-path (syntax-source #'here)))) +(define tools-dir (simplify-path (build-path here 'up "tools"))) +(define fixtures-dir (build-path here "lint-fixtures")) +;; exec-file can come back RELATIVE (just "racket"); system* does not search +;; PATH, so resolve it ourselves in that case. +(define racket-bin + (let ([p (find-system-path 'exec-file)]) + (if (absolute-path? p) p (or (find-executable-path p) p)))) + +;; Run a lint script on fixture files; return (values exit-ok? output). +;; Without --strict the lints always exit 0, so detection is asserted via +;; the "NEW: n" count in the report line. +(define (run-lint lint-name . fixture-names) + (define script (build-path tools-dir (string-append lint-name ".rkt"))) + (define args (for/list ([f (in-list fixture-names)]) + (path->string (build-path fixtures-dir f)))) + (define out (open-output-string)) + (define ok? + (parameterize ([current-output-port out] + [current-error-port out]) + (apply system* racket-bin (path->string script) args))) + (values ok? (get-output-string out))) + +(define (new-count output) + (define m (regexp-match #px"NEW: (\\d+)" output)) + (and m (string->number (cadr m)))) + +;; ============================================================ +;; lint-fire-fn-capture +;; ============================================================ + +(test-case "fire-fn-capture: the propagator-design.md WRONG example is flagged" + (define-values (ok? out) (run-lint "lint-fire-fn-capture" "fire-doc-bug.rktl")) + (check-true ok? out) + (check-equal? (new-count out) 1 out) + ;; the report names the captured variable + (check-regexp-match #px"uses 'n'" out)) + +(test-case "fire-fn-capture: corrected twin (net param everywhere) is clean" + (define-values (ok? out) (run-lint "lint-fire-fn-capture" "fire-clean.rktl")) + (check-true ok? out) + (check-equal? (new-count out) 0 out)) + +(test-case "fire-fn-capture: named helper passed by reference is flagged (rule c)" + (define-values (ok? out) (run-lint "lint-fire-fn-capture" "fire-named-helper.rktl")) + (check-true ok? out) + (check-equal? (new-count out) 1 out)) + +;; ============================================================ +;; lint-memo-hash +;; ============================================================ + +(test-case "memo-hash: (make-hash at end-of-line near memo context is flagged" + (define-values (ok? out) (run-lint "lint-memo-hash" "memo-eol.rktl")) + (check-true ok? out) + (check-equal? (new-count out) 1 out)) + +(test-case "memo-hash: make-hasheq twin is clean" + (define-values (ok? out) (run-lint "lint-memo-hash" "memo-clean.rktl")) + (check-true ok? out) + (check-equal? (new-count out) 0 out)) + +;; ============================================================ +;; lint-pnet-registration (fixed surfaces: syntax.rkt + pnet-serialize.rkt — +;; no fixture mode; pin that it parses the real tree and stays baseline-clean) +;; ============================================================ + +(test-case "pnet-registration: parses the real tree, strict-clean vs baseline" + (define script (build-path tools-dir "lint-pnet-registration.rkt")) + (define out (open-output-string)) + (define ok? + (parameterize ([current-output-port out] + [current-error-port out]) + (system* racket-bin (path->string script) "--strict"))) + (define s (get-output-string out)) + (check-true ok? s) + ;; struct discovery actually worked (the tree has 300+ expr structs); + ;; a parse regression that found 0 structs would otherwise pass silently + (define m (regexp-match #px"\\((\\d+) structs in syntax.rkt\\)" s)) + (check-true (and m (> (string->number (cadr m)) 300)) s)) diff --git a/racket/prologos/tools/lint-fire-fn-capture.rkt b/racket/prologos/tools/lint-fire-fn-capture.rkt index 01a2ef17..aafd2dad 100644 --- a/racket/prologos/tools/lint-fire-fn-capture.rkt +++ b/racket/prologos/tools/lint-fire-fn-capture.rkt @@ -15,9 +15,12 @@ ;;; (a) any lambda appearing inside the arguments of a propagator ;;; installer call (net-add-propagator, net-add-fire-once-propagator, ;;; net-add-broadcast-propagator, net-add-threshold, -;;; elab-add-propagator), or +;;; elab-add-propagator), ;;; (b) any define / let-family binding whose NAME matches #px"fire" -;;; (fire-fn, fire, make-*-fire-fn, ...). +;;; (fire-fn, fire, make-*-fire-fn, ...), or +;;; (c) any define / let-family binding whose name appears as an argument +;;; (after the network arg) of an installer call — a fire fn passed +;;; by reference under a name without "fire" in it. ;;; Within a fire scope, every cell-op call (net-cell-read, net-cell-write, ;;; elab-cell-read, elab-cell-write, ...) whose network argument is an ;;; identifier NOT bound anywhere inside that scope is flagged: the @@ -171,33 +174,56 @@ ;; Fire-scope discovery ;; ============================================================ -;; Returns a list of (cons stx datum) fire scopes found in the module. +;; Returns a list of fire-scope syntax objects found in the module. +;; +;; Two passes: pass 1 finds installer calls, collecting (a) inline lambdas +;; in their arguments and (rule c) SYMBOLS in argument positions after the +;; network arg — a fire fn passed BY REFERENCE (the Track 2B "discrimination +;; propagator" shape written as a named helper) would otherwise be invisible +;; when its name doesn't happen to match #px"fire". Pass 2 then treats any +;; define / let-binding as a fire scope when its name matches #px"fire" (rule +;; b) OR was referenced from an installer call (rule c). Value defines swept +;; in by rule c (cell-id lists etc.) are harmless: they contain no net-ops, +;; so they can only add coverage, never noise. (define (find-fire-scopes stx) (define scopes '()) + (define ref-candidates (make-hasheq)) + (define (scope-name? name) + (and name + (or (regexp-match? fire-name-rx (symbol->string name)) + (hash-ref ref-candidates name #f)))) + ;; pass 1: installer calls + (walk-stx + (lambda (s datum) + (define head (and (pair? datum) (car datum))) + (when (and (symbol? head) (memq head installer-heads)) + ;; every lambda within the arguments is a fire scope + (walk-stx + (lambda (inner-s inner-d) + (when (and (pair? inner-d) (memq (car inner-d) '(lambda λ case-lambda))) + (set! scopes (cons inner-s scopes)))) + s) + ;; rule c candidates: symbol args after the network argument + (when (and (pair? (cdr datum)) (list? (cddr datum))) + (for ([arg (in-list (cddr datum))] #:when (symbol? arg)) + (hash-set! ref-candidates arg #t))))) + stx) + ;; pass 2: named scopes (rule b by name, rule c by installer reference) (walk-stx (lambda (s datum) (define head (and (pair? datum) (car datum))) (cond - ;; installer call → every lambda within its arguments is a fire scope - [(and (symbol? head) (memq head installer-heads)) - (walk-stx - (lambda (inner-s inner-d) - (when (and (pair? inner-d) (memq (car inner-d) '(lambda λ case-lambda))) - (set! scopes (cons inner-s scopes)))) - s)] - ;; (define fire-ish ...) / (define (fire-ish ...) ...) / let-binding [(and (memq head '(define define-values)) (pair? (cdr datum))) (define target (cadr datum)) (define name (cond [(symbol? target) target] [(and (pair? target) (symbol? (car target))) (car target)] [else #f])) - (when (and name (regexp-match? fire-name-rx (symbol->string name))) + (when (scope-name? name) (set! scopes (cons s scopes)))] [(and (memq head '(let let* letrec)) (pair? (cdr datum)) (list? (cadr datum))) (for ([b (in-list (cadr datum))]) - (when (and (pair? b) (symbol? (car b)) - (regexp-match? fire-name-rx (symbol->string (car b)))) + (when (and (pair? b) (symbol? (car b)) (scope-name? (car b))) (set! scopes (cons s scopes))))] [else (void)])) stx) @@ -227,7 +253,10 @@ (define (scan-file path) (define rel (path->string (find-relative-path project-root (simplify-path path)))) (with-handlers ([exn:fail? (lambda (e) - (eprintf "SKIP ~a (read error: ~a)\n" rel (exn-message e)) + ;; read OR analysis error — either way the file + ;; goes unscanned; say so rather than blaming read + (eprintf "SKIP ~a (unscanned, read/analysis error: ~a)\n" + rel (exn-message e)) '())]) (define stx (read-module-stx path)) (for*/list ([scope (in-list (remove-duplicates (find-fire-scopes stx) eq?))] diff --git a/racket/prologos/tools/lint-memo-hash.rkt b/racket/prologos/tools/lint-memo-hash.rkt index 2168f185..eb1b7ea5 100644 --- a/racket/prologos/tools/lint-memo-hash.rkt +++ b/racket/prologos/tools/lint-memo-hash.rkt @@ -15,7 +15,7 @@ ;;; ;;; Detection: line-scan (comments intentionally count — "memoization" ;;; usually lives in a comment) for make-hash / make-weak-hash sites -;;; whose surrounding +-2-line window mentions memo/cache/seen. Each finding is keyed by file + nearest enclosing +;;; whose surrounding +-4-line window mentions memo/cache/seen. Each finding is keyed by file + nearest enclosing ;;; define name (line numbers drift). Baselined findings should each have ;;; been audited: fine when keys are NOT expr trees (symbols, strings, ;;; small fixed-shape keys); a real hazard when they are. @@ -41,9 +41,14 @@ (define project-root (simplify-path (build-path tools-dir 'up))) (define baseline-path (build-path tools-dir "memo-hash-baseline.txt")) -(define hash-site-rx #px"\\(make-(weak-)?hash[\\s)]") +;; `$` alternative: `(make-hash` with its argument on the NEXT line ends the +;; line right after "hash" — without it that layout evades the scan entirely. +(define hash-site-rx #px"\\(make-(weak-)?hash([\\s)]|$)") (define context-rx #px"(?i:memo|cache|seen)") -(define context-window 2) +;; ±4 lines: a typical comment block + blank line + define spans 3-4 lines; +;; ±2 missed that shape. Measured tree-wide cost of 2→4: exactly one extra +;; finding, already covered by an existing baseline key. +(define context-window 4) (define define-name-rx #px"\\(define(?:-values)?\\s+\\(?\\s*([a-zA-Z][a-zA-Z0-9!?*/<>+=:._-]*)") ;; ============================================================ diff --git a/tools/lint-hygiene.sh b/tools/lint-hygiene.sh index 1e3a416c..b1fc34a0 100755 --- a/tools/lint-hygiene.sh +++ b/tools/lint-hygiene.sh @@ -127,13 +127,24 @@ if [ -n "$files" ]; then # Bound the report-only step: review costs ~10-15s/file here, and an # unbounded sweep once ate CI's whole 10-min lint budget (PR #81). budget="${LINT_REVIEW_TIMEOUT:-240}" + # coreutils timeout on Linux/CI; perl-alarm fallback on macOS (stock + # macOS ships no `timeout`, and an uncapped run would make the + # pre-commit 60s cap a fiction on the primary dev machine). perl's + # alarm survives exec, so the review process dies on SIGALRM (rc 142). if command -v timeout >/dev/null 2>&1; then raw_out=$(timeout "$budget" "$RACKET" -l raco -- review "${targets[@]}" 2>&1) [ $? -eq 124 ] && review_timed_out=1 + elif command -v perl >/dev/null 2>&1; then + raw_out=$(perl -e 'alarm shift @ARGV; exec @ARGV' "$budget" \ + "$RACKET" -l raco -- review "${targets[@]}" 2>&1) + [ $? -eq 142 ] && review_timed_out=1 else raw_out=$("$RACKET" -l raco -- review "${targets[@]}" 2>&1) fi - review_out=$(printf '%s\n' "$raw_out" | grep -v 'should come before' || true) + # require-ordering noise comes in BOTH spellings ("should come before" + # / "should come after") — filter both. + review_out=$(printf '%s\n' "$raw_out" \ + | grep -vE 'should come (before|after)' || true) else review_out="" fi