@saasmakers/eslint 1.0.25 → 1.0.27

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
@@ -63,7 +63,7 @@ function checkUnion(context, node, maxLineLength, minItems) {
63
63
  function collapseBlankLine(text) {
64
64
  return text.replace(/\n[^\S\n]*\n[^\S\n]*/, "\n");
65
65
  }
66
- function expressionStartsWithIdentifier(node, identifier) {
66
+ function expressionStartsWithIdentifier$1(node, identifier) {
67
67
  if (node.type === index$1.distExports.AST_NODE_TYPES.Identifier && node.name === identifier) {
68
68
  return true;
69
69
  }
@@ -73,17 +73,17 @@ function expressionStartsWithIdentifier(node, identifier) {
73
73
  return true;
74
74
  }
75
75
  if (callee.type === index$1.distExports.AST_NODE_TYPES.MemberExpression) {
76
- return expressionStartsWithIdentifier(callee.object, identifier);
76
+ return expressionStartsWithIdentifier$1(callee.object, identifier);
77
77
  }
78
78
  if (callee.type === index$1.distExports.AST_NODE_TYPES.ChainExpression) {
79
- return expressionStartsWithIdentifier(callee.expression, identifier);
79
+ return expressionStartsWithIdentifier$1(callee.expression, identifier);
80
80
  }
81
81
  }
82
82
  if (node.type === index$1.distExports.AST_NODE_TYPES.MemberExpression) {
83
- return expressionStartsWithIdentifier(node.object, identifier);
83
+ return expressionStartsWithIdentifier$1(node.object, identifier);
84
84
  }
85
85
  if (node.type === index$1.distExports.AST_NODE_TYPES.ChainExpression) {
86
- return expressionStartsWithIdentifier(node.expression, identifier);
86
+ return expressionStartsWithIdentifier$1(node.expression, identifier);
87
87
  }
88
88
  return false;
89
89
  }
@@ -211,10 +211,10 @@ function hasLineCommentBetween(prevNode, nextNode, sourceCode) {
211
211
  });
212
212
  }
213
213
  function isAssertExpression(expression) {
214
- return expressionStartsWithIdentifier(expression, "assert");
214
+ return expressionStartsWithIdentifier$1(expression, "assert");
215
215
  }
216
216
  function isConsoleExpression(expression) {
217
- return expressionStartsWithIdentifier(expression, "console");
217
+ return expressionStartsWithIdentifier$1(expression, "console");
218
218
  }
