@sarj/eslint-plugin 15.1.0 → 15.2.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/dist/index.cjs CHANGED
@@ -1010,11 +1010,11 @@ function isTrivialInitializer(node) {
1010
1010
  }
1011
1011
  function restatesStatementHead(body2, statement) {
1012
1012
  if (statement === null) return false;
1013
- const words = body2.match(/[A-Za-z][\w$]*/g) ?? [];
1014
- const opener = words[0];
1015
- if (opener === void 0 || words.length > NARRATION_MAX_WORDS) return false;
1013
+ const words2 = body2.match(/[A-Za-z][\w$]*/g) ?? [];
1014
+ const opener = words2[0];
1015
+ if (opener === void 0 || words2.length > NARRATION_MAX_WORDS) return false;
1016
1016
  if (!NARRATION_VERB_RE.test(opener)) return false;
1017
- const content = words.slice(1).map(normalizeToken).filter((word) => !NARRATION_STOPWORDS.has(word));
1017
+ const content = words2.slice(1).map(normalizeToken).filter((word) => !NARRATION_STOPWORDS.has(word));
1018
1018
  if (content.length < NARRATION_MIN_CONTENT) return false;
1019
1019
  const head = statement.split("(")[0] ?? statement;
1020
1020
  const code = headTokens(head);
@@ -1282,8 +1282,8 @@ function isRedundantNarration(body2, statementBelow, standalone, isolatedEnumera
1282
1282
  if (META_COMMENTARY_RE.test(t) && !justified) return true;
1283
1283
  if (isBareDeferral(t) && !justified) return true;
1284
1284
  if (HELPER_OPENER_RE.test(t) || LETS_RE.test(t)) return true;
1285
- const words = t.split(/\s+/);
1286
- if (words.length > 1 && words.length <= 4 && DUMMY_TRANSLATION_RE.test(t) && !/[():=]/.test(t)) {
1285
+ const words2 = t.split(/\s+/);
1286
+ if (words2.length > 1 && words2.length <= 4 && DUMMY_TRANSLATION_RE.test(t) && !/[():=]/.test(t)) {
1287
1287
  const lowerT = t.toLowerCase();
1288
1288
  if (!RATIONALE_WORDS.some((word) => lowerT.includes(word)) && restatesWholeStatement(t, statementBelow)) {
1289
1289
  return true;
@@ -1365,8 +1365,8 @@ function isWeakWalkthroughComment(body2, statement) {
1365
1365
  if (normalized.length === 0 || normalized.endsWith("?") || normalized.split(/\s+/).length > WALL_MAX_WORDS || isDirective(normalized) || isProtected(normalized) || !WALL_NARRATION_RE.test(normalized)) {
1366
1366
  return false;
1367
1367
  }
1368
- const words = contentTokens(normalized);
1369
- const described = words.slice(1);
1368
+ const words2 = contentTokens(normalized);
1369
+ const described = words2.slice(1);
1370
1370
  if (described.length === 0) return false;
1371
1371
  const code = codeTokens(statement);
1372
1372
  const matched = described.filter((word) => restates([word], code)).length;
@@ -2710,7 +2710,7 @@ var no_fat_try_blocks_default = createRule({
2710
2710
  const sourceCode = context.sourceCode;
2711
2711
  return {
2712
2712
  TryStatement(node) {
2713
- if (node.finalizer !== null) {
2713
+ if (node.finalizer !== null && node.handler === null) {
2714
2714
  return;
2715
2715
  }
2716
2716
  if (handlerRethrows(node.handler)) {
@@ -2950,15 +2950,32 @@ var noHandRolledSpinnerDocumentation = {
2950
2950
  rationale: "One-off loading indicators duplicate a shared primitive and let accessibility and styling diverge.",
2951
2951
  remediation: "Render the design-system Spinner component instead.",
2952
2952
  category: "maintainability",
2953
- limitations: ["Only static className values on div and span elements are inspected."],
2953
+ limitations: ["Only static className values on div and span elements are inspected; tests, stories, generated files, and the design-system implementation are excluded."],
2954
2954
  examples: [
2955
2955
  { id: "design-system-spinner", title: "Use the shared spinner", outcome: "no-match", files: [{ path: "src/loading-state.tsx", source: '<Spinner className="size-4" />' }], focusPath: "src/loading-state.tsx", expectedCount: 0, public: true },
2956
2956
  { id: "border-ring-spinner", title: "Do not rebuild a spinner", outcome: "match", files: [{ path: "src/loading-state.tsx", source: '<div className="size-4 animate-spin rounded-full border-2 border-t-transparent" />' }], focusPath: "src/loading-state.tsx", expectedCount: 1, public: true }
2957
2957
  ]
2958
2958
  };
2959
2959
  var DESIGN_SYSTEM_PATH = /(?:^|[/\\])components[/\\]ui[/\\]/u;
2960
- var BORDER_WIDTH = /^border(?:-[0-9]+)?$/u;
2961
- var CONTRASTING_EDGE = /^border-[trbl]-(?!0$|[0-9]+$).+/u;
2960
+ var DIRECTIONAL_BORDER = /^border-([trblsexy])-(.+)$/u;
2961
+ var CSS_LENGTH = /^-?(?:\d+(?:\.\d+)?|\.\d+)(?:cap|ch|cm|dvh|dvw|em|ex|ic|in|lh|lvh|lvw|mm|pc|pt|px|q|rcap|rch|rem|rex|ric|rlh|svh|svw|vb|vh|vi|vmax|vmin|vw|%)$/u;
2962
+ var ARBITRARY_LENGTH_FUNCTION = /^(?:calc|clamp|max|min)\(.+\)$/u;
2963
+ function isBorderWidthValue(value) {
2964
+ if (/^\d+$/u.test(value)) return true;
2965
+ if (value.startsWith("[") && value.endsWith("]")) {
2966
+ const arbitrary = value.slice(1, -1);
2967
+ const length = arbitrary.startsWith("length:") ? arbitrary.slice("length:".length) : arbitrary;
2968
+ return CSS_LENGTH.test(length) || ARBITRARY_LENGTH_FUNCTION.test(length) || arbitrary.startsWith("length:") && /^var\(.+\)$/u.test(length);
2969
+ }
2970
+ return value.startsWith("(length:") && value.endsWith(")") && value.length > "(length:)".length;
2971
+ }
2972
+ function isBorderWidth(token) {
2973
+ return token === "border" || token.startsWith("border-") && isBorderWidthValue(token.slice("border-".length));
2974
+ }
2975
+ function isContrastingEdge(token) {
2976
+ const match = DIRECTIONAL_BORDER.exec(token);
2977
+ return match?.[2] !== void 0 && !isBorderWidthValue(match[2]);
2978
+ }
2962
2979
  function staticClassName(attribute) {
2963
2980
  const value = attribute.value;
2964
2981
  if (value?.type === import_utils13.AST_NODE_TYPES.Literal && typeof value.value === "string") {
@@ -2987,7 +3004,7 @@ var no_hand_rolled_spinner_default = createRule({
2987
3004
  },
2988
3005
  defaultOptions: [],
2989
3006
  create(context) {
2990
- if (DESIGN_SYSTEM_PATH.test(context.filename)) {
3007
+ if (DESIGN_SYSTEM_PATH.test(context.filename) || isTestFile(context.filename) || isStoryFile(context.filename) || isGeneratedFile(context.filename, context.sourceCode.text)) {
2991
3008
  return {};
2992
3009
  }
2993
3010
  return {
@@ -3002,7 +3019,7 @@ var no_hand_rolled_spinner_default = createRule({
3002
3019
  const className = staticClassName(classNameAttribute);
3003
3020
  if (className === null) return;
3004
3021
  const classes = className.split(/\s+/u);
3005
- if (classes.includes("animate-spin") && classes.includes("rounded-full") && classes.some((token) => BORDER_WIDTH.test(token)) && classes.some((token) => CONTRASTING_EDGE.test(token))) {
3022
+ if (classes.includes("animate-spin") && classes.includes("rounded-full") && classes.some(isBorderWidth) && classes.some(isContrastingEdge)) {
3006
3023
  context.report({ node, messageId: "handRolledSpinner" });
3007
3024
  }
3008
3025
  }
@@ -3068,9 +3085,9 @@ function nameWords(name) {
3068
3085
  return name.replaceAll(/([a-z0-9])([A-Z])/gu, "$1 $2").split(/[^A-Za-z0-9]+/u).filter(Boolean).map((word) => word.toLowerCase());
3069
3086
  }
3070
3087
  function isStrongSecurityName(name) {
3071
- const words = nameWords(name);
3072
- return words.some((word) => STRONG_SECURITY_WORDS.has(word)) || words.some(
3073
- (word, index) => word === "api" && words[index + 1] === "key" || word === "auth" && words[index + 1] === "id" || word === "verification" && words[index + 1] === "code"
3088
+ const words2 = nameWords(name);
3089
+ return words2.some((word) => STRONG_SECURITY_WORDS.has(word)) || words2.some(
3090
+ (word, index) => word === "api" && words2[index + 1] === "key" || word === "auth" && words2[index + 1] === "id" || word === "verification" && words2[index + 1] === "code"
3074
3091
  );
3075
3092
  }
3076
3093
  function isNonSecurityName(name) {
@@ -4032,7 +4049,7 @@ var VALUE_TAG_RE = /@(example|deprecated|see|remarks|throws|internal|public|alph
4032
4049
  var BOUNDARY_RE = /(?<=[.!?])["'`)\]]*\s+(?=[A-Z0-9`])/;
4033
4050
  var BULLET_RE = /^\s*(?:[-*+] |\d+[.)] )/;
4034
4051
  var HEADING_RE = /^[A-Za-z][A-Za-z ]+:$/;
4035
- var TECHNICAL_ANCHOR_RE = /https?:\/\/|`[^`\n]+`|:[a-z][a-z0-9_-]*:|(["'])[^"'\n]+\1|\d|\b[a-z][a-z0-9]*[A-Z][A-Za-z0-9]*\b|\b[A-Za-z][A-Za-z0-9]*_[A-Za-z0-9_]+\b|(?:^|\s)(?:[\w.-]+\/)+[\w.-]+|\b[\w.-]+\.(?:py|pyi|js|jsx|ts|tsx|json|ya?ml|toml|csv|parquet|md)\b|->|=>|==|!=|<=|>=|\|/mu;
4052
+ var TECHNICAL_ANCHOR_RE = /https?:\/\/|`[^`\n]+`|:[a-z][a-z0-9_-]*:|(["'])[^"'\n]+\1|\bv?\d+\.\d+(?:\.\d+)?\b|\b\d+(?:\.\d+)?\s?(?:ns|us|ms|s|sec|secs|seconds?|mins?|minutes?|hours?|days?|bytes?|kib|mib|gib|kb|mb|gb|hz|khz|mhz|px|%)\b|\b[a-z][a-z0-9]*[A-Z][A-Za-z0-9]*\b|\b[A-Za-z][A-Za-z0-9]*_[A-Za-z0-9_]+\b|(?:^|\s)(?:[\w.-]+\/)+[\w.-]+|\b[\w.-]+\.(?:py|pyi|js|jsx|ts|tsx|json|ya?ml|toml|csv|parquet|md)\b|->|=>|==|!=|<=|>=|\|/mu;
4036
4053
  function body(comment) {
4037
4054
  return comment.value.replace(/^\*/, "").split("\n").map((line) => line.replace(/^\s*\*?\s?/, "")).join("\n").trim();
4038
4055
  }
@@ -6848,22 +6865,25 @@ var noStorageInStatelessModulesDocumentation = {
6848
6865
  rationale: "Private storage in a stateless workflow creates another source of truth that can silently diverge.",
6849
6866
  remediation: "Read from the system of record or derive state from an artifact the workflow already produces.",
6850
6867
  category: "architecture",
6851
- limitations: ["The rule is disabled until module path patterns are configured and recognizes only configured storage method names."],
6868
+ limitations: ["The rule is disabled until module path patterns are configured, recognizes only configured storage method names, and requires storage-like receiver evidence for the overloaded `put` method."],
6852
6869
  examples: [
6853
6870
  { id: "system-of-record", title: "Read from the system of record", outcome: "no-match", files: [{ path: "src/engineer-digest/post.ts", source: "const issues = await linear.listIssues();" }], focusPath: "src/engineer-digest/post.ts", expectedCount: 0, public: true },
6854
6871
  { id: "private-storage", title: "Do not write private state in a stateless module", outcome: "match", files: [{ path: "src/engineer-digest/post.ts", source: "await kv.put('digest:last', timestamp);" }], focusPath: "src/engineer-digest/post.ts", expectedCount: 1, public: true }
6855
6872
  ]
6856
6873
  };
6857
6874
  function compile2(patterns) {
6858
- const compiled = [];
6859
- for (const pattern of patterns) {
6860
- try {
6861
- compiled.push(new RegExp(pattern));
6862
- } catch {
6863
- }
6864
- }
6865
- return compiled;
6875
+ return patterns.map((pattern) => new RegExp(pattern));
6866
6876
  }
6877
+ var STORAGE_RECEIVER_WORDS = /* @__PURE__ */ new Set([
6878
+ "bucket",
6879
+ "cache",
6880
+ "kv",
6881
+ "namespace",
6882
+ "r2",
6883
+ "redis",
6884
+ "storage",
6885
+ "store"
6886
+ ]);
6867
6887
  function storageMethodName(node, methods) {
6868
6888
  const callee = node.callee;
6869
6889
  if (callee.type !== import_utils35.AST_NODE_TYPES.MemberExpression || callee.computed || callee.property.type !== import_utils35.AST_NODE_TYPES.Identifier) {
@@ -6876,8 +6896,31 @@ function storageMethodName(node, methods) {
6876
6896
  if (node.arguments.length < (MIN_ARGUMENTS.get(name) ?? 1)) {
6877
6897
  return null;
6878
6898
  }
6899
+ if (name === "put" && !isStorageLikeReceiver(callee.object)) {
6900
+ return null;
6901
+ }
6879
6902
  return name;
6880
6903
  }
6904
+ function isStorageLikeReceiver(node) {
6905
+ if (node.type === import_utils35.AST_NODE_TYPES.Identifier) {
6906
+ return isStorageIdentifier(node.name);
6907
+ }
6908
+ if (node.type !== import_utils35.AST_NODE_TYPES.MemberExpression) {
6909
+ return false;
6910
+ }
6911
+ if (!node.computed && node.property.type === import_utils35.AST_NODE_TYPES.Identifier && isStorageIdentifier(node.property.name)) {
6912
+ return true;
6913
+ }
6914
+ return isStorageLikeReceiver(node.object);
6915
+ }
6916
+ function isStorageIdentifier(name) {
6917
+ return identifierWords(name).some(
6918
+ (word) => STORAGE_RECEIVER_WORDS.has(word.toLowerCase())
6919
+ );
6920
+ }
6921
+ function identifierWords(name) {
6922
+ return name.match(/[A-Z]?[a-z]+|[A-Z]+(?=[A-Z]|$)|\d+/gu) ?? [name];
6923
+ }
6881
6924
  var no_storage_in_stateless_modules_default = createRule({
6882
6925
  name: "no-storage-in-stateless-modules",
6883
6926
  documentation: noStorageInStatelessModulesDocumentation,
@@ -7279,12 +7322,86 @@ var no_tautological_expect_default = createRule({
7279
7322
  });
7280
7323
 
7281
7324
  // src/rules/no-typed-doc-sections.ts
7325
+ var TYPED_TAG_RE2 = /^\s*@(arg|argument|param|return|returns|yield|yields)\b(.*)$/iu;
7326
+ var PARAM_TAGS2 = /* @__PURE__ */ new Set(["arg", "argument", "param"]);
7327
+ var PARAMETER_FILLER = /* @__PURE__ */ new Set([
7328
+ "a",
7329
+ "an",
7330
+ "argument",
7331
+ "given",
7332
+ "input",
7333
+ "parameter",
7334
+ "passed",
7335
+ "provided",
7336
+ "the",
7337
+ "value"
7338
+ ]);
7339
+ var RESULT_FILLER = /* @__PURE__ */ new Set([
7340
+ "a",
7341
+ "an",
7342
+ "array",
7343
+ "boolean",
7344
+ "generator",
7345
+ "number",
7346
+ "object",
7347
+ "output",
7348
+ "promise",
7349
+ "result",
7350
+ "return",
7351
+ "returned",
7352
+ "returns",
7353
+ "string",
7354
+ "the",
7355
+ "value"
7356
+ ]);
7357
+ function hasVacuousTypedTag(text) {
7358
+ const tags = typedTags(text);
7359
+ return tags.length > 0 && tags.some(isVacuousTag);
7360
+ }
7361
+ function typedTags(text) {
7362
+ const tags = [];
7363
+ for (const raw of text.split("\n")) {
7364
+ const match = TYPED_TAG_RE2.exec(raw);
7365
+ if (match !== null) {
7366
+ tags.push({ kind: (match[1] ?? "").toLowerCase(), payload: (match[2] ?? "").trim() });
7367
+ } else if (tags.length > 0 && raw.trim().length > 0 && !raw.trim().startsWith("@")) {
7368
+ const last = tags.at(-1);
7369
+ last.payload = `${last.payload} ${raw.trim()}`.trim();
7370
+ }
7371
+ }
7372
+ return tags.map(({ kind, payload }) => {
7373
+ let rest = payload.replace(/^\{[^}\n]+\}\s*/u, "").trim();
7374
+ if (!PARAM_TAGS2.has(kind)) {
7375
+ return { kind, name: null, description: rest.replace(/^-\s*/u, "").trim() };
7376
+ }
7377
+ const match = /^(\[[^\]]+\]|[A-Za-z_$][\w$.[\]-]*)(?:\s+-\s*|\s+)?(.*)$/u.exec(rest);
7378
+ if (match === null) return { kind, name: null, description: "" };
7379
+ const rawName = (match[1] ?? "").replace(/^\[/u, "").replace(/\]$/u, "").split("=")[0] ?? "";
7380
+ rest = (match[2] ?? "").trim();
7381
+ return { kind, name: rawName, description: rest };
7382
+ });
7383
+ }
7384
+ function isVacuousTag(tag) {
7385
+ const description = words(tag.description).map(canonicalWord);
7386
+ if (description.length === 0) return true;
7387
+ if (tag.name === null) return description.every((word) => RESULT_FILLER.has(word));
7388
+ const nameWords2 = new Set(words(tag.name).map(canonicalWord));
7389
+ return description.every((word) => PARAMETER_FILLER.has(word) || nameWords2.has(word));
7390
+ }
7391
+ function words(text) {
7392
+ return text.replaceAll(/([a-z0-9])([A-Z])/gu, "$1 $2").toLowerCase().match(/[a-z][a-z0-9]*/gu) ?? [];
7393
+ }
7394
+ function canonicalWord(word) {
7395
+ if (["identifier", "identifiers", "ids"].includes(word)) return "id";
7396
+ if (word.endsWith("s") && word.length > 3) return word.slice(0, -1);
7397
+ return word;
7398
+ }
7282
7399
  var noTypedDocSectionsDocumentation = {
7283
7400
  summary: "Reject typed-signature repetition while preserving behavior that types cannot express.",
7284
7401
  rationale: "Parameter and return tags repeat typed signatures and can drift without adding runtime behavior or constraints.",
7285
7402
  remediation: "Remove repeated parameter and return tags; retain documentation for behavior, failures, and external contracts.",
7286
7403
  category: "maintainability",
7287
- limitations: ["Parameter and return tags are reported only when the documented function has corresponding explicit TypeScript types."],
7404
+ limitations: ["Description-free or name-restating parameter and return tags are reported only when the documented function has corresponding explicit TypeScript types."],
7288
7405
  examples: [
7289
7406
  {
7290
7407
  id: "behavioral-documentation",
@@ -7322,7 +7439,7 @@ var no_typed_doc_sections_default = createRule({
7322
7439
  return {
7323
7440
  Program() {
7324
7441
  for (const group of proseGroups(context.filename, context.sourceCode, true)) {
7325
- if (group.hasTypedTags && documentsTypedFunction(context.sourceCode, group.comment)) {
7442
+ if (group.hasTypedTags && hasVacuousTypedTag(group.text) && documentsTypedFunction(context.sourceCode, group.comment)) {
7326
7443
  context.report({ node: group.comment, messageId: "typedSection" });
7327
7444
  }
7328
7445
  }
@@ -7423,28 +7540,32 @@ var STOPWORDS3 = /* @__PURE__ */ new Set([
7423
7540
  "with"
7424
7541
  ]);
7425
7542
  var DIRECTIVE_RE5 = /^\s*(?:eslint\b|eslint-|sarj-noqa\b|@ts-|prettier|biome-|c8\b|v8\b|istanbul\b|todo\b|fixme\b|hack\b|xxx\b)/i;
7543
+ var UNIT_NAME_SUFFIX_RE = /(?:_(?:NS|US|MS|S|SEC|SECS|SECOND|SECONDS|MIN|MINS|MINUTE|MINUTES|HOUR|HOURS|DAY|DAYS|BYTE|BYTES|KB|MB|GB|HZ|KHZ|MHZ|PX)|(?:Ns|Us|Ms|Sec|Secs|Second|Seconds|Min|Mins|Minute|Minutes|Hour|Hours|Day|Days|Byte|Bytes|Kb|Mb|Gb|Hz|Khz|Mhz|Px))$/u;
7426
7544
  function narratesValue(body2, code) {
7427
7545
  if (body2.length === 0 || DIRECTIVE_RE5.test(body2) || hasExternalReference(body2)) return false;
7428
7546
  const codeNumbers = numbersIn(code);
7429
7547
  if (codeNumbers.size === 0) return false;
7430
- const words = (body2.match(WORD_RE3) ?? []).map((word) => word.toLowerCase());
7431
- if (words.length === 0) return false;
7548
+ const words2 = (body2.match(WORD_RE3) ?? []).map((word) => word.toLowerCase());
7549
+ if (words2.length === 0) return false;
7432
7550
  const commentNumbers = numbersIn(body2);
7433
7551
  if (commentNumbers.size === 0) return false;
7434
7552
  for (const number of commentNumbers) {
7435
7553
  if (!codeNumbers.has(number)) return false;
7436
7554
  }
7437
- if (!words.some((word) => UNIT_WORDS.has(word))) return false;
7555
+ if (!words2.some((word) => UNIT_WORDS.has(word))) return false;
7438
7556
  const identifiers = codeTokens(code);
7439
7557
  const stems = /* @__PURE__ */ new Set();
7440
7558
  for (const token of identifiers) stems.add(stem(token));
7441
- return words.every(
7559
+ return words2.every(
7442
7560
  (word) => STOPWORDS3.has(word) || UNIT_WORDS.has(word) || commentNumbers.has(word) || identifiers.has(word) || stems.has(stem(word))
7443
7561
  );
7444
7562
  }
7445
7563
  function numbersIn(text) {
7446
7564
  return new Set(text.match(NUMBER_RE) ?? []);
7447
7565
  }
7566
+ function nameAlreadyCarriesUnit(code) {
7567
+ return (code.match(/[A-Za-z_$][\w$]*/gu) ?? []).some((identifier) => UNIT_NAME_SUFFIX_RE.test(identifier));
7568
+ }
7448
7569
  var no_trailing_value_narration_default = createRule({
7449
7570
  name: "no-trailing-value-narration",
7450
7571
  documentation: noTrailingValueNarrationDocumentation,
@@ -7455,6 +7576,7 @@ var no_trailing_value_narration_default = createRule({
7455
7576
  },
7456
7577
  schema: [],
7457
7578
  messages: {
7579
+ deleteNarration: "Trailing comment restates the literal and the identifier already names its unit \u2014 delete the comment so it cannot drift.",
7458
7580
  narratesValue: "Trailing comment restates the literal on this line \u2014 put the unit in the name (STALE_TIME_MS) so it cannot drift."
7459
7581
  }
7460
7582
  },
@@ -7487,7 +7609,10 @@ var no_trailing_value_narration_default = createRule({
7487
7609
  const code = line.slice(0, comment.loc.start.column);
7488
7610
  const body2 = comment.value.replace(/^\*+/, "").replace(/\*+$/, "").trim();
7489
7611
  if (narratesValue(body2, code)) {
7490
- context.report({ node: comment, messageId: "narratesValue" });
7612
+ context.report({
7613
+ node: comment,
7614
+ messageId: nameAlreadyCarriesUnit(code) ? "deleteNarration" : "narratesValue"
7615
+ });
7491
7616
  }
7492
7617
  }
7493
7618
  }
@@ -8528,15 +8653,23 @@ var no_zod_native_enum_default = createRule({
8528
8653
  } catch {
8529
8654
  services = null;
8530
8655
  }
8531
- const zodImportedNames = /* @__PURE__ */ new Map();
8532
- const zodNamespaces = /* @__PURE__ */ new Set();
8656
+ const zodImportedBindings = /* @__PURE__ */ new Map();
8657
+ const zodNamespaceBindings = /* @__PURE__ */ new Set();
8658
+ function resolvedBinding(identifier) {
8659
+ return import_utils45.ASTUtils.findVariable(
8660
+ sourceCode.getScope(identifier),
8661
+ identifier.name
8662
+ );
8663
+ }
8533
8664
  function isZodMemberCall(node, api) {
8534
8665
  const callee = node.callee;
8535
- if (callee.type === import_utils45.AST_NODE_TYPES.MemberExpression && !callee.computed && callee.object.type === import_utils45.AST_NODE_TYPES.Identifier && zodNamespaces.has(callee.object.name) && callee.property.type === import_utils45.AST_NODE_TYPES.Identifier) {
8536
- return callee.property.name === api;
8666
+ if (callee.type === import_utils45.AST_NODE_TYPES.MemberExpression && !callee.computed && callee.object.type === import_utils45.AST_NODE_TYPES.Identifier && callee.property.type === import_utils45.AST_NODE_TYPES.Identifier) {
8667
+ const binding = resolvedBinding(callee.object);
8668
+ return binding !== null && zodNamespaceBindings.has(binding) && callee.property.name === api;
8537
8669
  }
8538
8670
  if (callee.type === import_utils45.AST_NODE_TYPES.Identifier) {
8539
- return zodImportedNames.get(callee.name) === api;
8671
+ const binding = resolvedBinding(callee);
8672
+ return binding !== null && zodImportedBindings.get(binding) === api;
8540
8673
  }
8541
8674
  return false;
8542
8675
  }
@@ -8571,10 +8704,14 @@ var no_zod_native_enum_default = createRule({
8571
8704
  }
8572
8705
  for (const spec of node.specifiers) {
8573
8706
  if (spec.type === import_utils45.AST_NODE_TYPES.ImportNamespaceSpecifier || spec.type === import_utils45.AST_NODE_TYPES.ImportDefaultSpecifier || spec.type === import_utils45.AST_NODE_TYPES.ImportSpecifier && (spec.imported.type === import_utils45.AST_NODE_TYPES.Identifier ? spec.imported.name === "z" : spec.imported.value === "z")) {
8574
- zodNamespaces.add(spec.local.name);
8707
+ const binding = resolvedBinding(spec.local);
8708
+ if (binding !== null) zodNamespaceBindings.add(binding);
8575
8709
  }
8576
8710
  if (spec.type === import_utils45.AST_NODE_TYPES.ImportSpecifier && spec.imported.type === import_utils45.AST_NODE_TYPES.Identifier) {
8577
- zodImportedNames.set(spec.local.name, spec.imported.name);
8711
+ const binding = resolvedBinding(spec.local);
8712
+ if (binding !== null) {
8713
+ zodImportedBindings.set(binding, spec.imported.name);
8714
+ }
8578
8715
  }
8579
8716
  }
8580
8717
  },
@@ -12356,6 +12493,9 @@ var requireAssertNeverDocumentation = {
12356
12493
  };
12357
12494
  var isRuntimeHandlingStatement = (statement) => {
12358
12495
  if (statement.type === import_utils62.AST_NODE_TYPES.EmptyStatement) return false;
12496
+ if (statement.type === import_utils62.AST_NODE_TYPES.BreakStatement) {
12497
+ return statement.label !== null;
12498
+ }
12359
12499
  if (statement.type === import_utils62.AST_NODE_TYPES.TSTypeAliasDeclaration || statement.type === import_utils62.AST_NODE_TYPES.TSInterfaceDeclaration) {
12360
12500
  return false;
12361
12501
  }
@@ -12385,7 +12525,12 @@ function isExhaustiveFiniteSwitch(node, services) {
12385
12525
  const discriminant = services.esTreeNodeToTSNodeMap.get(node.discriminant);
12386
12526
  const discriminantType = checker.getTypeAtLocation(discriminant);
12387
12527
  const constituents = discriminantType.isUnion() ? discriminantType.types : [discriminantType];
12388
- if (constituents.length === 0) return false;
12528
+ if (!discriminantType.isUnion() || constituents.length < 2) return false;
12529
+ if (constituents.every(
12530
+ (constituent) => (constituent.flags & import_typescript.default.TypeFlags.BooleanLiteral) !== 0
12531
+ )) {
12532
+ return false;
12533
+ }
12389
12534
  const expected = /* @__PURE__ */ new Set();
12390
12535
  for (const constituent of constituents) {
12391
12536
  const key = finiteTypeKey(constituent, checker);
@@ -13192,28 +13337,26 @@ var requireZodFormValidationDocumentation = {
13192
13337
  rationale: "FormData values are untrusted strings or files and need runtime validation before use.",
13193
13338
  remediation: "Read the value inside a Zod schema's `parse` or `safeParse` input.",
13194
13339
  category: "security",
13340
+ limitations: [
13341
+ "Tests are excluded; imported schema-shaped names are trusted when their implementation is outside the linted file.",
13342
+ "Delayed raw-value use is accepted only after an unconditional successful parse in the same block; safeParse remains valid when the raw binding has no unvalidated consumer."
13343
+ ],
13195
13344
  examples: [
13196
13345
  { id: "validated-form-value", title: "Validate the form value", outcome: "no-match", files: [{ path: "src/action.ts", source: "const input = UserSchema.parse({ name: formData.get('name') });" }], focusPath: "src/action.ts", expectedCount: 0, public: true },
13197
13346
  { id: "raw-form-value", title: "Do not use a raw form value", outcome: "match", files: [{ path: "src/action.ts", source: "const name = formData.get('name');" }], focusPath: "src/action.ts", expectedCount: 1, public: true }
13198
13347
  ]
13199
13348
  };
13200
- var isZodParseCall = (node) => {
13201
- if (node.type !== import_utils66.AST_NODE_TYPES.CallExpression) return false;
13202
- const callee = node.callee;
13203
- if (callee.type !== import_utils66.AST_NODE_TYPES.MemberExpression) return false;
13204
- if (callee.computed) return false;
13205
- if (callee.property.type !== import_utils66.AST_NODE_TYPES.Identifier) return false;
13206
- const method = callee.property.name;
13207
- if (method !== "parse" && method !== "safeParse" && method !== "parseAsync" && method !== "safeParseAsync") {
13208
- return false;
13209
- }
13210
- return looksLikeZodSchema(callee.object);
13211
- };
13212
- var looksLikeZodSchema = (node) => {
13349
+ var ZOD_PARSE_METHODS = /* @__PURE__ */ new Set([
13350
+ "parse",
13351
+ "safeParse",
13352
+ "parseAsync",
13353
+ "safeParseAsync"
13354
+ ]);
13355
+ var zodReceiverRoot = (node) => {
13213
13356
  let current = node;
13214
13357
  while (true) {
13215
13358
  if (current.type === import_utils66.AST_NODE_TYPES.Identifier) {
13216
- return current.name === "z" || ZOD_SCHEMA_NAME_RE.test(current.name);
13359
+ return current;
13217
13360
  }
13218
13361
  if (current.type === import_utils66.AST_NODE_TYPES.CallExpression) {
13219
13362
  current = current.callee;
@@ -13223,7 +13366,7 @@ var looksLikeZodSchema = (node) => {
13223
13366
  current = current.object;
13224
13367
  continue;
13225
13368
  }
13226
- return false;
13369
+ return null;
13227
13370
  }
13228
13371
  };
13229
13372
  var isFormDataMethodCall = (node) => {
@@ -13253,6 +13396,34 @@ var require_zod_form_validation_default = createRule({
13253
13396
  if (isTestFile(context.filename)) {
13254
13397
  return {};
13255
13398
  }
13399
+ const zodBindings = /* @__PURE__ */ new Set();
13400
+ const resolvedBinding = (identifier) => import_utils66.ASTUtils.findVariable(
13401
+ context.sourceCode.getScope(identifier),
13402
+ identifier.name
13403
+ );
13404
+ const isProvablyNonZodLocal = (identifier) => {
13405
+ const binding = resolvedBinding(identifier);
13406
+ if (binding === null || zodBindings.has(binding) || binding.defs.length !== 1) {
13407
+ return false;
13408
+ }
13409
+ const definition = binding.defs[0];
13410
+ if (definition?.type !== "Variable" || definition.node.type !== import_utils66.AST_NODE_TYPES.VariableDeclarator) {
13411
+ return false;
13412
+ }
13413
+ const init = definition.node.init;
13414
+ return init?.type === import_utils66.AST_NODE_TYPES.ObjectExpression || init?.type === import_utils66.AST_NODE_TYPES.ArrayExpression || init?.type === import_utils66.AST_NODE_TYPES.Literal || init?.type === import_utils66.AST_NODE_TYPES.ArrowFunctionExpression || init?.type === import_utils66.AST_NODE_TYPES.FunctionExpression;
13415
+ };
13416
+ const isZodParseCall = (node) => {
13417
+ if (node.type !== import_utils66.AST_NODE_TYPES.CallExpression) return false;
13418
+ const callee = node.callee;
13419
+ if (callee.type !== import_utils66.AST_NODE_TYPES.MemberExpression || callee.computed || callee.property.type !== import_utils66.AST_NODE_TYPES.Identifier || !ZOD_PARSE_METHODS.has(callee.property.name)) {
13420
+ return false;
13421
+ }
13422
+ const root = zodReceiverRoot(callee.object);
13423
+ if (root === null) return false;
13424
+ const binding = resolvedBinding(root);
13425
+ return binding !== null && zodBindings.has(binding) || (root.name === "z" || ZOD_SCHEMA_NAME_RE.test(root.name)) && !isProvablyNonZodLocal(root);
13426
+ };
13256
13427
  const isFormSourceIdentifier = (node) => {
13257
13428
  if (node.type !== import_utils66.AST_NODE_TYPES.Identifier) return false;
13258
13429
  if (/formdata/i.test(node.name)) return true;
@@ -13278,14 +13449,15 @@ var require_zod_form_validation_default = createRule({
13278
13449
  }
13279
13450
  return isFormSourceIdentifier(callee.object);
13280
13451
  };
13281
- const hasZodParseAncestor = (node) => {
13452
+ const zodParseAncestor = (node) => {
13282
13453
  let parent = node.parent;
13283
13454
  while (parent !== null && parent !== void 0) {
13284
- if (isZodParseCall(parent)) return true;
13455
+ if (isZodParseCall(parent)) return parent;
13285
13456
  parent = parent.parent;
13286
13457
  }
13287
- return false;
13458
+ return null;
13288
13459
  };
13460
+ const hasZodParseAncestor = (node) => zodParseAncestor(node) !== null;
13289
13461
  const isInstanceofNarrowing = (node) => {
13290
13462
  const parent = node.parent;
13291
13463
  return parent !== null && parent !== void 0 && parent.type === import_utils66.AST_NODE_TYPES.BinaryExpression && parent.operator === "instanceof" && parent.left === node && parent.right.type === import_utils66.AST_NODE_TYPES.Identifier && (parent.right.name === "File" || parent.right.name === "Blob");
@@ -13302,14 +13474,111 @@ var require_zod_form_validation_default = createRule({
13302
13474
  }
13303
13475
  return null;
13304
13476
  };
13477
+ const containingStatement = (node) => {
13478
+ let current = node;
13479
+ while (current.parent !== void 0) {
13480
+ const parent = current.parent;
13481
+ if (parent.type === import_utils66.AST_NODE_TYPES.BlockStatement || parent.type === import_utils66.AST_NODE_TYPES.Program) {
13482
+ return current;
13483
+ }
13484
+ current = parent;
13485
+ }
13486
+ return null;
13487
+ };
13488
+ const zodParseMethod = (call) => {
13489
+ const callee = call.callee;
13490
+ return callee.type === import_utils66.AST_NODE_TYPES.MemberExpression && !callee.computed && callee.property.type === import_utils66.AST_NODE_TYPES.Identifier ? callee.property.name : null;
13491
+ };
13492
+ const hasConditionalAncestorBeforeStatement = (node, statement) => {
13493
+ let current = node.parent;
13494
+ while (current !== void 0 && current !== statement) {
13495
+ if (current.type === import_utils66.AST_NODE_TYPES.LogicalExpression || current.type === import_utils66.AST_NODE_TYPES.ConditionalExpression) {
13496
+ return true;
13497
+ }
13498
+ current = current.parent;
13499
+ }
13500
+ return false;
13501
+ };
13502
+ const isAwaitedBeforeStatement = (node, statement) => {
13503
+ let current = node.parent;
13504
+ while (current !== void 0 && current !== statement) {
13505
+ if (current.type === import_utils66.AST_NODE_TYPES.AwaitExpression) return true;
13506
+ current = current.parent;
13507
+ }
13508
+ return false;
13509
+ };
13510
+ const guaranteedValidationStatement = (declarator, reference) => {
13511
+ const parse2 = zodParseAncestor(reference);
13512
+ if (parse2 === null) return null;
13513
+ const declarationStatement = containingStatement(declarator);
13514
+ const validationStatement = containingStatement(parse2);
13515
+ if (declarationStatement === null || validationStatement === null || declarationStatement.parent !== validationStatement.parent || validationStatement.range[0] <= declarationStatement.range[1] || hasConditionalAncestorBeforeStatement(parse2, validationStatement)) {
13516
+ return null;
13517
+ }
13518
+ if (validationStatement.type !== import_utils66.AST_NODE_TYPES.VariableDeclaration && validationStatement.type !== import_utils66.AST_NODE_TYPES.ExpressionStatement) {
13519
+ return null;
13520
+ }
13521
+ const method = zodParseMethod(parse2);
13522
+ if (method === "parse") return validationStatement;
13523
+ if (method === "parseAsync" && isAwaitedBeforeStatement(parse2, validationStatement)) {
13524
+ return validationStatement;
13525
+ }
13526
+ return null;
13527
+ };
13528
+ const isSafePrevalidationInspection = (identifier) => {
13529
+ const parent = identifier.parent;
13530
+ if (parent.type === import_utils66.AST_NODE_TYPES.UnaryExpression && parent.operator === "typeof") {
13531
+ return true;
13532
+ }
13533
+ if (parent.type !== import_utils66.AST_NODE_TYPES.BinaryExpression || parent.left !== identifier) {
13534
+ return false;
13535
+ }
13536
+ if (parent.operator === "instanceof") {
13537
+ return parent.right.type === import_utils66.AST_NODE_TYPES.Identifier && (parent.right.name === "File" || parent.right.name === "Blob");
13538
+ }
13539
+ return ["===", "!==", "==", "!="].includes(parent.operator) && (parent.right.type === import_utils66.AST_NODE_TYPES.Literal && parent.right.value === null || parent.right.type === import_utils66.AST_NODE_TYPES.Identifier && parent.right.name === "undefined");
13540
+ };
13541
+ const statementWithinBlock = (node, block) => {
13542
+ let current = node;
13543
+ while (current.parent !== void 0 && current.parent !== block) {
13544
+ current = current.parent;
13545
+ }
13546
+ return current.parent === block ? current : null;
13547
+ };
13305
13548
  const bindingIsValidated = (declarator) => {
13306
13549
  const variable = context.sourceCode.getDeclaredVariables(declarator)[0];
13307
13550
  if (variable === void 0) return false;
13308
- return variable.references.some(
13309
- (ref) => hasZodParseAncestor(ref.identifier) || isInstanceofNarrowing(ref.identifier)
13551
+ const references = variable.references.filter((reference) => !reference.isWriteOnly()).map((reference) => reference.identifier).filter(
13552
+ (identifier) => identifier.type === import_utils66.AST_NODE_TYPES.Identifier
13553
+ );
13554
+ if (references.length === 0) return false;
13555
+ if (references.some(isInstanceofNarrowing)) return true;
13556
+ const validationStatements = references.map((reference) => guaranteedValidationStatement(declarator, reference)).filter(
13557
+ (statement) => statement !== null
13310
13558
  );
13559
+ const declarationStatement = containingStatement(declarator);
13560
+ const declarationBlock = declarationStatement?.parent;
13561
+ return references.every((reference) => {
13562
+ if (zodParseAncestor(reference) !== null || isSafePrevalidationInspection(reference)) {
13563
+ return true;
13564
+ }
13565
+ if (declarationBlock === void 0) return false;
13566
+ const useStatement = statementWithinBlock(reference, declarationBlock);
13567
+ return useStatement !== null && validationStatements.some(
13568
+ (statement) => statement.range[1] < useStatement.range[0]
13569
+ );
13570
+ });
13311
13571
  };
13312
13572
  return {
13573
+ ImportDeclaration(node) {
13574
+ if (!isZodModule(node.source.value)) return;
13575
+ for (const specifier of node.specifiers) {
13576
+ if (specifier.type === import_utils66.AST_NODE_TYPES.ImportNamespaceSpecifier || specifier.type === import_utils66.AST_NODE_TYPES.ImportDefaultSpecifier || specifier.type === import_utils66.AST_NODE_TYPES.ImportSpecifier && (specifier.imported.type === import_utils66.AST_NODE_TYPES.Identifier ? specifier.imported.name === "z" : specifier.imported.value === "z")) {
13577
+ const binding = resolvedBinding(specifier.local);
13578
+ if (binding !== null) zodBindings.add(binding);
13579
+ }
13580
+ }
13581
+ },
13313
13582
  CallExpression(node) {
13314
13583
  if (!isFormDataGetCall(node)) return;
13315
13584
  if (hasZodParseAncestor(node) || isInstanceofNarrowing(node)) return;
@@ -14069,7 +14338,7 @@ var rules = {
14069
14338
  };
14070
14339
  var meta = {
14071
14340
  name: "@sarj/eslint-plugin",
14072
- version: "15.1.0"
14341
+ version: "15.2.0"
14073
14342
  };
14074
14343
  var applicationOnlyRules = [
14075
14344
  "no-restricted-library-load",