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
55 import noEffectInternalTags from "./oxlint-plugin-executor/rules/no-effect-internal-tags.js";
66 import noErrorConstructor from "./oxlint-plugin-executor/rules/no-error-constructor.js";
77 import noInlineObjectTypeAssertion from "./oxlint-plugin-executor/rules/no-inline-object-type-assertion.js";
8+import noInlineSchemaCompile from "./oxlint-plugin-executor/rules/no-inline-schema-compile.js";
89 import noInstanceofError from "./oxlint-plugin-executor/rules/no-instanceof-error.js";
910 import noInstanceofTaggedError from "./oxlint-plugin-executor/rules/no-instanceof-tagged-error.js";
1011 import noJsonParse from "./oxlint-plugin-executor/rules/no-json-parse.js";
@@ -42,6 +43,7 @@ export default { · 31 unmodified lines
4243 "no-error-constructor": noErrorConstructor,
4344 "no-ts-nocheck": noTsNocheck,
4445 "no-inline-object-type-assertion": noInlineObjectTypeAssertion,
46+ "no-inline-schema-compile": noInlineSchemaCompile,
4547 "no-instanceof-error": noInstanceofError,
4648 "no-instanceof-tagged-error": noInstanceofTaggedError,
4749 "no-json-parse": noJsonParse,
scripts/oxlint-plugin-executor/rules/no-inline-schema-compile.js+104−0
@@ -0,0 +1,104 @@
1+import { 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.
6+const 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+
41+const 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+
50+const 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+
59+const 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+
62+const 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+
65+export 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+};