219
219
  function isDirectivePrologue(node) {
220
220
  if (node.expression.type !== index$1.distExports.AST_NODE_TYPES.Literal) {
@@ -223,7 +223,7 @@ function isDirectivePrologue(node) {
223
223
  return typeof node.expression.value === "string";
224
224
  }
225
225
  function isExpectExpression(expression) {
226
- return expressionStartsWithIdentifier(expression, "expect");
226
+ return expressionStartsWithIdentifier$1(expression, "expect");
227
227
  }
228
228
  function isOwnLineComment(comment, sourceCode) {
229
229
  if (comment.type !== "Line") {
@@ -403,6 +403,7 @@ const rule$4 = {
403
403
  }
404
404
  };
405
405
 
406
+ const teardownViMethods = /* @__PURE__ */ new Set(["restoreAllMocks", "unstubAllGlobals", "useRealTimers"]);
406
407
  function compareTests(testA, testB) {
407
408
  const functionA = getFunctionName(testA);
408
409
  const functionB = getFunctionName(testB);
@@ -418,6 +419,40 @@ function compareTests(testA, testB) {
418
419
  const specificNameB = getSpecificName(testB);
419
420
  return specificNameA.localeCompare(specificNameB);
420
421
  }
422
+ function expressionStartsWithIdentifier(node, identifier) {
423
+ if (node.type === index$1.distExports.AST_NODE_TYPES.Identifier && node.name === identifier) {
424
+ return true;
425
+ }
426
+ if (node.type === index$1.distExports.AST_NODE_TYPES.CallExpression) {
427
+ const { callee } = node;
428
+ if (callee.type === index$1.distExports.AST_NODE_TYPES.Identifier && callee.name === identifier) {
429
+ return true;
430
+ }
431
+ if (callee.type === index$1.distExports.AST_NODE_TYPES.MemberExpression) {
432
+ return expressionStartsWithIdentifier(callee.object, identifier);
433
+ }
434
+ if (callee.type === index$1.distExports.AST_NODE_TYPES.ChainExpression) {
435
+ return expressionStartsWithIdentifier(callee.expression, identifier);
436
+ }
437
+ }
438
+ if (node.type === index$1.distExports.AST_NODE_TYPES.MemberExpression) {
439
+ return expressionStartsWithIdentifier(node.object, identifier);
440
+ }
441
+ if (node.type === index$1.distExports.AST_NODE_TYPES.ChainExpression) {
442
+ return expressionStartsWithIdentifier(node.expression, identifier);
443
+ }
444
+ return false;
445
+ }
446
+ function getExpressionFromStatement(statement) {
447
+ if (statement.type !== index$1.distExports.AST_NODE_TYPES.ExpressionStatement) {
448
+ return null;
449
+ }
450
+ let { expression } = statement;
451
+ if (expression.type === index$1.distExports.AST_NODE_TYPES.AwaitExpression) {
452
+ expression = expression.argument;
453
+ }
454
+ return expression;
455
+ }
421
456
  function getFunctionName(testName) {
422
457
  const parts = testName.split(".");
423
458
  return parts.slice(0, 2).join(".");
@@ -431,6 +466,20 @@ function getSpecificName(testName) {
431
466
  }
432
467
  return parts.slice(specialIndex + 1).join(".");
433
468
  }
469
+ function getTestCallbackBlockBody(node) {
470
+ const callback = node.arguments[1];
471
+ if (!callback) {
472
+ return null;
473
+ }
474
+ if (callback.type !== index$1.distExports.AST_NODE_TYPES.FunctionExpression && callback.type !== index$1.distExports.AST_NODE_TYPES.ArrowFunctionExpression) {
475
+ return null;
476
+ }
477
+ const { body } = callback;
478
+ if (body.type !== index$1.distExports.AST_NODE_TYPES.BlockStatement) {
479
+ return null;
480
+ }
481
+ return body;
482
+ }
434
483
  function getTestName(node) {
435
484
  const firstArgument = node.arguments[0];
436
485
  if (firstArgument?.type === index$1.distExports.AST_NODE_TYPES.Literal && typeof firstArgument.value === "string") {
@@ -451,12 +500,78 @@ function getTestPriority(testName) {
451
500
  return 1;
452
501
  }
453
502
  }
503
+ function hasAssertMethodCall(expression) {
504
+ if (expression.type !== index$1.distExports.AST_NODE_TYPES.CallExpression) {
505
+ return false;
506
+ }
507
+ const { callee } = expression;
508
+ if (callee.type === index$1.distExports.AST_NODE_TYPES.MemberExpression && callee.property.type === index$1.distExports.AST_NODE_TYPES.Identifier && callee.property.name.startsWith("assert")) {
509
+ if (callee.object.type === index$1.distExports.AST_NODE_TYPES.Identifier && callee.object.name === "expect") {
510
+ return false;
511
+ }
512
+ return true;
513
+ }
514
+ return false;
515
+ }
516
+ function isAssertionCountExpression(expression) {
517
+ if (expression.type !== index$1.distExports.AST_NODE_TYPES.CallExpression) {
518
+ return false;
519
+ }
520
+ const { callee } = expression;
521
+ if (callee.type !== index$1.distExports.AST_NODE_TYPES.MemberExpression || callee.object.type !== index$1.distExports.AST_NODE_TYPES.Identifier || callee.property.type !== index$1.distExports.AST_NODE_TYPES.Identifier) {
522
+ return false;
523
+ }
524
+ if (callee.object.name === "expect" && (callee.property.name === "assertions" || callee.property.name === "hasAssertions")) {
525
+ return true;
526
+ }
527
+ return callee.object.name === "assert" && callee.property.name === "plan";
528
+ }
529
+ function isAssertionCountStatement(statement) {
530
+ const expression = getExpressionFromStatement(statement);
531
+ if (!expression) {
532
+ return false;
533
+ }
534
+ return isAssertionCountExpression(expression);
535
+ }
536
+ function isAssertionExpression(expression) {
537
+ if (isAssertionCountExpression(expression)) {
538
+ return false;
539
+ }
540
+ if (expressionStartsWithIdentifier(expression, "assert") || expressionStartsWithIdentifier(expression, "expect")) {
541
+ return true;
542
+ }
543
+ return hasAssertMethodCall(expression);
544
+ }
545
+ function isAssertionStatement(statement) {
546
+ const expression = getExpressionFromStatement(statement);
547
+ if (!expression) {
548
+ return false;
549
+ }
550
+ return isAssertionExpression(expression);
551
+ }
552
+ function isTeardownStatement(statement) {
553
+ const expression = getExpressionFromStatement(statement);
554
+ if (!expression || expression.type !== index$1.distExports.AST_NODE_TYPES.CallExpression) {
555
+ return false;
556
+ }
557
+ const { callee } = expression;
558
+ if (callee.type === index$1.distExports.AST_NODE_TYPES.Identifier && callee.name === "cleanup") {
559
+ return true;
560
+ }
561
+ if (callee.type === index$1.distExports.AST_NODE_TYPES.MemberExpression && callee.object.type === index$1.distExports.AST_NODE_TYPES.Identifier && callee.object.name === "vi" && callee.property.type === index$1.distExports.AST_NODE_TYPES.Identifier && teardownViMethods.has(callee.property.name)) {
562
+ return true;
563
+ }
564
+ return false;
565
+ }
454
566
  const rule$3 = {
455
567
  defaultOptions: [],
456
568
  meta: {
457
- docs: { description: "Enforce sorted test functions grouped by method with sorted metrics, errors, exceptions and middlewares" },
569
+ docs: { description: "Enforce sorted test functions and setup-before-assertions layout in test bodies" },
458
570
  fixable: "code",
459
- messages: { sortError: "Test functions should be grouped by method with sorted metrics, errors, exceptions and middlewares." },
571
+ messages: {
572
+ setupAfterAssertion: "Setup statements must appear before assertions in test bodies.",
573
+ sortError: "Test functions should be grouped by method with sorted metrics, errors, exceptions and middlewares."
574
+ },
460
575
  schema: [],
461
576
  type: "suggestion"
462
577
  },
@@ -464,6 +579,25 @@ const rule$3 = {
464
579
  if (!context.filename.endsWith(".spec.ts") && !context.filename.endsWith(".test.ts")) {
465
580
  return {};
466
581
  }
582
+ function handleTestBody(node) {
583
+ const callbackBody = getTestCallbackBlockBody(node);
584
+ if (!callbackBody) {
585
+ return;
586
+ }
587
+ const firstAssertionIndex = callbackBody.body.findIndex(isAssertionStatement);
588
+ if (firstAssertionIndex === -1) {
589
+ return;
590
+ }
591
+ for (const statement of callbackBody.body.slice(firstAssertionIndex + 1)) {
592
+ if (isAssertionStatement(statement) || isAssertionCountStatement(statement) || isTeardownStatement(statement)) {
593
+ continue;
594
+ }
595
+ context.report({
596
+ messageId: "setupAfterAssertion",
597
+ node: statement
598
+ });
599
+ }
600
+ }
467
601
  function handleTestGroup(node) {
468
602
  const testGroup = node.arguments[0];
469
603
  if (!testGroup || !node.arguments[1]) {
@@ -512,6 +646,8 @@ const rule$3 = {
512
646
  }
513
647
  return {
514
648
  'CallExpression[callee.name="describe"]': handleTestGroup,
649
+ 'CallExpression[callee.name="it"]': handleTestBody,
650
+ 'CallExpression[callee.name="test"]': handleTestBody,
515
651
  'CallExpression[callee.object.name="describe"][callee.property.name="concurrent"]': handleTestGroup,
516
652
  'CallExpression[callee.object.name="test"][callee.property.name="group"]': handleTestGroup
517
653
  };
@@ -810,9 +946,61 @@ const propsPrefixRegex = /\$?props\.(\w+)/g;
810
946
  const trueAttributeRegex = /(?<![\w-]):?(?!aria-)([a-z0-9-]+)="true"/gi;
811
947
  const eventHandlerRegex = /(?:@|v-on:)[^\s="'<>/]+\s*=\s*"([^"]*)"/g;
812
948
  const bareIdentifierRegex = /^[a-z_$][\w$]*$/i;
949
+ const classBindingRegex = /(?<![\w-])(?::|v-bind:)class\s*=\s*"([^"]*)"/g;
950
+ const simpleTernaryRegex = /^([^?]+)\?\s*'([^']*)'\s*:\s*'([^']*)'$/;
813
951
  const singleCallRegex = /^([a-z_$][\w$]*)\s*\(.*\)$/is;
814
952
  const onPrefixRegex = /^on[A-Z]/;
815
953
  const exemptCallees = /* @__PURE__ */ new Set(["$emit", "emit"]);
954
+ function splitClassObjectProperties(body) {
955
+ const properties = [];
956
+ let depth = 0;
957
+ let quote;
958
+ let current = "";
959
+ for (let index = 0; index < body.length; index++) {
960
+ const char = body[index];
961
+ if (quote !== void 0) {
962
+ current += char;
963
+ if (char === quote && body[index - 1] !== "\\") {
964
+ quote = void 0;
965
+ }
966
+ continue;
967
+ }
968
+ if (char === "'" || char === '"' || char === "`") {
969
+ quote = char;
970
+ current += char;
971
+ continue;
972
+ }
973
+ if (char === "(" || char === "[" || char === "{") {
974
+ depth++;
975
+ } else if (char === ")" || char === "]" || char === "}") {
976
+ depth--;
977
+ }
978
+ if (char === "," && depth === 0) {
979
+ const property2 = current.trim();
980
+ if (property2 !== "") {
981
+ properties.push(property2);
982
+ }
983
+ current = "";
984
+ continue;
985
+ }
986
+ current += char;
987
+ }
988
+ const property = current.trim();
989
+ if (property !== "") {
990
+ properties.push(property);
991
+ }
992
+ return properties;
993
+ }
994
+ function buildClassObjectText(properties, baseIndent) {
995
+ if (properties.length <= 1) {
996
+ return `{ ${properties.join(", ")} }`;
997
+ }
998
+ const propertyIndent = `${baseIndent} `;
999
+ const lines = properties.map((property) => `${propertyIndent}${property},`);
1000
+ return `{
1001
+ ${lines.join("\n")}
1002
+ ${baseIndent}}`;
1003
+ }
816
1004
  const dollarTPatterns = [
817
1005
  {
818
1006
  pattern: / \$t\(/g,
@@ -838,13 +1026,16 @@ const dollarTPatterns = [
838
1026
  const rule = {
839
1027
  defaultOptions: [],
840
1028
  meta: {
841
- docs: { description: 'Format Vue templates: strip unnecessary props/$props prefixes, redundant ="true" attributes (except aria- attributes), enforce t() over $t(), require "on" prefix on named event listener handlers, and disallow inline event handler expressions' },
1029
+ docs: { description: 'Format Vue templates: strip unnecessary props/$props prefixes, redundant ="true" attributes (except aria- attributes), enforce t() over $t(), require "on" prefix on named event listener handlers, disallow inline event handler expressions, require object syntax for dynamic :class bindings, and keep single-property :class objects on one line while spreading multi-property objects across lines' },
842
1030
  fixable: "code",
843
1031
  messages: {
1032
+ dynamicClassMustBeObject: `Dynamic :class binding must use object syntax (e.g. :class="{ 'foo': condition }"). Ternaries, arrays, strings and bare variables are not allowed.`,
844
1033
  eventHandlerMustBeFunction: 'Event listener must reference a named handler function (e.g. @click="onClose") or call one (e.g. @click="onClose($event)"). Inline expressions are not allowed.',
1034
+ multilineClassObject: "Dynamic :class object with multiple properties must span multiple lines, one property per line.",
845
1035
  noPropsPrefix: "Unnecessary props/$props prefix in template. Props are automatically available in template scope.",
846
1036
  prefixEventHandlerWithOn: 'Event listener handler "{{name}}" must be prefixed with "on" (e.g. @click="onClick").',
847
1037
  removeTrueAttribute: 'Unnecessary ="true" attribute. Use the attribute name directly instead.',
1038
+ singleLineClassObject: "Dynamic :class object with a single property must stay on one line.",
848
1039
  useT: "Use t() instead of $t() in Vue templates as it does not work with <i18n> tags."
849
1040
  },
850
1041
  schema: [],
@@ -957,6 +1148,68 @@ const rule = {
957
1148
  }
958
1149
  eventHandlerMatch = eventHandlerRegex.exec(templateContent);
959
1150
  }
1151
+ classBindingRegex.lastIndex = 0;
1152
+ let classMatch = classBindingRegex.exec(templateContent);
1153
+ while (classMatch !== null) {
1154
+ const rawValue = classMatch[1];
1155
+ const trimmedValue = rawValue.trim();
1156
+ if (trimmedValue === "") {
1157
+ classMatch = classBindingRegex.exec(templateContent);
1158
+ continue;
1159
+ }
1160
+ const valueStart = templateStartIndex + classMatch.index + classMatch[0].length - 1 - rawValue.length;
1161
+ const lineStart = templateContent.lastIndexOf("\n", classMatch.index) + 1;
1162
+ const baseIndent = (/^[ \t]*/.exec(templateContent.slice(lineStart)) ?? [""])[0];
1163
+ if (trimmedValue.startsWith("{")) {
1164
+ const objectStartInRaw = rawValue.indexOf("{");
1165
+ const objectEndInRaw = rawValue.lastIndexOf("}");
1166
+ if (objectEndInRaw > objectStartInRaw) {
1167
+ const objectText = rawValue.slice(objectStartInRaw, objectEndInRaw + 1);
1168
+ const properties = splitClassObjectProperties(rawValue.slice(objectStartInRaw + 1, objectEndInRaw));
1169
+ const isMultiline = objectText.includes("\n");
1170
+ const shouldBeMultiline = properties.length >= 2;
1171
+ if (properties.length > 0 && shouldBeMultiline !== isMultiline) {
1172
+ const objectStart = valueStart + objectStartInRaw;
1173
+ const objectEnd = valueStart + objectEndInRaw + 1;
1174
+ context.report({
1175
+ fix: (fixer) => fixer.replaceTextRange([objectStart, objectEnd], buildClassObjectText(properties, baseIndent)),
1176
+ loc: {
1177
+ end: sourceCode.getLocFromIndex(objectEnd),
1178
+ start: sourceCode.getLocFromIndex(objectStart)
1179
+ },
1180
+ messageId: shouldBeMultiline ? "multilineClassObject" : "singleLineClassObject"
1181
+ });
1182
+ }
1183
+ }
1184
+ classMatch = classBindingRegex.exec(templateContent);
1185
+ continue;
1186
+ }
1187
+ const valueEnd = valueStart + rawValue.length;
1188
+ const ternaryMatch = simpleTernaryRegex.exec(trimmedValue);
1189
+ let fix;
1190
+ if (ternaryMatch) {
1191
+ const condition = ternaryMatch[1].trim();
1192
+ const truthyClass = ternaryMatch[2];
1193
+ const falsyClass = ternaryMatch[3];
1194
+ const entries = [];
1195
+ if (truthyClass !== "") {
1196
+ entries.push(`'${truthyClass}': ${condition}`);
1197
+ }
1198
+ if (falsyClass !== "") {
1199
+ entries.push(`'${falsyClass}': !(${condition})`);
1200
+ }
1201
+ fix = (fixer) => fixer.replaceTextRange([valueStart, valueEnd], buildClassObjectText(entries, baseIndent));
1202
+ }
1203
+ context.report({
1204
+ ...fix ? { fix } : {},
1205
+ loc: {
1206
+ end: sourceCode.getLocFromIndex(valueEnd),
1207
+ start: sourceCode.getLocFromIndex(valueStart)
1208
+ },
1209
+ messageId: "dynamicClassMustBeObject"
1210
+ });
1211
+ classMatch = classBindingRegex.exec(templateContent);
1212
+ }
960
1213
  }
961
1214
  };
962
1215
  }
package/dist/index.d.cts CHANGED
@@ -5198,12 +5198,12 @@ declare const _default: {
5198
5198
  maxLineLength?: number;
5199
5199
  minItems?: number;
5200
5200
  } | undefined)?], unknown, RuleListener>;
5201
- 'ts-format-tests': RuleModule<"sortError", [], unknown, RuleListener>;
5201
+ 'ts-format-tests': RuleModule<"setupAfterAssertion" | "sortError", [], unknown, RuleListener>;
5202
5202
  'vue-format-i18n': RuleModule<"sortError" | "indentError" | "invalidJson" | "invalidLocale" | "missingLocale" | "missingTranslations" | "unusedString", [({
5203
5203
  locales?: string[];
5204
5204
  } | undefined)?], unknown, RuleListener>;
5205
5205
  'vue-format-script': RuleModule<"multilineComputed" | "untypedEmits", [], unknown, RuleListener>;
5206
- 'vue-format-template': RuleModule<"eventHandlerMustBeFunction" | "noPropsPrefix" | "prefixEventHandlerWithOn" | "removeTrueAttribute" | "useT", [], unknown, RuleListener>;
5206
+ 'vue-format-template': RuleModule<"dynamicClassMustBeObject" | "eventHandlerMustBeFunction" | "multilineClassObject" | "noPropsPrefix" | "prefixEventHandlerWithOn" | "removeTrueAttribute" | "singleLineClassObject" | "useT", [], unknown, RuleListener>;
5207
5207
  };
5208
5208
  };
5209
5209
 
package/dist/index.d.mts CHANGED
@@ -5198,12 +5198,12 @@ declare const _default: {
5198
5198
  maxLineLength?: number;
5199
5199
  minItems?: number;
5200
5200
  } | undefined)?], unknown, RuleListener>;
5201
- 'ts-format-tests': RuleModule<"sortError", [], unknown, RuleListener>;
5201
+ 'ts-format-tests': RuleModule<"setupAfterAssertion" | "sortError", [], unknown, RuleListener>;
5202
5202
  'vue-format-i18n': RuleModule<"sortError" | "indentError" | "invalidJson" | "invalidLocale" | "missingLocale" | "missingTranslations" | "unusedString", [({
5203
5203
  locales?: string[];
5204
5204
  } | undefined)?], unknown, RuleListener>;
5205
5205
  'vue-format-script': RuleModule<"multilineComputed" | "untypedEmits", [], unknown, RuleListener>;
5206
- 'vue-format-template': RuleModule<"eventHandlerMustBeFunction" | "noPropsPrefix" | "prefixEventHandlerWithOn" | "removeTrueAttribute" | "useT", [], unknown, RuleListener>;
5206
+ 'vue-format-template': RuleModule<"dynamicClassMustBeObject" | "eventHandlerMustBeFunction" | "multilineClassObject" | "noPropsPrefix" | "prefixEventHandlerWithOn" | "removeTrueAttribute" | "singleLineClassObject" | "useT", [], unknown, RuleListener>;
5207
5207
  };
5208
5208
  };
5209
5209
 
package/dist/index.d.ts CHANGED
@@ -5198,12 +5198,12 @@ declare const _default: {
5198
5198
  maxLineLength?: number;
5199
5199
  minItems?: number;
5200
5200
  } | undefined)?], unknown, RuleListener>;
5201
- 'ts-format-tests': RuleModule<"sortError", [], unknown, RuleListener>;
5201
+ 'ts-format-tests': RuleModule<"setupAfterAssertion" | "sortError", [], unknown, RuleListener>;
5202
5202
  'vue-format-i18n': RuleModule<"sortError" | "indentError" | "invalidJson" | "invalidLocale" | "missingLocale" | "missingTranslations" | "unusedString", [({
5203
5203
  locales?: string[];
5204
5204
  } | undefined)?], unknown, RuleListener>;
5205
5205
  'vue-format-script': RuleModule<"multilineComputed" | "untypedEmits", [], unknown, RuleListener>;
5206
- 'vue-format-template': RuleModule<"eventHandlerMustBeFunction" | "noPropsPrefix" | "prefixEventHandlerWithOn" | "removeTrueAttribute" | "useT", [], unknown, RuleListener>;
5206
+ 'vue-format-template': RuleModule<"dynamicClassMustBeObject" | "eventHandlerMustBeFunction" | "multilineClassObject" | "noPropsPrefix" | "prefixEventHandlerWithOn" | "removeTrueAttribute" | "singleLineClassObject" | "useT", [], unknown, RuleListener>;
5207
5207
  };
5208
5208
  };
