@slip-stream-kit/eslint-plugin 0.13.0 → 0.15.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.js CHANGED
@@ -659,6 +659,165 @@ var resolvePackageType = (packageRoot) => {
659
659
  return type;
660
660
  };
661
661
 
662
+ // src/utils/playwright-call.ts
663
+ var TEST_MODIFIERS = /* @__PURE__ */ new Set(["only", "skip", "fixme", "fail"]);
664
+ var DESCRIBE_MODIFIERS = /* @__PURE__ */ new Set(["only", "skip", "fixme", "serial", "parallel"]);
665
+ var calleeNames = (callee) => {
666
+ if (callee.type === "Identifier") {
667
+ return [callee.name];
668
+ }
669
+ if (callee.type !== "MemberExpression" || callee.computed || callee.property.type !== "Identifier") {
670
+ return null;
671
+ }
672
+ const head = calleeNames(callee.object);
673
+ return head === null ? null : [...head, callee.property.name];
674
+ };
675
+ var playwrightCallKind = (node) => {
676
+ const names = calleeNames(node.callee);
677
+ if (names === null || names[0] !== "test") {
678
+ return null;
679
+ }
680
+ const [, second, ...rest] = names;
681
+ if (second === void 0) {
682
+ return "test";
683
+ }
684
+ if (second === "describe") {
685
+ const isDescribe = rest.every((name) => {
686
+ return DESCRIBE_MODIFIERS.has(name);
687
+ });
688
+ return isDescribe ? "describe" : null;
689
+ }
690
+ return rest.length === 0 && TEST_MODIFIERS.has(second) ? "test" : null;
691
+ };
692
+ var detailsArgument = (node) => {
693
+ const details = node.arguments[1];
694
+ return node.arguments.length >= 3 && details?.type === "ObjectExpression" ? details : null;
695
+ };
696
+ var staticTitle = (node) => {
697
+ const title = node.arguments[0];
698
+ if (title?.type === "Literal" && typeof title.value === "string") {
699
+ return title.value;
700
+ }
701
+ if (title?.type === "TemplateLiteral" && title.expressions.length === 0) {
702
+ return title.quasis[0]?.value.cooked ?? null;
703
+ }
704
+ return null;
705
+ };
706
+ var testStatementNames = (statement) => {
707
+ if (statement.type !== "ExpressionStatement" || statement.expression.type !== "CallExpression") {
708
+ return null;
709
+ }
710
+ const names = calleeNames(statement.expression.callee);
711
+ return names?.[0] === "test" ? names : null;
712
+ };
713
+ var isInE2ePackage = (context) => {
714
+ const packageRoot = findPackageRoot(context.filename.split("\\").join("/"));
715
+ return packageRoot !== null && resolvePackageType(packageRoot) === "e2e";
716
+ };
717
+
718
+ // src/rules/e2e-describe-order/e2e-describe-order.ts
719
+ var SPEC_FILE = /\.spec\.[cm]?[jt]sx?$/;
720
+ var SETUP_RANK = 0;
721
+ var BODY_RANK = 5;
722
+ var HOOK_RANK = { beforeAll: 1, beforeEach: 2, afterEach: 3, afterAll: 4 };
723
+ var SETUP_CALLS = /* @__PURE__ */ new Set(["use", "configure", "setTimeout", "slow"]);
724
+ var ANNOTATION_CALLS = /* @__PURE__ */ new Set(["skip", "fixme", "fail"]);
725
+ var RANK_NAMES = ["setup", "beforeAll hook", "beforeEach hook", "afterEach hook", "afterAll hook", "test"];
726
+ var isFunction = (node) => {
727
+ return node?.type === "ArrowFunctionExpression" || node?.type === "FunctionExpression";
728
+ };
729
+ var rankFor = (method, call) => {
730
+ if (method in HOOK_RANK) {
731
+ return HOOK_RANK[method];
732
+ }
733
+ const declaresTest = call.arguments.slice(1).some(isFunction);
734
+ return SETUP_CALLS.has(method) || ANNOTATION_CALLS.has(method) && !declaresTest ? SETUP_RANK : BODY_RANK;
735
+ };
736
+ var rankOf = (statement) => {
737
+ const names = testStatementNames(statement);
738
+ if (names === null) {
739
+ return null;
740
+ }
741
+ const call = statement.expression;
742
+ const rank = rankFor(names.at(-1), call);
743
+ const title = rank === BODY_RANK ? staticTitle(call) : null;
744
+ const label = title === null ? `\`${names.join(".")}\`` : `\`${names.join(".")}('${title}')\``;
745
+ return { rank, label: `${label} (${RANK_NAMES[rank]})` };
746
+ };
747
+ var describeBody = (call) => {
748
+ const callback = call.arguments.at(-1);
749
+ if (callback?.type !== "ArrowFunctionExpression" && callback?.type !== "FunctionExpression") {
750
+ return null;
751
+ }
752
+ return callback.body.type === "BlockStatement" ? callback.body.body : null;
753
+ };
754
+ var e2eDescribeOrder = {
755
+ meta: {
756
+ type: "suggestion",
757
+ docs: {
758
+ description: "Order an e2e `test.describe` body: `test.use` / `configure` / annotations, then hooks beforeAll \u2192 beforeEach \u2192 afterEach \u2192 afterAll, then tests and nested describes.",
759
+ recommended: true,
760
+ url: "https://github.com/ArthurSaenz/infra-kit/tree/main/apps/infra-kit/eslint-plugin#e2e-describe-order"
761
+ },
762
+ schema: [
763
+ {
764
+ type: "object",
765
+ properties: {
766
+ ignore: {
767
+ type: "array",
768
+ items: { type: "string" },
769
+ description: "Optional globs; the rule is skipped for matching files."
770
+ }
771
+ },
772
+ additionalProperties: false
773
+ }
774
+ ],
775
+ messages: {
776
+ outOfOrder: "{{label}} comes after {{afterLabel}} in describe {{describe}}. Order a describe body: `test.use` / `test.describe.configure` / annotations, then hooks beforeAll \u2192 beforeEach \u2192 afterEach \u2192 afterAll, then tests and nested describes."
777
+ }
778
+ },
779
+ create(context) {
780
+ const options = context.options[0] ?? {};
781
+ const filename = context.filename.split("\\").join("/");
782
+ if (!SPEC_FILE.test(filename) || options.ignore?.length && matchesAnyGlob(filename, options.ignore)) {
783
+ return {};
784
+ }
785
+ if (!isInE2ePackage(context)) {
786
+ return {};
787
+ }
788
+ return {
789
+ CallExpression(node) {
790
+ const body = playwrightCallKind(node) === "describe" ? describeBody(node) : null;
791
+ if (body === null) {
792
+ return;
793
+ }
794
+ const title = staticTitle(node);
795
+ const describe = title === null ? "block" : `'${title}'`;
796
+ let latest = null;
797
+ for (const statement of body) {
798
+ const ranked = rankOf(statement);
799
+ if (ranked === null) {
800
+ continue;
801
+ }
802
+ if (latest !== null && ranked.rank < latest.rank) {
803
+ context.report({
804
+ node: statement,
805
+ messageId: "outOfOrder",
806
+ data: {
807
+ label: ranked.label,
808
+ afterLabel: latest.label,
809
+ describe
810
+ }
811
+ });
812
+ } else {
813
+ latest = ranked;
814
+ }
815
+ }
816
+ }
817
+ };
818
+ }
819
+ };
820
+
662
821
  // src/rules/e2e-file-layout/e2e-file-layout.ts
