authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 11:41:29-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 11:41:29-07:00
logeacceb9e7b3791a9fa3d2d8dfb212f4b43e3c91d
tree4d9f95d0fd7bcf16f0d98b701c40ddccb110d63c
parent13a4789a5d2aabd7377fdb15509f17bcc779d84b
signature Signed by PGP key B5690EEEBB952194

[codex] Bound shared schema diagnostics (#3424)

Co-authored-by: codex <codex@users.noreply.github.com>

2 files changed, 228 insertions(+), 8 deletions(-)

packages/shared/src/schemaJson.test.ts+95-1
...@@ -1,7 +1,15 @@...@@ -1,7 +1,15 @@
1import * as Cause from "effect/Cause";
2import * as Exit from "effect/Exit";
3import * as Result from "effect/Result";
1import * as Schema from "effect/Schema";4import * as Schema from "effect/Schema";
2import { describe, expect, it } from "vite-plus/test";5import { describe, expect, it } from "vite-plus/test";
36
4import { extractJsonObject, fromLenientJson } from "./schemaJson.ts";7import {
8 decodeJsonResult,
9 extractJsonObject,
10 formatSchemaError,
11 fromLenientJson,
12} from "./schemaJson.ts";
513
6const decodeLenientJson = Schema.decodeUnknownSync(fromLenientJson(Schema.Unknown));14const decodeLenientJson = Schema.decodeUnknownSync(fromLenientJson(Schema.Unknown));
715
...@@ -48,4 +56,90 @@ Done.`),...@@ -48,4 +56,90 @@ Done.`),
48 it("rejects malformed JSON after lenient preprocessing", () => {56 it("rejects malformed JSON after lenient preprocessing", () => {
49 expect(() => decodeLenientJson('{ "enabled": true,, }')).toThrow();57 expect(() => decodeLenientJson('{ "enabled": true,, }')).toThrow();
50 });58 });
59
60 it("formats schema failures with paths without exposing invalid values", () => {
61 const decodeCredential = decodeJsonResult(Schema.Struct({ token: Schema.Number }));
62 const decoded = decodeCredential('{"token":"credential=secret-value"}');
63
64 expect(Result.isFailure(decoded)).toBe(true);
65 if (Result.isFailure(decoded)) {
66 expect(formatSchemaError(decoded.failure)).toBe('Invalid type\n at ["token"]');
67 }
68 });
69
70 it("preserves nested paths reported by schema filters", () => {
71 const decode = decodeJsonResult(
72 Schema.String.check(
73 Schema.makeFilter(() => ({
74 path: ["session", "token"],
75 issue: "credential is invalid",
76 })),
77 ),
78 );
79 const decoded = decode('"credential=secret-value"');
80
81 expect(Result.isFailure(decoded)).toBe(true);
82 if (Result.isFailure(decoded)) {
83 const diagnostic = formatSchemaError(decoded.failure);
84 expect(diagnostic).toBe('Invalid value\n at ["session"]["token"]');
85 expect(diagnostic).not.toContain("credential=secret-value");
86 }
87 });
88
89 it("does not expose malformed lenient JSON input in diagnostics", () => {
90 const decode = Schema.decodeUnknownExit(fromLenientJson(Schema.Unknown));
91 const exit = decode('{"token":"credential=secret-value",,}');
92
93 expect(Exit.isFailure(exit)).toBe(true);
94 if (Exit.isFailure(exit)) {
95 const diagnostic = formatSchemaError(exit.cause);
96 expect(diagnostic).toBe("Invalid value");
97 expect(diagnostic).not.toContain("credential=secret-value");
98 }
99 });
100
101 it("summarizes unexpected defects without serializing their messages", () => {
102 const diagnostic = formatSchemaError(Cause.die(new Error("credential=secret-value")));
103
104 expect(diagnostic).toBe(
105 "Schema validation failed (failureCount=0, defectCount=1, interruptionCount=0).",
106 );
107 });
108
109 it("bounds the number of formatted schema issues", () => {
110 const decode = decodeJsonResult(Schema.Struct({ token: Schema.Number }));
111 const failures: Array<Cause.Cause<Schema.SchemaError>> = [];
112 for (let index = 0; index < 10; index += 1) {
113 const decoded = decode(`{"token":"credential=secret-value-${index}"}`);
114 if (Result.isFailure(decoded)) {
115 failures.push(decoded.failure);
116 }
117 }
118
119 const cause = Cause.fromReasons(failures.flatMap((cause) => cause.reasons));
120 const diagnostic = formatSchemaError(cause);
121 expect(diagnostic.match(/Invalid type/g)).toHaveLength(8);
122 expect(diagnostic).toContain("... and 2 more issue(s)");
123 });
124
125 it("retains the omitted issue count when bounding long diagnostics", () => {
126 const longPath = Array.from({ length: 16 }, (_, index) => `${index}-${"segment".repeat(16)}`);
127 const decode = decodeJsonResult(
128 Schema.String.check(
129 Schema.makeFilter(() => ({ path: longPath, issue: "credential is invalid" })),
130 ),
131 );
132 const failures: Array<Cause.Cause<Schema.SchemaError>> = [];
133 for (let index = 0; index < 10; index += 1) {
134 const decoded = decode(`"credential=secret-value-${index}"`);
135 if (Result.isFailure(decoded)) {
136 failures.push(decoded.failure);
137 }
138 }
139
140 const cause = Cause.fromReasons(failures.flatMap((cause) => cause.reasons));
141 const diagnostic = formatSchemaError(cause);
142 expect(diagnostic.length).toBeLessThanOrEqual(2_048);
143 expect(diagnostic.endsWith("\n... and 2 more issue(s)")).toBe(true);
144 });
51});145});
packages/shared/src/schemaJson.ts+133-7
...@@ -8,6 +8,104 @@ import * as SchemaGetter from "effect/SchemaGetter";...@@ -8,6 +8,104 @@ import * as SchemaGetter from "effect/SchemaGetter";
8import * as SchemaIssue from "effect/SchemaIssue";8import * as SchemaIssue from "effect/SchemaIssue";
9import * as SchemaTransformation from "effect/SchemaTransformation";9import * as SchemaTransformation from "effect/SchemaTransformation";
1010
11const MAX_SCHEMA_DIAGNOSTIC_ISSUES = 8;
12const MAX_SCHEMA_DIAGNOSTIC_PATH_SEGMENTS = 16;
13const MAX_SCHEMA_DIAGNOSTIC_PATH_SEGMENT_LENGTH = 64;
14const MAX_SCHEMA_DIAGNOSTIC_LENGTH = 2_048;
15
16interface SchemaDiagnosticIssue {
17 readonly message: string;
18 readonly path: ReadonlyArray<PropertyKey>;
19}
20
21// Schema's default formatter includes actual values. These diagnostics cross
22// process and UI boundaries, so retain only issue kinds and bounded paths.
23
24function truncateDiagnostic(value: string, maxLength: number): string {
25 return value.length <= maxLength ? value : `${value.slice(0, maxLength - 3)}...`;
26}
27
28function formatDiagnosticPathSegment(key: PropertyKey): string {
29 if (typeof key === "number") {
30 return `[${key}]`;
31 }
32 const value = truncateDiagnostic(
33 typeof key === "symbol" ? String(key) : key,
34 MAX_SCHEMA_DIAGNOSTIC_PATH_SEGMENT_LENGTH,
35 );
36 return `[${JSON.stringify(value)}]`;
37}
38
39function formatDiagnosticIssue(issue: SchemaDiagnosticIssue): string {
40 if (issue.path.length === 0) {
41 return issue.message;
42 }
43 const path = issue.path
44 .slice(0, MAX_SCHEMA_DIAGNOSTIC_PATH_SEGMENTS)
45 .map(formatDiagnosticPathSegment)
46 .join("");
47 const suffix = issue.path.length > MAX_SCHEMA_DIAGNOSTIC_PATH_SEGMENTS ? "[...]" : "";
48 return `${issue.message}\n at ${path}${suffix}`;
49}
50
51function schemaDiagnosticMessage(issue: SchemaIssue.Issue): string {
52 switch (issue._tag) {
53 case "InvalidType":
54 return "Invalid type";
55 case "InvalidValue":
56 case "Filter":
57 case "AnyOf":
58 case "Encoding":
59 case "Pointer":
60 case "Composite":
61 return "Invalid value";
62 case "MissingKey":
63 return "Missing key";
64 case "UnexpectedKey":
65 return "Unexpected key";
66 case "Forbidden":
67 return "Forbidden operation";
68 case "OneOf":
69 return "Expected exactly one schema member to match";
70 }
71}
72
73function collectSchemaDiagnosticIssues(
74 issue: SchemaIssue.Issue,
75 path: ReadonlyArray<PropertyKey>,
76 diagnostics: Array<SchemaDiagnosticIssue>,
77): number {
78 switch (issue._tag) {
79 case "Encoding":
80 return collectSchemaDiagnosticIssues(issue.issue, path, diagnostics);
81 case "Filter":
82 if (issue.issue._tag !== "InvalidValue") {
83 return collectSchemaDiagnosticIssues(issue.issue, path, diagnostics);
84 }
85 break;
86 case "Pointer":
87 return collectSchemaDiagnosticIssues(issue.issue, [...path, ...issue.path], diagnostics);
88 case "Composite":
89 return issue.issues.reduce(
90 (count, issue) => count + collectSchemaDiagnosticIssues(issue, path, diagnostics),
91 0,
92 );
93 case "AnyOf":
94 if (issue.issues.length > 0) {
95 return issue.issues.reduce(
96 (count, issue) => count + collectSchemaDiagnosticIssues(issue, path, diagnostics),
97 0,
98 );
99 }
100 break;
101 }
102
103 if (diagnostics.length < MAX_SCHEMA_DIAGNOSTIC_ISSUES) {
104 diagnostics.push({ message: schemaDiagnosticMessage(issue), path });
105 }
106 return 1;
107}
108
11export const decodeJsonResult = <S extends Schema.Codec<unknown, unknown, never, never>>(109export const decodeJsonResult = <S extends Schema.Codec<unknown, unknown, never, never>>(
12 schema: S,110 schema: S,
13) => {111) => {
...@@ -35,10 +133,40 @@ export const decodeUnknownJsonResult = <S extends Schema.Codec<unknown, unknown,...@@ -35,10 +133,40 @@ export const decodeUnknownJsonResult = <S extends Schema.Codec<unknown, unknown,
35};133};
36134
37export const formatSchemaError = (cause: Cause.Cause<Schema.SchemaError>) => {135export const formatSchemaError = (cause: Cause.Cause<Schema.SchemaError>) => {
38 const squashed = Cause.squash(cause);136 const issues: Array<SchemaDiagnosticIssue> = [];
39 return Schema.isSchemaError(squashed)137 let issueCount = 0;
40 ? SchemaIssue.makeFormatterDefault()(squashed.issue)138 let failureCount = 0;
41 : Cause.pretty(cause);139 let defectCount = 0;
140 let interruptionCount = 0;
141
142 for (const reason of cause.reasons) {
143 switch (reason._tag) {
144 case "Fail":
145 failureCount += 1;
146 if (Schema.isSchemaError(reason.error)) {
147 issueCount += collectSchemaDiagnosticIssues(reason.error.issue, [], issues);
148 }
149 break;
150 case "Die":
151 defectCount += 1;
152 break;
153 case "Interrupt":
154 interruptionCount += 1;
155 break;
156 }
157 }
158
159 if (issues.length === 0) {
160 return `Schema validation failed (failureCount=${failureCount}, defectCount=${defectCount}, interruptionCount=${interruptionCount}).`;
161 }
162
163 const omittedIssueCount = issueCount - issues.length;
164 const formatted = issues.map(formatDiagnosticIssue).join("\n");
165 if (omittedIssueCount === 0) {
166 return truncateDiagnostic(formatted, MAX_SCHEMA_DIAGNOSTIC_LENGTH);
167 }
168 const suffix = `\n... and ${omittedIssueCount} more issue(s)`;
169 return truncateDiagnostic(formatted, MAX_SCHEMA_DIAGNOSTIC_LENGTH - suffix.length) + suffix;
42};170};
43171
44/**172/**
...@@ -67,9 +195,7 @@ const parseLenientJsonGetter = SchemaGetter.onSome((input: string) => {...@@ -67,9 +195,7 @@ const parseLenientJsonGetter = SchemaGetter.onSome((input: string) => {
67195
68 return decodeJsonString(stripped).pipe(196 return decodeJsonString(stripped).pipe(
69 Effect.map(Option.some),197 Effect.map(Option.some),
70 Effect.mapError(198 Effect.mapError((error) => error.issue),
71 (error) => new SchemaIssue.InvalidValue(Option.some(input), { message: String(error) }),
72 ),
73 );199 );
74});200});
75201