@isentinel/eslint-config 6.0.0-beta.43 → 6.0.0-beta.45

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/README.md CHANGED
@@ -928,6 +928,10 @@ are `{nodeName}`, `{NodeName}`, `{node_name}` and `{NODE_NAME}`. A file already
928
928
  matching a template is exempt from it. A source file with no test disables the
929
929
  rule at the source.
930
930
 
931
+ The rule needs no type information, so oxlint runs it too: the same option is
932
+ accepted by the oxlint factory, and hybrid mode (`oxlint: true`) hands the check
933
+ to oxlint.
934
+
931
935
  Two caveats. Deleting `foo.spec.ts` does not change `foo.ts`, so a cached run
932
936
  replays the old clean result — the check is only sound uncached. And the plugin
933
937
  writes `projectStructure.cache.json` into `projectRoot` while it has something
package/dist/cli.mjs CHANGED
@@ -9,7 +9,7 @@ import path from "node:path";
9
9
  import { existsSync, readFileSync } from "fs";
10
10
  import { execSync } from "node:child_process";
11
11
  //#region package.json
12
- var version = "6.0.0-beta.43";
12
+ var version = "6.0.0-beta.45";
13
13
  var package_default = {
14
14
  name: "@isentinel/eslint-config",
15
15
  version,
package/dist/index.mjs CHANGED
@@ -3932,6 +3932,7 @@ async function pnpm(options) {
3932
3932
  "pnpm/yaml-enforce-settings": ["error", { settings: {
3933
3933
  catalogMode: "prefer",
3934
3934
  cleanupUnusedCatalogs: true,
3935
+ minimumReleaseAgeExcludePrune: true,
3935
3936
  shellEmulator: true,
3936
3937
  trustPolicy: "no-downgrade",
3937
3938
  trustPolicyIgnoreAfter: 10080
@@ -3950,42 +3951,44 @@ async function detectCatalogUsage() {
3950
3951
  return yaml.includes("catalog:") || yaml.includes("catalogs:");
3951
3952
  }
3952
3953
  //#endregion
3953
- //#region src/eslint/configs/project-structure.ts
3954
+ //#region src/rules/project-structure.ts
3954
3955
  init_globs();
3955
3956
  /** Stands for the extension of the file a rule matched. */
3956
3957
  const EXTENSION_TOKEN = "{ext}";
3957
3958
  const DEFAULT_ENFORCE_EXISTENCE = [`{node-name}.spec.${EXTENSION_TOKEN}`];
3958
3959
  /** Reusable-rule id for "a folder of any name". */
3959
3960
  const ANY_FOLDER = "isentinelAnyFolder";
3960
- async function projectStructure({ enforceExistence = DEFAULT_ENFORCE_EXISTENCE, files = [GLOB_SRC], ignores = [
3961
+ /** The files the co-location check runs on, before `ignores`. */
3962
+ const PROJECT_STRUCTURE_FILES = [GLOB_SRC];
3963
+ /** The files the co-location check skips. */
3964
+ const PROJECT_STRUCTURE_IGNORES = [
3961
3965
  ...GLOB_TESTS,
3962
3966
  GLOB_DTS,
3963
3967
  ...GLOB_BUILD_CONFIGS
3964
- ], overrides = {}, projectRoot = process.cwd(), structureRoot } = {}) {
3965
- await ensurePackages(["eslint-plugin-project-structure"]);
3966
- const { projectStructurePlugin } = await import("eslint-plugin-project-structure");
3968
+ ];
3969
+ /**
3970
+ * Co-location rules shared between the ESLint and oxlint factories.
3971
+ *
3972
+ * `folder-structure` is not type-aware and runs unchanged as an oxlint
3973
+ * jsPlugin, so both engines have to hand it the same options or they disagree
3974
+ * about which files are co-located.
3975
+ *
3976
+ * @param options - The templates to enforce, and the roots they resolve
3977
+ * against.
3978
+ * @returns The rule map.
3979
+ */
3980
+ function folderStructureRules({ enforceExistence = DEFAULT_ENFORCE_EXISTENCE, projectRoot = process.cwd(), structureRoot } = {}) {
3967
3981
  const children = buildChildren(typeof enforceExistence === "string" ? [enforceExistence] : enforceExistence);
3968
- return [{
3969
- name: "isentinel/project-structure/setup",
3970
- plugins: { "project-structure": projectStructurePlugin }
3971
- }, {
3972
- name: "isentinel/project-structure/rules",
3973
- files,
3974
- ignores,
3975
- rules: {
3976
- "project-structure/folder-structure": ["error", {
3977
- longPathsInfo: false,
3978
- projectRoot,
3979
- rules: { [ANY_FOLDER]: {
3980
- name: "*",
3981
- children
3982
- } },
3983
- structure: children,
3984
- ...structureRoot === void 0 ? {} : { structureRoot }
3985
- }],
3986
- ...overrides
3987
- }
3988
- }];
3982
+ return { "project-structure/folder-structure": ["error", {
3983
+ longPathsInfo: false,
3984
+ projectRoot,
3985
+ rules: { [ANY_FOLDER]: {
3986
+ name: "*",
3987
+ children
3988
+ } },
3989
+ structure: children,
3990
+ ...structureRoot === void 0 ? {} : { structureRoot }
3991
+ }] };
3989
3992
  }
3990
3993
  /**
3991
3994
  * The rules matching the files inside one folder, in the order
@@ -4012,6 +4015,28 @@ function buildChildren(templates) {
4012
4015
  return children;
4013
4016
  }
4014
4017
  //#endregion
4018
+ //#region src/eslint/configs/project-structure.ts
4019
+ async function projectStructure({ enforceExistence, files = PROJECT_STRUCTURE_FILES, ignores = PROJECT_STRUCTURE_IGNORES, overrides = {}, projectRoot, structureRoot } = {}) {
4020
+ await ensurePackages(["eslint-plugin-project-structure"]);
4021
+ const { projectStructurePlugin } = await import("eslint-plugin-project-structure");
4022
+ return [{
4023
+ name: "isentinel/project-structure/setup",
4024
+ plugins: { "project-structure": projectStructurePlugin }
4025
+ }, {
4026
+ name: "isentinel/project-structure/rules",
4027
+ files,
4028
+ ignores,
4029
+ rules: {
4030
+ ...folderStructureRules({
4031
+ enforceExistence,
4032
+ projectRoot,
4033
+ structureRoot
4034
+ }),
4035
+ ...overrides
4036
+ }
4037
+ }];
4038
+ }
4039
+ //#endregion
4015
4040
  //#region src/rules/promise.ts
4016
4041
  /**
4017
4042
  * Promise rules shared between the ESLint and oxlint factories.
@@ -4883,6 +4908,7 @@ function sortPnpmWorkspace() {
4883
4908
  "blockExoticSubdeps",
4884
4909
  "cacheDir",
4885
4910
  "catalogMode",
4911
+ "catalogPrune",
4886
4912
  "cleanupUnusedCatalogs",
4887
4913
  "dedupeDirectDeps",
4888
4914
  "dedupePeerDependents",
@@ -4892,6 +4918,7 @@ function sortPnpmWorkspace() {
4892
4918
  "enablePrePostScripts",
4893
4919
  "engineStrict",
4894
4920
  "extendNodePath",
4921
+ "globalShims",
4895
4922
  "hoist",
4896
4923
  "hoistPattern",
4897
4924
  "hoistWorkspacePackages",
@@ -4907,6 +4934,7 @@ function sortPnpmWorkspace() {
4907
4934
  "managePackageManagerVersions",
4908
4935
  "minimumReleaseAge",
4909
4936
  "minimumReleaseAgeExclude",
4937
+ "minimumReleaseAgeExcludePrune",
4910
4938
  "minimumReleaseAgeIgnoreMissingTime",
4911
4939
  "minimumReleaseAgeStrict",
4912
4940
  "modulesDir",
@@ -4929,6 +4957,7 @@ function sortPnpmWorkspace() {
4929
4957
  "shamefullyHoist",
4930
4958
  "sharedWorkspaceLockfile",
4931
4959
  "shellEmulator",
4960
+ "sideEffectsCache",
4932
4961
  "stateDir",
4933
4962
  "strictDepBuilds",
4934
4963
  "strictPeerDependencies",
@@ -4941,7 +4970,8 @@ function sortPnpmWorkspace() {
4941
4970
  "trustPolicyIgnoreAfter",
4942
4971
  "updateConfig",
4943
4972
  "updateNotifier",
4944
- "verifyDepsBeforeRun"
4973
+ "verifyDepsBeforeRun",
4974
+ "virtualStoreType"
4945
4975
  ],
4946
4976
  ...[
4947
4977
  "gitBranchLockfile",
@@ -4968,7 +4998,8 @@ function sortPnpmWorkspace() {
4968
4998
  "onlyBuiltDependencies",
4969
4999
  "onlyBuiltDependenciesFile",
4970
5000
  "packageExtensions",
4971
- "peerDependencyRules"
5001
+ "peerDependencyRules",
5002
+ "tasks"
4972
5003
  ],
4973
5004
  { order: { type: "asc" } }
4974
5005
  ],
@@ -4993,7 +5024,7 @@ function sortPnpmWorkspace() {
4993
5024
  natural: true,
4994
5025
  type: "asc"
4995
5026
  },
4996
- pathPattern: "^(supportedArchitectures\\.(cpu|libc|os)|updateConfig\\.ignoreDependencies|peerDependencyRules\\.(allowAny|ignoreMissing)|auditConfig\\.(ignoreCves|ignoreGhsas))$"
5027
+ pathPattern: "^(supportedArchitectures\\.(cpu|libc|os)|update\\.ignoreDeps|updateConfig\\.ignoreDependencies|peerDependencyRules\\.(allowAny|ignoreMissing)|audit\\.ignore|auditConfig\\.(ignoreCves|ignoreGhsas)|sideEffectsCache\\.remote\\.packages|tasks\\.[^.]+\\.dependsOn)$"
4997
5028
  }
4998
5029
  ]
4999
5030
  }
@@ -15660,6 +15691,7 @@ var init_adapters = __esmMin((() => {
15660
15691
  "oxfmt": "eslint-plugin-oxfmt",
15661
15692
  "oxlint-comments": "oxlint-plugin-oxlint-comments",
15662
15693
  "perfectionist": "eslint-plugin-perfectionist",
15694
+ "project-structure": "eslint-plugin-project-structure",
15663
15695
  "promise-js": "eslint-plugin-promise",
15664
15696
  "react-jsx": "eslint-plugin-react-jsx",
15665
15697
  "react-naming-convention": "eslint-plugin-react-naming-convention",
@@ -16549,6 +16581,9 @@ var init_oxlint_capabilities = __esmMin((() => {
16549
16581
  "prefer-spread",
16550
16582
  "prefer-template",
16551
16583
  "preserve-caught-error",
16584
+ "project-structure/file-composition",
16585
+ "project-structure/folder-structure",
16586
+ "project-structure/independent-modules",
16552
16587
  "promise/always-return",
16553
16588
  "promise/avoid-new",
16554
16589
  "promise/catch-or-return",
@@ -19811,8 +19846,10 @@ var oxlint_drop_exports = /* @__PURE__ */ __exportAll$2({
19811
19846
  * Warn when a user-supplied config references a rule that oxlint owns in hybrid
19812
19847
  * mode (`oxlint: true`). The preset drops every oxlint-covered rule from the
19813
19848
  * ESLint side and lets oxlint format real JS/TS files, so such entries silently
19814
- * do nothing. Markdown-scoped configs are exempt: oxlint cannot lint Markdown
19815
- * virtual files, so the preset re-enables those rules there.
19849
+ * do nothing. Configs oxlint cannot reach are exempt: Markdown-scoped ones
19850
+ * (oxlint cannot lint Markdown virtual files, so the preset re-enables those
19851
+ * rules there) and ones scoped to a non-JS/TS language, which oxlint never
19852
+ * parses.
19816
19853
  *
19817
19854
  * @param configs - The resolved flat config items.
19818
19855
  * @param mode - The hybrid mode; in `native` mode jsPlugin rules and formatting
@@ -19842,6 +19879,11 @@ function warnMissingTsgolint() {
19842
19879
  * sibling is inserted directly after the original so later configs (for
19843
19880
  * example the markdown disables) still take precedence.
19844
19881
  *
19882
+ * Configs scoped to a language oxlint does not parse are skipped outright: it
19883
+ * reads only the JS/TS family, so handing it `oxfmt/oxfmt` for YAML, JSON, CSS
19884
+ * or GraphQL would drop the formatter from ESLint without any engine picking it
19885
+ * up. See {@link targetsJsOrTs}.
19886
+ *
19845
19887
  * @param configs - The resolved flat config items (mutated in place).
19846
19888
  * @param typeAware - Whether oxlint runs type-aware (oxlint-tsgolint present).
19847
19889
  * When `false`, tsgolint rules are kept in ESLint so they do not vanish from
@@ -19851,7 +19893,7 @@ function warnMissingTsgolint() {
19851
19893
  function dropOxlintCoveredRules(configs, typeAware = true, mode = "full") {
19852
19894
  for (let index = 0; index < configs.length; index += 1) {
19853
19895
  const config = configs[index];
19854
- if (config?.name === void 0 || !config.name.startsWith("isentinel/") || config.name.endsWith("/markdown-code") || config.rules === void 0 || targetsMarkdown(config.files)) continue;
19896
+ if (config?.name === void 0 || !config.name.startsWith("isentinel/") || config.name.endsWith("/markdown-code") || config.rules === void 0 || targetsMarkdown(config.files) || !targetsJsOrTs(config.files)) continue;
19855
19897
  const dropped = dropCoveredRulesFromConfig(config.rules, typeAware, mode);
19856
19898
  if (Object.keys(dropped).length === 0) continue;
19857
19899
  if ((config.ignores ?? []).some((pattern) => typeof pattern === "string" && pattern.includes(".md/"))) continue;
@@ -19900,10 +19942,98 @@ function targetsMarkdown(files) {
19900
19942
  const patterns = files.flat();
19901
19943
  return patterns.length > 0 && patterns.every((pattern) => typeof pattern === "string" && pattern.includes(".md"));
19902
19944
  }
19945
+ /**
19946
+ * The strings one part of a {@link GLOB_GROUP_SPLIT} split can stand for: the
19947
+ * comma-separated members of a brace group, the individual characters of a
19948
+ * bracket group, or the literal text itself. A part that still holds a group
19949
+ * delimiter is a nested or unterminated group, which is not expanded.
19950
+ *
19951
+ * @param part - One part of a glob fragment split on its groups.
19952
+ * @returns The alternatives, or `undefined` when the part is not expandable.
19953
+ */
19954
+ function groupAlternatives(part) {
19955
+ const body = part.slice(1, -1);
19956
+ if (part.startsWith("{")) return body.split(",");
19957
+ if (part.startsWith("[")) return body.match(GLOB_ANY_CHARACTER) ?? [];
19958
+ return GLOB_GROUP_DELIMITER.test(part) ? void 0 : [part];
19959
+ }
19960
+ /**
19961
+ * Expand the brace and character-class alternatives of a glob fragment.
19962
+ *
19963
+ * Only the closed forms the preset's globs use are handled (`{,c,m}`, `[jt]`);
19964
+ * anything with a wildcard, a nested group or an unterminated group yields
19965
+ * `undefined`, meaning "could be anything". Callers treat that as a match, so
19966
+ * an unknown pattern keeps the pre-existing drop behavior.
19967
+ *
19968
+ * @param fragment - The glob fragment to expand.
19969
+ * @returns Every literal string the fragment can produce, or `undefined` when
19970
+ * the fragment is not a closed set of literals.
19971
+ */
19972
+ function expandGlobAlternatives(fragment) {
19973
+ if (GLOB_WILDCARD.test(fragment)) return;
19974
+ let results = [""];
19975
+ for (const part of fragment.split(GLOB_GROUP_SPLIT)) {
19976
+ const alternatives = groupAlternatives(part);
19977
+ if (alternatives === void 0) return;
19978
+ results = results.flatMap((prefix) => {
19979
+ return alternatives.map((alternative) => prefix + alternative);
19980
+ });
19981
+ }
19982
+ return results;
19983
+ }
19984
+ /**
19985
+ * The index of the extension dot in a glob's last path segment, or `-1` when it
19986
+ * has none. Dots inside a brace or bracket group are masked out first: they
19987
+ * belong to an alternative, not to the extension boundary.
19988
+ *
19989
+ * @param segment - The last path segment of a `files` pattern.
19990
+ * @returns The index of the extension dot, or `-1`.
19991
+ */
19992
+ function extensionDotIndex(segment) {
19993
+ return segment.replaceAll(GLOB_GROUP, (group) => "\0".repeat(group.length)).lastIndexOf(".");
19994
+ }
19995
+ /**
19996
+ * Whether a single `files` pattern can match a file oxlint reads.
19997
+ *
19998
+ * Oxlint parses only the JS/TS family, so a pattern whose extension is a closed
19999
+ * set of non-JS/TS extensions (`**\/*.y{,a}ml`, `**\/*.json{,5,c}`) describes
20000
+ * files oxlint never sees. A pattern whose last segment has no extension, or
20001
+ * whose extension is not a closed set, matches conservatively.
20002
+ *
20003
+ * @param pattern - The `files` pattern to classify.
20004
+ * @returns Whether oxlint can lint something the pattern matches.
20005
+ */
20006
+ function patternTargetsJsOrTs(pattern) {
20007
+ const segment = pattern.slice(pattern.lastIndexOf("/") + 1);
20008
+ const dot = extensionDotIndex(segment);
20009
+ if (dot === -1) return true;
20010
+ const extensions = expandGlobAlternatives(segment.slice(dot + 1));
20011
+ if (extensions === void 0) return true;
20012
+ return extensions.some((extension) => JS_TS_EXTENSIONS.has(extension));
20013
+ }
20014
+ /**
20015
+ * Whether a config's `files` reach any file oxlint can lint.
20016
+ *
20017
+ * Hybrid mode only hands a rule to oxlint for the files oxlint actually parses.
20018
+ * A config scoped to YAML, JSON, CSS or GraphQL is invisible to oxlint, so its
20019
+ * rules must stay in ESLint or they run in neither engine — the case that
20020
+ * silently dropped `oxfmt/oxfmt` from every non-JS formatting config.
20021
+ *
20022
+ * @param files - The config's `files` patterns; nested arrays are AND-combined,
20023
+ * so they only reach JS/TS when every member does.
20024
+ * @returns Whether oxlint can lint something the config targets.
20025
+ */
20026
+ function targetsJsOrTs(files) {
20027
+ if (files === void 0 || files.length === 0) return true;
20028
+ return files.some((entry) => {
20029
+ if (typeof entry === "string") return patternTargetsJsOrTs(entry);
20030
+ return entry.every((pattern) => patternTargetsJsOrTs(pattern));
20031
+ });
20032
+ }
19903
20033
  function findDeadMappedRules(configs, mode) {
19904
20034
  const references = [];
19905
20035
  for (const config of configs) {
19906
- if (config.rules === void 0 || isPresetConfig(config) || targetsMarkdown(config.files)) continue;
20036
+ if (config.rules === void 0 || isPresetConfig(config) || targetsMarkdown(config.files) || !targetsJsOrTs(config.files)) continue;
19907
20037
  const label = describeConfig(config);
19908
20038
  for (const [rule, value] of Object.entries(config.rules)) if (value !== void 0 && (presetHandsRuleToOxlint(rule, mode) || mode === "full" && HYBRID_FORMATTING_RULES.has(rule))) references.push({
19909
20039
  config: label,
@@ -19964,11 +20094,17 @@ function markdownVirtualFiles(files) {
19964
20094
  return [GLOB_MARKDOWN_CODE, ...[pattern].flat()];
19965
20095
  });
19966
20096
  }
19967
- var HYBRID_FORMATTING_RULES;
20097
+ var HYBRID_FORMATTING_RULES, JS_TS_EXTENSIONS, GLOB_WILDCARD, GLOB_GROUP, GLOB_GROUP_SPLIT, GLOB_GROUP_DELIMITER, GLOB_ANY_CHARACTER;
19968
20098
  var init_oxlint_drop = __esmMin((() => {
19969
20099
  init_globs();
19970
20100
  init_routing();
19971
20101
  HYBRID_FORMATTING_RULES = /* @__PURE__ */ new Set(["oxfmt/oxfmt"]);
20102
+ JS_TS_EXTENSIONS = new Set(GLOB_SRC_EXTENSIONS);
20103
+ GLOB_WILDCARD = /[*?]/;
20104
+ GLOB_GROUP = /\{[^{}]*\}|\[[^[\]]*\]/gu;
20105
+ GLOB_GROUP_SPLIT = /(\{[^{}]*\}|\[[^[\]]*\])/u;
20106
+ GLOB_GROUP_DELIMITER = /[{}[\]]/u;
20107
+ GLOB_ANY_CHARACTER = /./gu;
19972
20108
  }));
19973
20109
  //#endregion
19974
20110
  //#region src/eslint/factory.ts
package/dist/lint-cli.mjs CHANGED
@@ -38,7 +38,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
38
38
  }) : target, mod));
39
39
  //#endregion
40
40
  //#region package.json
41
- var version = "6.0.0-beta.43";
41
+ var version = "6.0.0-beta.45";
42
42
  //#endregion
43
43
  //#region src/lint-cli/lib/cli/types.ts
44
44
  /**
package/dist/oxlint.d.mts CHANGED
@@ -3860,6 +3860,7 @@ interface OverrideSiteChains {
3860
3860
  e18e: MainChain;
3861
3861
  eslintPlugin: FeatureChain;
3862
3862
  javascript: MainChain;
3863
+ projectStructure: FeatureChain;
3863
3864
  react: ReactChain;
3864
3865
  roblox: MainChain;
3865
3866
  typescript: MainChain;
@@ -33259,7 +33260,7 @@ type OxlintCommentsRequireDescription = [] | [{
33259
33260
  ignore?: ("oxlint-disable" | "oxlint-disable-line" | "oxlint-disable-next-line" | "oxlint-enable")[];
33260
33261
  }];
33261
33262
  /** JsPlugin prefixes whose rules keep their ESLint-side names in oxlint configs. */
33262
- type OxlintKeptJsPluginPrefix = '@cspell' | 'antfu' | 'better-max-params' | 'comment-length' | 'de-morgan' | 'e18e' | 'erasable-syntax-only' | 'eslint-plugin' | 'flawless' | 'jest-extended' | 'oxfmt' | 'perfectionist' | 'react-jsx' | 'react-naming-convention' | 'roblox' | 'sentinel' | 'small-rules' | 'sonar' | 'style' | 'testing-library' | 'ts' | 'unused-imports';
33263
+ type OxlintKeptJsPluginPrefix = '@cspell' | 'antfu' | 'better-max-params' | 'comment-length' | 'de-morgan' | 'e18e' | 'erasable-syntax-only' | 'eslint-plugin' | 'flawless' | 'jest-extended' | 'oxfmt' | 'perfectionist' | 'project-structure' | 'react-jsx' | 'react-naming-convention' | 'roblox' | 'sentinel' | 'small-rules' | 'sonar' | 'style' | 'testing-library' | 'ts' | 'unused-imports';
33263
33264
  /** JsPlugin rules whose oxlint names match the ESLint-side names. */
33264
33265
  type OxlintKeptJsPluginRuleOptions = { [K in keyof RuleOptions as K extends `${OxlintKeptJsPluginPrefix}/${string}` ? K : never]: RuleOptions[K]; };
33265
33266
  /** All rules known to the oxlint factory, keyed by canonical oxlint rule name. */
@@ -33305,7 +33306,7 @@ type TypedOxlintConfigItem = Omit<OxlintOverride$1, "rules"> & {
33305
33306
  * Oxlint can only lint JS/TS files, so the JSON, YAML, TOML, Markdown and
33306
33307
  * pnpm-related options are omitted, along with ESLint-only options.
33307
33308
  */
33308
- type OxlintOptionsConfig = Omit<OptionsConfig, "autoRenamePlugins" | "flawless" | "jsonc" | "markdown" | "namedConfigs" | "naming" | "oxlint" | "pnpm" | "projectStructure" | "toml" | "yaml">;
33309
+ type OxlintOptionsConfig = Omit<OptionsConfig, "autoRenamePlugins" | "flawless" | "jsonc" | "markdown" | "namedConfigs" | "naming" | "oxlint" | "pnpm" | "toml" | "yaml">;
33309
33310
  /**
33310
33311
  * Options accepted by the oxlint factory function.
33311
33312
  *
package/dist/oxlint.mjs CHANGED
@@ -616,6 +616,9 @@ const jsPluginRuleNames = /* @__PURE__ */ new Set([
616
616
  "prefer-spread",
617
617
  "prefer-template",
618
618
  "preserve-caught-error",
619
+ "project-structure/file-composition",
620
+ "project-structure/folder-structure",
621
+ "project-structure/independent-modules",
619
622
  "promise/always-return",
620
623
  "promise/avoid-new",
621
624
  "promise/catch-or-return",
@@ -4747,6 +4750,7 @@ const oxlintJsPlugins = {
4747
4750
  "oxfmt": "eslint-plugin-oxfmt",
4748
4751
  "oxlint-comments": "oxlint-plugin-oxlint-comments",
4749
4752
  "perfectionist": "eslint-plugin-perfectionist",
4753
+ "project-structure": "eslint-plugin-project-structure",
4750
4754
  "promise-js": "eslint-plugin-promise",
4751
4755
  "react-jsx": "eslint-plugin-react-jsx",
4752
4756
  "react-naming-convention": "eslint-plugin-react-naming-convention",
@@ -5165,6 +5169,23 @@ const GLOB_ROOT = [
5165
5169
  ];
5166
5170
  const GLOB_SRC_EXT = "{,c,m}[jt]s{,x}";
5167
5171
  const GLOB_SRC = "**/*.{,c,m}[jt]s{,x}";
5172
+ /**
5173
+ * The real TS/JS-family file extensions {@link GLOB_SRC_EXT} encodes (the glob
5174
+ * additionally matches non-existent combinations such as `cjsx`). Kept next to
5175
+ * the glob so the two are maintained together; consumed by the lint CLI to size
5176
+ * its type-aware pass.
5177
+ */
5178
+ const GLOB_SRC_EXTENSIONS = [
5179
+ "ts",
5180
+ "tsx",
5181
+ "mts",
5182
+ "cts",
5183
+ "js",
5184
+ "jsx",
5185
+ "mjs",
5186
+ "cjs"
5187
+ ];
5188
+ [...GLOB_SRC_EXTENSIONS];
5168
5189
  const GLOB_JSX = "**/*.{,c,m}jsx";
5169
5190
  const GLOB_TSX = "**/*.{,c,m}tsx";
5170
5191
  const GLOB_DTS = "**/*.d.{,c,m}ts";
@@ -8013,6 +8034,94 @@ function oxlintPerfectionist(config) {
8013
8034
  })];
8014
8035
  }
8015
8036
  //#endregion
8037
+ //#region src/rules/project-structure.ts
8038
+ /** Stands for the extension of the file a rule matched. */
8039
+ const EXTENSION_TOKEN = "{ext}";
8040
+ const DEFAULT_ENFORCE_EXISTENCE = [`{node-name}.spec.${EXTENSION_TOKEN}`];
8041
+ /** Reusable-rule id for "a folder of any name". */
8042
+ const ANY_FOLDER = "isentinelAnyFolder";
8043
+ /** The files the co-location check runs on, before `ignores`. */
8044
+ const PROJECT_STRUCTURE_FILES = [GLOB_SRC];
8045
+ /** The files the co-location check skips. */
8046
+ const PROJECT_STRUCTURE_IGNORES = [
8047
+ ...GLOB_TESTS,
8048
+ GLOB_DTS,
8049
+ ...GLOB_BUILD_CONFIGS
8050
+ ];
8051
+ /**
8052
+ * Co-location rules shared between the ESLint and oxlint factories.
8053
+ *
8054
+ * `folder-structure` is not type-aware and runs unchanged as an oxlint
8055
+ * jsPlugin, so both engines have to hand it the same options or they disagree
8056
+ * about which files are co-located.
8057
+ *
8058
+ * @param options - The templates to enforce, and the roots they resolve
8059
+ * against.
8060
+ * @returns The rule map.
8061
+ */
8062
+ function folderStructureRules({ enforceExistence = DEFAULT_ENFORCE_EXISTENCE, projectRoot = process.cwd(), structureRoot } = {}) {
8063
+ const children = buildChildren(typeof enforceExistence === "string" ? [enforceExistence] : enforceExistence);
8064
+ return { "project-structure/folder-structure": ["error", {
8065
+ longPathsInfo: false,
8066
+ projectRoot,
8067
+ rules: { [ANY_FOLDER]: {
8068
+ name: "*",
8069
+ children
8070
+ } },
8071
+ structure: children,
8072
+ ...structureRoot === void 0 ? {} : { structureRoot }
8073
+ }] };
8074
+ }
8075
+ /**
8076
+ * The rules matching the files inside one folder, in the order
8077
+ * `folder-structure` tries them - first match wins.
8078
+ *
8079
+ * Every entry is permissive: `structure` is required by the rule and an
8080
+ * unmatched node is an error, so anything less would turn the co-location
8081
+ * check into whole-tree naming enforcement.
8082
+ *
8083
+ * @param templates - The `enforceExistence` templates, before substitution.
8084
+ * @returns The child rules, ending in the recursive folder rule.
8085
+ */
8086
+ function buildChildren(templates) {
8087
+ const children = templates.map((template) => {
8088
+ return { name: (template.split("/").pop() ?? template).replaceAll(/\{[^{}]*\}/g, "*") };
8089
+ });
8090
+ for (const extension of GLOB_SRC_EXTENSIONS) children.push({
8091
+ name: `*.${extension}`,
8092
+ enforceExistence: templates.map((template) => {
8093
+ return template.replaceAll(EXTENSION_TOKEN, () => extension);
8094
+ })
8095
+ });
8096
+ children.push({ ruleId: ANY_FOLDER });
8097
+ return children;
8098
+ }
8099
+ //#endregion
8100
+ //#region src/oxlint/configs/project-structure.ts
8101
+ /**
8102
+ * Co-location rules for standalone oxlint.
8103
+ *
8104
+ * `folder-structure` runs as a jsPlugin: it visits `Program` once per file and
8105
+ * answers from the filesystem, so oxlint needs no type information to run it.
8106
+ *
8107
+ * @param options - The templates to enforce, and the roots they resolve
8108
+ * against.
8109
+ * @returns The generated config fragments.
8110
+ */
8111
+ function oxlintProjectStructure({ enforceExistence, files = PROJECT_STRUCTURE_FILES, ignores = PROJECT_STRUCTURE_IGNORES, overrides = {}, projectRoot, structureRoot } = {}) {
8112
+ return createOxlintConfigs({
8113
+ name: "isentinel/project-structure",
8114
+ excludeFiles: ignores,
8115
+ files: files.flat(),
8116
+ overrides,
8117
+ rules: folderStructureRules({
8118
+ enforceExistence,
8119
+ projectRoot,
8120
+ structureRoot
8121
+ })
8122
+ });
8123
+ }
8124
+ //#endregion
8016
8125
  //#region src/rules/promise.ts
8017
8126
  /**
8018
8127
  * Promise rules shared between the ESLint and oxlint factories.
@@ -9230,7 +9339,7 @@ const NATIVE_ONLY_JS_PLUGINS = /* @__PURE__ */ new Set(["oxlint-comments"]);
9230
9339
  */
9231
9340
  function isentinel(factoryOptions, ...userConfigs) {
9232
9341
  const options = factoryOptions ?? { name: "isentinel" };
9233
- const { categories, componentExts: componentExtensions = [], e18e: enableE18e = true, env, eslintPlugin: enableEslintPlugin = false, formatters, gitignore: enableGitignore = true, globals, ignores, jsdoc: enableJsdoc = true, jsPlugins: userJsPlugins, jsx: enableJsx = true, options: linterOptions, oxc: enableOxc = true, react: enableReact = false, root: customRootGlobs, rules = {}, spellCheck: enableSpellCheck, test: enableTest = false, warnDroppedOverrides: enableDroppedOverrideWarning = true } = options;
9342
+ const { categories, componentExts: componentExtensions = [], e18e: enableE18e = true, env, eslintPlugin: enableEslintPlugin = false, formatters, gitignore: enableGitignore = true, globals, ignores, jsdoc: enableJsdoc = true, jsPlugins: userJsPlugins, jsx: enableJsx = true, options: linterOptions, oxc: enableOxc = true, projectStructure: enableProjectStructure = false, react: enableReact = false, root: customRootGlobs, rules = {}, spellCheck: enableSpellCheck, test: enableTest = false, warnDroppedOverrides: enableDroppedOverrideWarning = true } = options;
9234
9343
  const rootGlobs = mergeGlobs(GLOB_ROOT, customRootGlobs);
9235
9344
  const enableRoblox = options.roblox !== false;
9236
9345
  const reactOptions = resolveSubOptions(options, "react");
@@ -9359,6 +9468,7 @@ function isentinel(factoryOptions, ...userConfigs) {
9359
9468
  isInEditor
9360
9469
  }));
9361
9470
  if (enableEslintPlugin !== false) configs.push(oxlintEslintPlugin({ ...getOverrides(options, "eslintPlugin") }));
9471
+ if (enableProjectStructure !== false) configs.push(oxlintProjectStructure(resolveSubOptions(options, "projectStructure")));
9362
9472
  if (enableOxc) {
9363
9473
  configs.push(oxlintOxc({ roblox: enableRoblox }));
9364
9474
  if (needsComplementOverlay) configs.push(oxlintOxc({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@isentinel/eslint-config",
3
- "version": "6.0.0-beta.43",
3
+ "version": "6.0.0-beta.45",
4
4
  "description": "iSentinel's ESLint config",
5
5
  "keywords": [
6
6
  "eslint-config",
@@ -145,7 +145,7 @@
145
145
  "typescript": "6.0.3",
146
146
  "unplugin-unused": "0.5.7",
147
147
  "vitest": "4.1.10",
148
- "@isentinel/eslint-config": "6.0.0-beta.43"
148
+ "@isentinel/eslint-config": "6.0.0-beta.45"
149
149
  },
150
150
  "peerDependencies": {
151
151
  "@vitest/eslint-plugin": "^1.6.4",