From 40976a9e30c2c5972eaf1b133d22035e752d4fdb Mon Sep 17 00:00:00 2001 From: Christopher Tso Date: Sat, 6 Jun 2026 16:57:57 +0200 Subject: [PATCH] fix(core): allow templated use_target validation --- .../validation/targets-validator.ts | 15 +++++-- .../validation/targets-validator.test.ts | 45 +++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/packages/core/src/evaluation/validation/targets-validator.ts b/packages/core/src/evaluation/validation/targets-validator.ts index ca66fdbb3..6e22f4f35 100644 --- a/packages/core/src/evaluation/validation/targets-validator.ts +++ b/packages/core/src/evaluation/validation/targets-validator.ts @@ -19,6 +19,10 @@ function isObject(value: unknown): value is JsonObject { return typeof value === 'object' && value !== null && !Array.isArray(value); } +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + // Cross-provider settings derived from the schema source of truth in targets.ts. // Adding a field to COMMON_TARGET_SETTINGS automatically makes it valid here. const COMMON_SETTINGS = new Set(COMMON_TARGET_SETTINGS); @@ -320,9 +324,11 @@ export async function validateTargetsFile(filePath: string): Promise 0; + const rawTarget = rawTargets[i]; + const rawUseTarget = isObject(rawTarget) ? rawTarget.use_target : undefined; + const hasUseTarget = isNonEmptyString(target.use_target) || isNonEmptyString(rawUseTarget); const providerValue = typeof provider === 'string' ? provider.trim().toLowerCase() : undefined; const isTemplated = typeof provider === 'string' && /^\$\{\{.+\}\}$/.test(provider.trim()); if (!hasUseTarget && (typeof provider !== 'string' || provider.trim().length === 0)) { diff --git a/packages/core/test/evaluation/validation/targets-validator.test.ts b/packages/core/test/evaluation/validation/targets-validator.test.ts index 41f14e4e2..2eb46b938 100644 --- a/packages/core/test/evaluation/validation/targets-validator.test.ts +++ b/packages/core/test/evaluation/validation/targets-validator.test.ts @@ -122,6 +122,51 @@ describe('validateTargetsFile', () => { expect(result.valid).toBe(true); }); + it('accepts env-templated use_target values without resolving the env during validation', async () => { + const filePath = path.join(tempDir, 'templated-use-target.yaml'); + await writeFile( + filePath, + `targets: + - name: default + use_target: \${{ AGENT_TARGET }} + - name: grader + use_target: \${{ GRADER_TARGET }} + - name: codex-agent + provider: codex + grader_target: grader +`, + ); + + const originalAgentTarget = process.env.AGENT_TARGET; + const originalGraderTarget = process.env.GRADER_TARGET; + Reflect.deleteProperty(process.env, 'AGENT_TARGET'); + Reflect.deleteProperty(process.env, 'GRADER_TARGET'); + + try { + const result = await validateTargetsFile(filePath); + + expect(result.valid).toBe(true); + expect( + result.errors.some( + (error) => + error.severity === 'error' && + error.message.includes("Missing or invalid 'provider' field"), + ), + ).toBe(false); + } finally { + if (originalAgentTarget === undefined) { + Reflect.deleteProperty(process.env, 'AGENT_TARGET'); + } else { + process.env.AGENT_TARGET = originalAgentTarget; + } + if (originalGraderTarget === undefined) { + Reflect.deleteProperty(process.env, 'GRADER_TARGET'); + } else { + process.env.GRADER_TARGET = originalGraderTarget; + } + } + }); + it('rejects azure api_format with a migration error', async () => { const filePath = path.join(tempDir, 'azure-api-format.yaml'); await writeFile(