Skip to content

feat!: a variable without a default must resolve - #44

Merged
refsz merged 1 commit into
mainfrom
fix/missing-variables-are-errors
Aug 19, 2026
Merged

feat!: a variable without a default must resolve#44
refsz merged 1 commit into
mainfrom
fix/missing-variables-are-errors

Conversation

@refsz

@refsz refsz commented Aug 19, 2026

Copy link
Copy Markdown
Owner

This came out of external review of 0.2.0. The reviewer's point was narrower — migrating shellRaw() to shell() silently changes a command containing literal {{ — and following it up found the general case, which is worse.

What 0.2.0 does

{{ name }} was optional: a missing or misspelled variable substituted an empty string and the run continued. Against the released 0.2.0 PHAR, with deployPath never defined:

$ctx->shell('echo rm -rf {{ deployPath }}/');
  > echo rm -rf ''/
  rm -rf /

$ctx->exec(['echo', 'rm', '-rf', '{{ deployPath }}/']);
  > echo rm -rf /
  rm -rf /

Done — exit 0, no warning

echo stands in for rm here; the substitution is real. A typo in a variable name produces a destructive command that reports success, on both execution paths. The same typo in a template produces a config file with a blank value that looks fine.

This is the same failure mode the config validation in #40/#42 removed one layer up: a typo silently becomes an empty value and the run looks successful. Leaving it in the template layer while fixing it in the config layer would have been inconsistent.

The rule

{{ name }}           required - throws MissingVariableException
{{ name | "" }}      deliberately empty
{{ name | "x" }}     default, unchanged
{{! name }}          still accepted, now redundant

It lives in one place, Token::isRequired(): required unless it has a default. The parser already distinguished "no default" from "empty default" by capture offset, so | "" worked before this change — it just had nothing to opt out of.

Migration is in the error message

Missing required variable: deployPath (use {{ deployPath | "" }} if an empty value is intended)

Naming the escape hatch is the difference between a five-second fix and a docs hunt. For template files the message also carries the path.

Breaking

Templates and commands that relied on a missing variable rendering empty must now say so with | "". Consumers should expect to hit this once per genuinely-optional value — and, hopefully, once per typo they did not know they had.

Five tests encoded the old behaviour. They were rewritten rather than deleted, each now pinning the new rule and its explicit-empty counterpart, so a future change cannot quietly restore the old default:

  • testMissingVariableWithoutADefaultThrows / testAnExplicitEmptyDefaultIsHowYouAskForEmpty
  • testMissingVariableThrowsAndRunsNothing / testAnExplicitEmptyDefaultStillBecomesAnEmptyArgument
  • testMissingVariableThrowsInsteadOfRenderingEmpty / testEmptyIsAskedForWithAnExplicitDefault
  • the parser tests now assert that the plain form is required and only a declared default is optional

One mock needed has() stubbed alongside resolve(), which is a fair reflection of the stricter contract.

Verified

vendor/bin/phpunit                        760 tests, 1293 assertions, OK
vendor/bin/phpstan analyse                [OK] No errors
vendor/bin/php-cs-fixer fix --dry-run     0 of 153 files
vendor/bin/rector --dry-run               [OK]
mkdocs build --strict                     clean

And against the binary, same task, before and after:

0.2.0 PHAR      > echo rm -rf ''/   ->  rm -rf /            exit 0
this branch     [ERROR] Missing required variable: deployPath   exit 1

Not in this PR: the reserved-name behaviour (a project task named init currently kills the whole CLI), which is the other accepted review finding and a separate change.

🤖 Generated with Claude Code

https://claude.ai/code/session_018CTvnzcNYmFgm2HQcm821A

`{{ name }}` was optional: a missing or misspelled variable substituted an empty
string and the run continued. Against the released 0.2.0 PHAR, both execution
paths:

    $ctx->shell('echo rm -rf {{ deployPath }}/');   > echo rm -rf ''/   -> rm -rf /
    $ctx->exec(['echo', 'rm', '-rf', '{{ deployPath }}/']);             -> rm -rf /
    exit 0, no warning

deployPath was never defined. A typo in a variable name produced a destructive
command that reported success, and the same typo in a template produced a config
file with a blank value that looked fine.

Now a variable must resolve unless the template says what to use instead:

    {{ name }}           required - throws MissingVariableException
    {{ name | "" }}      deliberately empty
    {{ name | "x" }}     default, unchanged
    {{! name }}          still accepted, now redundant

The rule lives in one place, Token::isRequired(): required unless it has a
default. Because the parser already distinguishes "no default" from "empty
default" by capture offset, `| ""` was a working opt-in before this change; it
just had nothing to opt out of.

The error names the escape hatch, so migrating does not mean looking up syntax:

    Missing required variable: deployPath (use {{ deployPath | "" }} if an empty
    value is intended)

BREAKING: templates and commands that relied on a missing variable rendering
empty must now say so with `| ""`. Five tests encoded the old behaviour and were
rewritten rather than deleted - each now pins the new rule and its explicit-empty
counterpart.
@refsz
refsz merged commit 1db825d into main Aug 19, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant