@slip-stream-kit/eslint-plugin 0.11.12 → 0.11.16

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
@@ -513,6 +513,408 @@ var componentFileOrder = {
513
513
  }
514
514
  };
515
515
 
516
+ // src/utils/package-root.ts
517
+ import fs from "node:fs";
518
+ import path from "node:path";
519
+ var CONFIG_FILENAME = "infra-kit.config.ts";
520
+ var PACKAGE_MARKERS = [CONFIG_FILENAME, "package.json"];
521
+ var REPO_MARKERS = ["pnpm-workspace.yaml"];
522
+ var hasAnyMarker = (dir, markers) => {
523
+ return markers.some((marker) => {
524
+ return fs.existsSync(path.posix.join(dir, marker));
525
+ });
526
+ };
527
+ var rootCache = /* @__PURE__ */ new Map();
528
+ var findUp = (startDir, markers) => {
529
+ let current = startDir;
530
+ while (!hasAnyMarker(current, markers)) {
531
+ const parent = path.posix.dirname(current);
532
+ if (parent === current) {
533
+ return null;
534
+ }
535
+ current = parent;
536
+ }
537
+ return current;
538
+ };
539
+ var findPackageRoot = (filename) => {
540
+ if (!path.posix.isAbsolute(filename)) {
541
+ return null;
542
+ }
543
+ const dir = path.posix.dirname(filename);
544
+ const cached = rootCache.get(dir);
545
+ if (cached !== void 0) {
546
+ return cached;
547
+ }
548
+ const root = findUp(dir, PACKAGE_MARKERS);
549
+ rootCache.set(dir, root);
550
+ return root;
551
+ };
552
+ var dirsUnderSrc = (packageRoot, filename) => {
553
+ const srcPrefix = `${packageRoot}/src/`;
554
+ if (!filename.startsWith(srcPrefix)) {
555
+ return null;
556
+ }
557
+ return filename.slice(srcPrefix.length).split("/").slice(0, -1);
558
+ };
559
+ var findRepoRoot = (packageRoot) => {
560
+ return findUp(packageRoot, REPO_MARKERS);
561
+ };
562
+
563
+ // ../config/dist/package-type.js
564
+ import path2 from "node:path";
565
+ var PACKAGE_TYPES = ["frontend", "backend", "lib", "e2e", "mobile"];
566
+ var APP_DIRECTORY_TYPES = {
567
+ ui: "frontend",
568
+ api: "backend",
569
+ tests: "e2e"
570
+ };
571
+ var MOBILE_BASENAME = "mobile-app";
572
+ var DEPENDENCY_SIGNALS = [
573
+ { type: "e2e", packages: ["@playwright/test"] },
574
+ { type: "mobile", packages: ["@capacitor/core", "@capacitor/cli"] },
575
+ // `aws-lambda` / `@types/aws-lambda` are deliberately NOT signals: every consumer backend already
576
+ // lives at `apps/<app>/api` (row 2), while the only packages those two deps would catch are
577
+ // libraries (`packages/lib-core` in both consumers, the infra-kit CLI itself) — a lambda types
578
+ // dependency alone never means backend, exactly as `react` alone never means frontend.
579
+ { type: "backend", packages: ["serverless"] }
580
+ ];
581
+ var fromDirectory = (packageDir, repoRoot) => {
582
+ const segments = path2.relative(repoRoot, packageDir).split(path2.sep).filter((segment) => {
583
+ return segment !== "" && segment !== ".";
584
+ });
585
+ const [first, , third] = segments;
586
+ if (segments.length === 3 && first === "apps" && third !== void 0) {
587
+ const appType = APP_DIRECTORY_TYPES[third];
588
+ if (appType) return appType;
589
+ }
590
+ return segments.at(-1) === MOBILE_BASENAME ? "mobile" : null;
591
+ };
592
+ var fromDependencies = (pkgJson) => {
593
+ const deps = /* @__PURE__ */ new Set([
594
+ ...Object.keys(pkgJson.dependencies ?? {}),
595
+ ...Object.keys(pkgJson.devDependencies ?? {}),
596
+ ...Object.keys(pkgJson.peerDependencies ?? {})
597
+ ]);
598
+ for (const signal of DEPENDENCY_SIGNALS) {
599
+ if (signal.packages.some((name) => {
600
+ return deps.has(name);
601
+ }))
602
+ return signal.type;
603
+ }
604
+ const isViteReactApp = deps.has("vite") && deps.has("react") && Boolean(pkgJson.scripts?.dev);
605
+ return isViteReactApp ? "frontend" : null;
606
+ };
607
+ var detectPackageType = ({
608
+ packageDir,
609
+ repoRoot,
610
+ pkgJson,
611
+ declaredType
612
+ }) => {
613
+ return declaredType ?? fromDirectory(packageDir, repoRoot) ?? fromDependencies(pkgJson) ?? "lib";
614
+ };
615
+
616
+ // src/utils/package-type-reader.ts
617
+ import fs2 from "node:fs";
618
+ import path3 from "node:path";
619
+ var BLOCK_COMMENT = /\/\*[\s\S]*?\*\//g;
620
+ var FULL_LINE_COMMENT = /^[ \t]*\/\/.*$/gm;
621
+ var DECLARED_TYPE = new RegExp(`(?:^|[\\s{,])type:\\s*['"](${PACKAGE_TYPES.join("|")})['"]`, "m");
622
+ var readDeclaredType = (text) => {
623
+ const stripped = text.replace(BLOCK_COMMENT, "").replace(FULL_LINE_COMMENT, "");
624
+ const literal = DECLARED_TYPE.exec(stripped)?.[1];
625
+ return PACKAGE_TYPES.find((candidate) => {
626
+ return candidate === literal;
627
+ });
628
+ };
629
+ var typeCache = /* @__PURE__ */ new Map();
630
+ var configMtimeMs = (configPath) => {
631
+ try {
632
+ return fs2.statSync(configPath).mtimeMs;
633
+ } catch {
634
+ return -1;
635
+ }
636
+ };
637
+ var readManifest = (manifestPath) => {
638
+ try {
639
+ return JSON.parse(fs2.readFileSync(manifestPath, "utf8"));
640
+ } catch {
641
+ return {};
642
+ }
643
+ };
644
+ var resolvePackageType = (packageRoot) => {
645
+ const configPath = path3.posix.join(packageRoot, CONFIG_FILENAME);
646
+ const mtimeMs = configMtimeMs(configPath);
647
+ const hit = typeCache.get(packageRoot);
648
+ if (hit !== void 0 && hit.mtimeMs === mtimeMs) {
649
+ return hit.type;
650
+ }
651
+ const declaredType = mtimeMs >= 0 ? readDeclaredType(fs2.readFileSync(configPath, "utf8")) : void 0;
652
+ const type = detectPackageType({
653
+ packageDir: packageRoot,
654
+ repoRoot: findRepoRoot(packageRoot) ?? packageRoot,
655
+ pkgJson: readManifest(path3.posix.join(packageRoot, "package.json")),
656
+ declaredType
657
+ });
658
+ typeCache.set(packageRoot, { mtimeMs, type });
659
+ return type;
660
+ };
661
+
662
+ // src/rules/e2e-file-layout/e2e-file-layout.ts
663
+ var TEST_FILE = /\.(?:test|spec)\.[cm]?[jt]sx?$/;
664
+ var LEGACY_TEST_SUFFIX = /\.test(\.[cm]?[jt]sx?)$/;
665
+ var SPEC_LAYERS = /* @__PURE__ */ new Set(["tests", "visual"]);
666
+ var SUBFOLDER_BY_SUFFIX = [
667
+ [/\.(?:page|component)\.[cm]?[jt]sx?$/, "pages"],
668
+ [/\.fixture\.[cm]?[jt]sx?$/, "fixtures"],
669
+ [/\.mock\.[cm]?[jt]sx?$/, "mocks"],
670
+ [/\.data\.[cm]?[jt]sx?$/, "data"]
671
+ ];
672
+ var subfolderFor = (basename) => {
673
+ const match = SUBFOLDER_BY_SUFFIX.find(([pattern]) => {
674
+ return pattern.test(basename);
675
+ });
676
+ return match ? match[1] : "lib";
677
+ };
678
+ var specViolation = (dirs, basename) => {
679
+ if (LEGACY_TEST_SUFFIX.test(basename)) {
680
+ return {
681
+ messageId: "legacyTestSuffix",
682
+ data: { basename, target: basename.replace(LEGACY_TEST_SUFFIX, ".spec$1") }
683
+ };
684
+ }
685
+ const [layer, domain] = dirs;
686
+ if (layer === void 0 || !SPEC_LAYERS.has(layer) || domain === void 0) {
687
+ return { messageId: "specOutsideDomain", data: { basename, path: ["src", ...dirs, basename].join("/") } };
688
+ }
689
+ if (dirs.length > 2) {
690
+ return {
691
+ messageId: "nestedSpec",
692
+ data: { basename, path: ["src", ...dirs, basename].join("/"), target: `src/${layer}/${domain}/${basename}` }
693
+ };
694
+ }
695
+ return null;
696
+ };
697
+ var supportViolation = (dirs, basename) => {
698
+ const [layer, domain] = dirs;
699
+ if (dirs.length !== 2 || layer === void 0 || !SPEC_LAYERS.has(layer) || domain === void 0) {
700
+ return null;
701
+ }
702
+ const subfolder = subfolderFor(basename);
703
+ return {
704
+ messageId: "supportInDomainRoot",
705
+ data: { basename, domain, subfolder, target: `src/${layer}/${domain}/${subfolder}/${basename}` }
706
+ };
707
+ };
708
+ var findViolation = (filename, ignore) => {
709
+ if (ignore.length > 0 && matchesAnyGlob(filename, ignore)) {
710
+ return null;
711
+ }
712
+ const packageRoot = findPackageRoot(filename);
713
+ if (packageRoot === null || resolvePackageType(packageRoot) !== "e2e") {
714
+ return null;
715
+ }
716
+ const dirs = dirsUnderSrc(packageRoot, filename);
717
+ if (dirs === null) {
718
+ return null;
719
+ }
720
+ const basename = filename.slice(filename.lastIndexOf("/") + 1);
721
+ return TEST_FILE.test(basename) ? specViolation(dirs, basename) : supportViolation(dirs, basename);
722
+ };
723
+ var e2eFileLayout = {
724
+ meta: {
725
+ type: "problem",
726
+ docs: {
727
+ description: "Require e2e packages to keep `*.spec.ts` files at the root of `src/tests/<domain>/` and every other file in a domain subfolder.",
728
+ recommended: true,
729
+ url: "https://github.com/ArthurSaenz/infra-kit/tree/main/apps/infra-kit/eslint-plugin#e2e-file-layout"
730
+ },
731
+ schema: [
732
+ {
733
+ type: "object",
734
+ properties: {
735
+ ignore: {
736
+ type: "array",
737
+ items: { type: "string" },
738
+ description: "Optional globs; the rule is skipped for matching files."
739
+ }
740
+ },
741
+ additionalProperties: false
742
+ }
743
+ ],
744
+ messages: {
745
+ legacyTestSuffix: "`{{basename}}` uses `.test`. Rename it to `{{target}}` \u2014 every Playwright spec in an e2e package is `*.spec.ts`.",
746
+ specOutsideDomain: "`{{path}}` is a spec outside a domain folder. Move it into `src/tests/<domain>/`, where `<domain>` is the business domain it covers.",
747
+ nestedSpec: "`{{path}}` nests a spec below its domain folder. Move it to `{{target}}`, or split the domain into two sibling domains if it holds too many specs.",
748
+ supportInDomainRoot: "`{{basename}}` sits in the root of domain `{{domain}}`, which holds only `*.spec.ts`. Move it to `{{target}}`."
749
+ }
750
+ },
751
+ create(context) {
752
+ const options = context.options[0] ?? {};
753
+ const violation = findViolation(context.filename.split("\\").join("/"), options.ignore ?? []);
754
+ if (violation === null) {
755
+ return {};
756
+ }
757
+ return {
758
+ // Reported on Program: the violation is the file's location, not any node inside it.
759
+ Program(program) {
760
+ context.report({ node: program, messageId: violation.messageId, data: violation.data });
761
+ }
762
+ };
763
+ }
764
+ };
765
+
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) => {
770
+ if (callee.type === "Identifier") {
771
+ return [callee.name];
772
+ }
773
+ if (callee.type !== "MemberExpression" || callee.computed || callee.property.type !== "Identifier") {
774
+ return null;
775
+ }
776
+ const head = calleeNames(callee.object);
777
+ return head === null ? null : [...head, callee.property.name];
778
+ };
779
+ var playwrightCallKind = (node) => {
780
+ const names = calleeNames(node.callee);
781
+ if (names === null || names[0] !== "test") {
782
+ return null;
783
+ }
784
+ const [, second, ...rest] = names;
785
+ if (second === void 0) {
786
+ return "test";
787
+ }
788
+ if (second === "describe") {
789
+ const isDescribe = rest.every((name) => {
790
+ return DESCRIBE_MODIFIERS.has(name);
791
+ });
792
+ return isDescribe ? "describe" : null;
793
+ }
794
+ return rest.length === 0 && TEST_MODIFIERS.has(second) ? "test" : null;
795
+ };
796
+ var detailsArgument = (node) => {
797
+ const details = node.arguments[1];
798
+ return node.arguments.length >= 3 && details?.type === "ObjectExpression" ? details : null;
799
+ };
800
+ var staticTitle = (node) => {
801
+ const title = node.arguments[0];
802
+ if (title?.type === "Literal" && typeof title.value === "string") {
803
+ return title.value;
804
+ }
805
+ if (title?.type === "TemplateLiteral" && title.expressions.length === 0) {
806
+ return title.quasis[0]?.value.cooked ?? null;
807
+ }
808
+ return null;
809
+ };
810
+ var isInE2ePackage = (context) => {
811
+ const packageRoot = findPackageRoot(context.filename.split("\\").join("/"));
812
+ return packageRoot !== null && resolvePackageType(packageRoot) === "e2e";
813
+ };
814
+
815
+ // src/rules/e2e-test-tags/e2e-test-tags.ts
816
+ var DEFAULT_ALLOWED_TAGS = ["@smoke", "@readonly", "@mocked", "@slow"];
817
+ var tagValue = (details) => {
818
+ for (const property of details.properties) {
819
+ if (property.type === "Property" && !property.computed && property.key.type === "Identifier" && property.key.name === "tag") {
820
+ return property.value;
821
+ }
822
+ }
823
+ return null;
824
+ };
825
+ var literalTags = (value) => {
826
+ const entries = value.type === "ArrayExpression" ? value.elements : [value];
827
+ return entries.flatMap((entry) => {
828
+ return entry?.type === "Literal" && typeof entry.value === "string" ? [{ tag: entry.value, node: entry }] : [];
829
+ });
830
+ };
831
+ var e2eTestTags = {
832
+ meta: {
833
+ type: "problem",
834
+ docs: {
835
+ description: "Restrict Playwright test and describe tags in e2e packages to an allowed set.",
836
+ recommended: true,
837
+ url: "https://github.com/ArthurSaenz/infra-kit/tree/main/apps/infra-kit/eslint-plugin#e2e-test-tags"
838
+ },
839
+ schema: [
840
+ {
841
+ type: "object",
842
+ properties: {
843
+ allowed: {
844
+ type: "array",
845
+ items: { type: "string", pattern: "^@" },
846
+ uniqueItems: true,
847
+ description: "The only tags allowed; defaults to @smoke, @readonly, @mocked, @slow."
848
+ }
849
+ },
850
+ additionalProperties: false
851
+ }
852
+ ],
853
+ messages: {
854
+ unknownTag: "Tag `{{tag}}` is not an allowed e2e tag. Use one of {{allowed}}; browsers, devices and environments are Playwright projects, not tags."
855
+ }
856
+ },
857
+ create(context) {
858
+ if (!isInE2ePackage(context)) {
859
+ return {};
860
+ }
861
+ const options = context.options[0] ?? {};
862
+ const allowed = new Set(options.allowed ?? DEFAULT_ALLOWED_TAGS);
863
+ const allowedList = [...allowed].map((tag) => {
864
+ return `\`${tag}\``;
865
+ }).join(", ");
866
+ return {
867
+ CallExpression(node) {
868
+ const details = playwrightCallKind(node) === null ? null : detailsArgument(node);
869
+ const value = details === null ? null : tagValue(details);
870
+ if (value === null) {
871
+ return;
872
+ }
873
+ for (const { tag, node: tagNode } of literalTags(value)) {
874
+ if (!allowed.has(tag)) {
875
+ context.report({ node: tagNode, messageId: "unknownTag", data: { tag, allowed: allowedList } });
876
+ }
877
+ }
878
+ }
879
+ };
880
+ }
881
+ };
882
+
883
+ // src/rules/e2e-test-title/e2e-test-title.ts
884
+ var SHOULD_PREFIX = /^should\s+/i;
885
+ var e2eTestTitle = {
886
+ meta: {
887
+ type: "suggestion",
888
+ docs: {
889
+ description: "Require e2e test titles to state the behaviour directly, without a leading `should`.",
890
+ recommended: true,
891
+ url: "https://github.com/ArthurSaenz/infra-kit/tree/main/apps/infra-kit/eslint-plugin#e2e-test-title"
892
+ },
893
+ schema: [],
894
+ messages: {
895
+ shouldPrefix: 'Test title "{{title}}" starts with `should`. State the behaviour and its observable outcome directly: "{{suggested}}".'
896
+ }
897
+ },
898
+ create(context) {
899
+ if (!isInE2ePackage(context)) {
900
+ return {};
901
+ }
902
+ return {
903
+ CallExpression(node) {
904
+ const title = playwrightCallKind(node) === "test" ? staticTitle(node) : null;
905
+ if (title === null || !SHOULD_PREFIX.test(title)) {
906
+ return;
907
+ }
908
+ context.report({
909
+ node: node.arguments[0] ?? node,
910
+ messageId: "shouldPrefix",
911
+ data: { title, suggested: title.replace(SHOULD_PREFIX, "") }
912
+ });
913
+ }
914
+ };
915
+ }
916
+ };
917
+
516
918
  // src/rules/max-components-per-file/max-components-per-file.ts
