History

perf(schema): hoist Schema compilers and add lint rule

Schema.is, Schema.decode*, Schema.encode*, Schema.validate*, and Schema.parse* each compile a parser/guard from a schema. Calling them inside a function body rebuilds that function on every invocation. Adds executor/no-inline-schema-compile (oxlint plugin rule) that flags these calls when they appear inside a function body, with two severities: HIGH when the schema argument is itself an inline Schema.X(...) literal (both AST and compile rebuilt), MEDIUM when only the compiler is re-called. Fixes the 44 existing violations across 22 files by hoisting the compiled function (and where applicable, its schema literal) to module scope. Notable consolidations: - oauth-service.ts: 8 sites (5 decode + 3 encode of OAuthProviderState) collapse into two hoisted helpers - jwks-cache.ts, mcp-auth.ts: per-call guard compilation removed from JWT verify error path - React Add*Source / EditOpenApiSource components: shared decodeErrorMessage / isOpenApiSourceBindingValue hoisted migrate-connections.ts has one targeted oxlint-disable for a higher-order helper whose schema is a function parameter; the compile is hoisted into the outer closure but the rule (correctly) flags any Schema compiler call inside a function body.

Rhys Sullivan committed 6 months ago

Commit 25515e31 parent bfb87a025 files · +224 −99Browse files

Unified · Split

25 files
  1. .oxlintrc.jsonc +1 −0
  2. apps/cloud/src/jwks-cache.ts +4 −1
  3. apps/cloud/src/mcp-auth.ts +2 −1
  4. apps/cloud/src/mcp-miniflare.e2e.node.test.ts +3 −1
  5. apps/cloud/src/services/sources-api.node.test.ts +4 −3
  6. apps/local/src/server/migrate-connections.ts +5 −4
  7. apps/local/src/server/migrate-mcp-bindings.test.ts +4 −2
  8. apps/local/src/server/migrate-openapi-bindings.test.ts +14 −9
  9. packages/core/api/src/oauth-popup.test.ts +6 −6
  10. packages/core/api/src/observability.ts +3 −1
  11. packages/core/config/src/config.test.ts +5 −3
  12. packages/core/sdk/src/executor.ts +5 −5
  13. packages/core/sdk/src/oauth-service.ts +15 −17
  14. packages/core/storage-core/src/factory.ts +3 −3
  15. packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx +5 −7
  16. packages/plugins/graphql/src/react/AddGraphqlSource.tsx +5 −7
  17. packages/plugins/graphql/src/sdk/store.ts +8 −2
  18. packages/plugins/mcp/src/react/AddMcpSource.tsx +5 −7
  19. packages/plugins/openapi/src/react/AddOpenApiSource.tsx +5 −7
  20. packages/plugins/openapi/src/react/EditOpenApiSource.tsx +8 −9
  21. packages/plugins/openapi/src/sdk/non-json-body.test.ts +3 −2
  22. packages/plugins/openapi/src/sdk/store.ts +3 −1
  23. packages/plugins/workos-vault/src/sdk/client.ts +2 −1
  24. scripts/oxlint-plugin-executor.js +2 −0
  25. scripts/oxlint-plugin-executor/rules/no-inline-schema-compile.js +104 −0
