@slip-stream-kit/eslint-plugin 0.17.3 → 0.18.1

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
@@ -1634,6 +1634,238 @@ var e2eTopLevelDescribe = {
1634
1634
  }
1635
1635
  };
1636
1636
 
1637
+ // src/utils/import-target.ts
1638
+ import path4 from "node:path";
1639
+ var DEFAULT_ROOT_ALIAS = "#root";
1640
+ var INDEX_FILE = /^index(?:\.[cm]?[jt]sx?)?$/;
1641
+ var literalValue = (source) => {
1642
+ if (source === null || source === void 0) {
1643
+ return null;
1644
+ }
1645
+ const value = source.type === "TSLiteralType" ? source.literal?.value : source.value;
1646
+ return typeof value === "string" ? value : null;
1647
+ };
1648
+ var allSpecifiersAreTypes = (specifiers, key) => {
1649
+ return specifiers !== void 0 && specifiers.length > 0 && specifiers.every((specifier) => {
1650
+ return specifier[key] === "type";
1651
+ });
1652
+ };
1653
+ var readImportSource = (node) => {
1654
+ const candidate = node;
1655
+ const sourceNode = candidate.type === "TSImportType" ? candidate.source ?? candidate.argument : candidate.source;
1656
+ const specifier = literalValue(sourceNode);
1657
+ if (specifier === null || sourceNode === null || sourceNode === void 0) {
1658
+ return null;
1659
+ }
1660
+ const typeOnly = candidate.type === "TSImportType" || candidate.importKind === "type" || candidate.exportKind === "type" || allSpecifiersAreTypes(candidate.specifiers, candidate.type === "ImportDeclaration" ? "importKind" : "exportKind");
1661
+ return { specifier, node: sourceNode, typeOnly };
1662
+ };
1663
+ var srcDirOf = (filename) => {
1664
+ const packageRoot = findPackageRoot(filename);
1665
+ if (packageRoot !== null) {
1666
+ return filename.startsWith(`${packageRoot}/src/`) ? `${packageRoot}/src` : null;
1667
+ }
1668
+ const index = filename.lastIndexOf("/src/");
1669
+ return index >= 0 ? filename.slice(0, index + "/src".length) : null;
1670
+ };
1671
+ var resolveUnderSrc = (filename, srcDir, specifier, rootAlias) => {
1672
+ let absolute;
1673
+ if (specifier === rootAlias || specifier.startsWith(`${rootAlias}/`)) {
1674
+ absolute = path4.posix.join(srcDir, specifier.slice(rootAlias.length));
1675
+ } else if (specifier.startsWith(".")) {
1676
+ absolute = path4.posix.resolve(path4.posix.dirname(filename), specifier);
1677
+ } else {
1678
+ return null;
1679
+ }
1680
+ if (!absolute.startsWith(`${srcDir}/`)) {
1681
+ return null;
1682
+ }
1683
+ return absolute.slice(srcDir.length + 1).split("/");
1684
+ };
1685
+ var fileDirsUnderSrc = (filename, srcDir) => {
1686
+ return filename.slice(srcDir.length + 1).split("/").slice(0, -1);
1687
+ };
1688
+ var isFeaturePublicApi = (target) => {
1689
+ return target.length === 2 || target.length === 3 && INDEX_FILE.test(target[2]);
1690
+ };
1691
+ var featureNameOf = (segments) => {
1692
+ return segments[0] === "features" && segments.length >= 2 ? segments[1] : null;
1693
+ };
1694
+ var IMPORT_SELECTORS = [
1695
+ "ImportDeclaration",
1696
+ "ExportNamedDeclaration",
1697
+ "ExportAllDeclaration",
1698
+ "ImportExpression",
1699
+ "TSImportType"
1700
+ ];
1701
+ var UNLAYERED_TYPES = /* @__PURE__ */ new Set(["backend", "e2e"]);
1702
+ var isLayeredFile = (filename, ignore) => {
1703
+ if (ignore.length > 0 && matchesAnyGlob(filename, ignore)) {
1704
+ return false;
1705
+ }
1706
+ const packageRoot = findPackageRoot(filename);
1707
+ return packageRoot === null || !UNLAYERED_TYPES.has(resolvePackageType(packageRoot));
1708
+ };
1709
+
1710
+ // src/rules/feature-public-api/feature-public-api.ts
1711
+ var featurePublicApi = {
1712
+ meta: {
1713
+ type: "problem",
1714
+ docs: {
1715
+ description: "Require code outside a feature to import it only through its public API (`features/<name>` / its `index`), whatever the path form.",
1716
+ recommended: true,
1717
+ url: "https://github.com/ArthurSaenz/infra-kit/tree/main/apps/infra-kit/eslint-plugin#feature-public-api"
1718
+ },
1719
+ schema: [
1720
+ {
1721
+ type: "object",
1722
+ properties: {
1723
+ rootAlias: { type: "string", description: "The alias that maps to `src/` (default `#root`)." },
1724
+ ignore: {
1725
+ type: "array",
1726
+ items: { type: "string" },
1727
+ description: "Optional globs; the rule is skipped for matching files."
1728
+ }
1729
+ },
1730
+ additionalProperties: false
1731
+ }
1732
+ ],
1733
+ messages: {
1734
+ deepImport: "`{{specifier}}` reaches into the internals of feature `{{feature}}`. Import from its public API `{{publicApi}}` instead, exporting what you need from `features/{{feature}}/index.ts`."
1735
+ }
1736
+ },
1737
+ create(context) {
1738
+ const options = context.options[0] ?? {};
1739
+ const filename = context.filename.split("\\").join("/");
1740
+ const srcDir = srcDirOf(filename);
1741
+ if (srcDir === null || !isLayeredFile(filename, options.ignore ?? [])) {
1742
+ return {};
1743
+ }
1744
+ const rootAlias = options.rootAlias ?? DEFAULT_ROOT_ALIAS;
1745
+ const ownFeature = featureNameOf(fileDirsUnderSrc(filename, srcDir));
1746
+ const check = (node) => {
1747
+ const source = readImportSource(node);
1748
+ const target = source === null ? null : resolveUnderSrc(filename, srcDir, source.specifier, rootAlias);
1749
+ const feature = target === null ? null : featureNameOf(target);
1750
+ if (source === null || feature === null || feature === ownFeature || isFeaturePublicApi(target)) {
1751
+ return;
1752
+ }
1753
+ context.report({
1754
+ node: source.node,
1755
+ messageId: "deepImport",
1756
+ data: { specifier: source.specifier, feature, publicApi: `${rootAlias}/features/${feature}` }
1757
+ });
1758
+ };
1759
+ return Object.fromEntries(
1760
+ IMPORT_SELECTORS.map((selector) => {
1761
+ return [selector, check];
1762
+ })
1763
+ );
1764
+ }
1765
+ };
1766
+
1767
+ // src/rules/layer-imports/layer-imports.ts
1768
+ var TEST_FILE2 = /\.(?:test|spec)\.[cm]?[jt]sx?$/;
1769
+ var isTestFile = (filename) => {
1770
+ return filename.includes("/__tests__/") || TEST_FILE2.test(filename);
1771
+ };
1772
+ var DEFAULT_LAYERS = [
1773
+ ["lib", "components", "shared", "assets", "styles", "data"],
1774
+ ["core", "entities"],
1775
+ ["features"],
1776
+ ["pages", "routes"],
1777
+ ["app", "renderer", "mobile"]
1778
+ ];
1779
+ var levelIndex = (layers) => {
1780
+ return new Map(
1781
+ layers.flatMap((level, index) => {
1782
+ return level.map((layer) => {
1783
+ return [layer, index];
1784
+ });
1785
+ })
1786
+ );
1787
+ };
1788
+ var describeOrder = (layers) => {
1789
+ return layers.map((level) => {
1790
+ return level.join(", ");
1791
+ }).join(" \u2192 ");
1792
+ };
1793
+ var layerImports = {
1794
+ meta: {
1795
+ type: "problem",
1796
+ docs: {
1797
+ description: "Allow imports only to the same or a lower layer of `src/` (lib/components \u2192 entities \u2192 features \u2192 pages/routes \u2192 app).",
1798
+ recommended: true,
1799
+ url: "https://github.com/ArthurSaenz/infra-kit/tree/main/apps/infra-kit/eslint-plugin#layer-imports"
1800
+ },
1801
+ schema: [
1802
+ {
1803
+ type: "object",
1804
+ properties: {
1805
+ layers: {
1806
+ type: "array",
1807
+ items: { type: "array", items: { type: "string" }, minItems: 1 },
1808
+ minItems: 1,
1809
+ description: "Folders under `src/`, lowest level first; folders in one inner array share a level."
1810
+ },
1811
+ allowTypeImports: {
1812
+ type: "boolean",
1813
+ description: "Whether type-only imports may point at a higher layer (default true)."
1814
+ },
1815
+ rootAlias: { type: "string", description: "The alias that maps to `src/` (default `#root`)." },
1816
+ ignore: {
1817
+ type: "array",
1818
+ items: { type: "string" },
1819
+ description: "Optional globs; the rule is skipped for matching files."
1820
+ }
1821
+ },
1822
+ additionalProperties: false
1823
+ }
1824
+ ],
1825
+ messages: {
1826
+ upward: "`{{fromLayer}}` cannot import `{{specifier}}` from the higher layer `{{toLayer}}`. Layers import only their own level or below: {{order}}. Move the shared code down into a lower layer, or pass it in from above."
1827
+ }
1828
+ },
1829
+ create(context) {
1830
+ const options = context.options[0] ?? {};
1831
+ const filename = context.filename.split("\\").join("/");
1832
+ const srcDir = srcDirOf(filename);
1833
+ if (srcDir === null || isTestFile(filename) || !isLayeredFile(filename, options.ignore ?? [])) {
1834
+ return {};
1835
+ }
1836
+ const layers = options.layers ?? DEFAULT_LAYERS;
1837
+ const levels = levelIndex(layers);
1838
+ const fromLayer = fileDirsUnderSrc(filename, srcDir)[0];
1839
+ const fromLevel = fromLayer === void 0 ? void 0 : levels.get(fromLayer);
1840
+ if (fromLayer === void 0 || fromLevel === void 0) {
1841
+ return {};
1842
+ }
1843
+ const allowTypeImports = options.allowTypeImports ?? true;
1844
+ const rootAlias = options.rootAlias ?? DEFAULT_ROOT_ALIAS;
1845
+ const check = (node) => {
1846
+ const source = readImportSource(node);
1847
+ if (source === null || allowTypeImports && source.typeOnly) {
1848
+ return;
1849
+ }
1850
+ const toLayer = resolveUnderSrc(filename, srcDir, source.specifier, rootAlias)?.[0];
1851
+ const toLevel = toLayer === void 0 ? void 0 : levels.get(toLayer);
1852
+ if (toLevel === void 0 || toLevel <= fromLevel) {
1853
+ return;
1854
+ }
1855
+ context.report({
1856
+ node: source.node,
1857
+ messageId: "upward",
1858
+ data: { fromLayer, toLayer, specifier: source.specifier, order: describeOrder(layers) }
1859
+ });
1860
+ };
1861
+ return Object.fromEntries(
1862
+ IMPORT_SELECTORS.map((selector) => {
1863
+ return [selector, check];
1864
+ })
1865
+ );
1866
+ }
1867
+ };
1868
+
1637
1869
  // src/rules/max-components-per-file/max-components-per-file.ts