663
822
  var TEST_FILE = /\.(?:test|spec)\.[cm]?[jt]sx?$/;
664
823
  var LEGACY_TEST_SUFFIX = /\.test(\.[cm]?[jt]sx?)$/;
@@ -763,53 +922,469 @@ var e2eFileLayout = {
763
922
  }
764
923
  };
765
924
 
766
- // src/utils/playwright-call.ts
767
- var TEST_MODIFIERS = /* @__PURE__ */ new Set(["only", "skip", "fixme", "fail"]);
768
- var DESCRIBE_MODIFIERS = /* @__PURE__ */ new Set(["only", "skip", "fixme", "serial", "parallel"]);
769
- var calleeNames = (callee) => {
925
+ // src/utils/attached-comments.ts
926
+ var attachedStart = (sourceCode, node) => {
927
+ const previousEndLine = sourceCode.getTokenBefore(node)?.loc.end.line ?? 0;
928
+ let start = node.range[0];
929
+ let startLine = node.loc.start.line;
930
+ for (const comment of sourceCode.getCommentsBefore(node).toReversed()) {
931
+ if (comment.loc.end.line < startLine - 1 || comment.loc.start.line <= previousEndLine) {
932
+ break;
933
+ }
934
+ start = comment.range[0];
935
+ startLine = comment.loc.start.line;
936
+ }
937
+ return start;
938
+ };
939
+
940
+ // src/rules/e2e-file-order/e2e-file-order.ts
941
+ var FILE_KIND = [
942
+ [/\.(?:page|component)\.[cm]?[jt]sx?$/, "pageObject"],
943
+ [/\.fixture\.[cm]?[jt]sx?$/, "fixture"],
944
+ [/\.spec\.[cm]?[jt]sx?$/, "spec"],
945
+ [/\.mock\.[cm]?[jt]sx?$/, "mock"]
946
+ ];
947
+ var LIB_DIR = /\/lib\//;
948
+ var UNORDERED_FILE = /\.data\.[cm]?[jt]sx?$/;
949
+ var KIND_OPENING = {
950
+ pageObject: "a page object file opens with its imports, then the class",
951
+ fixture: "a fixture file opens with its imports, then the fixtures type and the `extend` call",
952
+ spec: "a spec file opens with its imports, then its `test.*` calls",
953
+ mock: "a mock file opens with its imports, then the exported mock function",
954
+ lib: "a lib file opens with its imports, then its exports"
955
+ };
956
+ var fileKindOf = (filename) => {
957
+ const match = FILE_KIND.find(([pattern]) => {
958
+ return pattern.test(filename);
959
+ });
960
+ if (match) {
961
+ return match[1];
962
+ }
963
+ return LIB_DIR.test(filename) && !UNORDERED_FILE.test(filename) ? "lib" : null;
964
+ };
965
+ var unwrapExport2 = (statement) => {
966
+ const isExport = statement.type === "ExportNamedDeclaration" || statement.type === "ExportDefaultDeclaration";
967
+ return isExport && statement.declaration ? statement.declaration : statement;
968
+ };
969
+ var calleeRoot = (callee) => {
770
970
  if (callee.type === "Identifier") {
771
- return [callee.name];
971
+ return callee.name;
772
972
  }
773
- if (callee.type !== "MemberExpression" || callee.computed || callee.property.type !== "Identifier") {
973
+ if (callee.type === "MemberExpression") {
974
+ return calleeRoot(callee.object);
975
+ }
976
+ return callee.type === "CallExpression" ? calleeRoot(callee.callee) : null;
977
+ };
978
+ var typeNamesOf = (node) => {
979
+ if (node === void 0) {
980
+ return [];
981
+ }
982
+ if (node.type === "TSTypeReference" && node.typeName?.type === "Identifier") {
983
+ return [node.typeName.name];
984
+ }
985
+ return (node.types ?? []).flatMap(typeNamesOf);
986
+ };
987
+ var pageObjectAnchor = (declaration, index) => {
988
+ if (declaration.type !== "ClassDeclaration") {
774
989
  return null;
775
990
  }
776
- const head = calleeNames(callee.object);
777
- return head === null ? null : [...head, callee.property.name];
991
+ return { index, label: `class \`${declaration.id?.name ?? "default"}\``, headerTypes: /* @__PURE__ */ new Set() };
778
992
  };
779
- var playwrightCallKind = (node) => {
780
- const names = calleeNames(node.callee);
781
- if (names === null || names[0] !== "test") {
993
+ var fixtureAnchor = (declaration, index) => {
994
+ if (declaration.type !== "VariableDeclaration") {
782
995
  return null;
783
996
  }
784
- const [, second, ...rest] = names;
785
- if (second === void 0) {
786
- return "test";
997
+ const [declarator] = declaration.declarations;
998
+ const init = declarator?.init;
999
+ if (init?.type !== "CallExpression" || init.callee.type !== "MemberExpression") {
1000
+ return null;
787
1001
  }
788
- if (second === "describe") {
789
- const isDescribe = rest.every((name) => {
790
- return DESCRIBE_MODIFIERS.has(name);
791
- });
792
- return isDescribe ? "describe" : null;
1002
+ const property = init.callee.property;
1003
+ if (property.type !== "Identifier" || property.name !== "extend") {
1004
+ return null;
793
1005
  }
794
- return rest.length === 0 && TEST_MODIFIERS.has(second) ? "test" : null;
1006
+ const name = declarator.id.type === "Identifier" ? declarator.id.name : "test";
1007
+ const typeArguments = init.typeArguments?.params ?? [];
1008
+ return { index, label: `fixture \`${name}\``, headerTypes: new Set(typeArguments.flatMap(typeNamesOf)) };
795
1009
  };
796
- var detailsArgument = (node) => {
797
- const details = node.arguments[1];
798
- return node.arguments.length >= 3 && details?.type === "ObjectExpression" ? details : null;
1010
+ var specAnchor = (statement, index) => {
1011
+ if (statement.type !== "ExpressionStatement" || statement.expression.type !== "CallExpression") {
1012
+ return null;
1013
+ }
1014
+ if (calleeRoot(statement.expression.callee) !== "test") {
1015
+ return null;
1016
+ }
1017
+ return { index, label: "the first `test.*` call", headerTypes: /* @__PURE__ */ new Set() };
799
1018
  };
800
- var staticTitle = (node) => {
801
- const title = node.arguments[0];
802
- if (title?.type === "Literal" && typeof title.value === "string") {
803
- return title.value;
1019
+ var isFunctionValue = (node) => {
1020
+ return node?.type === "ArrowFunctionExpression" || node?.type === "FunctionExpression";
1021
+ };
1022
+ var mockAnchor = (statement, index) => {
1023
+ if (statement.type !== "ExportNamedDeclaration" && statement.type !== "ExportDefaultDeclaration") {
1024
+ return null;
804
1025
  }
805
- if (title?.type === "TemplateLiteral" && title.expressions.length === 0) {
806
- return title.quasis[0]?.value.cooked ?? null;
1026
+ const declaration = unwrapExport2(statement);
1027
+ if (declaration.type === "FunctionDeclaration") {
1028
+ return { index, label: `mock \`${declaration.id?.name ?? "default"}\``, headerTypes: /* @__PURE__ */ new Set() };
1029
+ }
1030
+ const declarator = declaration.type === "VariableDeclaration" ? declaration.declarations[0] : void 0;
1031
+ if (declarator?.id.type !== "Identifier" || !isFunctionValue(declarator.init)) {
1032
+ return null;
1033
+ }
1034
+ return { index, label: `mock \`${declarator.id.name}\``, headerTypes: /* @__PURE__ */ new Set() };
1035
+ };
1036
+ var ANCHOR_FINDERS = {
1037
+ pageObject: (statement, index) => {
1038
+ return pageObjectAnchor(unwrapExport2(statement), index);
1039
+ },
1040
+ fixture: (statement, index) => {
1041
+ return fixtureAnchor(unwrapExport2(statement), index);
1042
+ },
1043
+ spec: specAnchor,
1044
+ mock: mockAnchor
1045
+ };
1046
+ var findAnchor = (body, kind) => {
1047
+ for (const [index, statement] of body.entries()) {
1048
+ const anchor = ANCHOR_FINDERS[kind](statement, index);
1049
+ if (anchor !== null) {
1050
+ return anchor;
1051
+ }
807
1052
  }
808
1053
  return null;
809
1054
  };
810
- var isInE2ePackage = (context) => {
811
- const packageRoot = findPackageRoot(context.filename.split("\\").join("/"));
812
- return packageRoot !== null && resolvePackageType(packageRoot) === "e2e";
1055
+ var isDirective = (statement) => {
1056
+ return statement.type === "ExpressionStatement" && statement.expression.type === "Literal" && typeof statement.expression.value === "string";
1057
+ };
1058
+ var anchoredCandidates = (body, kind) => {
1059
+ const anchor = findAnchor(body, kind);
1060
+ if (anchor === null) {
1061
+ return null;
1062
+ }
1063
+ const candidates = body.slice(0, anchor.index).flatMap((statement, index) => {
1064
+ const declaration = unwrapExport2(statement);
1065
+ const isHeader = statement.type === "ImportDeclaration" || isDirective(statement) || declaration.id?.name !== void 0 && anchor.headerTypes.has(declaration.id.name);
1066
+ return isHeader ? [] : [index];
1067
+ });
1068
+ return { candidates, target: anchor.label };
1069
+ };
1070
+ var locallyExportedNames = (body) => {
1071
+ const names = body.flatMap((statement) => {
1072
+ if (statement.type !== "ExportNamedDeclaration" || statement.source) {
1073
+ return [];
1074
+ }
1075
+ return statement.specifiers.flatMap((specifier) => {
1076
+ return specifier.local.type === "Identifier" ? [specifier.local.name] : [];
1077
+ });
1078
+ });
1079
+ return new Set(names);
1080
+ };
1081
+ var declaredNames = (statement) => {
1082
+ const declaration = unwrapExport2(statement);
1083
+ if (declaration.type === "VariableDeclaration") {
1084
+ return declaration.declarations.flatMap((declarator) => {
1085
+ return declarator.id.type === "Identifier" ? [declarator.id.name] : [];
1086
+ });
1087
+ }
1088
+ return declaration.id?.name ? [declaration.id.name] : [];
1089
+ };
1090
+ var libCandidates = (body) => {
1091
+ const exportedNames = locallyExportedNames(body);
1092
+ const isExported = (statement) => {
1093
+ return statement.type.startsWith("Export") || declaredNames(statement).some((name) => {
1094
+ return exportedNames.has(name);
1095
+ });
1096
+ };
1097
+ const lastExport = body.findLastIndex(isExported);
1098
+ if (lastExport === -1) {
1099
+ return null;
1100
+ }
1101
+ const candidates = body.slice(0, lastExport).flatMap((statement, index) => {
1102
+ return statement.type === "ImportDeclaration" || isDirective(statement) || isExported(statement) ? [] : [index];
1103
+ });
1104
+ return { candidates, target: "the exports" };
1105
+ };
1106
+ var misplacedStatements = (body, kind) => {
1107
+ return kind === "lib" ? libCandidates(body) : anchoredCandidates(body, kind);
1108
+ };
1109
+ var runsWithCall = (fn, call) => {
1110
+ if (call.callee === fn || playwrightCallKind(call) === "describe") {
1111
+ return true;
1112
+ }
1113
+ return calleeRoot(call.callee) !== "test";
1114
+ };
1115
+ var isFunctionNode = (node) => {
1116
+ return node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression" || node.type === "FunctionDeclaration";
1117
+ };
1118
+ var runsAtLoad = (node) => {
1119
+ let child = node;
1120
+ let parent = node.parent;
1121
+ while (parent) {
1122
+ if (isFunctionNode(child)) {
1123
+ if (parent.type !== "CallExpression" || !runsWithCall(child, parent)) {
1124
+ return false;
1125
+ }
1126
+ }
1127
+ const tsParent = parent;
1128
+ if (tsParent.type === "PropertyDefinition" && tsParent.value === child && !tsParent.static) {
1129
+ return false;
1130
+ }
1131
+ child = parent;
1132
+ parent = parent.parent;
1133
+ }
1134
+ return true;
1135
+ };
1136
+ var topLevelIndex = (node, body) => {
1137
+ let current = node;
1138
+ while (current.parent && current.parent.type !== "Program") {
1139
+ current = current.parent;
1140
+ }
1141
+ return body.indexOf(current);
1142
+ };
1143
+ var valueReferences = (sourceCode, statement) => {
1144
+ return sourceCode.getDeclaredVariables(unwrapExport2(statement)).flatMap((variable) => {
1145
+ return variable.references.filter((reference) => {
1146
+ const tsReference = reference;
1147
+ return tsReference.isValueReference !== false && reference.identifier !== variable.identifiers[0];
1148
+ });
1149
+ });
1150
+ };
1151
+ var movableStrays = (sourceCode, body, candidates) => {
1152
+ const movable = new Set(candidates);
1153
+ let changed = true;
1154
+ while (changed) {
1155
+ changed = false;
1156
+ for (const index of movable) {
1157
+ const pinned = valueReferences(sourceCode, body[index]).some((reference) => {
1158
+ const from = topLevelIndex(reference.identifier, body);
1159
+ return !movable.has(from) && (candidates.includes(from) || runsAtLoad(reference.identifier));
1160
+ });
1161
+ if (pinned) {
1162
+ movable.delete(index);
1163
+ changed = true;
1164
+ }
1165
+ }
1166
+ }
1167
+ return movable;
1168
+ };
1169
+ var describeStatement = (statement) => {
1170
+ const names = declaredNames(statement);
1171
+ if (names.length > 0) {
1172
+ return names.map((name) => {
1173
+ return `\`${name}\``;
1174
+ }).join(", ");
1175
+ }
1176
+ return `a ${unwrapExport2(statement).type} statement`;
1177
+ };
1178
+ var statementSpan = (sourceCode, statement) => {
1179
+ const trailing = sourceCode.getCommentsAfter(statement).filter((comment) => {
1180
+ return comment.loc.start.line === statement.loc.end.line;
1181
+ });
1182
+ return [attachedStart(sourceCode, statement), (trailing.at(-1) ?? statement).range[1]];
1183
+ };
1184
+ var buildFix = (sourceCode, body, movable) => {
1185
+ return (fixer) => {
1186
+ const spans = movable.map((index) => {
1187
+ return statementSpan(sourceCode, body[index]);
1188
+ });
1189
+ const moved = spans.map(([start, end2]) => {
1190
+ return sourceCode.text.slice(start, end2);
1191
+ });
1192
+ const end = sourceCode.text.length;
1193
+ const separator = sourceCode.text.endsWith("\n") ? "\n" : "\n\n";
1194
+ return [
1195
+ ...spans.map(([start, stop]) => {
1196
+ const next = sourceCode.text.slice(stop).search(/\S/);
1197
+ return fixer.removeRange([start, next === -1 ? end : stop + next]);
1198
+ }),
1199
+ fixer.insertTextAfterRange([end, end], `${separator}${moved.join("\n\n")}
1200
+ `)
1201
+ ];
1202
+ };
1203
+ };
1204
+ var e2eFileOrder = {
1205
+ meta: {
1206
+ type: "suggestion",
1207
+ fixable: "code",
1208
+ docs: {
1209
+ description: "Require e2e page objects, fixtures, specs, mocks and lib files to open with their imports followed by the class, the `extend` call, the `test.*` calls, the mock function or the exports \u2014 helpers and constants go below.",
1210
+ recommended: true,
1211
+ url: "https://github.com/ArthurSaenz/infra-kit/tree/main/apps/infra-kit/eslint-plugin#e2e-file-order"
1212
+ },
1213
+ schema: [
1214
+ {
1215
+ type: "object",
1216
+ properties: {
1217
+ ignore: {
1218
+ type: "array",
1219
+ items: { type: "string" },
1220
+ description: "Optional globs; the rule is skipped for matching files."
1221
+ }
1222
+ },
1223
+ additionalProperties: false
1224
+ }
1225
+ ],
1226
+ messages: {
1227
+ strayBeforeAnchor: "{{name}} sits between the imports and {{anchor}}. Move it below {{anchor}}: {{opening}}. The autofix moves it to the end of the file."
1228
+ }
1229
+ },
1230
+ create(context) {
1231
+ const options = context.options[0] ?? {};
1232
+ const filename = context.filename.split("\\").join("/");
1233
+ const kind = fileKindOf(filename);
1234
+ if (kind === null || options.ignore?.length && matchesAnyGlob(filename, options.ignore)) {
1235
+ return {};
1236
+ }
1237
+ if (!isInE2ePackage(context)) {
1238
+ return {};
1239
+ }
1240
+ return {
1241
+ Program(program) {
1242
+ const body = program.body;
1243
+ const misplaced = misplacedStatements(body, kind);
1244
+ if (misplaced === null) {
1245
+ return;
1246
+ }
1247
+ const movable = [...movableStrays(context.sourceCode, body, misplaced.candidates)].toSorted((a, b) => {
1248
+ return a - b;
1249
+ });
1250
+ if (movable.length === 0) {
1251
+ return;
1252
+ }
1253
+ const fix = buildFix(context.sourceCode, body, movable);
1254
+ for (const index of movable) {
1255
+ context.report({
1256
+ node: body[index],
1257
+ messageId: "strayBeforeAnchor",
1258
+ data: { name: describeStatement(body[index]), anchor: misplaced.target, opening: KIND_OPENING[kind] },
1259
+ fix
1260
+ });
1261
+ }
1262
+ }
1263
+ };
1264
+ }
1265
+ };
1266
+
1267
+ // src/rules/e2e-page-object-member-order/e2e-page-object-member-order.ts
1268
+ var PAGE_OBJECT_FILE = /\.(?:page|component)\.[cm]?[jt]sx?$/;
1269
+ var GROUPS = ["field", "constructor", "getter", "public method", "private method"];
1270
+ var isPrivate = (member) => {
1271
+ return member.accessibility === "private" || member.accessibility === "protected" || member.key?.type === "PrivateIdentifier";
1272
+ };
1273
+ var groupOf = (member) => {
1274
+ if (member.kind === "constructor") {
1275
+ return "constructor";
1276
+ }
1277
+ if (member.type !== "MethodDefinition") {
1278
+ return "field";
1279
+ }
1280
+ if (isPrivate(member)) {
1281
+ return "private method";
1282
+ }
1283
+ return member.kind === "get" || member.kind === "set" ? "getter" : "public method";
1284
+ };
1285
+ var memberName = (member) => {
1286
+ if (member.kind === "constructor") {
1287
+ return "the constructor";
1288
+ }
1289
+ const key = member.key;
1290
+ if (key?.type === "Identifier") {
1291
+ return `\`${key.name}\``;
1292
+ }
1293
+ return key?.type === "PrivateIdentifier" ? `\`#${key.name}\`` : "a computed member";
1294
+ };
1295
+ var buildFix2 = (sourceCode, members) => {
1296
+ return (fixer) => {
1297
+ const spans = members.map((_, index) => {
1298
+ return [attachedStart(sourceCode, members[index]), members[index].range[1]];
1299
+ });
1300
+ const texts = spans.map(([start, end]) => {
1301
+ return sourceCode.text.slice(start, end);
1302
+ });
1303
+ const order = members.map((member, index) => {
1304
+ return { index, rank: GROUPS.indexOf(groupOf(member)) };
1305
+ }).toSorted((a, b) => {
1306
+ return a.rank - b.rank || a.index - b.index;
1307
+ });
1308
+ return spans.map((span, slot) => {
1309
+ return fixer.replaceTextRange(span, texts[order[slot].index]);
1310
+ });
1311
+ };
1312
+ };
1313
+ var findViolations = (members) => {
1314
+ const violations = [];
1315
+ let latest = null;
1316
+ for (const member of members) {
1317
+ const group = groupOf(member);
1318
+ if (latest !== null && GROUPS.indexOf(group) < GROUPS.indexOf(latest.group)) {
1319
+ violations.push({ member, group, after: latest.member, afterGroup: latest.group });
1320
+ } else {
1321
+ latest = { member, group };
1322
+ }
1323
+ }
1324
+ return violations;
1325
+ };
1326
+ var e2ePageObjectMemberOrder = {
1327
+ meta: {
1328
+ type: "suggestion",
1329
+ fixable: "code",
1330
+ docs: {
1331
+ description: "Order the members of an e2e page object: fields, the constructor, getters, public methods, then private methods.",
1332
+ recommended: true,
1333
+ url: "https://github.com/ArthurSaenz/infra-kit/tree/main/apps/infra-kit/eslint-plugin#e2e-page-object-member-order"
1334
+ },
1335
+ schema: [
1336
+ {
1337
+ type: "object",
1338
+ properties: {
1339
+ ignore: {
1340
+ type: "array",
1341
+ items: { type: "string" },
1342
+ description: "Optional globs; the rule is skipped for matching files."
1343
+ }
1344
+ },
1345
+ additionalProperties: false
1346
+ }
1347
+ ],
1348
+ messages: {
1349
+ outOfOrder: "The {{group}} {{name}} comes after the {{afterGroup}} {{afterName}} in class `{{className}}`. Order a page object: fields, constructor, getters, public methods, private methods."
1350
+ }
1351
+ },
1352
+ create(context) {
1353
+ const options = context.options[0] ?? {};
1354
+ const filename = context.filename.split("\\").join("/");
1355
+ if (!PAGE_OBJECT_FILE.test(filename) || options.ignore?.length && matchesAnyGlob(filename, options.ignore)) {
1356
+ return {};
1357
+ }
1358
+ if (!isInE2ePackage(context)) {
1359
+ return {};
1360
+ }
1361
+ const check = (node) => {
1362
+ const members = node.body.body;
1363
+ const violations = findViolations(members);
1364
+ if (violations.length === 0) {
1365
+ return;
1366
+ }
1367
+ const fix = buildFix2(context.sourceCode, members);
1368
+ for (const violation of violations) {
1369
+ context.report({
1370
+ node: violation.member,
1371
+ messageId: "outOfOrder",
1372
+ data: {
1373
+ group: violation.group,
1374
+ name: memberName(violation.member),
1375
+ afterGroup: violation.afterGroup,
1376
+ afterName: memberName(violation.after),
1377
+ className: node.id?.name ?? "anonymous"
1378
+ },
1379
+ fix
1380
+ });
1381
+ }
1382
+ };
1383
+ return {
1384
+ ClassDeclaration: check,
1385
+ ClassExpression: check
1386
+ };
1387
+ }
813
1388
  };
814
1389
 
815
1390
  // src/rules/e2e-test-tags/e2e-test-tags.ts
@@ -915,6 +1490,150 @@ var e2eTestTitle = {
915
1490
  }
916
1491
  };
917
1492
 
1493
+ // src/rules/e2e-top-level-describe/e2e-top-level-describe.ts
1494
+ var SPEC_FILE2 = /\.spec\.[cm]?[jt]sx?$/;
1495
+ var DEFAULT_MAX = 1;
1496
+ var topLevelCalls = (body) => {
1497
+ return body.flatMap((statement) => {
1498
+ const names = testStatementNames(statement);
1499
+ if (names === null) {
1500
+ return [];
1501
+ }
1502
+ const expressionStatement = statement;
1503
+ return [{ statement: expressionStatement, call: expressionStatement.expression, names }];
1504
+ });
1505
+ };
1506
+ var describeLabel = (call) => {
1507
+ const title = staticTitle(call);
1508
+ return title === null ? "the `test.describe` block" : `\`test.describe('${title}')\``;
1509
+ };
1510
+ var callbackBlock = (call) => {
1511
+ const callback = call.arguments.at(-1);
1512
+ if (callback?.type !== "ArrowFunctionExpression" && callback?.type !== "FunctionExpression") {
1513
+ return null;
1514
+ }
1515
+ return callback.body.type === "BlockStatement" ? callback.body : null;
1516
+ };
1517
+ var removalRange = (sourceCode, statement) => {
1518
+ const end = statement.range[1];
1519
+ const next = sourceCode.text.slice(end).search(/\S/);
1520
+ return [attachedStart(sourceCode, statement), next === -1 ? sourceCode.text.length : end + next];
1521
+ };
1522
+ var buildFix3 = (sourceCode, moved, block) => {
1523
+ return (fixer) => {
1524
+ const indent = " ".repeat(block.body[0]?.loc?.start.column ?? 2);
1525
+ const texts = moved.map(({ statement }) => {
1526
+ const [start] = removalRange(sourceCode, statement);
1527
+ return sourceCode.text.slice(start, statement.range[1]).split("\n").map((line) => {
1528
+ return line === "" ? line : `${indent}${line}`;
1529
+ }).join("\n");
1530
+ });
1531
+ return [
1532
+ ...moved.map(({ statement }) => {
1533
+ return fixer.removeRange(removalRange(sourceCode, statement));
1534
+ }),
1535
+ fixer.insertTextAfterRange([block.range[0], block.range[0] + 1], `
1536
+ ${texts.join("\n\n")}
1537
+ `)
1538
+ ];
1539
+ };
1540
+ };
1541
+ var e2eTopLevelDescribe = {
1542
+ meta: {
1543
+ type: "suggestion",
1544
+ fixable: "code",
1545
+ docs: {
1546
+ description: "Require an e2e spec to hold one top-level `test.describe`, with its `test.use`, `configure`, hooks and tests inside it.",
1547
+ recommended: true,
1548
+ url: "https://github.com/ArthurSaenz/infra-kit/tree/main/apps/infra-kit/eslint-plugin#e2e-top-level-describe"
1549
+ },
1550
+ schema: [
1551
+ {
1552
+ type: "object",
1553
+ properties: {
1554
+ ignore: {
1555
+ type: "array",
1556
+ items: { type: "string" },
1557
+ description: "Optional globs; the rule is skipped for matching files."
1558
+ },
1559
+ max: {
1560
+ type: "integer",
1561
+ minimum: 1,
1562
+ description: "Top-level `test.describe` blocks a spec may hold."
1563
+ }
1564
+ },
1565
+ additionalProperties: false
1566
+ }
1567
+ ],
1568
+ messages: {
1569
+ extraDescribe: "{{describe}} is top-level describe number {{count}} in this spec; the limit is {{max}}. Move it to its own spec file, or nest it inside {{first}}.",
1570
+ outsideDescribe: "`{{call}}` sits at the top level of the spec. Move it inside {{describe}}: a spec holds one top-level describe, and its setup, hooks and tests live inside it.",
1571
+ testOutsideDescribe: "Test {{test}} sits at the top level of the spec. Wrap the spec\u2019s tests in a `test.describe` named after the feature they cover."
1572
+ }
1573
+ },
1574
+ create(context) {
1575
+ const options = context.options[0] ?? {};
1576
+ const filename = context.filename.split("\\").join("/");
1577
+ if (!SPEC_FILE2.test(filename) || options.ignore?.length && matchesAnyGlob(filename, options.ignore)) {
1578
+ return {};
1579
+ }
1580
+ if (!isInE2ePackage(context)) {
1581
+ return {};
1582
+ }
1583
+ const max = options.max ?? DEFAULT_MAX;
1584
+ return {
1585
+ Program(program) {
1586
+ const calls = topLevelCalls(program.body);
1587
+ const describes = calls.filter(({ call }) => {
1588
+ return playwrightCallKind(call) === "describe";
1589
+ });
1590
+ const tests = calls.filter(({ call }) => {
1591
+ return playwrightCallKind(call) === "test" && call.arguments.slice(1).some((argument) => {
1592
+ return argument.type === "ArrowFunctionExpression" || argument.type === "FunctionExpression";
1593
+ });
1594
+ });
1595
+ const setup = calls.filter((entry) => {
1596
+ return !describes.includes(entry) && !tests.includes(entry);
1597
+ });
1598
+ for (const [index, extra] of describes.slice(max).entries()) {
1599
+ context.report({
1600
+ node: extra.statement,
1601
+ messageId: "extraDescribe",
1602
+ data: {
1603
+ describe: describeLabel(extra.call),
1604
+ count: String(max + index + 1),
1605
+ max: String(max),
1606
+ first: describeLabel(describes[0].call)
1607
+ }
1608
+ });
1609
+ }
1610
+ for (const test of tests) {
1611
+ const title = staticTitle(test.call);
1612
+ context.report({
1613
+ node: test.statement,
1614
+ messageId: "testOutsideDescribe",
1615
+ data: { test: title === null ? `\`${test.names.join(".")}\`` : `'${title}'` }
1616
+ });
1617
+ }
1618
+ const [only] = describes;
1619
+ const block = describes.length === 1 && only ? callbackBlock(only.call) : null;
1620
+ const fix = block === null ? void 0 : buildFix3(context.sourceCode, setup, block);
1621
+ for (const entry of setup) {
1622
+ context.report({
1623
+ node: entry.statement,
1624
+ messageId: "outsideDescribe",
1625
+ data: {
1626
+ call: entry.names.join("."),
1627
+ describe: only ? describeLabel(only.call) : "a `test.describe` block"
1628
+ },
1629
+ fix
1630
+ });
1631
+ }
1632
+ }
1633
+ };
1634
+ }
1635
+ };
1636
+
918
1637
  // src/rules/max-components-per-file/max-components-per-file.ts
919
1638
  var DEFAULT_MAX_COMPONENTS = 4;
920
1639
  var ANONYMOUS_NAME2 = "component";
@@ -2396,7 +3115,7 @@ var locationViolation = (dirs, basename) => {
2396
3115
  data: { basename, target: [...fromSrc(dirs), TESTS_DIR, basename].join("/") }
2397
3116
  };
2398
3117
  };
2399
- var findViolations = (filename, ignore) => {
3118
+ var findViolations2 = (filename, ignore) => {
2400
3119
  if (!TEST_FILE2.test(filename) || ignore.length > 0 && matchesAnyGlob(filename, ignore)) {
2401
3120
  return [];
2402
3121
  }
@@ -2441,7 +3160,7 @@ var testLocation = {
2441
3160
  },
2442
3161
  create(context) {
2443
3162
  const options = context.options[0] ?? {};
2444
- const violations = findViolations(context.filename.split("\\").join("/"), options.ignore ?? []);
3163
+ const violations = findViolations2(context.filename.split("\\").join("/"), options.ignore ?? []);
2445
3164
  if (violations.length === 0) {
2446
3165
  return {};
2447
3166
  }
@@ -2473,6 +3192,10 @@ var rules = {
2473
3192
  "package-structure": packageStructure,
2474
3193
  "test-location": testLocation,
2475
3194
  "e2e-file-layout": e2eFileLayout,
3195
+ "e2e-file-order": e2eFileOrder,
3196
+ "e2e-page-object-member-order": e2ePageObjectMemberOrder,
3197
+ "e2e-describe-order": e2eDescribeOrder,
3198
+ "e2e-top-level-describe": e2eTopLevelDescribe,
2476
3199
  "e2e-test-tags": e2eTestTags,
2477
3200
  "e2e-test-title": e2eTestTitle
2478
3201
  };
@@ -2482,7 +3205,7 @@ var PLUGIN_NAME = "@wl";
2482
3205
  var plugin = {
2483
3206
  meta: {
2484
3207
  name: "@wl/eslint-plugin",
2485
- version: "0.13.0"
3208
+ version: "0.15.0"
2486
3209
  },
2487
3210
  rules,
2488
3211
  configs: {}
@@ -2592,6 +3315,10 @@ plugin.configs.recommended = [
2592
3315
  },
2593
3316
  rules: {
2594
3317
  [`${PLUGIN_NAME}/e2e-file-layout`]: "error",
3318
+ [`${PLUGIN_NAME}/e2e-file-order`]: "error",
3319
+ [`${PLUGIN_NAME}/e2e-page-object-member-order`]: "error",
3320
+ [`${PLUGIN_NAME}/e2e-describe-order`]: "error",
3321
+ [`${PLUGIN_NAME}/e2e-top-level-describe`]: "error",
2595
3322
  [`${PLUGIN_NAME}/e2e-test-tags`]: "error",
2596
3323
  [`${PLUGIN_NAME}/e2e-test-title`]: "error"
2597
3324
  }