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

Showing the changes to scripts (2 of 25 files). Show all

Unified · Split

2 files
  1. scripts/oxlint-plugin-executor.js +2 −0
  2. scripts/oxlint-plugin-executor/rules/no-inline-schema-compile.js +104 −0
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};