1638
1870
  var DEFAULT_MAX_COMPONENTS = 4;
1639
1871
  var ANONYMOUS_NAME2 = "component";
@@ -2122,6 +2354,72 @@ var maxJsxReturnSize = {
2122
2354
  }
2123
2355
  };
2124
2356
 
2357
+ // src/rules/no-feature-self-import/no-feature-self-import.ts
2358
+ var CONSUMER_DIRS = /* @__PURE__ */ new Set(["__tests__", "__stories__"]);
2359
+ var noFeatureSelfImport = {
2360
+ meta: {
2361
+ type: "problem",
2362
+ docs: {
2363
+ description: "Forbid a feature from importing itself through its own public API, which closes an import cycle through `index.ts`.",
2364
+ recommended: true,
2365
+ url: "https://github.com/ArthurSaenz/infra-kit/tree/main/apps/infra-kit/eslint-plugin#no-feature-self-import"
2366
+ },
2367
+ schema: [
2368
+ {
2369
+ type: "object",
2370
+ properties: {
2371
+ rootAlias: { type: "string", description: "The alias that maps to `src/` (default `#root`)." },
2372
+ ignore: {
2373
+ type: "array",
2374
+ items: { type: "string" },
2375
+ description: "Optional globs; the rule is skipped for matching files."
2376
+ }
2377
+ },
2378
+ additionalProperties: false
2379
+ }
2380
+ ],
2381
+ messages: {
2382
+ selfImport: "`{{specifier}}` imports feature `{{feature}}` through its own public API from inside it, which makes an import cycle via `index.ts`. Import the module that defines it with a relative path instead.",
2383
+ selfTypeImport: "`{{specifier}}` reads types of feature `{{feature}}` through its own public API from inside it. Import them from the module that defines them with a relative path; the public API is for code outside the feature."
2384
+ }
2385
+ },
2386
+ create(context) {
2387
+ const options = context.options[0] ?? {};
2388
+ const filename = context.filename.split("\\").join("/");
2389
+ const srcDir = srcDirOf(filename);
2390
+ if (srcDir === null || !isLayeredFile(filename, options.ignore ?? [])) {
2391
+ return {};
2392
+ }
2393
+ const dirs = fileDirsUnderSrc(filename, srcDir);
2394
+ const ownFeature = featureNameOf(dirs);
2395
+ const isConsumer = dirs.some((dir) => {
2396
+ return CONSUMER_DIRS.has(dir);
2397
+ });
2398
+ if (ownFeature === null || isConsumer) {
2399
+ return {};
2400
+ }
2401
+ const rootAlias = options.rootAlias ?? DEFAULT_ROOT_ALIAS;
2402
+ const check = (node) => {
2403
+ const source = readImportSource(node);
2404
+ const target = source === null ? null : resolveUnderSrc(filename, srcDir, source.specifier, rootAlias);
2405
+ if (source === null || target === null || featureNameOf(target) !== ownFeature || !isFeaturePublicApi(target)) {
2406
+ return;
2407
+ }
2408
+ context.report({
2409
+ node: source.node,
2410
+ // A type-only import is erased at build time, so it closes no runtime cycle.
2411
+ messageId: source.typeOnly ? "selfTypeImport" : "selfImport",
2412
+ data: { specifier: source.specifier, feature: ownFeature }
2413
+ });
2414
+ };
2415
+ return Object.fromEntries(
2416
+ IMPORT_SELECTORS.map((selector) => {
2417
+ return [selector, check];
2418
+ })
2419
+ );
2420
+ }
2421
+ };
2422
+
2125
2423
  // src/rules/package-structure/package-structure.ts