517
919
  var DEFAULT_MAX_COMPONENTS = 4;
518
920
  var ANONYMOUS_NAME2 = "component";
@@ -1001,163 +1403,18 @@ var maxJsxReturnSize = {
1001
1403
  }
1002
1404
  };
1003
1405
 
1004
- // src/utils/package-root.ts
1005
- import fs from "node:fs";
1006
- import path from "node:path";
1007
- var CONFIG_FILENAME = "infra-kit.config.ts";
1008
- var PACKAGE_MARKERS = [CONFIG_FILENAME, "package.json"];
1009
- var REPO_MARKERS = ["pnpm-workspace.yaml"];
1010
- var hasAnyMarker = (dir, markers) => {
1011
- return markers.some((marker) => {
1012
- return fs.existsSync(path.posix.join(dir, marker));
1013
- });
1014
- };
1015
- var rootCache = /* @__PURE__ */ new Map();
1016
- var findUp = (startDir, markers) => {
1017
- let current = startDir;
1018
- while (!hasAnyMarker(current, markers)) {
1019
- const parent = path.posix.dirname(current);
1020
- if (parent === current) {
1021
- return null;
1022
- }
1023
- current = parent;
1024
- }
1025
- return current;
1026
- };
1027
- var findPackageRoot = (filename) => {
1028
- if (!path.posix.isAbsolute(filename)) {
1029
- return null;
1030
- }
1031
- const dir = path.posix.dirname(filename);
1032
- const cached = rootCache.get(dir);
1033
- if (cached !== void 0) {
1034
- return cached;
1035
- }
1036
- const root = findUp(dir, PACKAGE_MARKERS);
1037
- rootCache.set(dir, root);
1038
- return root;
1039
- };
1040
- var dirsUnderSrc = (packageRoot, filename) => {
1041
- const srcPrefix = `${packageRoot}/src/`;
1042
- if (!filename.startsWith(srcPrefix)) {
1043
- return null;
1044
- }
1045
- return filename.slice(srcPrefix.length).split("/").slice(0, -1);
1046
- };
1047
- var findRepoRoot = (packageRoot) => {
1048
- return findUp(packageRoot, REPO_MARKERS);
1049
- };
1050
-
1051
- // ../config/dist/package-type.js
1052
- import path2 from "node:path";
1053
- var PACKAGE_TYPES = ["frontend", "backend", "lib", "e2e", "mobile"];
1054
- var APP_DIRECTORY_TYPES = {
1055
- ui: "frontend",
1056
- api: "backend",
1057
- tests: "e2e"
1058
- };
1059
- var MOBILE_BASENAME = "mobile-app";
1060
- var DEPENDENCY_SIGNALS = [
1061
- { type: "e2e", packages: ["@playwright/test"] },
1062
- { type: "mobile", packages: ["@capacitor/core", "@capacitor/cli"] },
1063
- // `aws-lambda` / `@types/aws-lambda` are deliberately NOT signals: every consumer backend already
1064
- // lives at `apps/<app>/api` (row 2), while the only packages those two deps would catch are
1065
- // libraries (`packages/lib-core` in both consumers, the infra-kit CLI itself) — a lambda types
1066
- // dependency alone never means backend, exactly as `react` alone never means frontend.
1067
- { type: "backend", packages: ["serverless"] }
1068
- ];
1069
- var fromDirectory = (packageDir, repoRoot) => {
1070
- const segments = path2.relative(repoRoot, packageDir).split(path2.sep).filter((segment) => {
1071
- return segment !== "" && segment !== ".";
1072
- });
1073
- const [first, , third] = segments;
1074
- if (segments.length === 3 && first === "apps" && third !== void 0) {
1075
- const appType = APP_DIRECTORY_TYPES[third];
1076
- if (appType) return appType;
1077
- }
1078
- return segments.at(-1) === MOBILE_BASENAME ? "mobile" : null;
1079
- };
1080
- var fromDependencies = (pkgJson) => {
1081
- const deps = /* @__PURE__ */ new Set([
1082
- ...Object.keys(pkgJson.dependencies ?? {}),
1083
- ...Object.keys(pkgJson.devDependencies ?? {}),
1084
- ...Object.keys(pkgJson.peerDependencies ?? {})
1085
- ]);
1086
- for (const signal of DEPENDENCY_SIGNALS) {
1087
- if (signal.packages.some((name) => {
1088
- return deps.has(name);
1089
- }))
1090
- return signal.type;
1091
- }
1092
- const isViteReactApp = deps.has("vite") && deps.has("react") && Boolean(pkgJson.scripts?.dev);
1093
- return isViteReactApp ? "frontend" : null;
1094
- };
1095
- var detectPackageType = ({
1096
- packageDir,
1097
- repoRoot,
1098
- pkgJson,
1099
- declaredType
1100
- }) => {
1101
- return declaredType ?? fromDirectory(packageDir, repoRoot) ?? fromDependencies(pkgJson) ?? "lib";
1102
- };
1103
-
1104
- // src/utils/package-type-reader.ts
1105
- import fs2 from "node:fs";
1106
- import path3 from "node:path";
1107
- var BLOCK_COMMENT = /\/\*[\s\S]*?\*\//g;
1108
- var FULL_LINE_COMMENT = /^[ \t]*\/\/.*$/gm;
1109
- var DECLARED_TYPE = new RegExp(`(?:^|[\\s{,])type:\\s*['"](${PACKAGE_TYPES.join("|")})['"]`, "m");
1110
- var readDeclaredType = (text) => {
1111
- const stripped = text.replace(BLOCK_COMMENT, "").replace(FULL_LINE_COMMENT, "");
1112
- const literal = DECLARED_TYPE.exec(stripped)?.[1];
1113
- return PACKAGE_TYPES.find((candidate) => {
1114
- return candidate === literal;
1115
- });
1116
- };
1117
- var typeCache = /* @__PURE__ */ new Map();
1118
- var configMtimeMs = (configPath) => {
1119
- try {
1120
- return fs2.statSync(configPath).mtimeMs;
1121
- } catch {
1122
- return -1;
1123
- }
1124
- };
1125
- var readManifest = (manifestPath) => {
1126
- try {
1127
- return JSON.parse(fs2.readFileSync(manifestPath, "utf8"));
1128
- } catch {
1129
- return {};
1130
- }
1131
- };
1132
- var resolvePackageType = (packageRoot) => {
1133
- const configPath = path3.posix.join(packageRoot, CONFIG_FILENAME);
1134
- const mtimeMs = configMtimeMs(configPath);
1135
- const hit = typeCache.get(packageRoot);
1136
- if (hit !== void 0 && hit.mtimeMs === mtimeMs) {
1137
- return hit.type;
1138
- }
1139
- const declaredType = mtimeMs >= 0 ? readDeclaredType(fs2.readFileSync(configPath, "utf8")) : void 0;
1140
- const type = detectPackageType({
1141
- packageDir: packageRoot,
1142
- repoRoot: findRepoRoot(packageRoot) ?? packageRoot,
1143
- pkgJson: readManifest(path3.posix.join(packageRoot, "package.json")),
1144
- declaredType
1145
- });
1146
- typeCache.set(packageRoot, { mtimeMs, type });
1147
- return type;
1148
- };
1149
-
1150
1406
  // src/rules/package-structure/package-structure.ts
