Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions packages/core/src/evaluation/interpolation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/evaluation/validation/config-validator.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -11,7 +12,7 @@ export async function validateConfigFile(filePath: string): Promise<ValidationRe

try {
const content = await readFile(filePath, 'utf8');
const parsed = parseYamlValue(content);
const parsed = interpolateEnv(parseYamlValue(content), process.env);

// Check if parsed content is an object
if (typeof parsed !== 'object' || parsed === null) {
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/evaluation/validation/targets-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
COMMON_TARGET_SETTINGS,
findDeprecatedCamelCaseTargetWarnings,
} from '../providers/targets.js';
import { interpolateEnv } from '../interpolation.js';
import { KNOWN_PROVIDERS, PROVIDER_ALIASES } from '../providers/types.js';
import { parseYamlValue } from '../yaml-loader.js';
import type { ValidationError, ValidationResult } from './types.js';
Expand Down Expand Up @@ -280,7 +281,7 @@ export async function validateTargetsFile(filePath: string): Promise<ValidationR
let parsed: unknown;
try {
const content = await readFile(absolutePath, 'utf8');
parsed = parseYamlValue(content);
parsed = interpolateEnv(parseYamlValue(content), process.env);
} catch (error) {
errors.push({
severity: 'error',
Expand Down
61 changes: 61 additions & 0 deletions packages/core/test/evaluation/interpolation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,67 @@ describe('interpolateEnv', () => {
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');
Expand Down
Loading