From 33bedd908114aa3f5084fd4bed788262f0b4ba9e Mon Sep 17 00:00:00 2001 From: kang-heewon Date: Thu, 24 Sep 2026 23:00:03 +0900 Subject: [PATCH 1/3] fix(framework-config): distinguish invalid environment values from missing settings --- .../clear-config-validation-diagnostics.md | 5 + .../src/classes/ConfigValidationProblem.md | 4 +- .../src/libs/problems/ConfigProblems.ts | 6 +- .../src/tests/ValidateConfig.spec.ts | 99 +++++++++++++++++++ .../framework-config/src/validateConfig.ts | 66 ++++++++++++- 5 files changed, 172 insertions(+), 8 deletions(-) create mode 100644 .changeset/clear-config-validation-diagnostics.md diff --git a/.changeset/clear-config-validation-diagnostics.md b/.changeset/clear-config-validation-diagnostics.md new file mode 100644 index 0000000000..9eabea8b3d --- /dev/null +++ b/.changeset/clear-config-validation-diagnostics.md @@ -0,0 +1,5 @@ +--- +"@croco/framework-config": patch +--- + +Report missing, malformed, and invalid environment settings distinctly while keeping input values out of validation diagnostics. diff --git a/packages/docs/src/content/docs/api/framework-config/src/classes/ConfigValidationProblem.md b/packages/docs/src/content/docs/api/framework-config/src/classes/ConfigValidationProblem.md index c2a4158f46..b6bccfb270 100644 --- a/packages/docs/src/content/docs/api/framework-config/src/classes/ConfigValidationProblem.md +++ b/packages/docs/src/content/docs/api/framework-config/src/classes/ConfigValidationProblem.md @@ -15,11 +15,11 @@ RFC 7807 Problem Details를 표현하는 기본 추상 에러 클래스입니다 ### Constructor -> **new ConfigValidationProblem**(`missingPaths`): `ConfigValidationProblem` +> **new ConfigValidationProblem**(`diagnostics`): `ConfigValidationProblem` #### Parameters -##### missingPaths +##### diagnostics `string`[] diff --git a/packages/framework-config/src/libs/problems/ConfigProblems.ts b/packages/framework-config/src/libs/problems/ConfigProblems.ts index ddad6022ce..9389c08d9b 100644 --- a/packages/framework-config/src/libs/problems/ConfigProblems.ts +++ b/packages/framework-config/src/libs/problems/ConfigProblems.ts @@ -9,13 +9,13 @@ export class ConfigSchemaNotFoundProblem extends Problem { } export class ConfigValidationProblem extends Problem { - constructor(missingPaths: string[]) { - const missing = missingPaths.join(", "); + constructor(diagnostics: string[]) { + const detail = diagnostics.join("; "); super( "framework-config/config-validation-failed", ProblemCategory.ValidationError, - `Missing required: ${missing}`, + `Config validation failed: ${detail}`, ); } } diff --git a/packages/framework-config/src/tests/ValidateConfig.spec.ts b/packages/framework-config/src/tests/ValidateConfig.spec.ts index adaf84b5bf..248e8c6d24 100644 --- a/packages/framework-config/src/tests/ValidateConfig.spec.ts +++ b/packages/framework-config/src/tests/ValidateConfig.spec.ts @@ -73,6 +73,105 @@ describe("validateConfig", () => { }); describe("with invalid config", () => { + it("should distinguish missing values from invalid values without exposing inputs", () => { + const schema = z.object({ + MISSING: z.string(), + PORT: z.coerce.number(), + API_URL: z.url(), + MODE: z.enum(["development", "production"]), + }); + const env = { + PORT: "secret-port-value", + API_URL: "secret-url-value", + MODE: "secret-mode-value", + }; + + let problem: ConfigValidationProblem | undefined; + try { + validateConfig(schema, env); + } catch (error) { + if (error instanceof ConfigValidationProblem) problem = error; + } + + expect(problem).toBeInstanceOf(ConfigValidationProblem); + expect(problem?.detail).toContain("MISSING: Missing required"); + expect(problem?.detail).toContain("PORT: invalid_type: Expected number"); + expect(problem?.detail).toContain("API_URL: invalid_format: Invalid URL"); + expect(problem?.detail).toContain("MODE: invalid_value: Invalid option"); + expect(problem?.detail).not.toContain("secret-"); + }); + + it("should report a missing coerced value as missing", () => { + const schema = z.object({ PORT: z.coerce.number() }); + + expect(() => validateConfig(schema, {})).toThrow("PORT: Missing required"); + }); + + it("should distinguish missing and invalid values through a wrapped object schema", () => { + const schema = z.object({ PORT: z.coerce.number() }).transform((value) => value); + + expect(() => validateConfig(schema, {})).toThrow("PORT: Missing required"); + expect(() => validateConfig(schema, { PORT: "abc" })).toThrow( + "PORT: invalid_type: Expected number", + ); + }); + + it("should distinguish missing and invalid values through an intersection", () => { + const schema = z.intersection( + z.object({ PORT: z.coerce.number() }), + z.object({ MODE: z.string().default("development") }), + ); + + expect(() => validateConfig(schema, {})).toThrow(": Missing required"); + expect(() => validateConfig(schema, { PORT: "abc" })).toThrow( + ": invalid_type: Expected number", + ); + }); + + it("should not call an invalid transformed child missing when its source is present", () => { + const schema = z.object({ + CONFIG: z + .string() + .transform((value) => ({ PORT: value })) + .pipe(z.object({ PORT: z.number() })), + }); + + expect(() => validateConfig(schema, { CONFIG: "abc" })).toThrow( + "CONFIG: invalid_type: Expected number", + ); + }); + + it("should exclude custom issue paths and type labels derived from input", () => { + const schema = z.object({ + TOKEN: z.string().superRefine((value, context) => { + context.addIssue({ code: "invalid_type", expected: value, path: [value] }); + }), + }); + + let problem: ConfigValidationProblem | undefined; + try { + validateConfig(schema, { TOKEN: "secret-token-value" }); + } catch (error) { + if (error instanceof ConfigValidationProblem) problem = error; + } + + expect(problem?.detail).toContain("TOKEN: invalid_type: Invalid type"); + expect(problem?.detail).not.toContain("secret-token-value"); + }); + + it("should not expose a custom validation message containing the input", () => { + const schema = z.object({ + TOKEN: z.string().refine(() => false, { message: "Secret is secret-token-value" }), + }); + + expect(() => validateConfig(schema, { TOKEN: "secret-token-value" })).toThrow( + "TOKEN: custom: Invalid value", + ); + expect(() => validateConfig(schema, { TOKEN: "secret-token-value" })).not.toThrow( + "secret-token-value", + ); + }); + it("should throw when required field is missing", () => { const schema = z.object({ DATABASE_URL: z.string(), diff --git a/packages/framework-config/src/validateConfig.ts b/packages/framework-config/src/validateConfig.ts index 1172ebcbbd..cb99f6a1f7 100644 --- a/packages/framework-config/src/validateConfig.ts +++ b/packages/framework-config/src/validateConfig.ts @@ -1,6 +1,60 @@ -import type { z } from "zod"; +import { z } from "zod"; import { ConfigValidationProblem } from "./libs/problems/ConfigProblems"; +function isMissingAtPath(data: unknown, path: PropertyKey[]): boolean { + let value = data; + for (const key of path) { + if (value === null || typeof value !== "object") return false; + if (!Object.prototype.hasOwnProperty.call(value, key)) return true; + value = Reflect.get(value, key); + if (value === undefined) return true; + } + return false; +} + +function objectFieldNames(schema: z.ZodType): string[] { + if (schema instanceof z.ZodObject) return Object.keys(schema.shape); + if (schema instanceof z.ZodPipe) { + const inputFields = schema.in instanceof z.ZodType ? objectFieldNames(schema.in) : []; + return inputFields.length > 0 + ? inputFields + : schema.out instanceof z.ZodType + ? objectFieldNames(schema.out) + : []; + } + if ("unwrap" in schema && typeof schema.unwrap === "function") { + const inner = schema.unwrap(); + if (inner instanceof z.ZodType) return objectFieldNames(inner); + } + return []; +} + +function safeIssuePath(schema: z.ZodType, path: PropertyKey[]): string { + const field = path[0]; + return typeof field === "string" && objectFieldNames(schema).includes(field) ? field : ""; +} + +function safeIssueMessage(issue: z.core.$ZodIssue): string { + switch (issue.code) { + case "invalid_type": + return /^(string|number|boolean|object|array|null|undefined|bigint|date|symbol|function)$/.test( + issue.expected, + ) + ? `Expected ${issue.expected}` + : "Invalid type"; + case "invalid_format": + return issue.format === "url" ? "Invalid URL" : "Invalid format"; + case "invalid_value": + return "Invalid option"; + case "too_small": + return "Value is too small"; + case "too_big": + return "Value is too large"; + default: + return "Invalid value"; + } +} + export function validateConfig( schema: z.ZodType, env?: Record, @@ -9,8 +63,14 @@ export function validateConfig( const result = schema.safeParse(data); if (!result.success) { - const missingPaths = result.error.issues.map((issue) => issue.path.join(".")); - throw new ConfigValidationProblem(missingPaths); + const diagnostics = result.error.issues.map((issue) => { + const path = safeIssuePath(schema, issue.path); + if (issue.path.length > 0 && isMissingAtPath(data, issue.path)) { + return `${path}: Missing required`; + } + return `${path}: ${issue.code}: ${safeIssueMessage(issue)}`; + }); + throw new ConfigValidationProblem(diagnostics); } return result.data; From 85b38ca0fb17aa9d4e795475ec1f5739bb43e71c Mon Sep 17 00:00:00 2001 From: kang-heewon Date: Fri, 25 Sep 2026 00:19:51 +0900 Subject: [PATCH 2/3] fix(framework-config): keep transformed validation failures distinct from missing settings --- .../src/tests/ValidateConfig.spec.ts | 100 ++++++++++++++++++ .../framework-config/src/validateConfig.ts | 79 +++++++++++--- 2 files changed, 167 insertions(+), 12 deletions(-) diff --git a/packages/framework-config/src/tests/ValidateConfig.spec.ts b/packages/framework-config/src/tests/ValidateConfig.spec.ts index 248e8c6d24..708512b124 100644 --- a/packages/framework-config/src/tests/ValidateConfig.spec.ts +++ b/packages/framework-config/src/tests/ValidateConfig.spec.ts @@ -141,6 +141,106 @@ describe("validateConfig", () => { ); }); + it("should not call a supplied value missing after a top-level transform renames it", () => { + const schema = z + .object({ RAW_PORT: z.string() }) + .transform(({ RAW_PORT }) => ({ PORT: RAW_PORT })) + .pipe(z.object({ PORT: z.coerce.number() })); + + expect(() => validateConfig(schema, { RAW_PORT: "abc" })).toThrow( + "PORT: invalid_type: Expected number", + ); + }); + + it("should not infer a missing piped field from the pre-transform input", () => { + const schema = z + .object({ RAW_PORT: z.string().optional() }) + .transform(({ RAW_PORT }) => ({ PORT: RAW_PORT ?? "abc" })) + .pipe(z.object({ PORT: z.coerce.number() })); + + expect(() => validateConfig(schema, {})).toThrow("PORT: invalid_type: Expected number"); + }); + + it("should classify piped output against its stage through a schema wrapper", () => { + const schema = z + .object({ RAW_PORT: z.string() }) + .transform(({ RAW_PORT }) => ({ PORT: RAW_PORT })) + .pipe(z.object({ PORT: z.coerce.number() })) + .optional(); + + expect(() => validateConfig(schema, { RAW_PORT: "abc" })).toThrow( + "PORT: invalid_type: Expected number", + ); + }); + + it("should not call a defaulted piped value missing", () => { + const schema = z + .object({ PORT: z.string().default("abc") }) + .pipe(z.object({ PORT: z.coerce.number() })); + + expect(() => validateConfig(schema, {})).toThrow("PORT: invalid_type: Expected number"); + }); + + it("should report an input-stage missing value before a pipe runs", () => { + const schema = z.object({ PORT: z.string() }).pipe(z.object({ PORT: z.coerce.number() })); + + expect(() => validateConfig(schema, {})).toThrow("PORT: Missing required"); + }); + + it("should report an input-stage missing coerced value before a pipe runs", () => { + const schema = z.object({ PORT: z.coerce.number() }).pipe(z.object({ PORT: z.number() })); + + expect(() => validateConfig(schema, {})).toThrow("PORT: Missing required"); + }); + + it("should retain transformed issue provenance through an intersection", () => { + const transformedPort = z + .object({ RAW_PORT: z.string() }) + .transform(({ RAW_PORT }) => ({ PORT: RAW_PORT })) + .pipe(z.object({ PORT: z.coerce.number() })); + const schema = z.intersection(transformedPort, z.object({ MODE: z.string().optional() })); + + expect(() => validateConfig(schema, { RAW_PORT: "abc" })).toThrow( + ": invalid_type: Expected number", + ); + }); + + it("should report a missing field when its schema overrides the error message", () => { + const schema = z.object({ PORT: z.string({ error: "Port is required" }) }); + + expect(() => validateConfig(schema, {})).toThrow("PORT: Missing required"); + }); + + it("should report a missing nested object", () => { + const schema = z.object({ DATABASE: z.object({ URL: z.string() }) }); + + expect(() => validateConfig(schema, {})).toThrow("DATABASE: Missing required"); + }); + + it("should report a missing field before its transform or pipe runs", () => { + const schema = z.object({ + PORT: z.coerce.number().pipe(z.number().positive()), + }); + + expect(() => validateConfig(schema, {})).toThrow("PORT: Missing required"); + }); + + it("should not confuse a reused output validator with an optional input validator", () => { + const port = z.coerce.number(); + const schema = z + .object({ PORT: port.optional() }) + .transform(() => ({ PORT: "abc" })) + .pipe(z.object({ PORT: port })); + + expect(() => validateConfig(schema, {})).toThrow("PORT: invalid_type: Expected number"); + }); + + it("should report a missing field behind a lazy schema", () => { + const schema = z.object({ PORT: z.lazy(() => z.coerce.number()) }); + + expect(() => validateConfig(schema, {})).toThrow("PORT: Missing required"); + }); + it("should exclude custom issue paths and type labels derived from input", () => { const schema = z.object({ TOKEN: z.string().superRefine((value, context) => { diff --git a/packages/framework-config/src/validateConfig.ts b/packages/framework-config/src/validateConfig.ts index cb99f6a1f7..92cc94d874 100644 --- a/packages/framework-config/src/validateConfig.ts +++ b/packages/framework-config/src/validateConfig.ts @@ -14,13 +14,14 @@ function isMissingAtPath(data: unknown, path: PropertyKey[]): boolean { function objectFieldNames(schema: z.ZodType): string[] { if (schema instanceof z.ZodObject) return Object.keys(schema.shape); + if (schema instanceof z.ZodLazy) { + const inner = schema._zod.innerType; + return inner instanceof z.ZodType ? objectFieldNames(inner) : []; + } if (schema instanceof z.ZodPipe) { const inputFields = schema.in instanceof z.ZodType ? objectFieldNames(schema.in) : []; - return inputFields.length > 0 - ? inputFields - : schema.out instanceof z.ZodType - ? objectFieldNames(schema.out) - : []; + const outputFields = schema.out instanceof z.ZodType ? objectFieldNames(schema.out) : []; + return [...new Set([...inputFields, ...outputFields])]; } if ("unwrap" in schema && typeof schema.unwrap === "function") { const inner = schema.unwrap(); @@ -29,12 +30,58 @@ function objectFieldNames(schema: z.ZodType): string[] { return []; } +function isRawInputIssue( + schema: z.ZodType, + path: PropertyKey[], + origin: unknown, + input: unknown, +): boolean { + if ( + input === undefined && + (schema instanceof z.ZodOptional || + schema instanceof z.ZodDefault || + schema instanceof z.ZodPrefault || + schema instanceof z.ZodCatch) + ) { + return false; + } + if (path.length === 0 && schema === origin) return true; + if (schema instanceof z.ZodPipe) { + return schema.in instanceof z.ZodType && isRawInputIssue(schema.in, path, origin, input); + } + if (schema instanceof z.ZodIntersection) { + return ( + (schema.def.left instanceof z.ZodType && + isRawInputIssue(schema.def.left, path, origin, input)) || + (schema.def.right instanceof z.ZodType && + isRawInputIssue(schema.def.right, path, origin, input)) + ); + } + if (schema instanceof z.ZodLazy) { + const inner = schema._zod.innerType; + return inner instanceof z.ZodType && isRawInputIssue(inner, path, origin, input); + } + if ("unwrap" in schema && typeof schema.unwrap === "function") { + const inner = schema.unwrap(); + if (inner instanceof z.ZodType) return isRawInputIssue(inner, path, origin, input); + } + if (schema instanceof z.ZodObject) { + const field = path[0]; + if (typeof field !== "string") return false; + const child = schema.shape[field]; + const childInput = + input !== null && typeof input === "object" ? Reflect.get(input, field) : undefined; + return child instanceof z.ZodType && isRawInputIssue(child, path.slice(1), origin, childInput); + } + return false; +} + function safeIssuePath(schema: z.ZodType, path: PropertyKey[]): string { const field = path[0]; return typeof field === "string" && objectFieldNames(schema).includes(field) ? field : ""; } -function safeIssueMessage(issue: z.core.$ZodIssue): string { +function safeIssueMessage(issue: z.core.$ZodRawIssue): string { switch (issue.code) { case "invalid_type": return /^(string|number|boolean|object|array|null|undefined|bigint|date|symbol|function)$/.test( @@ -60,12 +107,20 @@ export function validateConfig( env?: Record, ): T { const data = env ?? process.env; - const result = schema.safeParse(data); + // safeParse removes issue.inst, which identifies the stage that rejected a value. + const result = schema._zod.run({ value: data, issues: [] }, { async: false }); + if (result instanceof Promise) throw new z.core.$ZodAsyncError(); - if (!result.success) { - const diagnostics = result.error.issues.map((issue) => { - const path = safeIssuePath(schema, issue.path); - if (issue.path.length > 0 && isMissingAtPath(data, issue.path)) { + if (result.issues.length > 0) { + const diagnostics = result.issues.map((issue) => { + const issuePath = issue.path ?? []; + const path = safeIssuePath(schema, issuePath); + if ( + issuePath.length > 0 && + (issue.code === "invalid_type" || issue.code === "invalid_value") && + isRawInputIssue(schema, issuePath, issue.inst, data) && + isMissingAtPath(data, issuePath) + ) { return `${path}: Missing required`; } return `${path}: ${issue.code}: ${safeIssueMessage(issue)}`; @@ -73,5 +128,5 @@ export function validateConfig( throw new ConfigValidationProblem(diagnostics); } - return result.data; + return result.value as T; } From c4e533a0589ceaaee5142f1e3862454e23910747 Mon Sep 17 00:00:00 2001 From: kang-heewon Date: Fri, 25 Sep 2026 01:36:58 +0900 Subject: [PATCH 3/3] fix(verification): allow generated app smoke to finish within CI job budget --- scripts/tests/verification-manifest.spec.ts | 2 +- scripts/verification-manifest.mts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/tests/verification-manifest.spec.ts b/scripts/tests/verification-manifest.spec.ts index a7ec4b08f8..6b0f299333 100644 --- a/scripts/tests/verification-manifest.spec.ts +++ b/scripts/tests/verification-manifest.spec.ts @@ -325,7 +325,7 @@ describe("verification manifest", () => { expect( createHash("sha256").update(JSON.stringify(manifests)).digest("hex"), "The pre-split monolithic manifest changed; update this digest only after intentionally verifying the new serialized commands.", - ).toBe("d733978721940a09d0e1ab182e5e6c6223e8d1e86d5a5aa13ca4136ff2d753a5"); + ).toBe("c284a3b539bca69f0f2498cf321dc27415a8db411b0edde8bfc0ea31f3faea07"); }); it("classifies every dependency edge and every cross-lane edge for synthesis", () => { diff --git a/scripts/verification-manifest.mts b/scripts/verification-manifest.mts index 35498de7eb..e762f56728 100644 --- a/scripts/verification-manifest.mts +++ b/scripts/verification-manifest.mts @@ -899,7 +899,7 @@ const spineOnly = ( ? ["--tier", "spine-blocking"] : affectedGeneratedSmokeCases), ), - timeoutMs: minutes(45), + timeoutMs: minutes(75), applicable: generatedAppSmokeApplicable, artifacts: [ {