@sarj/eslint-plugin 6.0.0 → 6.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -108,6 +108,7 @@ Both distilled from two years of PR-review comments across ~1,065 PRs.
108
108
  | `prefer-zod-enum` | `z.union([z.literal("a"), z.literal("b")])` — autofixes closed string choices to the shorter, equivalent `z.enum(["a", "b"])`. | warn / error |
109
109
  | `prefer-zod-infer` | An `interface`/`type` that restates a Zod schema declared in the same module instead of deriving it with `z.infer`. Options: `ignoreTypeNames`, `requireIdenticalShape` (default `true`). | warn / error |
110
110
  | `prefer-module-level-constant` | A literal-only `const` collection (array, object, `Set`, `Map`, `Object.freeze`) or non-global regex declared inside a function body, never mutated and never escaping — hoist it to module scope. Options: `minElements` (default 3), `checkRegex`, `ignoreTestFiles`. | warn / error |
111
+ | `prefer-module-level-schema` | A Zod schema built inside a function body that closes over nothing the function owns — hoist it to module scope instead of rebuilding it per call, per request, per render. Silent when it references a parameter, local, type parameter, local type, or `this` (that is a schema FACTORY), when it is already memoized, and inside `z.lazy`. Options: `factories` (default: the object-like composites), `minProperties` (default 1), `ignoreTestFiles`. | warn / error |
111
112
  | `prefer-non-nullable-collection` | An array type explicitly combined with `null`/`undefined`, creating two equivalent empty states. | warn / error |
112
113
 
113
114
  ## Options
package/dist/index.cjs CHANGED
@@ -8647,6 +8647,258 @@ var no_hand_rolled_sleep_default = import_utils55.ESLintUtils.RuleCreator(
8647
8647
  }
8648
8648
  });
8649
8649
 
