oxlint-plugin-arch 0.3.3 โ†’ 0.4.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/CHANGELOG.md CHANGED
@@ -1,8 +1,18 @@
1
1
  # Changelog
2
2
 
3
- ## 0.3.2
3
+ ## Unreleased
4
4
 
5
- - Fix `chain-newline` reporting already-wrapped chains whose root is parenthesized, like `(db as TestDb)\n .insertInto()`, spans several lines, or has a comment between two links.
5
+ - Add `prefer-namespace-type-import` with a configurable named-type-import limit (default 3), module-specific namespace names, and scope-aware autofixes that qualify type references while reporting unsafe rewrites without a fix.
6
+
7
+ - Options that took one regex string now also take a non-empty list, where any pattern may match: `pattern`, `allowPattern`, `allowNamePattern`, `denyTypePattern`, `hookPattern`, `rootPattern`, `factoryPattern`, `forbid`, and `require`. Single strings keep their behavior.
8
+ - `no-type-declarations` `allowPattern` entries starting with `=` or `^=` match a type alias's value (`= ` plus its source text) instead of its name, so `'^= ReturnType<typeof '` keeps factory return types next to their factory. A name pattern that starts with `=` or `^=` is now read as a value pattern.
9
+ - Fix `declaration-name` `pattern` with `g` or `y` flags rejecting every name after the first match.
10
+
11
+ ## 0.3.3
12
+
13
+ Version 0.3.2 was tagged but never published to npm; 0.3.3 contains its fixes.
14
+
15
+ - Fix `chain-newline` reporting chains that already have one call per line when the root is parenthesized (`(db as TestDb)\n .insertInto()`), the root spans several lines, or a comment sits between two links.
6
16
  - Autofix `call-array-multiline` arrays with parenthesized elements, like `Promise.all([(a), (b)])`.
7
17
 
8
18
  ## 0.3.1
package/README.md CHANGED
@@ -6,7 +6,7 @@ Keep a codebase shaped the way you designed it: files in the right folders, expo
6
6
 
7
7
  - ๐Ÿ—‚๏ธ **Filename-aware.** Rules derive expected names from the file they run on (`user-action.create.ts` โ†’ `makeUserActionCreate`).
8
8
  - ๐ŸŽ›๏ธ **Configurable.** No app names, folders, or libraries are baked in. Globs and policy live in your config.
9
- - ๐Ÿ›Ÿ **Safe autofix.** Fixable rules only touch whitespace or comments, and never guess when a fix could change your code.
9
+ - ๐Ÿ›Ÿ **Safe autofix.** Layout and comment rules only touch whitespace or comments. Type-import fixes qualify scope-resolved references and skip unsafe rewrites.
10
10
  - โšก **Fast.** Built on Oxlint's JS plugin API and ESTree, with no extra parser.
11
11
 
12
12
  ---
