Build1 publisher3 min readPublished
Your REPL Is Not A Container: Put Free-Variable Checks In CI Before Generated Code Ships
A generated feature-flag helper returned the right answer on a laptop and raised NameError in a clean Docker image. An AST walk over loaded-but-unbound names catches the whole class.
The Engineer · Build desk
Drafted by a language model from the sources cited here and checked against its claim ledger before publication. How we use AISend a correction
What happened
- The author almost shipped a small feature-flag helper that worked on his laptop and crashed in the clean Docker image.
- The clean container did not have the globals the generated function expected, so the same code raised NameError at the first call.
- The generated function was silently reading module-level variables that the author's interactive shell still held from an older script.
- The generated source defined is_enabled(feature), returning 'feature in FEATURE_STORE' when DEBUG is true and 'feature in DEFAULT_FEATURES' otherwise.
- The function has three names it expects to find elsewhere: DEBUG, FEATURE_STORE and DEFAULT_FEATURES; none is a parameter, a return value or an import.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
A developer writing on dev.to came within a commit of shipping a small feature-flag helper that returned the boolean he expected on his laptop and then crashed in the clean Docker image [1]. The generated function was quietly reading module-level names his interactive shell still held from an older script, so the container, which had none of them, raised NameError on the first call [3][2].
The artifact was short enough to look trustworthy. It defined `is_enabled(feature)`, returning `feature in FEATURE_STORE` when `DEBUG` was true and `feature in DEFAULT_FEATURES` otherwise [4]. Three names appear there that the function never binds: `DEBUG`, `FEATURE_STORE` and `DEFAULT_FEATURES`, none of them a parameter, a return value or an import [5]. The source does not label them as free globals, and the author's point is that a quick read of the body can miss them entirely [6].
The two runs make the asymmetry concrete. Executed in a namespace pre-seeded with `DEBUG` false, a `FEATURE_STORE` dict and a `DEFAULT_FEATURES` set, the call printed `False`, exactly as expected [7]. Executed in an empty namespace standing in for the container, the same source failed with `name 'DEBUG' is not defined` [8]. The author's reading is that only the second run told the truth: the first passed because his environment supplied the missing pieces, not because the code was complete [9].
Worth noticing how little the passing test actually exercised. With `DEBUG` bound to false, that single call evaluated `DEBUG` and `DEFAULT_FEATURES` and never touched `FEATURE_STORE` [19]. And NameError is raised one name at a time, which is why the clean run named `DEBUG` and stopped there [8]. A test loop that only runs the code in an empty namespace therefore needs up to three iterations, one crash each, to enumerate a three-name dependency set [20]. Dynamic execution reports the first thing missing on the path you happened to take; it does not report the contract.
Static inspection does. The recommended check is to find every name the generated code loads but neither assigns nor receives as a parameter, using an AST walk rather than a regex, because identifier scanning is too easily fooled by nested functions, comprehensions and string annotations [11][12]. The analyzer in the post seeds a set of bound names with `dir(builtins)`, adds every Store-context `Name`, every `ast.arg`, every function and class definition name, and every import alias, then returns the sorted difference between Load-context names and that set [13]. On the helper it prints `['DEFAULT_FEATURES', 'DEBUG', 'FEATURE_STORE']` [14]. That is all three undeclared names recovered in one pass, without executing anything [18]. The rule the author draws is the operationally useful part: a non-empty result means the artifact is not self-contained and should not be treated as finished until the caller either passes those names explicitly or marks them as an intentional runtime dependency [15].
Two things to watch if you wire this up. The analyzer proves nothing about behaviour, so the post pairs it with a second guard: execute the generated source in a namespace containing only builtins plus values you deliberately inject [16]. And the gate belongs in the code path that turns model output into an evaluation artifact, which the author describes as provider-neutral, since clean syntax and undeclared state dependencies coexist happily [10]. One caveat on provenance: the author discloses that the bug was captured while generating the helper with MonkeyCode's free model access, that the clean-namespace check was re-run on its free server option, and that the article was prepared as part of MonkeyCode's product outreach [17].