2126
2424
  var DEFAULT_ENTRIES = {
2127
2425
  frontend: {
@@ -2607,10 +2905,10 @@ var propsTypeReference = {
2607
2905
 
2608
2906
  // src/rules/require-component-stories/require-component-stories.ts
2609
2907
  import { existsSync } from "node:fs";
2610
- import path5 from "node:path";
2908
+ import path6 from "node:path";
2611
2909
 
2612
2910
  // src/utils/story-path.ts
2613
- import path4 from "node:path";
2911
+ import path5 from "node:path";
2614
2912
  var DEFAULT_STORY_PATH_OPTIONS = {
2615
2913
  storiesDir: "__stories__",
2616
2914
  storySuffix: ".stories",
@@ -2629,10 +2927,10 @@ var parse = (filePath) => {
2629
2927
  const normalized = toPosix(filePath);
2630
2928
  const segments = normalized.split("/");
2631
2929
  const basename = segments[segments.length - 1] ?? "";
2632
- const ext = path4.posix.extname(basename);
2930
+ const ext = path5.posix.extname(basename);
2633
2931
  const base = ext ? basename.slice(0, -ext.length) : basename;
2634
2932
  return {
2635
- dir: path4.posix.dirname(normalized),
2933
+ dir: path5.posix.dirname(normalized),
2636
2934
  base,
2637
2935
  ext,
2638
2936
  parent: segments[segments.length - 2] ?? "",
@@ -2674,10 +2972,10 @@ var deriveExpectedStoryPaths = (filePath, opts) => {
2674
2972
  return [];
2675
2973
  }
2676
2974
  const parsed = parse(filePath);
2677
- const storyBaseDir = classification.mode === "feature-root" ? path4.posix.dirname(parsed.dir) : parsed.dir;
2678
- const storyDir = path4.posix.join(storyBaseDir, options.storiesDir);
2975
+ const storyBaseDir = classification.mode === "feature-root" ? path5.posix.dirname(parsed.dir) : parsed.dir;
2976
+ const storyDir = path5.posix.join(storyBaseDir, options.storiesDir);
2679
2977
  return options.storyExtensions.map((extension) => {
2680
- return path4.posix.join(storyDir, `${parsed.base}${options.storySuffix}${extension}`);
2978
+ return path5.posix.join(storyDir, `${parsed.base}${options.storySuffix}${extension}`);
2681
2979
  });
2682
2980
  };
2683
2981
 
@@ -2797,7 +3095,7 @@ var requireComponentStories = {
2797
3095
  node: program,
2798
3096
  messageId: "missingStory",
2799
3097
  data: {
2800
- component: path5.posix.basename(context.filename.split("\\").join("/")),
3098
+ component: path6.posix.basename(context.filename.split("\\").join("/")),
2801
3099
  expected: candidates[0] ?? ""
2802
3100
  }
2803
3101
  });
@@ -3083,7 +3381,7 @@ var requireJsdocExample = {
3083
3381
  };
3084
3382
 
3085
3383
  // src/rules/test-location/test-location.ts
3086
- var TEST_FILE2 = /\.(?:test|spec)\.[cm]?[jt]sx?$/;
3384
+ var TEST_FILE3 = /\.(?:test|spec)\.[cm]?[jt]sx?$/;
3087
3385
  var SPEC_SUFFIX = /\.spec(\.[cm]?[jt]sx?)$/;
3088
3386
  var TESTS_DIR = "__tests__";
3089
3387
  var FEATURES_DIR = "features";
@@ -3116,7 +3414,7 @@ var locationViolation = (dirs, basename) => {
3116
3414
  };
3117
3415
  };
3118
3416
  var findViolations2 = (filename, ignore) => {
3119
- if (!TEST_FILE2.test(filename) || ignore.length > 0 && matchesAnyGlob(filename, ignore)) {
3417
+ if (!TEST_FILE3.test(filename) || ignore.length > 0 && matchesAnyGlob(filename, ignore)) {
3120
3418
  return [];
3121
3419
  }
3122
3420
  const packageRoot = findPackageRoot(filename);
@@ -3191,6 +3489,9 @@ var rules = {
3191
3489
  "require-jsdoc-example": requireJsdocExample,
3192
3490
  "package-structure": packageStructure,
3193
3491
  "test-location": testLocation,
3492
+ "layer-imports": layerImports,
3493
+ "feature-public-api": featurePublicApi,
3494
+ "no-feature-self-import": noFeatureSelfImport,
3194
3495
  "e2e-file-layout": e2eFileLayout,
3195
3496
  "e2e-file-order": e2eFileOrder,
3196
3497
  "e2e-page-object-member-order": e2ePageObjectMemberOrder,
@@ -3205,7 +3506,7 @@ var PLUGIN_NAME = "@wl";
3205
3506
  var plugin = {
3206
3507
  meta: {
3207
3508
  name: "@wl/eslint-plugin",
3208
- version: "0.17.3"
3509
+ version: "0.18.1"
3209
3510
  },
3210
3511
  rules,
3211
3512
  configs: {}
@@ -3306,6 +3607,19 @@ plugin.configs.recommended = [
3306
3607
  [`${PLUGIN_NAME}/test-location`]: ["error", { ignore: ["**/*.e2e.test.*"] }]
3307
3608
  }
3308
3609
  },
3610
+ // Import-path rules read where a specifier lands under `src/`, so they reach every source extension.
3611
+ // They gate themselves on the package type (backend and e2e packages have no UI layering).
3612
+ {
3613
+ files: ["**/*.{ts,tsx,js,jsx,mts,cts,mjs,cjs}"],
3614
+ plugins: {
3615
+ [PLUGIN_NAME]: plugin
3616
+ },
3617
+ rules: {
3618
+ [`${PLUGIN_NAME}/layer-imports`]: "error",
3619
+ [`${PLUGIN_NAME}/feature-public-api`]: "error",
3620
+ [`${PLUGIN_NAME}/no-feature-self-import`]: "error"
3621
+ }
3622
+ },
3309
3623
  // The e2e rules gate themselves on the package type, so a repo-wide glob costs one cached type
3310
3624
  // lookup per file elsewhere.
3311
3625
  {