.oxlintrc.jsonc+1−0
@@ -11,6 +11,7 @@ · 10 unmodified lines
11 "executor/no-effect-escape-hatch": "error",11 "executor/no-effect-escape-hatch": "error",
12 "executor/no-effect-internal-tags": "error",12 "executor/no-effect-internal-tags": "error",
13 "executor/no-error-constructor": "error",13 "executor/no-error-constructor": "error",
14 "executor/no-inline-schema-compile": "error",
14 "executor/no-instanceof-error": "error",15 "executor/no-instanceof-error": "error",
15 "executor/no-instanceof-tagged-error": "error",16 "executor/no-instanceof-tagged-error": "error",
16 "executor/no-json-parse": "error",17 "executor/no-json-parse": "error",
apps/cloud/src/jwks-cache.ts+4−1
@@ -61,8 +61,11 @@ const JsonWebKeySetPayload = Schema.Struct({ · 60 unmodified lines
61});61});
62const decodeJsonWebKeySetPayload = Schema.decodeUnknownPromise(JsonWebKeySetPayload);62const decodeJsonWebKeySetPayload = Schema.decodeUnknownPromise(JsonWebKeySetPayload);
63 63
64const ErrorWithCode = Schema.Struct({ code: Schema.String });
65const isErrorWithCode = Schema.is(ErrorWithCode);
66
64const isJwksNoMatchingKey = (cause: unknown): boolean =>67const isJwksNoMatchingKey = (cause: unknown): boolean =>
65 Schema.is(Schema.Struct({ code: Schema.String }))(cause) && cause.code === JWKSNoMatchingKey.code;68 isErrorWithCode(cause) && cause.code === JWKSNoMatchingKey.code;
66 69
67interface CacheEntry {70interface CacheEntry {
68 jwks: JSONWebKeySet;71 jwks: JSONWebKeySet;
apps/cloud/src/mcp-auth.ts+2−1
@@ -15,9 +15,10 @@ export class McpJwtVerificationError extends Data.TaggedError("McpJwtVerificatio · 14 unmodified lines
15}> {}15}> {}
16 16
17const JoseErrorCode = Schema.Struct({ code: Schema.String });17const JoseErrorCode = Schema.Struct({ code: Schema.String });
18const isJoseErrorCodeShape = Schema.is(JoseErrorCode);
18 19
19const getJoseErrorCode = (cause: unknown): string | null =>20const getJoseErrorCode = (cause: unknown): string | null =>
20 Schema.is(JoseErrorCode)(cause) ? cause.code : null;21 isJoseErrorCodeShape(cause) ? cause.code : null;
21 22
22const isJoseErrorCode = (code: string): boolean => code.startsWith("ERR_J");23const isJoseErrorCode = (code: string): boolean => code.startsWith("ERR_J");
23 24
apps/cloud/src/mcp-miniflare.e2e.node.test.ts+3−1
@@ -186,6 +186,8 @@ const OtlpPayloadFromJson = Schema.fromJsonString( · 185 unmodified lines
186 }),186 }),
187);187);
188 188
189const decodeOtlpPayload = Schema.decodeUnknownOption(OtlpPayloadFromJson);
190
189const unwrapAttrValue = (v?: OtlpAttributeValue): unknown => {191const unwrapAttrValue = (v?: OtlpAttributeValue): unknown => {
190 if (!v) return undefined;192 if (!v) return undefined;
191 if (v.stringValue !== undefined) return v.stringValue;193 if (v.stringValue !== undefined) return v.stringValue;
@@ -218,7 +220,7 @@ const TelemetryReceiverLive = Layer.effect(TelemetryReceiver)( · 26 unmodified lines
218 body += chunk;220 body += chunk;
219 });221 });
220 req.on("end", () => {222 req.on("end", () => {
221 const maybePayload = Schema.decodeUnknownOption(OtlpPayloadFromJson)(body);223 const maybePayload = decodeOtlpPayload(body);
222 if (Option.isSome(maybePayload)) {224 if (Option.isSome(maybePayload)) {
223 const payload = maybePayload.value;225 const payload = maybePayload.value;
224 for (const rs of payload.resourceSpans ?? []) {226 for (const rs of payload.resourceSpans ?? []) {
apps/cloud/src/services/sources-api.node.test.ts+4−3
@@ -177,6 +177,9 @@ const GraphqlRequestSchema = Schema.Struct({ · 176 unmodified lines
177 variables: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),177 variables: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
178});178});
179 179
180const GraphqlRequestFromJson = Schema.fromJsonString(GraphqlRequestSchema);
181const decodeGraphqlRequest = Schema.decodeUnknownPromise(GraphqlRequestFromJson);
182
180const startGraphqlServer = () => {183const startGraphqlServer = () => {
181 const requests: Array<{ readonly query: string; readonly variables: unknown }> = [];184 const requests: Array<{ readonly query: string; readonly variables: unknown }> = [];
182 const server = http.createServer(async (req, res) => {185 const server = http.createServer(async (req, res) => {
@@ -186,9 +189,7 @@ const startGraphqlServer = () => { · 3 unmodified lines
186 return;189 return;
187 }190 }
188 191
189 const parsed = await Schema.decodeUnknownPromise(Schema.fromJsonString(GraphqlRequestSchema))(192 const parsed = await decodeGraphqlRequest(await readBody(req));
190 await readBody(req),
191 );
192 const query = parsed.query ?? "";193 const query = parsed.query ?? "";
193 requests.push({ query, variables: parsed.variables ?? null });194 requests.push({ query, variables: parsed.variables ?? null });
194 195
apps/local/src/server/migrate-connections.ts+5−4
@@ -30,10 +30,11 @@ const isString = (v: unknown): v is string => typeof v === "string"; · 29 unmodified lines
30const JsonObject = Schema.Record(Schema.String, Schema.Unknown);30const JsonObject = Schema.Record(Schema.String, Schema.Unknown);
31const JsonObjectFromString = Schema.fromJsonString(JsonObject);31const JsonObjectFromString = Schema.fromJsonString(JsonObject);
32 32
33const decodeUnknownOptionAs =33const decodeUnknownOptionAs = <A>(schema: Schema.Decoder<A>) => {
34 <A>(schema: Schema.Decoder<A>) =>34 // oxlint-disable-next-line executor/no-inline-schema-compile -- schema bound by parameter; compiler hoisted into closure
35 (input: unknown): Option.Option<A> =>35 const decode = Schema.decodeUnknownOption(schema);
36 Schema.decodeUnknownOption(schema)(input);36 return (input: unknown): Option.Option<A> => decode(input);
37};
37 38
38const decodeJsonObjectString = Schema.decodeUnknownOption(JsonObjectFromString);39const decodeJsonObjectString = Schema.decodeUnknownOption(JsonObjectFromString);
39 40
apps/local/src/server/migrate-mcp-bindings.test.ts+4−2
@@ -26,6 +26,8 @@ const ConfigJson = Schema.fromJsonString( · 25 unmodified lines
26 }),26 }),
27);27);
28 28
29const decodeConfigJson = Schema.decodeUnknownSync(ConfigJson);
30
29const tempDirs: Array<string> = [];31const tempDirs: Array<string> = [];
30 32
31const makeDbPath = () => {33const makeDbPath = () => {
@@ -87,7 +89,7 @@ describe("0007_normalize_plugin_secret_refs (mcp)", () => { · 55 unmodified lines
87 expect(row.auth_secret_id).toBe("tok-secret");89 expect(row.auth_secret_id).toBe("tok-secret");
88 expect(row.auth_secret_prefix).toBe("Bearer ");90 expect(row.auth_secret_prefix).toBe("Bearer ");
89 // The auth key should be stripped from config json after migration.91 // The auth key should be stripped from config json after migration.
90 const config = Schema.decodeUnknownSync(ConfigJson)(row.config);92 const config = decodeConfigJson(row.config);
91 expect(config.auth).toBeUndefined();93 expect(config.auth).toBeUndefined();
92 expect(config.transport).toBe("remote");94 expect(config.transport).toBe("remote");
93 expect(config.endpoint).toBe("https://example.com/mcp");95 expect(config.endpoint).toBe("https://example.com/mcp");
@@ -200,7 +202,7 @@ describe("0007_normalize_plugin_secret_refs (mcp)", () => { · 106 unmodified lines
200 };202 };
201 expect(row.auth_kind).toBe("none");203 expect(row.auth_kind).toBe("none");
202 expect(row.auth_secret_id).toBeNull();204 expect(row.auth_secret_id).toBeNull();
203 const config = Schema.decodeUnknownSync(ConfigJson)(row.config);205 const config = decodeConfigJson(row.config);
204 expect(config.transport).toBe("stdio");206 expect(config.transport).toBe("stdio");
205 expect(config.command).toBe("/usr/bin/server");207 expect(config.command).toBe("/usr/bin/server");
206 after.close();208 after.close();
apps/local/src/server/migrate-openapi-bindings.test.ts+14−9
@@ -53,6 +53,13 @@ const CountRow = Schema.Struct({ · 52 unmodified lines
53 n: Schema.Number,53 n: Schema.Number,
54});54});
55 55
56const decodeBindingRows = Schema.decodeUnknownSync(Schema.Array(BindingRow));
57const decodeQueryParamRows = Schema.decodeUnknownSync(Schema.Array(QueryParamRow));
58const decodeFetchHeaderRows = Schema.decodeUnknownSync(Schema.Array(FetchHeaderRow));
59const decodeFetchQueryParamRows = Schema.decodeUnknownSync(Schema.Array(FetchQueryParamRow));
60const decodeTableInfoRows = Schema.decodeUnknownSync(Schema.Array(TableInfoRow));
61const decodeCountRow = Schema.decodeUnknownSync(CountRow);
62
56describe("0007_normalize_plugin_secret_refs (openapi)", () => {63describe("0007_normalize_plugin_secret_refs (openapi)", () => {
57 let dir: string;64 let dir: string;
58 let dbPath: string;65 let dbPath: string;
@@ -139,7 +146,7 @@ describe("0007_normalize_plugin_secret_refs (openapi)", () => { · 80 unmodified lines
139 closeDatabase(drizzleSqlite);146 closeDatabase(drizzleSqlite);
140 147
141 const after = openDatabase(dbPath, { readonly: true });148 const after = openDatabase(dbPath, { readonly: true });
142 const rows = Schema.decodeUnknownSync(Schema.Array(BindingRow))(149 const rows = decodeBindingRows(
143 after150 after
144 .prepare(151 .prepare(
145 "SELECT id, kind, secret_id, connection_id, text_value FROM openapi_source_binding ORDER BY id",152 "SELECT id, kind, secret_id, connection_id, text_value FROM openapi_source_binding ORDER BY id",
@@ -169,7 +176,7 @@ describe("0007_normalize_plugin_secret_refs (openapi)", () => { · 23 unmodified lines
169 text_value: "literal",176 text_value: "literal",
170 });177 });
171 // value json column dropped.178 // value json column dropped.
172 const cols = Schema.decodeUnknownSync(Schema.Array(TableInfoRow))(179 const cols = decodeTableInfoRows(
173 after.prepare("PRAGMA table_info('openapi_source_binding')").all(),180 after.prepare("PRAGMA table_info('openapi_source_binding')").all(),
174 );181 );
175 expect(cols.some((c) => c.name === "value")).toBe(false);182 expect(cols.some((c) => c.name === "value")).toBe(false);
@@ -213,7 +220,7 @@ describe("0007_normalize_plugin_secret_refs (openapi)", () => { · 37 unmodified lines
213 220
214 const after = openDatabase(dbPath, { readonly: true });221 const after = openDatabase(dbPath, { readonly: true });
215 222
216 const qpRows = Schema.decodeUnknownSync(Schema.Array(QueryParamRow))(223 const qpRows = decodeQueryParamRows(
217 after224 after
218 .prepare(225 .prepare(
219 "SELECT name, kind, text_value, secret_id FROM openapi_source_query_param WHERE source_id = ? ORDER BY name",226 "SELECT name, kind, text_value, secret_id FROM openapi_source_query_param WHERE source_id = ? ORDER BY name",
@@ -231,7 +238,7 @@ describe("0007_normalize_plugin_secret_refs (openapi)", () => { · 11 unmodified lines
231 text_value: "true",238 text_value: "true",
232 });239 });
233 240
234 const fetchHeaders = Schema.decodeUnknownSync(Schema.Array(FetchHeaderRow))(241 const fetchHeaders = decodeFetchHeaderRows(
235 after242 after
236 .prepare(243 .prepare(
237 "SELECT name, kind, secret_id, secret_prefix FROM openapi_source_spec_fetch_header WHERE source_id = ?",244 "SELECT name, kind, secret_id, secret_prefix FROM openapi_source_spec_fetch_header WHERE source_id = ?",
@@ -246,7 +253,7 @@ describe("0007_normalize_plugin_secret_refs (openapi)", () => { · 8 unmodified lines
246 secret_prefix: "Bearer ",253 secret_prefix: "Bearer ",
247 });254 });
248 255
249 const fetchQp = Schema.decodeUnknownSync(Schema.Array(FetchQueryParamRow))(256 const fetchQp = decodeFetchQueryParamRows(
250 after257 after
251 .prepare(258 .prepare(
252 "SELECT name, secret_id FROM openapi_source_spec_fetch_query_param WHERE source_id = ?",259 "SELECT name, secret_id FROM openapi_source_spec_fetch_query_param WHERE source_id = ?",
@@ -257,9 +264,7 @@ describe("0007_normalize_plugin_secret_refs (openapi)", () => { · 4 unmodified lines
257 expect(fetchQp[0]).toMatchObject({ name: "token", secret_id: "fetch-qp" });264 expect(fetchQp[0]).toMatchObject({ name: "token", secret_id: "fetch-qp" });
258 265
259 // Old json columns dropped.266 // Old json columns dropped.
260 const cols = Schema.decodeUnknownSync(Schema.Array(TableInfoRow))(267 const cols = decodeTableInfoRows(after.prepare("PRAGMA table_info('openapi_source')").all());
261 after.prepare("PRAGMA table_info('openapi_source')").all(),
262 );
263 expect(cols.some((c) => c.name === "query_params")).toBe(false);268 expect(cols.some((c) => c.name === "query_params")).toBe(false);
264 expect(cols.some((c) => c.name === "invocation_config")).toBe(false);269 expect(cols.some((c) => c.name === "invocation_config")).toBe(false);
265 });270 });
@@ -281,7 +286,7 @@ describe("0007_normalize_plugin_secret_refs (openapi)", () => { · 15 unmodified lines
281 closeDatabase(drizzleSqlite);286 closeDatabase(drizzleSqlite);
282 287
283 const after = openDatabase(dbPath, { readonly: true });288 const after = openDatabase(dbPath, { readonly: true });
284 const qpCount = Schema.decodeUnknownSync(CountRow)(289 const qpCount = decodeCountRow(
285 after290 after
286 .prepare("SELECT count(*) as n FROM openapi_source_query_param WHERE source_id = ?")291 .prepare("SELECT count(*) as n FROM openapi_source_query_param WHERE source_id = ?")
287 .get("bare"),292 .get("bare"),
packages/core/api/src/oauth-popup.test.ts+6−6
@@ -21,6 +21,12 @@ type GoogleAuth = { · 20 unmodified lines
21 refreshTokenSecretId: string | null;21 refreshTokenSecretId: string | null;
22};22};
23 23
24const DomainErrorShape = Schema.Struct({
25 _tag: Schema.Literal("DomainError"),
26 message: Schema.String,
27});
28const isDomainError = Schema.is(DomainErrorShape);
29
24// ---------------------------------------------------------------------------30// ---------------------------------------------------------------------------
25// popupDocument31// popupDocument
26// ---------------------------------------------------------------------------32// ---------------------------------------------------------------------------
@@ -172,12 +178,6 @@ describe("runOAuthCallback", () => { · 145 unmodified lines
172 class DomainError extends Data.TaggedError("DomainError")<{178 class DomainError extends Data.TaggedError("DomainError")<{
173 readonly message: string;179 readonly message: string;
174 }> {}180 }> {}
175 const isDomainError = Schema.is(
176 Schema.Struct({
177 _tag: Schema.Literal("DomainError"),
178 message: Schema.String,
179 }),
180 );
181 const html = await Effect.runPromise(181 const html = await Effect.runPromise(
182 runOAuthCallback<GoogleAuth, DomainError, never>({182 runOAuthCallback<GoogleAuth, DomainError, never>({
183 complete: () => Effect.fail(new DomainError({ message: "Code expired" })),183 complete: () => Effect.fail(new DomainError({ message: "Code expired" })),
packages/core/api/src/observability.ts+3−1
@@ -115,12 +115,14 @@ export const capture = <A, E, R>( · 114 unmodified lines
115 * widened `YieldableError` channel on `ExecutionEngineService` is115 * widened `YieldableError` channel on `ExecutionEngineService` is
116 * narrowed to `InternalError` before leaving the handler body.116 * narrowed to `InternalError` before leaving the handler body.
117 */117 */
118const isInternalError = Schema.is(InternalError);
119
118export const captureEngineError = <A, R>(120export const captureEngineError = <A, R>(
119 eff: Effect.Effect<A, Cause.YieldableError, R>,121 eff: Effect.Effect<A, Cause.YieldableError, R>,
120): Effect.Effect<A, InternalError, R> =>122): Effect.Effect<A, InternalError, R> =>
121 eff.pipe(123 eff.pipe(
122 Effect.catch((err) =>124 Effect.catch((err) =>
123 Schema.is(InternalError)(err)125 isInternalError(err)
124 ? Effect.fail(err)126 ? Effect.fail(err)
125 : resolveCapture.pipe(127 : resolveCapture.pipe(
126 Effect.flatMap((c) => c.captureException(Cause.fail(err))),128 Effect.flatMap((c) => c.captureException(Cause.fail(err))),
packages/core/config/src/config.test.ts+5−3
@@ -14,6 +14,8 @@ import { · 13 unmodified lines
14 removeSecretFromConfig,14 removeSecretFromConfig,
15} from "./write";15} from "./write";
16 16
17const decodeExecutorFileConfig = Schema.decodeUnknownSync(ExecutorFileConfig);
18
17const withTmpDir = <A, E>(fn: (dir: string) => Effect.Effect<A, E, FileSystem.FileSystem>) =>19const withTmpDir = <A, E>(fn: (dir: string) => Effect.Effect<A, E, FileSystem.FileSystem>) =>
18 Effect.gen(function* () {20 Effect.gen(function* () {
19 const fs = yield* FileSystem.FileSystem;21 const fs = yield* FileSystem.FileSystem;
@@ -26,7 +28,7 @@ const withTmpDir = <A, E>(fn: (dir: string) => Effect.Effect<A, E, FileSystem.Fi · 6 unmodified lines
26describe("ExecutorFileConfig schema", () => {28describe("ExecutorFileConfig schema", () => {
27 it("decodes a minimal config", () => {29 it("decodes a minimal config", () => {
28 const raw = { sources: [] };30 const raw = { sources: [] };
29 const result = Schema.decodeUnknownSync(ExecutorFileConfig)(raw);31 const result = decodeExecutorFileConfig(raw);
30 expect(result.sources).toEqual([]);32 expect(result.sources).toEqual([]);
31 });33 });
32 34
@@ -70,7 +72,7 @@ describe("ExecutorFileConfig schema", () => { · 37 unmodified lines
70 },72 },
71 };73 };
72 74
73 const result = Schema.decodeUnknownSync(ExecutorFileConfig)(raw);75 const result = decodeExecutorFileConfig(raw);
74 expect(result.sources).toHaveLength(4);76 expect(result.sources).toHaveLength(4);
75 expect(result.name).toBe("test");77 expect(result.name).toBe("test");
76 expect(result.secrets!["my-token"]!.name).toBe("My Token");78 expect(result.secrets!["my-token"]!.name).toBe("My Token");
@@ -80,7 +82,7 @@ describe("ExecutorFileConfig schema", () => { · 3 unmodified lines
80 const raw = {82 const raw = {
81 sources: [{ kind: "invalid", endpoint: "http://example.com" }],83 sources: [{ kind: "invalid", endpoint: "http://example.com" }],
82 };84 };
83 expect(() => Schema.decodeUnknownSync(ExecutorFileConfig)(raw)).toThrow();85 expect(() => decodeExecutorFileConfig(raw)).toThrow();
84 });86 });
85});87});
86 88
packages/core/sdk/src/executor.ts+5−5
@@ -347,14 +347,16 @@ const staticDeclToSource = (decl: StaticSourceDecl, pluginId: string): Source => · 346 unmodified lines
347 runtime: true,347 runtime: true,
348});348});
349 349
350const decodeJsonFromString = Schema.decodeUnknownOption(Schema.UnknownFromJsonString);
351
350const decodeJsonColumn = (value: unknown): unknown => {352const decodeJsonColumn = (value: unknown): unknown => {
351 if (value === null || value === undefined) return undefined;353 if (value === null || value === undefined) return undefined;
352 if (typeof value !== "string") return value;354 if (typeof value !== "string") return value;
353 return Schema.decodeUnknownOption(Schema.UnknownFromJsonString)(value).pipe(355 return decodeJsonFromString(value).pipe(Option.getOrElse(() => value));
354 Option.getOrElse(() => value),
355 );
356};356};
357 357
358const decodeProviderState = Schema.decodeUnknownOption(ConnectionProviderState);
359
358const rowToTool = (row: ToolRow, annotations?: ToolAnnotations): Tool => ({360const rowToTool = (row: ToolRow, annotations?: ToolAnnotations): Tool => ({
359 id: row.id,361 id: row.id,
360 sourceId: row.source_id,362 sourceId: row.source_id,
@@ -1177,8 +1179,6 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 816 unmodified lines
1177 // behavior under the new SDK orchestration stays identical.1179 // behavior under the new SDK orchestration stays identical.
1178 const CONNECTION_REFRESH_SKEW_MS = 60_000;1180 const CONNECTION_REFRESH_SKEW_MS = 60_000;
1179 1181
1180 const decodeProviderState = Schema.decodeUnknownOption(ConnectionProviderState);
1181
1182 const rowToConnection = (row: ConnectionRow): ConnectionRef =>1182 const rowToConnection = (row: ConnectionRow): ConnectionRef =>
1183 new ConnectionRef({1183 new ConnectionRef({
1184 id: ConnectionId.make(row.id),1184 id: ConnectionId.make(row.id),
packages/core/sdk/src/oauth-service.ts+15−17
@@ -137,11 +137,15 @@ type OAuthSessionPayload = typeof OAuthSessionPayload.Type; · 136 unmodified lines
137const decodeSessionPayload = Schema.decodeUnknownSync(OAuthSessionPayload);137const decodeSessionPayload = Schema.decodeUnknownSync(OAuthSessionPayload);
138const encodeSessionPayload = Schema.encodeSync(OAuthSessionPayload);138const encodeSessionPayload = Schema.encodeSync(OAuthSessionPayload);
139 139
140const UnknownFromJsonString = Schema.fromJsonString(Schema.Unknown);
141const decodeUnknownJsonOption = Schema.decodeUnknownOption(UnknownFromJsonString);
142
143const decodeProviderStateSync = Schema.decodeUnknownSync(OAuthProviderStateSchema);
144const encodeProviderStateSync = Schema.encodeSync(OAuthProviderStateSchema);
145
140const coerceJson = (value: unknown): unknown => {146const coerceJson = (value: unknown): unknown => {
141 if (typeof value !== "string") return value;147 if (typeof value !== "string") return value;
142 return Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))(value).pipe(148 return decodeUnknownJsonOption(value).pipe(Option.getOrElse(() => value));
143 Option.getOrElse(() => value),
144 );
145};149};
146 150
147const stringArray = (value: unknown): readonly string[] =>151const stringArray = (value: unknown): readonly string[] =>
@@ -167,7 +171,7 @@ const decodeProviderState = (value: unknown): OAuthProviderState => { · 19 unmodified lines
167 if (record && !("kind" in record) && "flow" in record && "tokenUrl" in record) {171 if (record && !("kind" in record) && "flow" in record && "tokenUrl" in record) {
168 const flow = record.flow;172 const flow = record.flow;
169 if (flow === "authorizationCode") {173 if (flow === "authorizationCode") {
170 return Schema.decodeUnknownSync(OAuthProviderStateSchema)({174 return decodeProviderStateSync({
171 kind: "authorization-code",175 kind: "authorization-code",
172 tokenEndpoint: record.tokenUrl,176 tokenEndpoint: record.tokenUrl,
173 issuerUrl: originOrNull(record.authorizationEndpoint),177 issuerUrl: originOrNull(record.authorizationEndpoint),
@@ -178,7 +182,7 @@ const decodeProviderState = (value: unknown): OAuthProviderState => { · 4 unmodified lines
178 });182 });
179 }183 }
180 if (flow === "clientCredentials") {184 if (flow === "clientCredentials") {
181 return Schema.decodeUnknownSync(OAuthProviderStateSchema)({185 return decodeProviderStateSync({
182 kind: "client-credentials",186 kind: "client-credentials",
183 tokenEndpoint: record.tokenUrl,187 tokenEndpoint: record.tokenUrl,
184 clientIdSecretId: record.clientIdSecretId,188 clientIdSecretId: record.clientIdSecretId,
@@ -192,7 +196,7 @@ const decodeProviderState = (value: unknown): OAuthProviderState => { · 7 unmodified lines
192 196
193 if (record && !("kind" in record) && "clientIdSecretId" in record && "scopes" in record) {197 if (record && !("kind" in record) && "clientIdSecretId" in record && "scopes" in record) {
194 const scopes = stringArray(record.scopes);198 const scopes = stringArray(record.scopes);
195 return Schema.decodeUnknownSync(OAuthProviderStateSchema)({199 return decodeProviderStateSync({
196 kind: "authorization-code",200 kind: "authorization-code",
197 tokenEndpoint: "https://oauth2.googleapis.com/token",201 tokenEndpoint: "https://oauth2.googleapis.com/token",
198 issuerUrl: "https://accounts.google.com",202 issuerUrl: "https://accounts.google.com",
@@ -208,7 +212,7 @@ const decodeProviderState = (value: unknown): OAuthProviderState => { · 9 unmodified lines
208 const authorizationServerMetadata = isRecord(record.authorizationServerMetadata)212 const authorizationServerMetadata = isRecord(record.authorizationServerMetadata)
209 ? record.authorizationServerMetadata213 ? record.authorizationServerMetadata
210 : null;214 : null;
211 return Schema.decodeUnknownSync(OAuthProviderStateSchema)({215 return decodeProviderStateSync({
212 kind: "dynamic-dcr",216 kind: "dynamic-dcr",
213 tokenEndpoint:217 tokenEndpoint:
214 typeof record.tokenEndpoint === "string"218 typeof record.tokenEndpoint === "string"
@@ -233,7 +237,7 @@ const decodeProviderState = (value: unknown): OAuthProviderState => { · 18 unmodified lines
233 });237 });
234 }238 }
235 239
236 return Schema.decodeUnknownSync(OAuthProviderStateSchema)(raw);240 return decodeProviderStateSync(raw);
237};241};
238 242
239// ---------------------------------------------------------------------------243// ---------------------------------------------------------------------------
@@ -605,10 +609,7 @@ export const makeOAuth2Service = ( · 365 unmodified lines
605 refreshToken: null,609 refreshToken: null,
606 expiresAt,610 expiresAt,
607 oauthScope: tokens.scope ?? null,611 oauthScope: tokens.scope ?? null,
608 providerState: Schema.encodeSync(OAuthProviderStateSchema)(providerState) as Record<612 providerState: encodeProviderStateSync(providerState) as Record<string, unknown>,
609 string,
610 unknown
611 >,
612 }),613 }),
613 )614 )
614 .pipe(615 .pipe(
@@ -847,10 +848,7 @@ export const makeOAuth2Service = ( · 232 unmodified lines
847 : null,848 : null,
848 expiresAt: connectionExpiresAt,849 expiresAt: connectionExpiresAt,
849 oauthScope: exchangeResult.tokens.scope ?? null,850 oauthScope: exchangeResult.tokens.scope ?? null,
850 providerState: Schema.encodeSync(OAuthProviderStateSchema)(providerState) as Record<851 providerState: encodeProviderStateSync(providerState) as Record<string, unknown>,
851 string,
852 unknown
853 >,
854 }),852 }),
855 )853 )
856 .pipe(854 .pipe(
@@ -1219,7 +1217,7 @@ export const makeOAuth2Service = ( · 362 unmodified lines
1219 refreshToken: tokens.refresh_token,1217 refreshToken: tokens.refresh_token,
1220 expiresAt,1218 expiresAt,
1221 oauthScope: tokens.scope ?? input.oauthScope,1219 oauthScope: tokens.scope ?? input.oauthScope,
1222 providerState: Schema.encodeSync(OAuthProviderStateSchema)({1220 providerState: encodeProviderStateSync({
1223 ...state,1221 ...state,
1224 tokenEndpoint,1222 tokenEndpoint,
1225 scope: tokens.scope ?? state.scope,1223 scope: tokens.scope ?? state.scope,
packages/core/storage-core/src/factory.ts+3−3
@@ -81,6 +81,8 @@ const withApplyDefault = ( · 80 unmodified lines
81 return value;81 return value;
82};82};
83 83
84const decodeJsonFromString = Schema.decodeUnknownOption(Schema.UnknownFromJsonString);
85
84// ---------------------------------------------------------------------------86// ---------------------------------------------------------------------------
85// Factory87// Factory
86// ---------------------------------------------------------------------------88// ---------------------------------------------------------------------------
@@ -199,9 +201,7 @@ export const createAdapter = (options: CreateAdapterOptions): DBAdapter => { · 112 unmodified lines
199 };201 };
200 202
201 const decodeJsonFallback = (value: string): unknown =>203 const decodeJsonFallback = (value: string): unknown =>
202 Schema.decodeUnknownOption(Schema.UnknownFromJsonString)(value).pipe(204 decodeJsonFromString(value).pipe(Option.getOrElse((): unknown => value));
203 Option.getOrElse((): unknown => value),
204 );
205 205
206 const decodeValue = (attr: DBFieldAttribute | undefined, value: unknown): unknown => {206 const decodeValue = (attr: DBFieldAttribute | undefined, value: unknown): unknown => {
207 if (value === undefined || value === null) return value;207 if (value === undefined || value === null) return value;
packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx+5−7
@@ -51,15 +51,13 @@ import { GOOGLE_DISCOVERY_OAUTH_POPUP_NAME, googleDiscoveryOAuthStrategy } from · 50 unmodified lines
51import { googleDiscoveryPresets, type GoogleDiscoveryPreset } from "../sdk/presets";51import { googleDiscoveryPresets, type GoogleDiscoveryPreset } from "../sdk/presets";
52 52
53const ErrorMessage = Schema.Struct({ message: Schema.String });53const ErrorMessage = Schema.Struct({ message: Schema.String });
54const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage);
54 55
55const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string =>56const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string =>
56 Option.match(57 Option.match(Option.flatMap(Exit.findErrorOption(exit), decodeErrorMessage), {
57 Option.flatMap(Exit.findErrorOption(exit), Schema.decodeUnknownOption(ErrorMessage)),58 onNone: () => fallback,
58 {59 onSome: ({ message }) => message,
59 onNone: () => fallback,60 });
60 onSome: ({ message }) => message,
61 },
62 );
63 61
64type GoogleAuthKind = "none" | "oauth2";62type GoogleAuthKind = "none" | "oauth2";
65 63
packages/plugins/graphql/src/react/AddGraphqlSource.tsx+5−7
@@ -40,15 +40,13 @@ import { initialGraphqlCredentials } from "./defaults"; · 39 unmodified lines
40import type { HeaderValue } from "../sdk/types";40import type { HeaderValue } from "../sdk/types";
41 41
42const ErrorMessage = Schema.Struct({ message: Schema.String });42const ErrorMessage = Schema.Struct({ message: Schema.String });
43const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage);
43 44
44const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string =>45const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string =>
45 Option.match(46 Option.match(Option.flatMap(Exit.findErrorOption(exit), decodeErrorMessage), {
46 Option.flatMap(Exit.findErrorOption(exit), Schema.decodeUnknownOption(ErrorMessage)),47 onNone: () => fallback,
47 {48 onSome: ({ message }) => message,
48 onNone: () => fallback,49 });
49 onSome: ({ message }) => message,
50 },
51 );
52 50
53type AuthMode = "none" | "oauth2";51type AuthMode = "none" | "oauth2";
54 52
packages/plugins/graphql/src/sdk/store.ts+8−2
@@ -110,11 +110,17 @@ export interface StoredOperation { · 109 unmodified lines
110 readonly binding: OperationBinding;110 readonly binding: OperationBinding;
111}111}
112 112
113const OperationBindingFromJsonString = Schema.fromJsonString(OperationBinding);
114const decodeOperationBindingFromJsonString = Schema.decodeUnknownSync(
115 OperationBindingFromJsonString,
116);
117const decodeOperationBinding = Schema.decodeUnknownSync(OperationBinding);
118
113const decodeBinding = (value: unknown): OperationBinding => {119const decodeBinding = (value: unknown): OperationBinding => {
114 if (typeof value === "string") {120 if (typeof value === "string") {
115 return Schema.decodeUnknownSync(Schema.fromJsonString(OperationBinding))(value);121 return decodeOperationBindingFromJsonString(value);
116 }122 }
117 return Schema.decodeUnknownSync(OperationBinding)(value);123 return decodeOperationBinding(value);
118};124};
119 125
120const encodeBinding = Schema.encodeSync(OperationBinding);126const encodeBinding = Schema.encodeSync(OperationBinding);
packages/plugins/mcp/src/react/AddMcpSource.tsx+5−7
@@ -56,15 +56,13 @@ import { probeMcpEndpoint, addMcpSourceOptimistic } from "./atoms"; · 55 unmodified lines
56import { mcpPresets, type McpPreset } from "../sdk/presets";56import { mcpPresets, type McpPreset } from "../sdk/presets";
57 57
58const ErrorMessage = Schema.Struct({ message: Schema.String });58const ErrorMessage = Schema.Struct({ message: Schema.String });
59const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage);
59 60
60const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string =>61const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string =>
61 Option.match(62 Option.match(Option.flatMap(Exit.findErrorOption(exit), decodeErrorMessage), {
62 Option.flatMap(Exit.findErrorOption(exit), Schema.decodeUnknownOption(ErrorMessage)),63 onNone: () => fallback,
63 {64 onSome: ({ message }) => message,
64 onNone: () => fallback,65 });
65 onSome: ({ message }) => message,
66 },
67 );
68 66
69// ---------------------------------------------------------------------------67// ---------------------------------------------------------------------------
70// Preset lookup68// Preset lookup
packages/plugins/openapi/src/react/AddOpenApiSource.tsx+5−7
@@ -86,15 +86,13 @@ export const OPENAPI_OAUTH_POPUP_NAME = "openapi-oauth"; · 85 unmodified lines
86export const OPENAPI_OAUTH_CALLBACK_PATH = "/api/oauth/callback";86export const OPENAPI_OAUTH_CALLBACK_PATH = "/api/oauth/callback";
87 87
88const ErrorMessage = Schema.Struct({ message: Schema.String });88const ErrorMessage = Schema.Struct({ message: Schema.String });
89const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage);
89 90
90const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string =>91const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string =>
91 Option.match(92 Option.match(Option.flatMap(Exit.findErrorOption(exit), decodeErrorMessage), {
92 Option.flatMap(Exit.findErrorOption(exit), Schema.decodeUnknownOption(ErrorMessage)),93 onNone: () => fallback,
93 {94 onSome: ({ message }) => message,
94 onNone: () => fallback,95 });
95 onSome: ({ message }) => message,
96 },
97 );
98 96
99const substituteUrlVariables = (url: string, values: Record<string, string>): string => {97const substituteUrlVariables = (url: string, values: Record<string, string>): string => {
100 let out = url;98 let out = url;
packages/plugins/openapi/src/react/EditOpenApiSource.tsx+8−9
@@ -51,15 +51,14 @@ import { · 50 unmodified lines
51} from "../sdk/types";51} from "../sdk/types";
52 52
53const ErrorMessage = Schema.Struct({ message: Schema.String });53const ErrorMessage = Schema.Struct({ message: Schema.String });
54const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage);
55const isOpenApiSourceBindingValue = Schema.is(OpenApiSourceBindingValue);
54 56
55const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string =>57const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string =>
56 Option.match(58 Option.match(Option.flatMap(Exit.findErrorOption(exit), decodeErrorMessage), {
57 Option.flatMap(Exit.findErrorOption(exit), Schema.decodeUnknownOption(ErrorMessage)),59 onNone: () => fallback,
58 {60 onSome: ({ message }) => message,
59 onNone: () => fallback,61 });
60 onSome: ({ message }) => message,
61 },
62 );
63 62
64type SlotDef =63type SlotDef =
65 | {64 | {
@@ -137,12 +136,12 @@ const effectiveBindingForScope = ( · 71 unmodified lines
137const isSecretBindingValue = (136const isSecretBindingValue = (
138 value: unknown,137 value: unknown,
139): value is Extract<OpenApiSourceBindingValueType, { readonly kind: "secret" }> =>138): value is Extract<OpenApiSourceBindingValueType, { readonly kind: "secret" }> =>
140 Schema.is(OpenApiSourceBindingValue)(value) && value.kind === "secret";139 isOpenApiSourceBindingValue(value) && value.kind === "secret";
141 140
142const isConnectionBindingValue = (141const isConnectionBindingValue = (
143 value: unknown,142 value: unknown,
144): value is Extract<OpenApiSourceBindingValueType, { readonly kind: "connection" }> =>143): value is Extract<OpenApiSourceBindingValueType, { readonly kind: "connection" }> =>
145 Schema.is(OpenApiSourceBindingValue)(value) && value.kind === "connection";144 isOpenApiSourceBindingValue(value) && value.kind === "connection";
146 145
147export default function EditOpenApiSource(props: {146export default function EditOpenApiSource(props: {
148 readonly sourceId: string;147 readonly sourceId: string;
packages/plugins/openapi/src/sdk/non-json-body.test.ts+3−2
@@ -33,6 +33,7 @@ const JsonNameBody = Schema.fromJsonString( · 32 unmodified lines
33 name: Schema.String,33 name: Schema.String,
34 }),34 }),
35);35);
36const decodeJsonNameBody = Schema.decodeUnknownSync(JsonNameBody);
36 37
37const memoryProvider: SecretProvider = (() => {38const memoryProvider: SecretProvider = (() => {
38 const store = new Map<string, string>();39 const store = new Map<string, string>();
@@ -216,7 +217,7 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 177 unmodified lines
216 expect(captured.contentType).toBe("text/xml");217 expect(captured.contentType).toBe("text/xml");
217 const body = captured.body.toString("utf8");218 const body = captured.body.toString("utf8");
218 expect(body).not.toBe("[object Object]");219 expect(body).not.toBe("[object Object]");
219 expect(Schema.decodeUnknownSync(JsonNameBody)(body)).toEqual({ name: "Acme" });220 expect(decodeJsonNameBody(body)).toEqual({ name: "Acme" });
220 }),221 }),
221 );222 );
222 223
@@ -364,7 +365,7 @@ describe("OpenAPI non-JSON request body dispatch", () => { · 141 unmodified lines
364 );365 );
365 366
366 expect(captured.contentType).toBe("application/json");367 expect(captured.contentType).toBe("application/json");
367 expect(Schema.decodeUnknownSync(JsonNameBody)(captured.body.toString("utf8"))).toEqual({368 expect(decodeJsonNameBody(captured.body.toString("utf8"))).toEqual({
368 name: "Acme",369 name: "Acme",
369 });370 });
370 }),371 }),
packages/plugins/openapi/src/sdk/store.ts+3−1
@@ -287,8 +287,10 @@ const SourceNameStorageRow = Schema.Struct({ · 286 unmodified lines
287});287});
288const decodeSourceNameStorageRow = Schema.decodeUnknownSync(SourceNameStorageRow);288const decodeSourceNameStorageRow = Schema.decodeUnknownSync(SourceNameStorageRow);
289 289
290const decodeStorageString = Schema.decodeUnknownSync(Schema.String);
291
290const decodeStorageDate = (value: unknown): Date =>292const decodeStorageDate = (value: unknown): Date =>
291 value instanceof Date ? value : new Date(Schema.decodeUnknownSync(Schema.String)(value));293 value instanceof Date ? value : new Date(decodeStorageString(value));
292 294
293interface ChildRow {295interface ChildRow {
294 readonly id: string;296 readonly id: string;
packages/plugins/workos-vault/src/sdk/client.ts+2−1
@@ -26,12 +26,13 @@ const WORKOS_KEK_NOT_READY_MESSAGE = · 25 unmodified lines
26const CauseWithStatusSchema = Schema.Struct({26const CauseWithStatusSchema = Schema.Struct({
27 status: Schema.Number,27 status: Schema.Number,
28});28});
29const decodeCauseWithStatusOption = Schema.decodeUnknownOption(CauseWithStatusSchema);
29 30
30const statusFromWorkOSCause = (cause: unknown): number | undefined => {31const statusFromWorkOSCause = (cause: unknown): number | undefined => {
31 if (cause instanceof GenericServerException || cause instanceof NotFoundException) {32 if (cause instanceof GenericServerException || cause instanceof NotFoundException) {
32 return cause.status;33 return cause.status;
33 }34 }
34 return Option.match(Schema.decodeUnknownOption(CauseWithStatusSchema)(cause), {35 return Option.match(decodeCauseWithStatusOption(cause), {
35 onNone: () => undefined,36 onNone: () => undefined,
36 onSome: (decoded) => decoded.status,37 onSome: (decoded) => decoded.status,
37 });38 });
scripts/oxlint-plugin-executor.js+2−0
@@ -5,6 +5,7 @@ import noEffectEscapeHatch from "./oxlint-plugin-executor/rules/no-effect-escape · 4 unmodified lines
5import noEffectInternalTags from "./oxlint-plugin-executor/rules/no-effect-internal-tags.js";5import noEffectInternalTags from "./oxlint-plugin-executor/rules/no-effect-internal-tags.js";
6import noErrorConstructor from "./oxlint-plugin-executor/rules/no-error-constructor.js";6import noErrorConstructor from "./oxlint-plugin-executor/rules/no-error-constructor.js";
7import noInlineObjectTypeAssertion from "./oxlint-plugin-executor/rules/no-inline-object-type-assertion.js";7import noInlineObjectTypeAssertion from "./oxlint-plugin-executor/rules/no-inline-object-type-assertion.js";
8import noInlineSchemaCompile from "./oxlint-plugin-executor/rules/no-inline-schema-compile.js";
8import noInstanceofError from "./oxlint-plugin-executor/rules/no-instanceof-error.js";9import noInstanceofError from "./oxlint-plugin-executor/rules/no-instanceof-error.js";
9import noInstanceofTaggedError from "./oxlint-plugin-executor/rules/no-instanceof-tagged-error.js";10import noInstanceofTaggedError from "./oxlint-plugin-executor/rules/no-instanceof-tagged-error.js";
10import noJsonParse from "./oxlint-plugin-executor/rules/no-json-parse.js";11import noJsonParse from "./oxlint-plugin-executor/rules/no-json-parse.js";
@@ -42,6 +43,7 @@ export default { · 31 unmodified lines
42 "no-error-constructor": noErrorConstructor,43 "no-error-constructor": noErrorConstructor,
43 "no-ts-nocheck": noTsNocheck,44 "no-ts-nocheck": noTsNocheck,
44 "no-inline-object-type-assertion": noInlineObjectTypeAssertion,45 "no-inline-object-type-assertion": noInlineObjectTypeAssertion,
46 "no-inline-schema-compile": noInlineSchemaCompile,
45 "no-instanceof-error": noInstanceofError,47 "no-instanceof-error": noInstanceofError,
46 "no-instanceof-tagged-error": noInstanceofTaggedError,48 "no-instanceof-tagged-error": noInstanceofTaggedError,
47 "no-json-parse": noJsonParse,49 "no-json-parse": noJsonParse,
scripts/oxlint-plugin-executor/rules/no-inline-schema-compile.js+104−0
@@ -0,0 +1,104 @@
1import { getPropertyName, isIdentifier, unwrapExpression } from "../utils.js";
2
3// Schema methods that compile a parser/guard from a schema. Calling these
4// allocates a new function per invocation; the result should be hoisted to
5// module (or at least closure) scope.
6const COMPILER_METHODS = new Set([
7 "is",
8 "asserts",
9 "decode",
10 "decodeSync",
11 "decodePromise",
12 "decodeOption",
13 "decodeEither",
14 "decodeUnknown",
15 "decodeUnknownSync",
16 "decodeUnknownPromise",
17 "decodeUnknownOption",
18 "decodeUnknownEither",
19 "encode",
20 "encodeSync",
21 "encodePromise",
22 "encodeOption",
23 "encodeEither",
24 "encodeUnknown",
25 "encodeUnknownSync",
26 "encodeUnknownPromise",
27 "encodeUnknownOption",
28 "encodeUnknownEither",
29 "validate",
30 "validateSync",
31 "validatePromise",
32 "validateOption",
33 "validateEither",
34 "parse",
35 "parseSync",
36 "parsePromise",
37 "parseOption",
38 "parseEither",
39]);
40
41const getSchemaCompilerMethod = (callee) => {
42 const expression = unwrapExpression(callee);
43 if (expression?.type !== "MemberExpression") return undefined;
44 const object = unwrapExpression(expression.object);
45 if (!isIdentifier(object, "Schema")) return undefined;
46 const method = getPropertyName(expression.property);
47 return method && COMPILER_METHODS.has(method) ? method : undefined;
48};
49
50const isNestedSchemaCall = (node) => {
51 const expression = unwrapExpression(node);
52 if (expression?.type !== "CallExpression") return false;
53 const callee = unwrapExpression(expression.callee);
54 if (callee?.type !== "MemberExpression") return false;
55 const object = unwrapExpression(callee.object);
56 return isIdentifier(object, "Schema");
57};
58
59const messageHigh = (method) =>
60 `Hoist Schema.${method}(...) to module scope: both the inline schema literal and the compiled function are rebuilt on every call. Move the compiled function to a module-level const.`;
61
62const messageMedium = (method) =>
63 `Hoist Schema.${method}(...) to module scope: the compiled function is rebuilt on every call. Move it to a module-level const.`;
64
65export default {
66 meta: {
67 type: "problem",
68 docs: {
69 description:
70 "Disallow Schema compiler calls (Schema.is/decode*/encode*/validate*/parse*) inside function bodies; hoist them to module scope.",
71 },
72 },
73 create(context) {
74 let functionDepth = 0;
75
76 const enterFunction = () => {
77 functionDepth++;
78 };
79 const exitFunction = () => {
80 functionDepth--;
81 };
82
83 return {
84 FunctionDeclaration: enterFunction,
85 "FunctionDeclaration:exit": exitFunction,
86 FunctionExpression: enterFunction,
87 "FunctionExpression:exit": exitFunction,
88 ArrowFunctionExpression: enterFunction,
89 "ArrowFunctionExpression:exit": exitFunction,
90
91 CallExpression(node) {
92 if (functionDepth === 0) return;
93 const method = getSchemaCompilerMethod(node.callee);
94 if (!method) return;
95 const firstArg = node.arguments[0];
96 const high = firstArg && isNestedSchemaCall(firstArg);
97 context.report({
98 node: node.callee,
99 message: high ? messageHigh(method) : messageMedium(method),
100 });
101 },
102 };
103 },
104};