5209
5209
 
package/dist/index.mjs CHANGED
@@ -61,7 +61,7 @@ function checkUnion(context, node, maxLineLength, minItems) {
61
61
  function collapseBlankLine(text) {
62
62
  return text.replace(/\n[^\S\n]*\n[^\S\n]*/, "\n");
63
63
  }
64
- function expressionStartsWithIdentifier(node, identifier) {
64
+ function expressionStartsWithIdentifier$1(node, identifier) {
65
65
  if (node.type === distExports.AST_NODE_TYPES.Identifier && node.name === identifier) {
66
66
  return true;
67
67
  }
@@ -71,17 +71,17 @@ function expressionStartsWithIdentifier(node, identifier) {
71
71
  return true;
72
72
  }
73
73
  if (callee.type === distExports.AST_NODE_TYPES.MemberExpression) {
74
- return expressionStartsWithIdentifier(callee.object, identifier);
74
+ return expressionStartsWithIdentifier$1(callee.object, identifier);
75
75
  }
76
76
  if (callee.type === distExports.AST_NODE_TYPES.ChainExpression) {
77
- return expressionStartsWithIdentifier(callee.expression, identifier);
77
+ return expressionStartsWithIdentifier$1(callee.expression, identifier);
78
78
  }
79
79
  }
80
80
  if (node.type === distExports.AST_NODE_TYPES.MemberExpression) {
81
- return expressionStartsWithIdentifier(node.object, identifier);
81
+ return expressionStartsWithIdentifier$1(node.object, identifier);
82
82
  }
83
83
  if (node.type === distExports.AST_NODE_TYPES.ChainExpression) {
84
- return expressionStartsWithIdentifier(node.expression, identifier);
84
+ return expressionStartsWithIdentifier$1(node.expression, identifier);
85
85
  }
86
86
  return false;
87
87
  }
@@ -209,10 +209,10 @@ function hasLineCommentBetween(prevNode, nextNode, sourceCode) {
209
209
  });