@@ -65,7 +65,7 @@ export default defineConfig({
65
65
  | --- | --- |
66
66
  | [`minimal.oxlint.config.ts`](examples/minimal.oxlint.config.ts) | You want the smallest possible starting point. |
67
67
  | [`monorepo.oxlint.config.ts`](examples/monorepo.oxlint.config.ts) | โญ You are starting an `apps/server` + `apps/web` monorepo and want a strict, copy-ready setup. |
68
- | [`full.oxlint.config.ts`](examples/full.oxlint.config.ts) | You want to see every one of the 42 rules with its options. |
68
+ | [`full.oxlint.config.ts`](examples/full.oxlint.config.ts) | You want to see every one of the 43 rules with its options. |
69
69
 
70
70
  > [!TIP]
71
71
  > `monorepo.oxlint.config.ts` already loads `oxlint-plugin-arch`, so you can copy it as-is and adjust globs and names. The other two load `../src/index.ts`; change that specifier to `oxlint-plugin-arch` when you copy them.
@@ -74,7 +74,9 @@ export default defineConfig({
74
74
 
75
75
  ## ๐Ÿงฉ Rules
76
76
 
77
- 42 rules in 9 groups. ๐Ÿ”ง means the rule can autofix. Each rule file in [`src/rules/`](src/rules) explains its behavior in plain English, and the tests in [`src/tests/`](src/tests) show every option shape.
77
+ 43 rules in 9 groups. ๐Ÿ”ง means the rule can autofix. Each rule file in [`src/rules/`](src/rules) explains its behavior in plain English, and the tests in [`src/tests/`](src/tests) show every option shape.
78
+
79
+ Options that take one regex string (`pattern`, `allowPattern`, `forbid`, `require`, and the other `*Pattern` options) also take a non-empty list; any pattern in the list may match. Options that were already lists, such as `allowPatterns` and `allowCallees`, are unchanged.
78
80
 
79
81
  ### ๐Ÿ“ Files & folders
80
82
 
@@ -115,6 +117,7 @@ export default defineConfig({
115
117
  | `no-type-declarations` | No type aliases or interfaces in matched files, so types live in one place. |
116
118
  | `no-runtime-in-types` | Type modules stay free of runtime code. Opt in to also ban runtime imports and re-exports. |
117
119
  | `no-imported-type-alias` | No exported aliases that only rename an imported type. |
120
+ | `prefer-namespace-type-import` ๐Ÿ”ง | Long type-only named imports become a namespace import with qualified references. [Details โ†“](#prefer-namespace-type-import) |
118
121
 
119
122
  ### ๐Ÿงช Schemas & APIs
120
123
 
@@ -198,6 +201,46 @@ Use `no-type-declarations` separately to control where named types may live. Wit
198
201
 
199
202
  </details>
200
203
 
204
+ ### `prefer-namespace-type-import`
205
+
206
+ Prefers a namespace when a type-only import has **more than** `max` named specifiers. Both `import type { A, B }` and `import { type A, type B }` are checked; any runtime or default binding leaves the declaration alone.
207
+
208
+ | Option | Default | What it does |
209
+ | --- | --- | --- |
210
+ | `max` | `3` | Maximum named specifiers allowed (non-negative integer). Three passes; four fails. |
211
+ | `names` | `{}` | Exact module-path-to-namespace-name map, such as `{ '#/types/room.types': 'RoomTypes' }`. Values must be ASCII binding identifiers, not reserved words. |
212
+
213
+ The default name is the last path segment in PascalCase, stripping JS/TS extensions and adding `Types` unless already present: `#/types/room.types` โ†’ `RoomTypes`, `./harness.types.ts` โ†’ `HarnessTypes`, `@orbs/server/client` โ†’ `ClientTypes`. Other punctuation separates words; names starting with a digit get a `Module` prefix.
214
+
215
+ ```ts
216
+ // Before
217
+ import type { A, B as LocalB, C, D } from './room.types'
218
+ export type Result = [A, LocalB, C, D]
219
+
220
+ // After
221
+ import type * as RoomTypes from './room.types'
222
+ export type Result = [RoomTypes.A, RoomTypes.B, RoomTypes.C, RoomTypes.D]
223
+ ```
224
+
225
+ The autofix updates scope-resolved type references, generics, indexed access, type-position `typeof`, interface heritage, and class implementations, including TSX. Shadowed local names are untouched. Exported type declarations are updated, but local export lists (`export type { A }`, `export { type A }`, or `export { A }`) are **report-only**; direct re-exports from another module are untouched.
226
+
227
+ The diagnostic explains why an autofix was skipped: comments inside the import, an existing namespace import from the same module (no merging), a namespace name colliding with any binding/reference in the file or another proposed namespace, an unsupported imported name, or a reference outside the supported type syntax. Multiple qualifying imports from the same module are report-only too. Choose distinct `names` overrides to resolve collisions between different modules. Fixes preserve the module string, import attributes, semicolon, and comments outside the import.
228
+
229
+ ### `no-type-declarations`
230
+
231
+ Rejects type aliases and interfaces in matched files, so types live in one place.
232
+
233
+ | Option | Default | What it does |
234
+ | --- | --- | --- |
235
+ | `allowPattern` | none | Keep a declaration whose **name** matches. A pattern that starts with `=` or `^=` matches a type alias's **value** instead: `= ` followed by the value's source text, without surrounding parentheses or comments. Interfaces have no value, so only name patterns apply to them. |
236
+
237
+ ```ts
238
+ // allowPattern: ['(Service|Deps)$', '^= ReturnType<typeof ']
239
+ export type AiRetry = ReturnType<typeof makeAiRetry> // โœ… value matches
240
+ export interface OrderDeps { db: Database } // โœ… name matches
241
+ export type OrderCreateParams = { id: string } // โŒ move it to your types file
242
+ ```
243
+
201
244
  ### `only-export-constants`
202
245
 
203
246
  Every runtime export must point to a local `const`. Direct, destructured, renamed, and default exports of local constants all work.
package/dist/index.js CHANGED
@@ -551,6 +551,27 @@ function factoriesObjectPropertyName(property) {
551
551
  function optionsFirst(context, fallback) {
552
552
  return context.options[0] ?? fallback;
553
553
  }
554
+ var optionsPatternSchema = {
555
+ anyOf: [
556
+ { type: "string" },
557
+ { type: "array", items: { type: "string" }, minItems: 1 }
558
+ ]
559
+ };
560
+ function optionsPatterns(pattern, flags) {
561
+ return pattern === undefined ? [] : [pattern].flat().map((source) => new RegExp(source, flags));
562
+ }
563
+ function optionsOptionalPatterns(pattern, flags) {
564
+ return optionsPatterns(pattern === undefined ? undefined : [pattern].flat().filter((source) => source !== ""), flags);
565
+ }
566
+ function optionsPatternLabel(pattern) {
567
+ return pattern === undefined ? "" : [pattern].flat().join(" | ");
568
+ }
569
+ function optionsPatternsTest(patterns, value) {
570
+ return patterns.some((pattern) => {
571
+ pattern.lastIndex = 0;
572
+ return pattern.test(value);
573
+ });
574
+ }
554
575
  // src/utils/helpers/orpc.ts
555
576
  function orpcFindChainCall(expression, method) {
556
577
  if (expression.type === "CallExpression") {
@@ -947,7 +968,7 @@ function chainNewlineRootIdentifier(node) {
947
968
  function chainNewlineMatches(root, links, group) {
948
969
  if (links.length < group.minDepth)
949
970
  return false;
950
- const hasCriteria = group.methods !== undefined || group.onlyMethods !== undefined || group.rootPattern !== undefined;
971
+ const hasCriteria = group.methods !== undefined || group.onlyMethods !== undefined || group.rootPatterns.length > 0;
951
972
  if (!hasCriteria)
952
973
  return true;
953
974
  if (group.methods && links.some((link) => link.methodName !== null && group.methods?.has(link.methodName))) {
@@ -956,7 +977,7 @@ function chainNewlineMatches(root, links, group) {
956
977
  if (group.onlyMethods && links.every((link) => link.methodName !== null && group.onlyMethods?.has(link.methodName)))
957
978
  return true;
958
979
  const rootName = chainNewlineRootIdentifier(root);
959
- return rootName !== null && group.rootPattern !== undefined && group.rootPattern.test(rootName);
980
+ return rootName !== null && optionsPatternsTest(group.rootPatterns, rootName);
960
981
  }
961
982
  var chainNewline = defineRule2({
962
983
  meta: {
@@ -975,7 +996,7 @@ var chainNewline = defineRule2({
975
996
  minDepth: { type: "integer", minimum: 1 },
976
997
  methods: { type: "array", items: { type: "string" } },
977
998
  onlyMethods: { type: "array", items: { type: "string" } },
978
- rootPattern: { type: "string" }
999
+ rootPattern: optionsPatternSchema
979
1000
  },
980
1001
  required: ["minDepth"]
981
1002
  }
@@ -1001,7 +1022,7 @@ var chainNewline = defineRule2({
1001
1022
  minDepth: group.minDepth,
1002
1023
  methods: group.methods ? new Set(group.methods) : undefined,
1003
1024
  onlyMethods: group.onlyMethods ? new Set(group.onlyMethods) : undefined,
1004
- rootPattern: group.rootPattern === undefined ? undefined : new RegExp(group.rootPattern)
1025
+ rootPatterns: optionsPatterns(group.rootPattern)
1005
1026
  }));
1006
1027
  indentUnit = layoutIndentUnit(options.indent);
1007
1028
  newline = layoutNewline(context.sourceCode.text);
@@ -1074,14 +1095,14 @@ var declarationName = defineRule3({
1074
1095
  type: "array",
1075
1096
  items: { type: "string", enum: [...declarationsKinds] }
1076
1097
  },
1077
- pattern: { type: "string" },
1098
+ pattern: optionsPatternSchema,
1078
1099
  flags: { type: "string" },
1079
1100
  stem: { type: "string", enum: [...namingStemModes] },
1080
1101
  trailingRoles: { type: "array", items: { type: "string" } },
1081
1102
  roleSeparators: { type: "array", items: { type: "string" } },
1082
1103
  normalize: { type: "string", enum: ["remove-separators", "none"] },
1083
1104
  singularize: { type: "string", enum: ["none", "trailing-s"] },
1084
- allowPattern: { type: "string" }
1105
+ allowPattern: optionsPatternSchema
1085
1106
  }
1086
1107
  }],
1087
1108
  messages: {
@@ -1092,23 +1113,23 @@ var declarationName = defineRule3({
1092
1113
  createOnce(context) {
1093
1114
  let options = {};
1094
1115
  let kinds = null;
1095
- let allowed = null;
1096
- let expected = null;
1116
+ let allowed = [];
1117
+ let expected = [];
1097
1118
  let prefixes = [];
1098
1119
  function check(node) {
1099
1120
  const item = declarationsNamedFromNode(node);
1100
- if (!item || kinds && !kinds.has(item.kind) || allowed?.test(item.name)) {
1121
+ if (!item || kinds && !kinds.has(item.kind) || optionsPatternsTest(allowed, item.name)) {
1101
1122
  return;
1102
1123
  }
1103
1124
  const comparableName = options.normalize === "none" ? item.name : item.name.replaceAll("_", "").toLowerCase();
1104
- const matches = expected ? expected.test(item.name) : prefixes.some((prefix) => comparableName.startsWith(prefix));
1125
+ const matches = expected.length > 0 ? optionsPatternsTest(expected, item.name) : prefixes.some((prefix) => comparableName.startsWith(prefix));
1105
1126
  if (matches) {
1106
1127
  return;
1107
1128
  }
1108
1129
  context.report({
1109
1130
  node: item.node,
1110
- messageId: expected ? "pattern" : "prefix",
1111
- data: expected ? { name: item.name, pattern: options.pattern ?? "" } : { name: item.name, prefix: prefixes.join(" or ") }
1131
+ messageId: expected.length > 0 ? "pattern" : "prefix",
1132
+ data: expected.length > 0 ? { name: item.name, pattern: optionsPatternLabel([options.pattern ?? []].flat().filter((source) => source !== "")) } : { name: item.name, prefix: prefixes.join(" or ") }
1112
1133
  });
1113
1134
  }
1114
1135
  return {
@@ -1118,8 +1139,8 @@ var declarationName = defineRule3({
1118
1139
  normalize: "remove-separators"
1119
1140
  });
1120
1141
  kinds = options.kinds && options.kinds.length > 0 ? new Set(options.kinds) : null;
1121
- allowed = options.allowPattern ? new RegExp(options.allowPattern) : null;
1122
- expected = options.pattern ? new RegExp(options.pattern, options.flags) : null;
1142
+ allowed = optionsOptionalPatterns(options.allowPattern);
1143
+ expected = optionsOptionalPatterns(options.pattern, options.flags);
1123
1144
  const basename = namingFileBasename(context.filename).replace(/\.(tsx?|jsx?)$/, "");
1124
1145
  const stem = namingFileStem(basename, options.stem, options.trailingRoles, options.roleSeparators);
1125
1146
  prefixes = namingFilePrefixes(stem, options.normalize ?? "remove-separators", options.singularize ?? "none");
@@ -1149,7 +1170,7 @@ var exportFilePrefix = defineRule4({
1149
1170
  normalize: { type: "string", enum: ["remove-separators", "none"] },
1150
1171
  singularize: { type: "string", enum: ["none", "trailing-s"] },
1151
1172
  allDeclarations: { type: "boolean" },
1152
- allowPattern: { type: "string" }
1173
+ allowPattern: optionsPatternSchema
1153
1174
  }
1154
1175
  }],
1155
1176
  messages: {
@@ -1166,7 +1187,7 @@ var exportFilePrefix = defineRule4({
1166
1187
  const basename = namingFileBasename(context.filename).replace(/\.(tsx?|jsx?)$/, "");
1167
1188
  const stem = namingFileStem(basename, options.stem, options.trailingRoles, options.roleSeparators);
1168
1189
  const prefixes = namingFilePrefixes(stem, options.normalize ?? "remove-separators", options.singularize ?? "none");
1169
- const allowed = options.allowPattern ? new RegExp(options.allowPattern) : null;
1190
+ const allowed = optionsOptionalPatterns(options.allowPattern);
1170
1191
  const names = options.allDeclarations ? declarationsCollectNamed(program) : exportsCollect(program).map((binding) => ({
1171
1192
  name: binding.localName ?? binding.exportedName,
1172
1193
  node: binding.node
@@ -1175,7 +1196,7 @@ var exportFilePrefix = defineRule4({
1175
1196
  for (const item of names) {
1176
1197
  const key = `${item.name}:${item.node.start}:${item.node.end}`;
1177
1198
  const comparableName = options.normalize === "none" ? item.name : item.name.replaceAll("_", "").toLowerCase();
1178
- if (seen.has(key) || allowed?.test(item.name) || prefixes.some((prefix) => comparableName.startsWith(prefix))) {
1199
+ if (seen.has(key) || optionsPatternsTest(allowed, item.name) || prefixes.some((prefix) => comparableName.startsWith(prefix))) {
1179
1200
  continue;
1180
1201
  }
1181
1202
  seen.add(key);
@@ -1199,11 +1220,11 @@ var exportNamePattern = defineRule5({
1199
1220
  type: "object",
1200
1221
  additionalProperties: false,
1201
1222
  properties: {
1202
- pattern: { type: "string" },
1223
+ pattern: optionsPatternSchema,
1203
1224
  flags: { type: "string" },
1204
1225
  ignoreTypeExports: { type: "boolean" },
1205
1226
  allDeclarations: { type: "boolean" },
1206
- allowPattern: { type: "string" }
1227
+ allowPattern: optionsPatternSchema
1207
1228
  },
1208
1229
  required: ["pattern"]
1209
1230
  }],
@@ -1221,19 +1242,20 @@ var exportNamePattern = defineRule5({
1221
1242
  allDeclarations = false,
1222
1243
  allowPattern
1223
1244
  } = optionsFirst(context);
1224
- const allowed = allowPattern ? new RegExp(allowPattern) : null;
1245
+ const allowed = optionsOptionalPatterns(allowPattern);
1246
+ const expected = optionsPatterns(pattern, flags);
1225
1247
  const names = allDeclarations ? declarationsCollectNamed(program) : exportsCollect(program).flatMap((binding) => ignoreTypeExports && binding.typeOnly ? [] : [{ name: binding.localName ?? binding.exportedName, node: binding.node }]);
1226
1248
  const seen = new Set;
1227
1249
  for (const item of names) {
1228
1250
  const key = `${item.name}:${item.node.start}:${item.node.end}`;
1229
- if (seen.has(key) || allowed?.test(item.name) || new RegExp(pattern, flags).test(item.name)) {
1251
+ if (seen.has(key) || optionsPatternsTest(allowed, item.name) || optionsPatternsTest(expected, item.name)) {
1230
1252
  continue;
1231
1253
  }
1232
1254
  seen.add(key);
1233
1255
  context.report({
1234
1256
  node: item.node,
1235
1257
  messageId: "pattern",
1236
- data: { name: item.name, pattern }
1258
+ data: { name: item.name, pattern: optionsPatternLabel(pattern) }
1237
1259
  });
1238
1260
  }
1239
1261
  }
@@ -1312,7 +1334,7 @@ var filenameMatch = defineRule7({
1312
1334
  type: "object",
1313
1335
  additionalProperties: false,
1314
1336
  properties: {
1315
- pattern: { type: "string" },
1337
+ pattern: optionsPatternSchema,
1316
1338
  message: { type: "string" },
1317
1339
  flags: { type: "string" }
1318
1340
  },
@@ -1326,7 +1348,8 @@ var filenameMatch = defineRule7({
1326
1348
  return {
1327
1349
  Program(program) {
1328
1350
  const { pattern, message, flags } = optionsFirst(context);
1329
- if (!new RegExp(pattern, flags).test(namingFileBasename(context.filename))) {
1351
+ const patterns = optionsPatterns(pattern, flags);
1352
+ if (!optionsPatternsTest(patterns, namingFileBasename(context.filename))) {
1330
1353
  context.report({
1331
1354
  node: program,
1332
1355
  messageId: "mismatch",
@@ -1798,7 +1821,7 @@ import { defineRule as defineRule13 } from "@oxlint/plugins";
1798
1821
  function compileFactoryKeys(pattern, keys, requireKeys) {
1799
1822
  const allowedList = keys.concat(requireKeys.filter((key) => !keys.includes(key)));
1800
1823
  return {
1801
- pattern: new RegExp(pattern),
1824
+ patterns: optionsPatterns(pattern),
1802
1825
  allowed: new Set(allowedList),
1803
1826
  allowedList,
1804
1827
  requireKeys
@@ -1812,7 +1835,7 @@ var noExtraFactoryKeys = defineRule13({
1812
1835
  additionalProperties: false,
1813
1836
  properties: {
1814
1837
  keys: { type: "array", items: { type: "string" } },
1815
- factoryPattern: { type: "string" },
1838
+ factoryPattern: optionsPatternSchema,
1816
1839
  requireKeys: { type: "array", items: { type: "string" } },
1817
1840
  keySets: {
1818
1841
  type: "array",
@@ -1820,7 +1843,7 @@ var noExtraFactoryKeys = defineRule13({
1820
1843
  type: "object",
1821
1844
  additionalProperties: false,
1822
1845
  properties: {
1823
- factoryPattern: { type: "string" },
1846
+ factoryPattern: optionsPatternSchema,
1824
1847
  keys: { type: "array", items: { type: "string" } },
1825
1848
  requireKeys: { type: "array", items: { type: "string" } }
1826
1849
  },
@@ -1839,11 +1862,11 @@ var noExtraFactoryKeys = defineRule13({
1839
1862
  let keySets = [];
1840
1863
  let fallback;
1841
1864
  function resolveFactoryKeys(name) {
1842
- const matched = keySets.find((item) => item.pattern.test(name));
1865
+ const matched = keySets.find((item) => optionsPatternsTest(item.patterns, name));
1843
1866
  if (matched) {
1844
1867
  return matched;
1845
1868
  }
1846
- if (fallback?.pattern.test(name)) {
1869
+ if (fallback && optionsPatternsTest(fallback.patterns, name)) {
1847
1870
  return fallback;
1848
1871
  }
1849
1872
  }
@@ -1910,9 +1933,9 @@ var noFileLevelHelpers = defineRule14({
1910
1933
  type: "object",
1911
1934
  additionalProperties: false,
1912
1935
  properties: {
1913
- allowPattern: { type: "string" },
1936
+ allowPattern: optionsPatternSchema,
1914
1937
  detectComponents: { type: "boolean" },
1915
- hookPattern: { type: "string" }
1938
+ hookPattern: optionsPatternSchema
1916
1939
  }
1917
1940
  }],
1918
1941
  messages: {
@@ -1927,10 +1950,10 @@ var noFileLevelHelpers = defineRule14({
1927
1950
  detectComponents = true,
1928
1951
  hookPattern = "^use[A-Z]"
1929
1952
  } = optionsFirst(context, {});
1930
- const allowed = allowPattern ? new RegExp(allowPattern) : null;
1931
- const hooks = new RegExp(hookPattern);
1953
+ const allowed = optionsOptionalPatterns(allowPattern);
1954
+ const hooks = optionsPatterns(hookPattern);
1932
1955
  for (const candidate of declarationsFileLevelFunctionCandidates(program)) {
1933
- if (detectComponents && reactComponentsIsLike(program, candidate) || hooks.test(candidate.name) || allowed?.test(candidate.name)) {
1956
+ if (detectComponents && reactComponentsIsLike(program, candidate) || optionsPatternsTest(hooks, candidate.name) || optionsPatternsTest(allowed, candidate.name)) {
1934
1957
  continue;
1935
1958
  }
1936
1959
  context.report({
@@ -2443,7 +2466,7 @@ var noModuleMutableState = defineRule21({
2443
2466
  type: "array",
2444
2467
  items: { type: "string", enum: ["let", "var"] }
2445
2468
  },
2446
- allowNamePattern: { type: "string" }
2469
+ allowNamePattern: optionsPatternSchema
2447
2470
  }
2448
2471
  }],
2449
2472
  messages: {
@@ -2452,12 +2475,12 @@ var noModuleMutableState = defineRule21({
2452
2475
  },
2453
2476
  createOnce(context) {
2454
2477
  let kindSet = new Set(["let", "var"]);
2455
- let allowed = null;
2478
+ let allowed = [];
2456
2479
  return {
2457
2480
  before() {
2458
2481
  const { kinds = ["let", "var"], allowNamePattern } = optionsFirst(context, {});
2459
2482
  kindSet = new Set(kinds);
2460
- allowed = allowNamePattern ? new RegExp(allowNamePattern) : null;
2483
+ allowed = optionsOptionalPatterns(allowNamePattern);
2461
2484
  },
2462
2485
  VariableDeclaration(node) {
2463
2486
  if (node.declare || !kindSet.has(node.kind)) {
@@ -2468,7 +2491,7 @@ var noModuleMutableState = defineRule21({
2468
2491
  }
2469
2492
  for (const declarator of node.declarations) {
2470
2493
  for (const binding of moduleMutableStateBindings(declarator.id)) {
2471
- if (!allowed?.test(binding.name)) {
2494
+ if (!optionsPatternsTest(allowed, binding.name)) {
2472
2495
  context.report({
2473
2496
  node: binding.node,
2474
2497
  messageId: "moduleState"
@@ -3349,7 +3372,7 @@ var noTopLevelFunctions = defineRule29({
3349
3372
  additionalProperties: false,
3350
3373
  properties: {
3351
3374
  banReExports: { type: "boolean" },
3352
- allowPattern: { type: "string" }
3375
+ allowPattern: optionsPatternSchema
3353
3376
  }
3354
3377
  }],
3355
3378
  defaultOptions: [{ banReExports: true }],
@@ -3362,7 +3385,7 @@ var noTopLevelFunctions = defineRule29({
3362
3385
  return {
3363
3386
  Program(program) {
3364
3387
  const { banReExports, allowPattern } = optionsFirst(context);
3365
- const allowed = allowPattern ? new RegExp(allowPattern) : null;
3388
+ const allowed = optionsOptionalPatterns(allowPattern);
3366
3389
  for (const statement of program.body) {
3367
3390
  if (banReExports && (statement.type === "ExportAllDeclaration" || statement.type === "ExportNamedDeclaration" && statement.source)) {
3368
3391
  context.report({ node: statement, messageId: "noReExport" });
@@ -3370,7 +3393,7 @@ var noTopLevelFunctions = defineRule29({
3370
3393
  }
3371
3394
  const declaration = statement.type === "ExportNamedDeclaration" || statement.type === "ExportDefaultDeclaration" ? statement.declaration : statement;
3372
3395
  if (declaration?.type === "FunctionDeclaration") {
3373
- if (!allowed?.test(declarationsFunctionName(declaration))) {
3396
+ if (!optionsPatternsTest(allowed, declarationsFunctionName(declaration))) {
3374
3397
  context.report({
3375
3398
  node: declaration,
3376
3399
  messageId: "noFunction",
@@ -3383,7 +3406,7 @@ var noTopLevelFunctions = defineRule29({
3383
3406
  continue;
3384
3407
  }
3385
3408
  for (const item of declaration.declarations) {
3386
- if ((item.init?.type === "FunctionExpression" || item.init?.type === "ArrowFunctionExpression") && !allowed?.test(declarationsFunctionName(item))) {
3409
+ if ((item.init?.type === "FunctionExpression" || item.init?.type === "ArrowFunctionExpression") && !optionsPatternsTest(allowed, declarationsFunctionName(item))) {
3387
3410
  context.report({
3388
3411
  node: item,
3389
3412
  messageId: "noFunction",
@@ -3432,7 +3455,7 @@ var noTrivialFunctions = defineRule30({
3432
3455
  type: "object",
3433
3456
  additionalProperties: false,
3434
3457
  properties: {
3435
- allowPattern: { type: "string" },
3458
+ allowPattern: optionsPatternSchema,
3436
3459
  allowCallees: { type: "array", items: { type: "string" } },
3437
3460
  allowAsync: { type: "boolean" }
3438
3461
  }
@@ -3442,13 +3465,13 @@ var noTrivialFunctions = defineRule30({
3442
3465
  }
3443
3466
  },
3444
3467
  createOnce(context) {
3445
- let allowed = null;
3468
+ let allowed = [];
3446
3469
  let allowedCallees = [];
3447
3470
  let allowAsync = false;
3448
3471
  return {
3449
3472
  before() {
3450
3473
  const { allowPattern, allowCallees: calleePatterns = [], allowAsync: asyncAllowed = false } = optionsFirst(context, {});
3451
- allowed = allowPattern ? new RegExp(allowPattern) : null;
3474
+ allowed = optionsOptionalPatterns(allowPattern);
3452
3475
  allowedCallees = calleePatterns.map((pattern) => new RegExp(pattern));
3453
3476
  allowAsync = asyncAllowed;
3454
3477
  },
@@ -3456,7 +3479,7 @@ var noTrivialFunctions = defineRule30({
3456
3479
  const seen = new Set;
3457
3480
  for (const item of [...exportsCollectFunctions(program), ...declarationsTopLevelUnexportedFunctions(program)]) {
3458
3481
  const key = `${item.name}:${item.node.start}:${item.node.end}`;
3459
- if (seen.has(key) || allowed?.test(item.name) || !declarationsIsTrivialFunction(item.node)) {
3482
+ if (seen.has(key) || optionsPatternsTest(allowed, item.name) || !declarationsIsTrivialFunction(item.node)) {
3460
3483
  continue;
3461
3484
  }
3462
3485
  const expression = forwardedExpression(item.node);
@@ -3486,7 +3509,7 @@ var noTypeDeclarations = defineRule31({
3486
3509
  type: "object",
3487
3510
  additionalProperties: false,
3488
3511
  properties: {
3489
- allowPattern: { type: "string" }
3512
+ allowPattern: optionsPatternSchema
3490
3513
  }
3491
3514
  }],
3492
3515
  messages: {
@@ -3494,11 +3517,13 @@ var noTypeDeclarations = defineRule31({
3494
3517
  }
3495
3518
  },
3496
3519
  createOnce(context) {
3497
- let allowed = null;
3520
+ let allowedNames = [];
3521
+ let allowedValues = [];
3498
3522
  function check(node) {
3499
- if (allowed?.test(node.id.name)) {
3523
+ if (optionsPatternsTest(allowedNames, node.id.name))
3524
+ return;
3525
+ if (node.type === "TSTypeAliasDeclaration" && allowedValues.length > 0 && optionsPatternsTest(allowedValues, `= ${context.sourceCode.getText(node.typeAnnotation)}`))
3500
3526
  return;
3501
- }
3502
3527
  context.report({
3503
3528
  node,
3504
3529
  messageId: "typeDeclaration",
@@ -3508,7 +3533,10 @@ var noTypeDeclarations = defineRule31({
3508
3533
  return {
3509
3534
  before() {
3510
3535
  const { allowPattern } = optionsFirst(context, {});
3511
- allowed = allowPattern ? new RegExp(allowPattern) : null;
3536
+ const sources = allowPattern === undefined ? [] : [allowPattern].flat();
3537
+ const isValuePattern = (source) => /^\^?=/.test(source);
3538
+ allowedNames = optionsOptionalPatterns(sources.filter((source) => !isValuePattern(source)));
3539
+ allowedValues = optionsOptionalPatterns(sources.filter(isValuePattern));
3512
3540
  },
3513
3541
  TSTypeAliasDeclaration: check,
3514
3542
  TSInterfaceDeclaration: check
@@ -3671,7 +3699,7 @@ var onlyExportComponents = defineRule34({
3671
3699
  properties: {
3672
3700
  matchFileName: { type: "boolean" },
3673
3701
  allowTypeExports: { type: "boolean" },
3674
- denyTypePattern: { type: "string" }
3702
+ denyTypePattern: optionsPatternSchema
3675
3703
  }
3676
3704
  }],
3677
3705
  messages: {
@@ -3690,7 +3718,7 @@ var onlyExportComponents = defineRule34({
3690
3718
  denyTypePattern
3691
3719
  } = optionsFirst(context, {});
3692
3720
  const expected = reactComponentsPascalFromBasename(namingFileBasename(context.filename));
3693
- const denied = denyTypePattern ? new RegExp(denyTypePattern) : null;
3721
+ const denied = optionsOptionalPatterns(denyTypePattern);
3694
3722
  const components = [];
3695
3723
  for (const candidate of reactComponentsExportCandidates(program)) {
3696
3724
  if (candidate.typeOnly) {
@@ -3700,7 +3728,7 @@ var onlyExportComponents = defineRule34({
3700
3728
  messageId: "typeExport",
3701
3729
  data: { name: candidate.name, expected }
3702
3730
  });
3703
- } else if (denied?.test(candidate.name)) {
3731
+ } else if (optionsPatternsTest(denied, candidate.name)) {
3704
3732
  context.report({
3705
3733
  node: candidate.node,
3706
3734
  messageId: "deniedType",
@@ -3995,9 +4023,142 @@ var paddingBetweenStatements = defineRule36({
3995
4023
  }
3996
4024
  });
3997
4025
 
3998
- // src/rules/require-file-factory.ts
4026
+ // src/rules/prefer-namespace-type-import.ts
3999
4027
  import { defineRule as defineRule37 } from "@oxlint/plugins";
4000
- var requireFileFactory = defineRule37({
4028
+ var bindingName = /^[A-Za-z_$][\w$]*$/;
4029
+ var reservedNames = new Set("await break case catch class const continue debugger default delete do else enum export extends false finally for function if import in instanceof new null return super switch this throw true try typeof var void while with yield implements interface let package private protected public static arguments eval".split(" "));
4030
+ function namespaceName(source, names) {
4031
+ if (Object.hasOwn(names, source)) {
4032
+ return names[source];
4033
+ }
4034
+ const stem = namingFileBasename(source).replace(/(?:\.d)?\.[cm]?[jt]sx?$/i, "");
4035
+ const name = namingPascalCase(stem.replace(/[^A-Za-z0-9_$.-]/g, "-")) || "Module";
4036
+ const prefixed = /^\d/.test(name) ? `Module${name}` : name;
4037
+ return prefixed.endsWith("Types") ? prefixed : `${prefixed}Types`;
4038
+ }
4039
+ function isTypeOnlyNamedImport(node) {
4040
+ return node.specifiers.length > 0 && node.specifiers.every((specifier) => specifier.type === "ImportSpecifier" && (node.importKind === "type" || specifier.importKind === "type"));
4041
+ }
4042
+ function safeReference(node) {
4043
+ let current = node;
4044
+ let parent = current.parent;
4045
+ while (parent?.type === "TSQualifiedName" && parent.left === current || parent?.type === "MemberExpression" && !parent.computed && parent.object === current) {
4046
+ current = parent;
4047
+ parent = current.parent;
4048
+ }
4049
+ return parent?.type === "TSTypeReference" && parent.typeName === current || parent?.type === "TSTypeQuery" && parent.exprName === current || (parent?.type === "TSInterfaceHeritage" || parent?.type === "TSClassImplements") && parent.expression === current;
4050
+ }
4051
+ var preferNamespaceTypeImport = defineRule37({
4052
+ meta: {
4053
+ type: "suggestion",
4054
+ fixable: "code",
4055
+ schema: [{
4056
+ type: "object",
4057
+ additionalProperties: false,
4058
+ properties: {
4059
+ max: { type: "integer", minimum: 0 },
4060
+ names: { type: "object", additionalProperties: { type: "string", pattern: bindingName.source } }
4061
+ }
4062
+ }],
4063
+ defaultOptions: [{ max: 3, names: {} }],
4064
+ messages: {
4065
+ namespace: "Use a type namespace '{{name}}' for {{count}} named imports (maximum {{max}}).{{reason}}"
4066
+ }
4067
+ },
4068
+ createOnce(context) {
4069
+ const imports = [];
4070
+ return {
4071
+ before() {
4072
+ imports.length = 0;
4073
+ },
4074
+ ImportDeclaration(node) {
4075
+ imports.push(node);
4076
+ },
4077
+ "Program:exit"() {
4078
+ const { max, names } = optionsFirst(context);
4079
+ const candidates = imports.filter((node) => isTypeOnlyNamedImport(node) && node.specifiers.length > max);
4080
+ if (candidates.length === 0) {
4081
+ return;
4082
+ }
4083
+ const sourceCode = context.sourceCode;
4084
+ const occupied = new Set;
4085
+ for (const scope of sourceCode.scopeManager.scopes) {
4086
+ for (const variable of scope.variables) {
4087
+ occupied.add(variable.name);
4088
+ }
4089
+ for (const reference of scope.references) {
4090
+ occupied.add(reference.identifier.name);
4091
+ }
4092
+ }
4093
+ const proposed = candidates.map((node) => namespaceName(node.source.value, names));
4094
+ const comments = sourceCode.getAllComments();
4095
+ const reports = [];
4096
+ const edits = [];
4097
+ for (const [index, node] of candidates.entries()) {
4098
+ const name = proposed[index];
4099
+ let reason = "";
4100
+ const replacements = new Map;
4101
+ if (!bindingName.test(name) || reservedNames.has(name)) {
4102
+ reason = "invalid namespace binding";
4103
+ } else if (imports.some((other) => other.source.value === node.source.value && other.specifiers.some((specifier) => specifier.type === "ImportNamespaceSpecifier"))) {
4104
+ reason = "existing namespace import from this module";
4105
+ } else if (occupied.has(name) || proposed.some((other, otherIndex) => otherIndex !== index && other === name)) {
4106
+ reason = "namespace name collides with a binding, reference, or another proposed namespace";
4107
+ } else if (comments.some((comment) => comment.range[0] >= node.range[0] && comment.range[1] <= node.range[1])) {
4108
+ reason = "comment inside import";
4109
+ } else {
4110
+ const variables = sourceCode.getDeclaredVariables(node);
4111
+ for (const specifier of node.specifiers) {
4112
+ if (specifier.type !== "ImportSpecifier") {
4113
+ continue;
4114
+ }
4115
+ const imported = specifier.imported.type === "Identifier" ? specifier.imported.name : specifier.imported.value;
4116
+ const variable = variables.find((binding) => binding.identifiers.some((id) => id.range[0] === specifier.local.range[0]));
4117
+ if (!bindingName.test(imported) || !variable || variable.defs.length !== 1) {
4118
+ reason = "unsupported imported name or binding";
4119
+ break;
4120
+ }
4121
+ for (const reference of variable.references) {
4122
+ const identifier = reference.identifier;
4123
+ if (identifier.parent.type === "ExportSpecifier") {
4124
+ reason = "local re-export";
4125
+ break;
4126
+ }
4127
+ if (!safeReference(identifier)) {
4128
+ reason = "unsupported reference syntax";
4129
+ break;
4130
+ }
4131
+ replacements.set(identifier, `${name}.${imported}`);
4132
+ }
4133
+ if (reason) {
4134
+ break;
4135
+ }
4136
+ }
4137
+ }
4138
+ reports.push({ node, name, reason });
4139
+ if (!reason) {
4140
+ edits.push({ range: [node.range[0], node.source.range[0]], text: `import type * as ${name} from ` });
4141
+ for (const [identifier, text] of replacements) {
4142
+ edits.push({ range: identifier.range, text });
4143
+ }
4144
+ }
4145
+ }
4146
+ for (const { node, name, reason } of reports) {
4147
+ context.report({
4148
+ node,
4149
+ messageId: "namespace",
4150
+ data: { name, count: node.specifiers.length, max, reason: reason ? ` Autofix skipped: ${reason}.` : "" },
4151
+ fix: reason ? undefined : (fixer) => edits.map((edit) => fixer.replaceTextRange(edit.range, edit.text))
4152
+ });
4153
+ }
4154
+ }
4155
+ };
4156
+ }
4157
+ });
4158
+
4159
+ // src/rules/require-file-factory.ts
4160
+ import { defineRule as defineRule38 } from "@oxlint/plugins";
4161
+ var requireFileFactory = defineRule38({
4001
4162
  meta: {
4002
4163
  type: "problem",
4003
4164
  schema: [{
@@ -4043,8 +4204,8 @@ var requireFileFactory = defineRule37({
4043
4204
  });
4044
4205
 
4045
4206
  // src/rules/require-object-params.ts
4046
- import { defineRule as defineRule38 } from "@oxlint/plugins";
4047
- var requireObjectParams = defineRule38({
4207
+ import { defineRule as defineRule39 } from "@oxlint/plugins";
4208
+ var requireObjectParams = defineRule39({
4048
4209
  meta: {
4049
4210
  type: "problem",
4050
4211
  schema: [{
@@ -4053,7 +4214,7 @@ var requireObjectParams = defineRule38({
4053
4214
  properties: {
4054
4215
  maxParams: { type: "integer", minimum: 1 },
4055
4216
  allDeclarations: { type: "boolean" },
4056
- allowPattern: { type: "string" }
4217
+ allowPattern: optionsPatternSchema
4057
4218
  }
4058
4219
  }],
4059
4220
  messages: {
@@ -4064,12 +4225,12 @@ var requireObjectParams = defineRule38({
4064
4225
  return {
4065
4226
  Program(program) {
4066
4227
  const { maxParams = 1, allDeclarations = false, allowPattern } = optionsFirst(context, {});
4067
- const allowed = allowPattern ? new RegExp(allowPattern) : null;
4228
+ const allowed = optionsOptionalPatterns(allowPattern);
4068
4229
  const seen = new Set;
4069
4230
  const functions = allDeclarations ? [...exportsCollectFunctions(program), ...declarationsTopLevelUnexportedFunctions(program)] : exportsCollectFunctions(program);
4070
4231
  for (const item of functions) {
4071
4232
  const key = `${item.name}:${item.node.start}:${item.node.end}`;
4072
- if (seen.has(key) || allowed?.test(item.name)) {
4233
+ if (seen.has(key) || optionsPatternsTest(allowed, item.name)) {
4073
4234
  continue;
4074
4235
  }
4075
4236
  seen.add(key);
@@ -4092,8 +4253,8 @@ var requireObjectParams = defineRule38({
4092
4253
  });
4093
4254
 
4094
4255
  // src/rules/require-orpc-output.ts
4095
- import { defineRule as defineRule39 } from "@oxlint/plugins";
4096
- var requireOrpcOutput = defineRule39({
4256
+ import { defineRule as defineRule40 } from "@oxlint/plugins";
4257
+ var requireOrpcOutput = defineRule40({
4097
4258
  meta: {
4098
4259
  type: "problem",
4099
4260
  schema: [{
@@ -4135,8 +4296,8 @@ var requireOrpcOutput = defineRule39({
4135
4296
  });
4136
4297
 
4137
4298
  // src/rules/require-paired-call.ts
4138
- import { defineRule as defineRule40 } from "@oxlint/plugins";
4139
- var requirePairedCall = defineRule40({
4299
+ import { defineRule as defineRule41 } from "@oxlint/plugins";
4300
+ var requirePairedCall = defineRule41({
4140
4301
  meta: {
4141
4302
  type: "problem",
4142
4303
  schema: [{
@@ -4211,8 +4372,8 @@ var requirePairedCall = defineRule40({
4211
4372
  });
4212
4373
 
4213
4374
  // src/rules/route-surface.ts
4214
- import { defineRule as defineRule41 } from "@oxlint/plugins";
4215
- var routeSurface = defineRule41({
4375
+ import { defineRule as defineRule42 } from "@oxlint/plugins";
4376
+ var routeSurface = defineRule42({
4216
4377
  meta: {
4217
4378
  type: "problem",
4218
4379
  schema: [{
@@ -4254,7 +4415,7 @@ var routeSurface = defineRule41({
4254
4415
  });
4255
4416
 
4256
4417
  // src/rules/test-title-pattern.ts
4257
- import { defineRule as defineRule42 } from "@oxlint/plugins";
4418
+ import { defineRule as defineRule43 } from "@oxlint/plugins";
4258
4419
  var TEST_MODIFIERS = new Set(["only", "skip", "concurrent", "todo", "each"]);
4259
4420
  function isConfiguredName(name, callees) {
4260
4421
  if (!name) {
@@ -4291,7 +4452,10 @@ function regexMatches(regex, value) {
4291
4452
  regex.lastIndex = 0;
4292
4453
  return regex.test(value);
4293
4454
  }
4294
- var testTitlePattern = defineRule42({
4455
+ function titlePatterns(pattern, flags) {
4456
+ return pattern === undefined ? [] : [pattern].flat().map((source) => ({ regex: new RegExp(source, flags), pattern: source }));
4457
+ }
4458
+ var testTitlePattern = defineRule43({
4295
4459
  meta: {
4296
4460
  type: "suggestion",
4297
4461
  schema: [{
@@ -4299,8 +4463,8 @@ var testTitlePattern = defineRule42({
4299
4463
  additionalProperties: false,
4300
4464
  properties: {
4301
4465
  callees: { type: "array", items: { type: "string" } },
4302
- forbid: { type: "string" },
4303
- require: { type: "string" },
4466
+ forbid: optionsPatternSchema,
4467
+ require: optionsPatternSchema,
4304
4468
  flags: { type: "string", pattern: "^[dgimsuvy]*$" }
4305
4469
  }
4306
4470
  }],
@@ -4315,16 +4479,18 @@ var testTitlePattern = defineRule42({
4315
4479
  },
4316
4480
  createOnce(context) {
4317
4481
  let callees = [];
4318
- let forbid;
4319
- let required;
4482
+ let forbid = [];
4483
+ let required = [];
4484
+ let requiredLabel = "";
4320
4485
  return {
4321
4486
  before() {
4322
4487
  const options = optionsFirst(context);
4323
4488
  callees = options.callees;
4324
4489
  const flags = options.flags ?? "";
4325
- forbid = options.forbid === undefined ? undefined : { regex: new RegExp(options.forbid, flags), pattern: options.forbid };
4326
- required = options.require === undefined ? undefined : { regex: new RegExp(options.require, flags), pattern: options.require };
4327
- if (forbid === undefined && required === undefined) {
4490
+ forbid = titlePatterns(options.forbid, flags);
4491
+ required = titlePatterns(options.require, flags);
4492
+ requiredLabel = optionsPatternLabel(options.require);
4493
+ if (forbid.length === 0 && required.length === 0) {
4328
4494
  return false;
4329
4495
  }
4330
4496
  },
@@ -4340,18 +4506,19 @@ var testTitlePattern = defineRule42({
4340
4506
  if (text === undefined) {
4341
4507
  return;
4342
4508
  }
4343
- if (forbid && regexMatches(forbid.regex, text)) {
4509
+ const forbidden = forbid.find((item) => regexMatches(item.regex, text));
4510
+ if (forbidden) {
4344
4511
  context.report({
4345
4512
  node: title,
4346
4513
  messageId: "forbidden",
4347
- data: { pattern: forbid.pattern }
4514
+ data: { pattern: forbidden.pattern }
4348
4515
  });
4349
4516
  }
4350
- if (required && !regexMatches(required.regex, text)) {
4517
+ if (required.length > 0 && !required.some((item) => regexMatches(item.regex, text))) {
4351
4518
  context.report({
4352
4519
  node: title,
4353
4520
  messageId: "required",
4354
- data: { pattern: required.pattern }
4521
+ data: { pattern: requiredLabel }
4355
4522
  });
4356
4523
  }
4357
4524
  }
@@ -4399,6 +4566,7 @@ var src_default = eslintCompatPlugin({
4399
4566
  "only-export-components": onlyExportComponents,
4400
4567
  "only-export-constants": onlyExportConstants,
4401
4568
  "padding-between-statements": paddingBetweenStatements,
4569
+ "prefer-namespace-type-import": preferNamespaceTypeImport,
4402
4570
  "require-file-factory": requireFileFactory,
4403
4571
  "require-object-params": requireObjectParams,
4404
4572
  "require-orpc-output": requireOrpcOutput,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oxlint-plugin-arch",
3
- "version": "0.3.3",
3
+ "version": "0.4.0",
4
4
  "description": "Configurable architecture rules for Oxlint JavaScript plugins.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",