8650
+ // src/rules/prefer-module-level-schema.ts
8651
+ var import_utils56 = require("@typescript-eslint/utils");
8652
+ var DEFAULT_FACTORIES = [
8653
+ "discriminatedUnion",
8654
+ "intersection",
8655
+ "looseObject",
8656
+ "object",
8657
+ "record",
8658
+ "strictObject",
8659
+ "tuple",
8660
+ "union"
8661
+ ];
8662
+ var DEFAULT_MIN_PROPERTIES = 1;
8663
+ var MEMO_CALLEES = /* @__PURE__ */ new Set([
8664
+ "lazy",
8665
+ "memo",
8666
+ "once",
8667
+ "useMemo"
8668
+ ]);
8669
+ var TERMINAL_METHODS = /* @__PURE__ */ new Set([
8670
+ "isNullable",
8671
+ "isOptional",
8672
+ "parse",
8673
+ "parseAsync",
8674
+ "safeParse",
8675
+ "safeParseAsync",
8676
+ "spa"
8677
+ ]);
8678
+ var FUNCTION_TYPES6 = /* @__PURE__ */ new Set([
8679
+ import_utils56.AST_NODE_TYPES.ArrowFunctionExpression,
8680
+ import_utils56.AST_NODE_TYPES.FunctionDeclaration,
8681
+ import_utils56.AST_NODE_TYPES.FunctionExpression
8682
+ ]);
8683
+ function schemaExpression(node) {
8684
+ let current = node;
8685
+ for (; ; ) {
8686
+ const parent = current.parent ?? void 0;
8687
+ if (parent === void 0) {
8688
+ return current;
8689
+ }
8690
+ if (parent.type === import_utils56.AST_NODE_TYPES.MemberExpression && parent.object === current && !parent.computed && parent.property.type === import_utils56.AST_NODE_TYPES.Identifier && TERMINAL_METHODS.has(parent.property.name)) {
8691
+ return current;
8692
+ }
8693
+ if (parent.type === import_utils56.AST_NODE_TYPES.MemberExpression && parent.object === current || parent.type === import_utils56.AST_NODE_TYPES.CallExpression && parent.callee === current || parent.type === import_utils56.AST_NODE_TYPES.TSAsExpression && parent.expression === current || parent.type === import_utils56.AST_NODE_TYPES.TSNonNullExpression && parent.expression === current) {
8694
+ current = parent;
8695
+ continue;
8696
+ }
8697
+ return current;
8698
+ }
8699
+ }
8700
+ function outermostEnclosingFunction(node) {
8701
+ let outermost;
8702
+ let current = node.parent ?? void 0;
8703
+ while (current !== void 0) {
8704
+ if (FUNCTION_TYPES6.has(current.type)) {
8705
+ outermost = current;
8706
+ }
8707
+ current = current.parent ?? void 0;
8708
+ }
8709
+ return outermost;
8710
+ }
8711
+ function readsReceiver(node) {
8712
+ let found = false;
8713
+ const visit = (value) => {
8714
+ if (found || value === null || typeof value !== "object") {
8715
+ return;
8716
+ }
8717
+ if (Array.isArray(value)) {
8718
+ for (const item of value) {
8719
+ visit(item);
8720
+ }
8721
+ return;
8722
+ }
8723
+ const candidate = value;
8724
+ if (typeof candidate.type !== "string") {
8725
+ return;
8726
+ }
8727
+ if (candidate.type === import_utils56.AST_NODE_TYPES.ThisExpression || candidate.type === import_utils56.AST_NODE_TYPES.Super || candidate.type === import_utils56.AST_NODE_TYPES.Identifier && candidate.name === "arguments") {
8728
+ found = true;
8729
+ return;
8730
+ }
8731
+ for (const key of Object.keys(candidate)) {
8732
+ if (key === "parent" || key === "loc" || key === "range") {
8733
+ continue;
8734
+ }
8735
+ visit(candidate[key]);
8736
+ }
8737
+ };
8738
+ visit(node);
8739
+ return found;
8740
+ }
8741
+ function collectReferences(scope, out) {
8742
+ out.push(...scope.references);
8743
+ for (const child of scope.childScopes) {
8744
+ collectReferences(child, out);
8745
+ }
8746
+ }
8747
+ var prefer_module_level_schema_default = import_utils56.ESLintUtils.RuleCreator(
8748
+ (name) => `https://github.com/sarj-ai/standards/tree/main/packages/typescript#${name}`
8749
+ )({
8750
+ name: "prefer-module-level-schema",
8751
+ meta: {
8752
+ type: "problem",
8753
+ docs: {
8754
+ description: "Declare a Zod schema at module scope when it closes over nothing in the enclosing function"
8755
+ },
8756
+ schema: [
8757
+ {
8758
+ type: "object",
8759
+ properties: {
8760
+ factories: {
8761
+ type: "array",
8762
+ items: { type: "string" },
8763
+ description: "Zod factory names to check. Defaults to the object-like composites; add `array` / `enum` to widen."
8764
+ },
8765
+ ignoreTestFiles: {
8766
+ type: "boolean",
8767
+ description: "Skip test files, where a fixture schema belongs next to its assertion."
8768
+ },
8769
+ minProperties: {
8770
+ type: "number",
8771
+ minimum: 0,
8772
+ description: "Minimum key count before an object-like schema is reported."
8773
+ }
8774
+ },
8775
+ additionalProperties: false
8776
+ }
8777
+ ],
8778
+ messages: {
8779
+ hoistSchema: "Move this `{{factory}}` schema to module scope. It uses nothing from `{{owner}}`, so it is rebuilt on every call for no benefit and cannot be exported, reused, or `z.infer`-ed from where it is."
8780
+ }
8781
+ },
8782
+ defaultOptions: [{}],
8783
+ create(context, [options]) {
8784
+ const factories = new Set(options?.factories ?? DEFAULT_FACTORIES);
8785
+ const ignoreTestFiles = options?.ignoreTestFiles ?? true;
8786
+ const minProperties = options?.minProperties ?? DEFAULT_MIN_PROPERTIES;
8787
+ const sourceCode = context.sourceCode;
8788
+ const filename = context.filename;
8789
+ if (isGeneratedFile(filename, sourceCode.getText())) {
8790
+ return {};
8791
+ }
8792
+ if (ignoreTestFiles && isTestFile(filename)) {
8793
+ return {};
8794
+ }
8795
+ const zodNamespaces = /* @__PURE__ */ new Set();
8796
+ function isZodCall(node) {
8797
+ return node.type === import_utils56.AST_NODE_TYPES.CallExpression && node.callee.type === import_utils56.AST_NODE_TYPES.MemberExpression && !node.callee.computed && node.callee.object.type === import_utils56.AST_NODE_TYPES.Identifier && zodNamespaces.has(node.callee.object.name);
8798
+ }
8799
+ function isCovered(node) {
8800
+ let current = node.parent ?? void 0;
8801
+ while (current !== void 0) {
8802
+ if (current !== node && isZodCall(current) && current.callee.type === import_utils56.AST_NODE_TYPES.MemberExpression && current.callee.property.type === import_utils56.AST_NODE_TYPES.Identifier && factories.has(current.callee.property.name)) {
8803
+ return true;
8804
+ }
8805
+ if (current.type === import_utils56.AST_NODE_TYPES.CallExpression && (current.callee.type === import_utils56.AST_NODE_TYPES.Identifier && MEMO_CALLEES.has(current.callee.name) || current.callee.type === import_utils56.AST_NODE_TYPES.MemberExpression && !current.callee.computed && current.callee.property.type === import_utils56.AST_NODE_TYPES.Identifier && MEMO_CALLEES.has(current.callee.property.name))) {
8806
+ return true;
8807
+ }
8808
+ current = current.parent ?? void 0;
8809
+ }
8810
+ return false;
8811
+ }
8812
+ function closesOverNothing(node, enclosing) {
8813
+ const references = [];
8814
+ collectReferences(sourceCode.getScope(node), references);
8815
+ const [schemaStart, schemaEnd] = node.range;
8816
+ const [functionStart, functionEnd] = enclosing.range;
8817
+ for (const reference of references) {
8818
+ const [identifierStart] = reference.identifier.range;
8819
+ if (identifierStart < schemaStart || identifierStart >= schemaEnd) {
8820
+ continue;
8821
+ }
8822
+ const resolved = reference.resolved;
8823
+ if (resolved === null) {
8824
+ continue;
8825
+ }
8826
+ for (const definition of resolved.defs) {
8827
+ if (definition.type === "ImportBinding") {
8828
+ continue;
8829
+ }
8830
+ const [defStart, defEnd] = definition.node.range;
8831
+ if (defStart >= functionStart && defEnd <= functionEnd) {
8832
+ return false;
8833
+ }
8834
+ }
8835
+ }
8836
+ return true;
8837
+ }
8838
+ function ownerName(enclosing) {
8839
+ const parent = enclosing.parent ?? void 0;
8840
+ if (enclosing.type === import_utils56.AST_NODE_TYPES.FunctionDeclaration && enclosing.id !== null) {
8841
+ return enclosing.id.name;
8842
+ }
8843
+ if (parent !== void 0 && parent.type === import_utils56.AST_NODE_TYPES.VariableDeclarator && parent.id.type === import_utils56.AST_NODE_TYPES.Identifier) {
8844
+ return parent.id.name;
8845
+ }
8846
+ if (parent !== void 0 && (parent.type === import_utils56.AST_NODE_TYPES.MethodDefinition || parent.type === import_utils56.AST_NODE_TYPES.Property) && parent.key.type === import_utils56.AST_NODE_TYPES.Identifier) {
8847
+ return parent.key.name;
8848
+ }
8849
+ return "this function";
8850
+ }
8851
+ return {
8852
+ ImportDeclaration(node) {
8853
+ if (!isZodModule(node.source.value)) {
8854
+ return;
8855
+ }
8856
+ for (const specifier of node.specifiers) {
8857
+ if (specifier.type === import_utils56.AST_NODE_TYPES.ImportNamespaceSpecifier || specifier.type === import_utils56.AST_NODE_TYPES.ImportDefaultSpecifier || specifier.type === import_utils56.AST_NODE_TYPES.ImportSpecifier && specifier.imported.type === import_utils56.AST_NODE_TYPES.Identifier && specifier.imported.name === "z") {
8858
+ zodNamespaces.add(specifier.local.name);
8859
+ }
8860
+ }
8861
+ },
8862
+ CallExpression(node) {
8863
+ if (zodNamespaces.size === 0 || !isZodCall(node)) {
8864
+ return;
8865
+ }
8866
+ const callee = node.callee;
8867
+ if (callee.property.type !== import_utils56.AST_NODE_TYPES.Identifier) {
8868
+ return;
8869
+ }
8870
+ const factory = callee.property.name;
8871
+ if (!factories.has(factory)) {
8872
+ return;
8873
+ }
8874
+ const enclosing = outermostEnclosingFunction(node);
8875
+ if (enclosing === void 0) {
8876
+ return;
8877
+ }
8878
+ if (isCovered(node)) {
8879
+ return;
8880
+ }
8881
+ const shape = node.arguments[0];
8882
+ if (shape !== void 0 && shape.type === import_utils56.AST_NODE_TYPES.ObjectExpression && shape.properties.length < minProperties) {
8883
+ return;
8884
+ }
8885
+ const expression = schemaExpression(node);
8886
+ if (readsReceiver(expression)) {
8887
+ return;
8888
+ }
8889
+ if (!closesOverNothing(expression, enclosing)) {
8890
+ return;
8891
+ }
8892
+ context.report({
8893
+ node,
8894
+ messageId: "hoistSchema",
8895
+ data: { factory: `z.${factory}`, owner: ownerName(enclosing) }
8896
+ });
8897
+ }
8898
+ };
8899
+ }
8900
+ });
8901
+
8650
8902
  // src/index.ts