210
210
  }
211
211
  function isAssertExpression(expression) {
212
- return expressionStartsWithIdentifier(expression, "assert");
212
+ return expressionStartsWithIdentifier$1(expression, "assert");
213
213
  }
214
214
  function isConsoleExpression(expression) {
215
- return expressionStartsWithIdentifier(expression, "console");
215
+ return expressionStartsWithIdentifier$1(expression, "console");
216
216
  }
217
217
  function isDirectivePrologue(node) {
218
218
  if (node.expression.type !== distExports.AST_NODE_TYPES.Literal) {
@@ -221,7 +221,7 @@ function isDirectivePrologue(node) {
221
221
  return typeof node.expression.value === "string";
222
222
  }
223
223
  function isExpectExpression(expression) {
224
- return expressionStartsWithIdentifier(expression, "expect");
224
+ return expressionStartsWithIdentifier$1(expression, "expect");
225
225
  }
226
226
  function isOwnLineComment(comment, sourceCode) {
227
227
  if (comment.type !== "Line") {
@@ -401,6 +401,7 @@ const rule$4 = {
401
401
  }
402
402
  };
403
403
 
404
+ const teardownViMethods = /* @__PURE__ */ new Set(["restoreAllMocks", "unstubAllGlobals", "useRealTimers"]);
404
405
  function compareTests(testA, testB) {
405
406
  const functionA = getFunctionName(testA);
406
407
  const functionB = getFunctionName(testB);
@@ -416,6 +417,40 @@ function compareTests(testA, testB) {
416
417
  const specificNameB = getSpecificName(testB);
417
418
  return specificNameA.localeCompare(specificNameB);
418
419
  }
420
+ function expressionStartsWithIdentifier(node, identifier) {
421
+ if (node.type === distExports.AST_NODE_TYPES.Identifier && node.name === identifier) {
422
+ return true;
423
+ }
424
+ if (node.type === distExports.AST_NODE_TYPES.CallExpression) {
425
+ const { callee } = node;
426
+ if (callee.type === distExports.AST_NODE_TYPES.Identifier && callee.name === identifier) {
427
+ return true;
428
+ }
429
+ if (callee.type === distExports.AST_NODE_TYPES.MemberExpression) {
430
+ return expressionStartsWithIdentifier(callee.object, identifier);
431
+ }
432
+ if (callee.type === distExports.AST_NODE_TYPES.ChainExpression) {
433
+ return expressionStartsWithIdentifier(callee.expression, identifier);
434
+ }
435
+ }
436
+ if (node.type === distExports.AST_NODE_TYPES.MemberExpression) {
437
+ return expressionStartsWithIdentifier(node.object, identifier);
438
+ }
439
+ if (node.type === distExports.AST_NODE_TYPES.ChainExpression) {
440
+ return expressionStartsWithIdentifier(node.expression, identifier);
441
+ }
442
+ return false;
443
+ }
444
+ function getExpressionFromStatement(statement) {
445
+ if (statement.type !== distExports.AST_NODE_TYPES.ExpressionStatement) {
446
+ return null;
447
+ }
448
+ let { expression } = statement;
449
+ if (expression.type === distExports.AST_NODE_TYPES.AwaitExpression) {
450
+ expression = expression.argument;
451
+ }
452
+ return expression;
453
+ }
419
454
  function getFunctionName(testName) {
420
455
  const parts = testName.split(".");
421
456
  return parts.slice(0, 2).join(".");
@@ -429,6 +464,20 @@ function getSpecificName(testName) {
429
464
  }
430
465
  return parts.slice(specialIndex + 1).join(".");
431
466
  }
467
+ function getTestCallbackBlockBody(node) {
468
+ const callback = node.arguments[1];
469
+ if (!callback) {
470
+ return null;
471
+ }
472
+ if (callback.type !== distExports.AST_NODE_TYPES.FunctionExpression && callback.type !== distExports.AST_NODE_TYPES.ArrowFunctionExpression) {
473
+ return null;
474
+ }
475
+ const { body } = callback;
476
+ if (body.type !== distExports.AST_NODE_TYPES.BlockStatement) {
477
+ return null;
478
+ }
479
+ return body;
480
+ }
432
481
  function getTestName(node) {
433
482
  const firstArgument = node.arguments[0];
434
483
  if (firstArgument?.type === distExports.AST_NODE_TYPES.Literal && typeof firstArgument.value === "string") {
@@ -449,12 +498,78 @@ function getTestPriority(testName) {
449
498
  return 1;
450
499
  }
451
500
  }
501
+ function hasAssertMethodCall(expression) {
502
+ if (expression.type !== distExports.AST_NODE_TYPES.CallExpression) {
503
+ return false;
504
+ }
505
+ const { callee } = expression;
506
+ if (callee.type === distExports.AST_NODE_TYPES.MemberExpression && callee.property.type === distExports.AST_NODE_TYPES.Identifier && callee.property.name.startsWith("assert")) {
507
+ if (callee.object.type === distExports.AST_NODE_TYPES.Identifier && callee.object.name === "expect") {
508
+ return false;
509
+ }
510
+ return true;
511
+ }
512
+ return false;
513
+ }
514
+ function isAssertionCountExpression(expression) {
515
+ if (expression.type !== distExports.AST_NODE_TYPES.CallExpression) {
516
+ return false;
517
+ }
518
+ const { callee } = expression;
519
+ if (callee.type !== distExports.AST_NODE_TYPES.MemberExpression || callee.object.type !== distExports.AST_NODE_TYPES.Identifier || callee.property.type !== distExports.AST_NODE_TYPES.Identifier) {
520
+ return false;
521
+ }
522
+ if (callee.object.name === "expect" && (callee.property.name === "assertions" || callee.property.name === "hasAssertions")) {
523
+ return true;
524
+ }
525
+ return callee.object.name === "assert" && callee.property.name === "plan";
526
+ }
527
+ function isAssertionCountStatement(statement) {
528
+ const expression = getExpressionFromStatement(statement);
529
+ if (!expression) {
530
+ return false;
531
+ }
532
+ return isAssertionCountExpression(expression);
533
+ }
534
+ function isAssertionExpression(expression) {
535
+ if (isAssertionCountExpression(expression)) {
536
+ return false;
537
+ }
538
+ if (expressionStartsWithIdentifier(expression, "assert") || expressionStartsWithIdentifier(expression, "expect")) {
539
+ return true;
540
+ }
541
+ return hasAssertMethodCall(expression);
542
+ }
543
+ function isAssertionStatement(statement) {
544
+ const expression = getExpressionFromStatement(statement);
545
+ if (!expression) {
546
+ return false;
547
+ }
548
+ return isAssertionExpression(expression);
549
+ }
550
+ function isTeardownStatement(statement) {
551
+ const expression = getExpressionFromStatement(statement);
552
+ if (!expression || expression.type !== distExports.AST_NODE_TYPES.CallExpression) {
553
+ return false;
554
+ }
555
+ const { callee } = expression;
556
+ if (callee.type === distExports.AST_NODE_TYPES.Identifier && callee.name === "cleanup") {
557
+ return true;
558
+ }
559
+ if (callee.type === distExports.AST_NODE_TYPES.MemberExpression && callee.object.type === distExports.AST_NODE_TYPES.Identifier && callee.object.name === "vi" && callee.property.type === distExports.AST_NODE_TYPES.Identifier && teardownViMethods.has(callee.property.name)) {
560
+ return true;
561
+ }
562
+ return false;
563
+ }
452
564
  const rule$3 = {
453
565
  defaultOptions: [],
454
566
  meta: {
455
- docs: { description: "Enforce sorted test functions grouped by method with sorted metrics, errors, exceptions and middlewares" },
567
+ docs: { description: "Enforce sorted test functions and setup-before-assertions layout in test bodies" },
456
568
  fixable: "code",
457
- messages: { sortError: "Test functions should be grouped by method with sorted metrics, errors, exceptions and middlewares." },
569
+ messages: {
570
+ setupAfterAssertion: "Setup statements must appear before assertions in test bodies.",
571
+ sortError: "Test functions should be grouped by method with sorted metrics, errors, exceptions and middlewares."
572
+ },
458
573
  schema: [],
459
574
  type: "suggestion"
460
575
  },
@@ -462,6 +577,25 @@ const rule$3 = {
462
577
  if (!context.filename.endsWith(".spec.ts") && !context.filename.endsWith(".test.ts")) {
463
578
  return {};
464
579
  }
580
+ function handleTestBody(node) {
581
+ const callbackBody = getTestCallbackBlockBody(node);
582
+ if (!callbackBody) {
583
+ return;
584
+ }
585
+ const firstAssertionIndex = callbackBody.body.findIndex(isAssertionStatement);
586
+ if (firstAssertionIndex === -1) {
587
+ return;
588
+ }
589
+ for (const statement of callbackBody.body.slice(firstAssertionIndex + 1)) {
590
+ if (isAssertionStatement(statement) || isAssertionCountStatement(statement) || isTeardownStatement(statement)) {
591
+ continue;
592
+ }
593
+ context.report({
594
+ messageId: "setupAfterAssertion",
595
+ node: statement
596
+ });
597
+ }
598
+ }
465
599
  function handleTestGroup(node) {
466
600
  const testGroup = node.arguments[0];
467
601
  if (!testGroup || !node.arguments[1]) {
@@ -510,6 +644,8 @@ const rule$3 = {
510
644
  }
511
645
  return {
512
646
  'CallExpression[callee.name="describe"]': handleTestGroup,
647
+ 'CallExpression[callee.name="it"]': handleTestBody,
648
+ 'CallExpression[callee.name="test"]': handleTestBody,
513
649
  'CallExpression[callee.object.name="describe"][callee.property.name="concurrent"]': handleTestGroup,
514
650
  'CallExpression[callee.object.name="test"][callee.property.name="group"]': handleTestGroup
515
651
  };
@@ -808,9 +944,61 @@ const propsPrefixRegex = /\$?props\.(\w+)/g;
808
944
  const trueAttributeRegex = /(?<![\w-]):?(?!aria-)([a-z0-9-]+)="true"/gi;
809
945
  const eventHandlerRegex = /(?:@|v-on:)[^\s="'<>/]+\s*=\s*"([^"]*)"/g;
810
946
  const bareIdentifierRegex = /^[a-z_$][\w$]*$/i;
947
+ const classBindingRegex = /(?<![\w-])(?::|v-bind:)class\s*=\s*"([^"]*)"/g;
948
+ const simpleTernaryRegex = /^([^?]+)\?\s*'([^']*)'\s*:\s*'([^']*)'$/;
811
949
  const singleCallRegex = /^([a-z_$][\w$]*)\s*\(.*\)$/is;
812
950
  const onPrefixRegex = /^on[A-Z]/;
813
951
  const exemptCallees = /* @__PURE__ */ new Set(["$emit", "emit"]);
952
+ function splitClassObjectProperties(body) {
953
+ const properties = [];
954
+ let depth = 0;
955
+ let quote;
956
+ let current = "";
957
+ for (let index = 0; index < body.length; index++) {
958
+ const char = body[index];
959
+ if (quote !== void 0) {
960
+ current += char;
961
+ if (char === quote && body[index - 1] !== "\\") {
962
+ quote = void 0;
963
+ }
964
+ continue;
965
+ }
966
+ if (char === "'" || char === '"' || char === "`") {
967
+ quote = char;
968
+ current += char;
969
+ continue;
970
+ }
971
+ if (char === "(" || char === "[" || char === "{") {
972
+ depth++;
973
+ } else if (char === ")" || char === "]" || char === "}") {
974
+ depth--;
975
+ }
976
+ if (char === "," && depth === 0) {
977
+ const property2 = current.trim();
978
+ if (property2 !== "") {
979
+ properties.push(property2);
980
+ }
981
+ current = "";
982
+ continue;
983
+ }
984
+ current += char;
985
+ }
986
+ const property = current.trim();
987
+ if (property !== "") {
988
+ properties.push(property);
989
+ }
990
+ return properties;
991
+ }
992
+ function buildClassObjectText(properties, baseIndent) {
993
+ if (properties.length <= 1) {
994
+ return `{ ${properties.join(", ")} }`;
995
+ }
996
+ const propertyIndent = `${baseIndent} `;
997
+ const lines = properties.map((property) => `${propertyIndent}${property},`);
998
+ return `{
999
+ ${lines.join("\n")}
1000
+ ${baseIndent}}`;
1001
+ }
814
1002
  const dollarTPatterns = [
815
1003
  {
816
1004
  pattern: / \$t\(/g,
@@ -836,13 +1024,16 @@ const dollarTPatterns = [
836
1024
  const rule = {
837
1025
  defaultOptions: [],
838
1026
  meta: {
839
- docs: { description: 'Format Vue templates: strip unnecessary props/$props prefixes, redundant ="true" attributes (except aria- attributes), enforce t() over $t(), require "on" prefix on named event listener handlers, and disallow inline event handler expressions' },
1027
+ docs: { description: 'Format Vue templates: strip unnecessary props/$props prefixes, redundant ="true" attributes (except aria- attributes), enforce t() over $t(), require "on" prefix on named event listener handlers, disallow inline event handler expressions, require object syntax for dynamic :class bindings, and keep single-property :class objects on one line while spreading multi-property objects across lines' },
840
1028
  fixable: "code",
841
1029
  messages: {
1030
+ dynamicClassMustBeObject: `Dynamic :class binding must use object syntax (e.g. :class="{ 'foo': condition }"). Ternaries, arrays, strings and bare variables are not allowed.`,
842
1031
  eventHandlerMustBeFunction: 'Event listener must reference a named handler function (e.g. @click="onClose") or call one (e.g. @click="onClose($event)"). Inline expressions are not allowed.',
1032
+ multilineClassObject: "Dynamic :class object with multiple properties must span multiple lines, one property per line.",
843
1033
  noPropsPrefix: "Unnecessary props/$props prefix in template. Props are automatically available in template scope.",
844
1034
  prefixEventHandlerWithOn: 'Event listener handler "{{name}}" must be prefixed with "on" (e.g. @click="onClick").',
845
1035
  removeTrueAttribute: 'Unnecessary ="true" attribute. Use the attribute name directly instead.',
1036
+ singleLineClassObject: "Dynamic :class object with a single property must stay on one line.",
846
1037
  useT: "Use t() instead of $t() in Vue templates as it does not work with <i18n> tags."
847
1038
  },
848
1039
  schema: [],
@@ -955,6 +1146,68 @@ const rule = {
955
1146
  }
956
1147
  eventHandlerMatch = eventHandlerRegex.exec(templateContent);
957
1148
  }
1149
+ classBindingRegex.lastIndex = 0;
1150
+ let classMatch = classBindingRegex.exec(templateContent);
1151
+ while (classMatch !== null) {
1152
+ const rawValue = classMatch[1];
1153
+ const trimmedValue = rawValue.trim();
1154
+ if (trimmedValue === "") {
1155
+ classMatch = classBindingRegex.exec(templateContent);
1156
+ continue;
1157
+ }
1158
+ const valueStart = templateStartIndex + classMatch.index + classMatch[0].length - 1 - rawValue.length;
1159
+ const lineStart = templateContent.lastIndexOf("\n", classMatch.index) + 1;
1160
+ const baseIndent = (/^[ \t]*/.exec(templateContent.slice(lineStart)) ?? [""])[0];
1161
+ if (trimmedValue.startsWith("{")) {
1162
+ const objectStartInRaw = rawValue.indexOf("{");
1163
+ const objectEndInRaw = rawValue.lastIndexOf("}");
1164
+ if (objectEndInRaw > objectStartInRaw) {
1165
+ const objectText = rawValue.slice(objectStartInRaw, objectEndInRaw + 1);
1166
+ const properties = splitClassObjectProperties(rawValue.slice(objectStartInRaw + 1, objectEndInRaw));
1167
+ const isMultiline = objectText.includes("\n");
1168
+ const shouldBeMultiline = properties.length >= 2;
1169
+ if (properties.length > 0 && shouldBeMultiline !== isMultiline) {
1170
+ const objectStart = valueStart + objectStartInRaw;
1171
+ const objectEnd = valueStart + objectEndInRaw + 1;
1172
+ context.report({
1173
+ fix: (fixer) => fixer.replaceTextRange([objectStart, objectEnd], buildClassObjectText(properties, baseIndent)),
1174
+ loc: {
1175
+ end: sourceCode.getLocFromIndex(objectEnd),
1176
+ start: sourceCode.getLocFromIndex(objectStart)
1177
+ },
1178
+ messageId: shouldBeMultiline ? "multilineClassObject" : "singleLineClassObject"
1179
+ });
1180
+ }
1181
+ }
1182
+ classMatch = classBindingRegex.exec(templateContent);
1183
+ continue;
1184
+ }
1185
+ const valueEnd = valueStart + rawValue.length;
1186
+ const ternaryMatch = simpleTernaryRegex.exec(trimmedValue);
1187
+ let fix;
1188
+ if (ternaryMatch) {
1189
+ const condition = ternaryMatch[1].trim();
1190
+ const truthyClass = ternaryMatch[2];
1191
+ const falsyClass = ternaryMatch[3];
1192
+ const entries = [];
1193
+ if (truthyClass !== "") {
1194
+ entries.push(`'${truthyClass}': ${condition}`);
1195
+ }
1196
+ if (falsyClass !== "") {
1197
+ entries.push(`'${falsyClass}': !(${condition})`);
1198
+ }
1199
+ fix = (fixer) => fixer.replaceTextRange([valueStart, valueEnd], buildClassObjectText(entries, baseIndent));
1200
+ }
1201
+ context.report({
1202
+ ...fix ? { fix } : {},
1203
+ loc: {
1204
+ end: sourceCode.getLocFromIndex(valueEnd),
1205
+ start: sourceCode.getLocFromIndex(valueStart)
1206
+ },
1207
+ messageId: "dynamicClassMustBeObject"
1208
+ });
1209
+ classMatch = classBindingRegex.exec(templateContent);
1210
+ }
958
1211
  }
959
1212
  };
960
1213
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saasmakers/eslint",
3
- "version": "1.0.25",
3
+ "version": "1.0.27",
4
4
  "private": false,
5
5
  "description": "Shared ESLint config and rules for SaaS Makers projects",
6
6
  "license": "MIT",