Tcl · Module 27
A Variable Contract Between Scripts
A shared setup file is a shared global namespace. A contract is what turns reading one of those variables from an invisible dependency into a declared one.
Foundation18 min readtcledapart-iiinfrastructurevalidationconventions
Part II · Module 27 · Chapter 27.4 · A Variable Contract Between Scripts
Chapter 27.3 §4 ended on a problem: a shared setup file is a shared mutable global namespace, and a script that reads one of its variables creates a dependency that leaves no trace.
A contract is the smallest thing that fixes that.
1. What the default failure looks like
Without a contract, a missing variable is discovered by using it:
can't read "LIB_PATH": no such variable
That message is accurate and nearly useless. It does not say which script needed it, what it was for, whether it was supposed to have a default, or who was meant to set it. And it arrives wherever the variable was first read — typically three steps into a flow, after the tool has loaded a design and a licence has been checked out.
The whole of this chapter is the difference between that and a message produced before any work starts.
2. The contract
namespace eval ::flow {
variable CONTRACT {
{DESIGN required {} {the top-level module name}}
{FLOW_ROOT required {} {absolute path to the flow tree}}
{STAGE required {} {synth | place | route}}
{OUT_DIR optional work {where outputs are written}}
{LIB_PATH optional libs {technology library directory}}
{VERBOSE optional 0 {1 for per-step detail}}
{STOP_ON_ERR optional 1 {0 to continue past a failed step}}
}
}Four fields per entry: the name, whether it is required, its default, and what it means.
That last field is what makes this a contract rather than a list. A name alone tells a reader nothing about whether OUT_DIR is absolute or relative, or what STAGE may contain — and the meaning string is what appears in the error message, where it is needed most.
Three properties of the shape are worth naming:
It is data, not code. Adding a variable is one line. The validator does not change, and neither does anything else — the same argument as Chapter 25.3 §4's rules-as-data and Chapter 26.4 §3's prune-rules-as-data.
Required and optional are distinguished explicitly. Without that distinction a validator cannot tell "the caller forgot" from "the caller accepted the default".
The defaults live beside the declaration, not scattered through the code as [expr {[info exists ::X] ? $::X : 0}]. One place to read what happens when a variable is absent.
3. Validating in two passes
proc ::flow::validate_vars {} {
# PASS 1: find every missing required variable, and change NOTHING.
set missing {}
foreach e $::flow::CONTRACT {
lassign $e name kind default meaning
if {$kind eq "required" && ![info exists ::$name]} {
lappend missing [format " %-12s %s" $name $meaning]
}
}
if {[llength $missing]} {
error "[llength $missing] required variable(s) not set:\n[join $missing \n]" \
{} [list FLOW MISSINGVARS [llength $missing]]
}
# PASS 2: only now apply the optional defaults.
set defaulted {}
foreach e $::flow::CONTRACT {
lassign $e name kind default meaning
if {$kind eq "optional" && ![info exists ::$name]} {
set ::$name $default
lappend defaulted [format "%s=%s" $name $default]
}
}
return $defaulted
}=== nothing set: the error names every missing variable ===
catch 1
3 required variable(s) not set:
DESIGN the top-level module name
FLOW_ROOT absolute path to the flow tree
STAGE synth | place | routeAll three at once, each with its meaning. A validator that raised on the first missing variable would need three runs to tell you what you forgot, and the registry's phrase — "fails immediately with a clear message" — means one message naming everything.
4. Why two passes
Two smaller defects in the same file, both found by running it rather than reading it:
The error listed DESIGN {the top-level module name} with braces. The line was a Tcl list, and lappend missing [list $name $meaning] then renders the two-word meaning braced. Building each line with format instead produces prose.
[join $missing \n ] does not indent. The trailing spaces before the ] are argument separators, so the join string is just \n — the indent was silently dropped. Building the indent into each line with format " %-12s %s" is what actually works. Chapter 14.8 is the general case: whitespace inside a command is structure, not content.
5. Where validation goes
The ordering follows from Chapter 27.3 §6:
source [file join $FLOW_ROOT lib setup.tcl] ;# defaults
if {[file exists $CFG]} { source $CFG } ;# overrides
::flow::validate_vars ;# validate LASTValidation runs after the override file, because validating first checks state that is about to be replaced. A run that would have been fine gets rejected, and — worse — a run that an override made invalid gets accepted.
And it runs before any tool work. Chapter 27.1 §7's exit statuses depend on that: a missing variable is exit 2, a flow failure is exit 1, and the two are only distinguishable if the variable check happens before the flow starts.
if {[catch {::flow::validate_vars} e]} {
puts stderr "run_flow.tcl: $e"
exit 2
}Note the message is prefixed with the script's own name. An error that reaches a job scheduler's log alongside fifty others needs to say which script produced it — and $argv0 or a literal is both fine; what matters is that it is there.
6. A contract checks presence, not sense
Being honest about what this does and does not buy, because "validated" is a word people over-trust.
Every one of these passes validation:
| Set to | Why it passes | What happens later |
|---|---|---|
DESIGN = "" | the variable exists | a path like work//synth, or a tool error about an empty name |
STAGE = synht | the variable exists | a stage directory nobody looks in, and no error |
OUT_DIR = /root/x | the variable exists | ensure_dir fails on permissions, three steps in |
VERBOSE = yes | the variable exists | if {$VERBOSE} raises — yes is not a Tcl boolean in expr |
So presence is the cheapest check and not the only one. The contract extends naturally:
{STAGE required {} {synth | place | route} {enum synth place route}}
{VERBOSE optional 0 {1 for per-step detail} {boolean}}
{OUT_DIR optional work {where outputs are written} {writable-dir}}...with a validator that dispatches on the fifth field. Worth doing for the handful of variables where a wrong value is expensive, and not worth doing for all of them — a validation layer nobody maintains is Chapter 25.3 §5's rule that never fires.
7. Exercises
Exercise 1. What is wrong with can't read "LIB_PATH": no such variable as a failure?
Exercise 2. Name the four fields of a contract entry and say what the last one is for.
Exercise 3. Why must required and optional be distinguished explicitly?
Exercise 4. Why does the validator report all missing variables rather than the first?
Exercise 5. What did the single-pass validator do wrong, and what is the general rule?
Exercise 6. Why did the error message render a meaning in braces?
Exercise 7. Why does [join $missing \n ] not indent?
Exercise 8. Why must validation run after the override file?
Exercise 9. Give two values that pass a presence check and break later.
Exercise 10. Why is the empty-string check the one value check always worth having, and why string trim?
Where this fits
Part of the Tcl curriculum.
