Background
The current interpolateEnv() implementation does post-parse coercion: it replaces ${{ VAR }} references after YAML is parsed, then coerces the resulting string to its native type (boolean, number) based on the resolved value.
This is field-agnostic — the function has no schema knowledge, so coercion applies to every whole-value substitution regardless of the expected type of that field.
Problem
Post-parse coercion is fragile by nature:
- A string field like
branch_prefix: ${{ PREFIX }} will be coerced to a number if PREFIX=42, even though branch_prefix is typed as string.
- Missing variables resolve to empty string
"" rather than null, which can differ semantically from leaving the field unset.
- Requires
PLAIN_NUMBER_PATTERN to exclude edge cases like "1e3", "0x10", "Infinity" — this is treating symptoms, not the root cause.
Better Approach: Pre-parse rendering (Helm/Ansible pattern)
Replace ${{ VAR }} references in the raw YAML string before parsing, so the YAML parser itself handles type inference:
YAML text → replace ${{ VAR }} → "auto_push: true" → parseYamlValue() → boolean true
This is how Helm, Ansible/Jinja2, and Docker Compose work. Types are correct by construction because YAML's own parser understands true as boolean, 42 as integer, etc. No coercion logic required.
Trade-offs
- Injection risk: same as any shell-based tool (env vars are from the user's own environment — already trusted).
- Missing vars: unset
${{ AUTOPUSH }} renders to auto_push: → YAML parses as null (falsy), which is more useful than empty string.
- Migration: the public API of
interpolateEnv() changes — it would accept string (raw YAML text) and return unknown (parsed result), or it becomes two functions: renderTemplate(raw: string, env): string + parse. The post-parse recursive object walk becomes unnecessary for env var expansion (though it may still be needed for other reasons like eval case interpolation inside already-parsed objects).
Acceptance Criteria
Related
Background
The current
interpolateEnv()implementation does post-parse coercion: it replaces${{ VAR }}references after YAML is parsed, then coerces the resulting string to its native type (boolean, number) based on the resolved value.This is field-agnostic — the function has no schema knowledge, so coercion applies to every whole-value substitution regardless of the expected type of that field.
Problem
Post-parse coercion is fragile by nature:
branch_prefix: ${{ PREFIX }}will be coerced to a number ifPREFIX=42, even thoughbranch_prefixis typed asstring.""rather thannull, which can differ semantically from leaving the field unset.PLAIN_NUMBER_PATTERNto exclude edge cases like"1e3","0x10","Infinity"— this is treating symptoms, not the root cause.Better Approach: Pre-parse rendering (Helm/Ansible pattern)
Replace
${{ VAR }}references in the raw YAML string before parsing, so the YAML parser itself handles type inference:This is how Helm, Ansible/Jinja2, and Docker Compose work. Types are correct by construction because YAML's own parser understands
trueas boolean,42as integer, etc. No coercion logic required.Trade-offs
${{ AUTOPUSH }}renders toauto_push:→ YAML parses asnull(falsy), which is more useful than empty string.interpolateEnv()changes — it would acceptstring(raw YAML text) and returnunknown(parsed result), or it becomes two functions:renderTemplate(raw: string, env): string+ parse. The post-parse recursive object walk becomes unnecessary for env var expansion (though it may still be needed for other reasons like eval case interpolation inside already-parsed objects).Acceptance Criteria
${{ VAR }}references are expanded in the raw YAML string beforeparseYamlValue()is calledAUTOPUSH=true) yields a YAML boolean, not a coerced stringWORKERS=4) yields a YAML integer"prefix-${{ VAR }}") is preservedPLAIN_NUMBER_PATTERNandcoercePrimitive()are deleted (no longer needed)Related
interpolateEnv(cases-validator.ts,file-reference-validator.ts,workspace-path-validator.ts) may become a non-issue under pre-parse rendering since type coercion happens at the YAML boundary.