diff --git a/packages/core/src/evaluation/interpolation.ts b/packages/core/src/evaluation/interpolation.ts index 6fee62ab..7bd2dbc1 100644 --- a/packages/core/src/evaluation/interpolation.ts +++ b/packages/core/src/evaluation/interpolation.ts @@ -2,13 +2,61 @@ import type { EnvLookup } from './providers/types.js'; const ENV_VAR_PATTERN = /\$\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g; +/** + * Regex that matches a string consisting of exactly one `${{ VAR }}` reference + * and nothing else. Used to detect whole-value substitutions eligible for type coercion. + */ +const WHOLE_VAR_PATTERN = /^\$\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}$/; + +/** + * Pattern matching plain integers (e.g. "42", "-7") and decimal fractions + * (e.g. "3.14", "-0.5"). Excludes hex ("0x10"), scientific notation ("1e3"), + * "Infinity", "NaN", and whitespace-only strings that `Number()` accepts. + */ +const PLAIN_NUMBER_PATTERN = /^-?(?:0|[1-9]\d*)(?:\.\d+)?$/; + +/** + * Coerce a resolved string to its native primitive type when appropriate. + * "true"/"false" become booleans; plain integer/decimal strings become numbers. + * Strings that happen to be valid JS numbers but are not plain decimal notation + * (hex, scientific notation, "Infinity") are left as strings. + * All other strings (including empty string) are returned as-is. + */ +function coercePrimitive(value: string): unknown { + if (value === 'true') return true; + if (value === 'false') return false; + if (PLAIN_NUMBER_PATTERN.test(value)) return Number(value); + return value; +} + /** * Recursively interpolate `${{ VAR }}` references in all string values. * Missing variables resolve to empty string. * Non-string values pass through unchanged. Returns a new object (no mutation). + * + * Type coercion: when the **entire** string value is a single `${{ VAR }}` reference + * (no surrounding text), the resolved value is coerced to its native type — + * `"true"`/`"false"` become booleans, numeric strings become numbers. This allows + * boolean and numeric config fields to be driven by environment variables: + * + * ```yaml + * # .agentv/config.yaml + * results: + * export: + * auto_push: ${{ AGENTV_AUTO_PUSH }} # AGENTV_AUTO_PUSH=true → boolean true + * ``` + * + * Inline/partial substitutions (e.g. `"prefix-${{ VAR }}"`) are always strings. */ export function interpolateEnv(value: unknown, env: EnvLookup): unknown { if (typeof value === 'string') { + // Whole-value substitution: coerce the resolved value to its native type. + const wholeMatch = WHOLE_VAR_PATTERN.exec(value); + if (wholeMatch) { + const resolved = env[wholeMatch[1] as string] ?? ''; + return coercePrimitive(resolved); + } + // Partial/inline substitution: always produces a string. return value.replace(ENV_VAR_PATTERN, (_, varName: string) => env[varName] ?? ''); } if (Array.isArray(value)) { diff --git a/packages/core/src/evaluation/validation/config-validator.ts b/packages/core/src/evaluation/validation/config-validator.ts index df695f12..a6f5d1af 100644 --- a/packages/core/src/evaluation/validation/config-validator.ts +++ b/packages/core/src/evaluation/validation/config-validator.ts @@ -1,5 +1,6 @@ import { readFile } from 'node:fs/promises'; +import { interpolateEnv } from '../interpolation.js'; import { parseYamlValue } from '../yaml-loader.js'; import type { ValidationError, ValidationResult } from './types.js'; @@ -11,7 +12,7 @@ export async function validateConfigFile(filePath: string): Promise { expect(interpolateEnv('${{ EMPTY }}', env)).toBe(''); }); + describe('whole-value type coercion', () => { + it('coerces "true" to boolean true', () => { + expect(interpolateEnv('${{ FLAG }}', { FLAG: 'true' })).toBe(true); + }); + + it('coerces "false" to boolean false', () => { + expect(interpolateEnv('${{ FLAG }}', { FLAG: 'false' })).toBe(false); + }); + + it('coerces integer string to number', () => { + expect(interpolateEnv('${{ COUNT }}', { COUNT: '10' })).toBe(10); + }); + + it('coerces float string to number', () => { + expect(interpolateEnv('${{ RATIO }}', { RATIO: '0.75' })).toBe(0.75); + }); + + it('leaves empty string as string (missing var)', () => { + expect(interpolateEnv('${{ MISSING }}', {})).toBe(''); + }); + + it('leaves plain string values as strings', () => { + expect(interpolateEnv('${{ HOME }}', env)).toBe('/home/user'); + }); + + it('does not coerce partial/inline substitutions', () => { + // "true" appears only after inline replacement — no coercion + expect(interpolateEnv('enabled=${{ FLAG }}', { FLAG: 'true' })).toBe('enabled=true'); + }); + + it('coerces inside nested objects', () => { + const input = { auto_push: '${{ PUSH }}', label: 'runs' }; + expect(interpolateEnv(input, { PUSH: 'true' })).toEqual({ auto_push: true, label: 'runs' }); + }); + + // Numeric edge-case regression tests — these must stay as strings + it('does not coerce scientific notation (1e3)', () => { + expect(interpolateEnv('${{ VAL }}', { VAL: '1e3' })).toBe('1e3'); + }); + + it('does not coerce hex strings (0x10)', () => { + expect(interpolateEnv('${{ VAL }}', { VAL: '0x10' })).toBe('0x10'); + }); + + it('does not coerce "Infinity"', () => { + expect(interpolateEnv('${{ VAL }}', { VAL: 'Infinity' })).toBe('Infinity'); + }); + + it('does not coerce whitespace-only string', () => { + expect(interpolateEnv('${{ VAL }}', { VAL: ' ' })).toBe(' '); + }); + + it('does not coerce leading-zero string (00123)', () => { + expect(interpolateEnv('${{ VAL }}', { VAL: '00123' })).toBe('00123'); + }); + + it('coerces negative integer', () => { + expect(interpolateEnv('${{ VAL }}', { VAL: '-7' })).toBe(-7); + }); + }); + it('is case-sensitive for variable names', () => { expect(interpolateEnv('${{ home }}', env)).toBe(''); expect(interpolateEnv('${{ HOME }}', env)).toBe('/home/user');