@slip-stream-kit/eslint-plugin 0.10.0 → 0.11.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
@@ -1001,6 +1001,317 @@ var maxJsxReturnSize = {
1001
1001
  }
1002
1002
  };
1003
1003
 
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
+ // src/rules/package-structure/package-structure.ts
1151
+ var DEFAULT_ENTRIES = {
1152
+ frontend: {
1153
+ layers: ["app", "features", "lib", "components", "pages", "routes"],
1154
+ segments: {
1155
+ "features/*": ["containers", "components", "services", "__stories__", "__tests__"]
1156
+ },
1157
+ skill: "/infra-kit:fe-architect"
1158
+ },
1159
+ backend: {
1160
+ layers: ["controllers", "services", "lib", "config"],
1161
+ segments: {
1162
+ "services/*": ["__tests__"]
1163
+ },
1164
+ // The skill does not exist yet; the user adds `plugins/infra-kit/skills/be-architect/SKILL.md`
1165
+ // and accepted the dangling pointer until then.
1166
+ skill: "/infra-kit:be-architect"
1167
+ },
1168
+ e2e: {
1169
+ layers: ["tests", "pages", "fixtures", "mocks", "data", "config", "lib", "components"],
1170
+ skill: "/infra-kit:e2e-architect"
1171
+ }
1172
+ };
1173
+ var resolveEntry = (type, options) => {
1174
+ return options[type] ?? DEFAULT_ENTRIES[type];
1175
+ };
1176
+ var UNIT_KEY_SUFFIX = "/*";
1177
+ var assertSegmentKeysNameLayers = (type, entry) => {
1178
+ for (const key of Object.keys(entry.segments ?? {})) {
1179
+ const layer = key.endsWith(UNIT_KEY_SUFFIX) ? key.slice(0, -UNIT_KEY_SUFFIX.length) : key;
1180
+ if (!entry.layers.includes(layer)) {
1181
+ throw new Error(
1182
+ `@wl/package-structure: \`${type}.segments\` key "${key}" names a layer that is not in \`${type}.layers\` (${entry.layers.join(", ")}).`
1183
+ );
1184
+ }
1185
+ }
1186
+ };
1187
+ var LAYER_ENTRY_SCHEMA = {
1188
+ type: "object",
1189
+ properties: {
1190
+ layers: {
1191
+ type: "array",
1192
+ items: { type: "string", minLength: 1 },
1193
+ uniqueItems: true,
1194
+ description: "First-level directories allowed under `src/`."
1195
+ },
1196
+ segments: {
1197
+ type: "object",
1198
+ propertyNames: { pattern: "^[^/]+(/\\*)?$" },
1199
+ additionalProperties: {
1200
+ type: "array",
1201
+ items: { type: "string", minLength: 1 },
1202
+ uniqueItems: true
1203
+ },
1204
+ description: "Allowed directories one level down, keyed by `<layer>` (directly inside it) or `<layer>/*` (inside each of its folders)."
1205
+ },
1206
+ skill: {
1207
+ type: "string",
1208
+ description: 'Skill or doc that owns this layout, appended to the message; `""` drops the sentence.'
1209
+ }
1210
+ },
1211
+ required: ["layers"],
1212
+ additionalProperties: false
1213
+ };
1214
+ var findSegmentViolation = (dirs, entry) => {
1215
+ const [layer, second, third] = dirs;
1216
+ const segments = entry.segments ?? {};
1217
+ const perUnit = segments[`${layer}${UNIT_KEY_SUFFIX}`];
1218
+ if (perUnit !== void 0) {
1219
+ return second !== void 0 && third !== void 0 && !perUnit.includes(third) ? { messageId: "unknownSegment", name: third, scope: `${layer}/${second}`, allowed: perUnit } : null;
1220
+ }
1221
+ const direct = segments[layer];
1222
+ if (direct !== void 0 && second !== void 0 && !direct.includes(second)) {
1223
+ return { messageId: "unknownSegment", name: second, scope: layer, allowed: direct };
1224
+ }
1225
+ return null;
1226
+ };
1227
+ var findViolation = (dirs, entry) => {
1228
+ const layer = dirs[0];
1229
+ if (!entry.layers.includes(layer)) {
1230
+ return { messageId: "unknownLayer", name: layer, scope: "src", allowed: entry.layers };
1231
+ }
1232
+ return findSegmentViolation(dirs, entry);
1233
+ };
1234
+ var inspect = (filename, options) => {
1235
+ const packageRoot = findPackageRoot(filename);
1236
+ if (packageRoot === null) {
1237
+ return null;
1238
+ }
1239
+ const dirs = dirsUnderSrc(packageRoot, filename);
1240
+ if (dirs === null || dirs.length === 0) {
1241
+ return null;
1242
+ }
1243
+ const type = resolvePackageType(packageRoot);
1244
+ const entry = resolveEntry(type, options);
1245
+ if (entry === void 0) {
1246
+ return null;
1247
+ }
1248
+ assertSegmentKeysNameLayers(type, entry);
1249
+ const violation = findViolation(dirs, entry);
1250
+ return violation === null ? null : { violation, type, entry };
1251
+ };
1252
+ var packageStructure = {
1253
+ meta: {
1254
+ type: "problem",
1255
+ docs: {
1256
+ description: "Allow only the `src/` layers, and the segments inside them, that belong to the package type.",
1257
+ recommended: true,
1258
+ url: "https://github.com/ArthurSaenz/infra-kit/tree/main/apps/infra-kit/eslint-plugin#package-structure"
1259
+ },
1260
+ schema: [
1261
+ {
1262
+ type: "object",
1263
+ properties: {
1264
+ frontend: LAYER_ENTRY_SCHEMA,
1265
+ backend: LAYER_ENTRY_SCHEMA,
1266
+ lib: LAYER_ENTRY_SCHEMA,
1267
+ e2e: LAYER_ENTRY_SCHEMA,
1268
+ mobile: LAYER_ENTRY_SCHEMA,
1269
+ ignore: {
1270
+ type: "array",
1271
+ items: { type: "string" },
1272
+ description: "Optional globs; the rule is skipped for matching files."
1273
+ }
1274
+ },
1275
+ additionalProperties: false
1276
+ }
1277
+ ],
1278
+ messages: {
1279
+ unknownLayer: "`{{name}}` is not an allowed `src/` layer for package type `{{type}}` (allowed: {{allowed}}).{{seeSkill}}",
1280
+ unknownSegment: "`{{name}}` is not an allowed segment of `{{scope}}` for package type `{{type}}` (allowed: {{allowed}}).{{seeSkill}}"
1281
+ }
1282
+ },
1283
+ create(context) {
1284
+ const options = context.options[0] ?? {};
1285
+ const ignore = options.ignore ?? [];
1286
+ if (ignore.length > 0 && matchesAnyGlob(context.filename, ignore)) {
1287
+ return {};
1288
+ }
1289
+ const found = inspect(context.filename, options);
1290
+ if (found === null) {
1291
+ return {};
1292
+ }
1293
+ const { violation, type, entry } = found;
1294
+ return {
1295
+ // Reported on Program so the IDE marks the file from line 1: every file under the offending
1296
+ // directory carries the same message, which is how a "red folder" surfaces in the explorer.
1297
+ Program(program) {
1298
+ context.report({
1299
+ node: program,
1300
+ messageId: violation.messageId,
1301
+ data: {
1302
+ name: violation.name,
1303
+ scope: violation.scope,
1304
+ type,
1305
+ allowed: violation.allowed.join(", "),
1306
+ // Message templates have no conditionals, so the optional sentence is assembled here.
1307
+ seeSkill: entry.skill ? ` See ${entry.skill} for the ${type} layout.` : ""
1308
+ }
1309
+ });
1310
+ }
1311
+ };
1312
+ }
1313
+ };
1314
+
1004
1315
  // src/rules/props-destructuring-blank-line/props-destructuring-blank-line.ts
