ADR-014: Undefined names in rendered files¶
Status: accepted, staged — opt-in now, default in a later minor
Context¶
git-tpl treats an unknown name in two opposite ways depending on where it appears.
In a manifest expression — computed, when, default, choices_from — the dependency graph rejects it before
the first prompt, names it, and offers a suggestion (ADR-007).
This is one of the better diagnostics in the tool.
In a rendered file it does nothing at all.
MiniJinja's default undefined behaviour is lenient, so {{ projct_name }} renders to the empty string and the
command exits 0.
What comes out is:
which parses. Or:
which is valid YAML. Nothing fails until a human reads the generated project, which may be days later and in someone else's repository.
This is the worst available failure mode. It is worse still for a caller driving git-tpl non-interactively, which has no output to read and no signal to branch on: the render succeeded, the files exist, and one of them has a hole in it.
The asymmetry is not defensible once noticed. It is not a decision anybody made — it is MiniJinja's default, inherited.
Decision¶
Rendered files may be strict, and eventually will be by default.
template.toml gains strict = true, which sets UndefinedBehavior::Strict for file bodies and path segments.
An undefined name then fails the render with tpl::render::content, wrapping the tpl::eval::expression that
names the expression.
Manifest expressions stay lenient.
The graph has already rejected an unknown name in one, so making the environment strict there would be a second
check for a case that cannot arise — and when-gated questions are deliberately absent from the context rather
than null, which strictness would turn into an error in a construct the documentation recommends.
git tpl lint reports the same names as tpl::lint::undeclared warnings, whatever strict says.
Staging¶
- Now.
strictis opt-in.lintwarns. - A later minor. The default flips;
strict = falseopts out.
A flag day would break templates that render correctly today, for a change whose whole purpose is to prevent silent breakage. Warning first means the flip is a change authors have already been told about, in the words of the diagnostic they will see.
Consequences¶
Under strict, an intentionally optional value must say so:
Both already work and both are clearer than relying on leniency — the template states that the name may be absent, rather than the reader having to know that it might be.
lint needs the set of declared names, which is the manifest's questions and computed values plus the data and
template namespaces.
Names arriving from a ${{ … }} that MiniJinja would consume are excluded: matrix belongs to GitHub Actions, and
telling an author to declare it would be telling them to do the wrong thing.
The lint cannot see through {% import %} — undeclared_variables does not follow one — so a macro's own
references are not checked.
That is the same limitation the graph analysis has, and for the same reason.
What {% import %}/{% from %} does put in the file's own namespace — the alias itself — is checked, separately,
by tpl::lint::shadowed_name: an alias that reuses a question or computed name shadows it, and no name is ever
undefined, so the strict-undefined path above cannot catch it.
See lint.
A when-gated question is a third way an author can be caught out without any name ever being undeclared.
It is declared — it is a manifest key — so tpl::lint::undeclared has nothing to say about it; the gap named
above, in the reason manifest expressions stay lenient, is precisely that it has no value for every answer set
where its when is false, and reading it bare in a file that is not itself gated the same way renders fine for
every other answer set.
tpl::lint::unguarded_gate closes that: a warning when a file body reads a when-gated question without the
is defined/is not defined/default(...) idiom this ADR already recommends.
See lint.
A fourth way an author can be caught out even while following that idiom correctly: is defined/default(...)
above assumes an absent name resolves to nothing at all, but MiniJinja does not stop looking there. It falls
through the absent context entry to its own registered globals — range, dict, namespace, debug — before
finally giving up. A when-gated question named after one of those four is never undefined: is defined reports
true for the builtin, and printing the bare name renders the builtin's own representation, not the empty value
the idiom above is written to produce. tpl::lint::shadowed_builtin reports a question or computed value
declared with one of those four names, whether or not it is gated right now, since a name that is safe unguarded
today is not proof it stays safe once a when is added to it later.
See lint.