8651
8903
  var rules = {
8652
8904
  "enforce-file-structure": enforce_file_structure_default,
@@ -8698,11 +8950,12 @@ var rules = {
8698
8950
  "require-interface-for-injected-service": require_interface_for_injected_service_default,
8699
8951
  "strict-test-assertions": strict_test_assertions_default,
8700
8952
  "prefer-non-nullable-collection": prefer_non_nullable_collection_default,
8701
- "no-async-callback-in-waitfor": no_async_callback_in_waitfor_default
8953
+ "no-async-callback-in-waitfor": no_async_callback_in_waitfor_default,
8954
+ "prefer-module-level-schema": prefer_module_level_schema_default
8702
8955
  };
8703
8956
  var meta = {
8704
8957
  name: "@sarj/eslint-plugin",
8705
- version: "6.0.0"
8958
+ version: "6.1.0"
8706
8959
  };
8707
8960
  var recommendedRules = {
8708
8961
  "@sarj/zod-naming-convention": "warn",
@@ -8801,6 +9054,14 @@ var recommendedRules = {
8801
9054
  "@sarj/require-interface-for-injected-service": "warn",
8802
9055
  "@sarj/prefer-non-nullable-collection": "warn",
8803
9056
  "@sarj/no-async-callback-in-waitfor": "warn",
9057
+ // A Zod schema built inside a function is rebuilt on every call, every
9058
+ // request, every render. Zod sibling of `prefer-module-level-constant`,
9059
+ // which cannot reach it: that rule's hoist gate requires literal leaves and
9060
+ // `z.object({...})` is a call. 378 reports over 30,546 .ts/.tsx files in 17
9061
+ // repos; the free-variable, chain and `this` gates are what keep the schema
9062
+ // FACTORY case (closes over a parameter, a type parameter, or a local type)
9063
+ // silent.
9064
+ "@sarj/prefer-module-level-schema": "warn",
8804
9065
  // `strict-test-assertions` shipped in `rules` but in NEITHER preset, so a
8805
9066
  // consumer using `configs.recommended`/`configs.strict` instead of the shared
8806
9067
  // `eslint.strict.mjs` had silently never run it. `flat-presets.test.ts`
@@ -8887,6 +9148,8 @@ var strictRules = {
8887
9148
  "@sarj/require-interface-for-injected-service": "error",
8888
9149
  "@sarj/prefer-non-nullable-collection": "error",
8889
9150
  "@sarj/no-async-callback-in-waitfor": "error",
9151
+ // Zod sibling of `prefer-module-level-constant` -- see `recommended`.
9152
+ "@sarj/prefer-module-level-schema": "error",
8890
9153
  // See the `recommended` block.
8891
9154
  "@sarj/strict-test-assertions": "error"
8892
9155
  };