1005
1316
  var isPropsDestructuring = (statement) => {
1006
1317
  if (statement.type !== "VariableDeclaration") {
@@ -1315,10 +1626,10 @@ var propsTypeReference = {
1315
1626
 
1316
1627
  // src/rules/require-component-stories/require-component-stories.ts
1317
1628
  import { existsSync } from "node:fs";
1318
- import path2 from "node:path";
1629
+ import path5 from "node:path";
1319
1630
 
1320
1631
  // src/utils/story-path.ts
1321
- import path from "node:path";
1632
+ import path4 from "node:path";
1322
1633
  var DEFAULT_STORY_PATH_OPTIONS = {
1323
1634
  storiesDir: "__stories__",
1324
1635
  storySuffix: ".stories",
@@ -1337,10 +1648,10 @@ var parse = (filePath) => {
1337
1648
  const normalized = toPosix(filePath);
1338
1649
  const segments = normalized.split("/");
1339
1650
  const basename = segments[segments.length - 1] ?? "";
1340
- const ext = path.posix.extname(basename);
1651
+ const ext = path4.posix.extname(basename);
1341
1652
  const base = ext ? basename.slice(0, -ext.length) : basename;
1342
1653
  return {
1343
- dir: path.posix.dirname(normalized),
1654
+ dir: path4.posix.dirname(normalized),
1344
1655
  base,
1345
1656
  ext,
1346
1657
  parent: segments[segments.length - 2] ?? "",
@@ -1382,10 +1693,10 @@ var deriveExpectedStoryPaths = (filePath, opts) => {
1382
1693
  return [];
1383
1694
  }
1384
1695
  const parsed = parse(filePath);
1385
- const storyBaseDir = classification.mode === "feature-root" ? path.posix.dirname(parsed.dir) : parsed.dir;
1386
- const storyDir = path.posix.join(storyBaseDir, options.storiesDir);
1696
+ const storyBaseDir = classification.mode === "feature-root" ? path4.posix.dirname(parsed.dir) : parsed.dir;
1697
+ const storyDir = path4.posix.join(storyBaseDir, options.storiesDir);
1387
1698
  return options.storyExtensions.map((extension) => {
1388
- return path.posix.join(storyDir, `${parsed.base}${options.storySuffix}${extension}`);
1699
+ return path4.posix.join(storyDir, `${parsed.base}${options.storySuffix}${extension}`);
1389
1700
  });
1390
1701
  };
1391
1702
 
@@ -1505,7 +1816,7 @@ var requireComponentStories = {
1505
1816
  node: program,
1506
1817
  messageId: "missingStory",
1507
1818
  data: {
1508
- component: path2.posix.basename(context.filename.split("\\").join("/")),
1819
+ component: path5.posix.basename(context.filename.split("\\").join("/")),
1509
1820
  expected: candidates[0] ?? ""
1510
1821
  }
1511
1822
  });
@@ -1803,7 +2114,8 @@ var rules = {
1803
2114
  "max-jsdoc-lines": maxJsdocLines,
1804
2115
  "max-jsdoc-summary-lines": maxJsdocSummaryLines,
1805
2116
  "require-component-stories": requireComponentStories,
1806
- "require-jsdoc-example": requireJsdocExample
2117
+ "require-jsdoc-example": requireJsdocExample,
2118
+ "package-structure": packageStructure
1807
2119
  };
1808
2120
 
1809
2121
  // src/index.ts
@@ -1811,7 +2123,7 @@ var PLUGIN_NAME = "@wl";
1811
2123
  var plugin = {
1812
2124
  meta: {
1813
2125
  name: "@wl/eslint-plugin",
1814
- version: "0.10.0"
2126
+ version: "0.11.0"
1815
2127
  },
1816
2128
  rules,
1817
2129
  configs: {}
@@ -1887,6 +2199,19 @@ plugin.configs.recommended = [
1887
2199
  rules: {
1888
2200
  [`${PLUGIN_NAME}/require-jsdoc-example`]: "warn"
1889
2201
  }
2202
+ },
2203
+ // `package-structure` judges a file by its directory, not its contents, so it must reach every
2204
+ // source extension — the `**/*.tsx` block above would never see a `.ts` under `src/core/`. Its
2205
+ // own block (like `require-jsdoc-example`) with `plugins` re-declared keeps it self-contained.
2206
+ // `error` from day one: the per-type entries are overridable per package via `config({ rules })`.
2207
+ {
2208
+ files: ["**/*.{ts,tsx,js,jsx}"],
2209
+ plugins: {
2210
+ [PLUGIN_NAME]: plugin
2211
+ },
2212
+ rules: {
2213
+ [`${PLUGIN_NAME}/package-structure`]: "error"
2214
+ }
1890
2215
  }
1891
2216
  ];
1892
2217
  var meta = plugin.meta;