1151
1407
  var DEFAULT_ENTRIES = {
1152
1408
  frontend: {
1153
- layers: ["app", "features", "lib", "components", "pages", "routes"],
1409
+ // `__tests__` holds the tests of files that sit directly in `src/` (see `test-location`).
1410
+ layers: ["app", "features", "lib", "components", "pages", "routes", "__tests__"],
1154
1411
  segments: {
1155
1412
  "features/*": ["containers", "components", "services", "__stories__", "__tests__"]
1156
1413
  },
1157
1414
  skill: "/infra-kit:fe-architect"
1158
1415
  },
1159
1416
  backend: {
1160
- layers: ["controllers", "services", "lib", "config"],
1417
+ layers: ["controllers", "services", "lib", "config", "__tests__"],
1161
1418
  segments: {
1162
1419
  "services/*": ["__tests__"]
1163
1420
  },
@@ -1166,9 +1423,11 @@ var DEFAULT_ENTRIES = {
1166
1423
  skill: "/infra-kit:be-architect"
1167
1424
  },
1168
1425
  e2e: {
1169
- layers: ["tests", "pages", "config", "lib", "components", "mocks", "fixtures"],
1426
+ layers: ["tests", "visual", "setup", "pages", "config", "lib", "components", "mocks", "fixtures"],
1427
+ // A domain folder's root holds only specs; its support files always sit in one of these.
1170
1428
  segments: {
1171
- "tests/*": ["fixtures", "data", "mocks"]
1429
+ "tests/*": ["pages", "fixtures", "mocks", "data", "lib"],
1430
+ "visual/*": ["pages", "fixtures", "mocks", "data", "lib"]
1172
1431
  },
1173
1432
  skill: "/infra-kit:e2e-architect"
1174
1433
  }
@@ -1227,7 +1486,7 @@ var findSegmentViolation = (dirs, entry) => {
1227
1486
  }
1228
1487
  return null;
1229
1488
  };
1230
- var findViolation = (dirs, entry) => {
1489
+ var findViolation2 = (dirs, entry) => {
1231
1490
  const layer = dirs[0];
1232
1491
  if (!entry.layers.includes(layer)) {
1233
1492
  return { messageId: "unknownLayer", name: layer, scope: "src", allowed: entry.layers };
@@ -1249,7 +1508,7 @@ var inspect = (filename, options) => {
1249
1508
  return null;
1250
1509
  }
1251
1510
  assertSegmentKeysNameLayers(type, entry);
1252
- const violation = findViolation(dirs, entry);
1511
+ const violation = findViolation2(dirs, entry);
1253
1512
  return violation === null ? null : { violation, type, entry };
1254
1513
  };
1255
1514
  var packageStructure = {
@@ -2104,6 +2363,99 @@ var requireJsdocExample = {
2104
2363
  }
2105
2364
  };
2106
2365
 
2366
+ // src/rules/test-location/test-location.ts
2367
+ var TEST_FILE2 = /\.(?:test|spec)\.[cm]?[jt]sx?$/;
2368
+ var SPEC_SUFFIX = /\.spec(\.[cm]?[jt]sx?)$/;
2369
+ var TESTS_DIR = "__tests__";
2370
+ var FEATURES_DIR = "features";
2371
+ var fromSrc = (dirs) => {
2372
+ const srcIndex = dirs.lastIndexOf("src");
2373
+ return srcIndex >= 0 ? dirs.slice(srcIndex) : dirs.slice(-1);
2374
+ };
2375
+ var featureRoot = (dirs) => {
2376
+ const srcIndex = dirs.lastIndexOf("src");
2377
+ const featuresIndex = dirs.lastIndexOf(FEATURES_DIR);
2378
+ return srcIndex >= 0 && featuresIndex > srcIndex && featuresIndex + 1 < dirs.length ? dirs.slice(0, featuresIndex + 2) : null;
2379
+ };
2380
+ var locationViolation = (dirs, basename) => {
2381
+ const feature = featureRoot(dirs);
2382
+ if (feature !== null) {
2383
+ if (dirs[feature.length] === TESTS_DIR) {
2384
+ return null;
2385
+ }
2386
+ return {
2387
+ messageId: "outsideFeatureTestsDir",
2388
+ data: { basename, target: [...fromSrc(feature), TESTS_DIR, basename].join("/") }
2389
+ };
2390
+ }
2391
+ if (dirs.includes(TESTS_DIR)) {
2392
+ return null;
2393
+ }
2394
+ return {
2395
+ messageId: "outsideTestsDir",
2396
+ data: { basename, target: [...fromSrc(dirs), TESTS_DIR, basename].join("/") }
2397
+ };
2398
+ };
2399
+ var findViolations = (filename, ignore) => {
2400
+ if (!TEST_FILE2.test(filename) || ignore.length > 0 && matchesAnyGlob(filename, ignore)) {
2401
+ return [];
2402
+ }
2403
+ const packageRoot = findPackageRoot(filename);
2404
+ if (packageRoot !== null && resolvePackageType(packageRoot) === "e2e") {
2405
+ return [];
2406
+ }
2407
+ const dirs = filename.split("/");
2408
+ const basename = dirs.pop();
2409
+ const location = locationViolation(dirs, basename);
2410
+ const suffix = SPEC_SUFFIX.test(basename) ? { messageId: "specSuffix", data: { basename, target: basename.replace(SPEC_SUFFIX, ".test$1") } } : null;
2411
+ return [location, suffix].filter((violation) => {
2412
+ return violation !== null;
2413
+ });
2414
+ };
2415
+ var testLocation = {
2416
+ meta: {
2417
+ type: "problem",
2418
+ docs: {
2419
+ description: "Require unit tests to be `*.test.*` files in a `__tests__/` folder beside the code they test \u2014 the feature root\u2019s inside a frontend feature.",
2420
+ recommended: true,
2421
+ url: "https://github.com/ArthurSaenz/infra-kit/tree/main/apps/infra-kit/eslint-plugin#test-location"
2422
+ },
2423
+ schema: [
2424
+ {
2425
+ type: "object",
2426
+ properties: {
2427
+ ignore: {
2428
+ type: "array",
2429
+ items: { type: "string" },
2430
+ description: "Optional globs; the rule is skipped for matching files."
2431
+ }
2432
+ },
2433
+ additionalProperties: false
2434
+ }
2435
+ ],
2436
+ messages: {
2437
+ outsideTestsDir: "`{{basename}}` sits next to its source. Move it to `{{target}}` \u2014 tests live in a `__tests__/` folder beside the code they test.",
2438
+ outsideFeatureTestsDir: "`{{basename}}` is a feature test outside the feature root. Move it to `{{target}}` \u2014 a feature keeps all its tests in one `__tests__/` at its root.",
2439
+ specSuffix: "`{{basename}}` uses `.spec`. Rename it to `{{target}}` \u2014 unit tests are `*.test.*`; `.spec` belongs to Playwright specs in an e2e package."
2440
+ }
2441
+ },
2442
+ create(context) {
2443
+ const options = context.options[0] ?? {};
2444
+ const violations = findViolations(context.filename.split("\\").join("/"), options.ignore ?? []);
2445
+ if (violations.length === 0) {
2446
+ return {};
2447
+ }
2448
+ return {
2449
+ // Reported on Program: the violation is the file's location, not any node inside it.
2450
+ Program(program) {
2451
+ for (const violation of violations) {
2452
+ context.report({ node: program, messageId: violation.messageId, data: violation.data });
2453
+ }
2454
+ }
2455
+ };
2456
+ }
2457
+ };
2458
+
2107
2459
  // src/rules/index.ts
2108
2460
  var rules = {
2109
2461
  "props-destructuring-newline": propsDestructuringNewline,
@@ -2118,7 +2470,11 @@ var rules = {
2118
2470
  "max-jsdoc-summary-lines": maxJsdocSummaryLines,
2119
2471
  "require-component-stories": requireComponentStories,
2120
2472
  "require-jsdoc-example": requireJsdocExample,
2121
- "package-structure": packageStructure
2473
+ "package-structure": packageStructure,
2474
+ "test-location": testLocation,
2475
+ "e2e-file-layout": e2eFileLayout,
2476
+ "e2e-test-tags": e2eTestTags,
2477
+ "e2e-test-title": e2eTestTitle
2122
2478
  };
2123
2479
 
2124
2480
  // src/index.ts
@@ -2126,7 +2482,7 @@ var PLUGIN_NAME = "@wl";
2126
2482
  var plugin = {
2127
2483
  meta: {
2128
2484
  name: "@wl/eslint-plugin",
2129
- version: "0.11.12"
2485
+ version: "0.11.16"
2130
2486
  },
2131
2487
  rules,
2132
2488
  configs: {}
@@ -2215,6 +2571,30 @@ plugin.configs.recommended = [
2215
2571
  rules: {
2216
2572
  [`${PLUGIN_NAME}/package-structure`]: "error"
2217
2573
  }
2574
+ },
2575
+ // Scoped by filename, so it never runs on non-test files. e2e packages are exempt inside the rule
2576
+ // (by package type); `*.e2e.test.*` covers e2e suites that sit in a package of another type.
2577
+ {
2578
+ files: ["**/*.{test,spec}.{ts,tsx,js,jsx,mts,cts,mjs,cjs}"],
2579
+ plugins: {
2580
+ [PLUGIN_NAME]: plugin
2581
+ },
2582
+ rules: {
2583
+ [`${PLUGIN_NAME}/test-location`]: ["error", { ignore: ["**/*.e2e.test.*"] }]
2584
+ }
2585
+ },
2586
+ // The e2e rules gate themselves on the package type, so a repo-wide glob costs one cached type
2587
+ // lookup per file elsewhere.
2588
+ {
2589
+ files: ["**/*.{ts,tsx,js,jsx,mts,cts,mjs,cjs}"],
2590
+ plugins: {
2591
+ [PLUGIN_NAME]: plugin
2592
+ },
2593
+ rules: {
2594
+ [`${PLUGIN_NAME}/e2e-file-layout`]: "error",
2595
+ [`${PLUGIN_NAME}/e2e-test-tags`]: "error",
2596
+ [`${PLUGIN_NAME}/e2e-test-title`]: "error"
2597
+ }
2218
2598
  }
2219
2599
  ];
2220
2600
  var meta = plugin.meta;