@slip-stream-kit/eslint-plugin 0.11.11 → 0.11.15
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 +534 -154
- package/dist/index.js.map +4 -4
- package/dist/rules/e2e-file-layout/e2e-file-layout.d.ts +6 -0
- package/dist/rules/e2e-file-layout/index.d.ts +1 -0
- package/dist/rules/e2e-test-tags/e2e-test-tags.d.ts +7 -0
- package/dist/rules/e2e-test-tags/index.d.ts +1 -0
- package/dist/rules/e2e-test-title/e2e-test-title.d.ts +2 -0
- package/dist/rules/e2e-test-title/index.d.ts +1 -0
- package/dist/rules/test-location/index.d.ts +1 -0
- package/dist/rules/test-location/test-location.d.ts +6 -0
- package/dist/utils/playwright-call.d.ts +16 -0
- package/package.json +2 -2
- package/readme.md +126 -8
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
|
-
|
|
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", "
|
|
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
|
|
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 =
|
|
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.
|
|
2485
|
+
version: "0.11.15"
|
|
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;
|